File

projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts

Implements

OnInit OnDestroy

Metadata

changeDetection ChangeDetectionStrategy.OnPush
selector cx-searchbox
templateUrl ./search-box.component.html

Index

Properties
Methods
Inputs
Accessors

Constructor

constructor(searchBoxComponentService: SearchBoxComponentService, componentData: CmsComponentData<CmsSearchBoxComponent>, winRef: WindowRef, routingService: RoutingService)
Parameters :
Name Type Optional
searchBoxComponentService SearchBoxComponentService No
componentData CmsComponentData<CmsSearchBoxComponent> No
winRef WindowRef No
routingService RoutingService No

Inputs

config
Type : SearchBoxConfig
queryText
Type : string

Sets the search box input field

Methods

avoidReopen
avoidReopen(event: UIEvent)

Especially in mobile we do not want the search icon to focus the input again when it's already open.

Parameters :
Name Type Optional
event UIEvent No
Returns : void
Protected blurSearchBox
blurSearchBox(event: UIEvent)
Parameters :
Name Type Optional
event UIEvent No
Returns : void
clear
clear(el: HTMLInputElement)

Clears the search box input field

Parameters :
Name Type Optional
el HTMLInputElement No
Returns : void
close
close(event: UIEvent, force?: boolean)

Closes the type-ahead searchBox.

Parameters :
Name Type Optional
event UIEvent No
force boolean Yes
Returns : void
disableClose
disableClose()

Disables closing the search result list.

Returns : void
dispatchProductEvent
dispatchProductEvent(eventData: SearchBoxProductSelectedEvent)

Dispatch UI events for Product selected

Parameters :
Name Type Optional Description
eventData SearchBoxProductSelectedEvent No

the data for the event

Returns : void
dispatchSuggestionEvent
dispatchSuggestionEvent(eventData: SearchBoxSuggestionSelectedEvent)

Dispatch UI events for Suggestion selected

Parameters :
Name Type Optional Description
eventData SearchBoxSuggestionSelectedEvent No

the data for the event

Returns : void
focusNextChild
focusNextChild(event: UIEvent)
Parameters :
Name Type Optional
event UIEvent No
Returns : void
focusPreviousChild
focusPreviousChild(event: UIEvent)
Parameters :
Name Type Optional
event UIEvent No
Returns : void
Private getFocusedElement
getFocusedElement()
Returns : HTMLElement
Private getFocusedIndex
getFocusedIndex()
Returns : number
Private getResultElements
getResultElements()
Returns : HTMLElement[]
Private isSearchBoxFocused
isSearchBoxFocused()
Returns : boolean
launchSearchResult
launchSearchResult(event: UIEvent, query: string)

Opens the PLP with the given query.

TODO: if there's a single product match, we could open the PDP.

Parameters :
Name Type Optional
event UIEvent No
query string No
Returns : void
ngOnDestroy
ngOnDestroy()
Returns : void
ngOnInit
ngOnInit()
Returns : void
open
open()

Opens the type-ahead searchBox

Returns : void
preventDefault
preventDefault(ev: UIEvent)
Parameters :
Name Type Optional
ev UIEvent No
Returns : void
search
search(query: string)

Closes the searchBox and opens the search result page.

Parameters :
Name Type Optional
query string No
Returns : void
updateChosenWord
updateChosenWord(chosenWord: string)
Parameters :
Name Type Optional
chosenWord string No
Returns : void

Properties

chosenWord
Type : string
Default value : ''
config
Type : SearchBoxConfig
Decorators :
@Input()
Protected config$
Type : Observable<SearchBoxConfig>
Default value : ( this.componentData?.data$ || of({} as any) ).pipe( map((config) => { const isBool = (obj: SearchBoxConfig, prop: string): boolean => obj?.[prop] !== 'false' && obj?.[prop] !== false; return { ...DEFAULT_SEARCH_BOX_CONFIG, ...config, displayProducts: isBool(config, 'displayProducts'), displayProductImages: isBool(config, 'displayProductImages'), displaySuggestions: isBool(config, 'displaySuggestions'), // we're merging the (optional) input of this component, but write the merged // result back to the input property, as the view logic depends on it. ...this.config, }; }), tap((config) => (this.config = config)) )

Returns the SearchBox configuration. The configuration is driven by multiple layers: default configuration, (optional) backend configuration and (optional) input configuration.

iconTypes
Default value : ICON_TYPE
Private ignoreCloseEvent
Default value : false

In some occasions we need to ignore the close event, for example when we click inside the search result section.

results$
Type : Observable<SearchResults>
Default value : this.config$.pipe( switchMap((config) => this.searchBoxComponentService.getResults(config)) )
Public subscription
Type : Subscription

Accessors

queryText
setqueryText(value: string)

Sets the search box input field

Parameters :
Name Type Optional
value string No
Returns : void
import {
  ChangeDetectionStrategy,
  Component,
  Input,
  OnDestroy,
  OnInit,
  Optional,
} from '@angular/core';
import {
  CmsSearchBoxComponent,
  PageType,
  RoutingService,
  WindowRef,
} from '@spartacus/core';
import { Observable, of, Subscription } from 'rxjs';
import { filter, map, switchMap, tap } from 'rxjs/operators';
import { ICON_TYPE } from '../../../cms-components/misc/icon/index';
import { CmsComponentData } from '../../../cms-structure/page/model/cms-component-data';
import { SearchBoxComponentService } from './search-box-component.service';
import {
  SearchBoxProductSelectedEvent,
  SearchBoxSuggestionSelectedEvent,
} from './search-box.events';
import { SearchBoxConfig, SearchResults } from './search-box.model';

const DEFAULT_SEARCH_BOX_CONFIG: SearchBoxConfig = {
  minCharactersBeforeRequest: 1,
  displayProducts: true,
  displaySuggestions: true,
  maxProducts: 5,
  maxSuggestions: 5,
  displayProductImages: true,
};

@Component({
  selector: 'cx-searchbox',
  templateUrl: './search-box.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchBoxComponent implements OnInit, OnDestroy {
  @Input() config: SearchBoxConfig;

  /**
   * Sets the search box input field
   */
  @Input('queryText')
  set queryText(value: string) {
    if (value) {
      this.search(value);
    }
  }

  iconTypes = ICON_TYPE;

  /**
   * In some occasions we need to ignore the close event,
   * for example when we click inside the search result section.
   */
  private ignoreCloseEvent = false;
  chosenWord = '';
  public subscription: Subscription;

  constructor(
    protected searchBoxComponentService: SearchBoxComponentService,
    @Optional()
    protected componentData: CmsComponentData<CmsSearchBoxComponent>,
    protected winRef: WindowRef,
    protected routingService: RoutingService
  ) {}

  /**
   * Returns the SearchBox configuration. The configuration is driven by multiple
   * layers: default configuration, (optional) backend configuration and (optional)
   * input configuration.
   */
  protected config$: Observable<SearchBoxConfig> = (
    this.componentData?.data$ || of({} as any)
  ).pipe(
    map((config) => {
      const isBool = (obj: SearchBoxConfig, prop: string): boolean =>
        obj?.[prop] !== 'false' && obj?.[prop] !== false;

      return {
        ...DEFAULT_SEARCH_BOX_CONFIG,
        ...config,
        displayProducts: isBool(config, 'displayProducts'),
        displayProductImages: isBool(config, 'displayProductImages'),
        displaySuggestions: isBool(config, 'displaySuggestions'),
        // we're merging the (optional) input of this component, but write the merged
        // result back to the input property, as the view logic depends on it.
        ...this.config,
      };
    }),
    tap((config) => (this.config = config))
  );

  results$: Observable<SearchResults> = this.config$.pipe(
    switchMap((config) => this.searchBoxComponentService.getResults(config))
  );

  ngOnInit(): void {
    this.subscription = this.routingService
      .getRouterState()
      .pipe(filter((data) => !data.nextState))
      .subscribe((data) => {
        if (
          !(
            data.state.context?.id === 'search' &&
            data.state.context?.type === PageType.CONTENT_PAGE
          )
        )
          this.chosenWord = '';
      });
  }

  /**
   * Closes the searchBox and opens the search result page.
   */
  search(query: string): void {
    this.searchBoxComponentService.search(query, this.config);
    // force the searchBox to open
    this.open();
  }

  /**
   * Opens the type-ahead searchBox
   */
  open(): void {
    this.searchBoxComponentService.toggleBodyClass('searchbox-is-active', true);
  }

  /**
   * Dispatch UI events for Suggestion selected
   *
   * @param eventData the data for the event
   */
  dispatchSuggestionEvent(eventData: SearchBoxSuggestionSelectedEvent): void {
    this.searchBoxComponentService.dispatchSuggestionSelectedEvent(eventData);
  }

  /**
   * Dispatch UI events for Product selected
   *
   * @param eventData the data for the event
   */
  dispatchProductEvent(eventData: SearchBoxProductSelectedEvent): void {
    this.searchBoxComponentService.dispatchProductSelectedEvent(eventData);
  }

  /**
   * Closes the type-ahead searchBox.
   */
  close(event: UIEvent, force?: boolean): void {
    // Use timeout to detect changes
    setTimeout(() => {
      if ((!this.ignoreCloseEvent && !this.isSearchBoxFocused()) || force) {
        this.blurSearchBox(event);
      }
    });
  }

  protected blurSearchBox(event: UIEvent): void {
    this.searchBoxComponentService.toggleBodyClass(
      'searchbox-is-active',
      false
    );
    if (event && event.target) {
      (<HTMLElement>event.target).blur();
    }
  }

  // Check if focus is on searchbox or result list elements
  private isSearchBoxFocused(): boolean {
    return (
      this.getResultElements().includes(this.getFocusedElement()) ||
      this.winRef.document.querySelector('input[aria-label="Search"]') ===
        this.getFocusedElement()
    );
  }

  /**
   * Especially in mobile we do not want the search icon
   * to focus the input again when it's already open.
   * */
  avoidReopen(event: UIEvent): void {
    if (this.searchBoxComponentService.hasBodyClass('searchbox-is-active')) {
      this.close(event);
      event.preventDefault();
    }
  }

  // Return result list as HTMLElement array
  private getResultElements(): HTMLElement[] {
    return Array.from(
      this.winRef.document.querySelectorAll(
        '.products > li a, .suggestions > li a'
      )
    );
  }

  // Return focused element as HTMLElement
  private getFocusedElement(): HTMLElement {
    return <HTMLElement>this.winRef.document.activeElement;
  }

  updateChosenWord(chosenWord: string): void {
    this.chosenWord = chosenWord;
  }

  private getFocusedIndex(): number {
    return this.getResultElements().indexOf(this.getFocusedElement());
  }

  // Focus on previous item in results list
  focusPreviousChild(event: UIEvent) {
    event.preventDefault(); // Negate normal keyscroll
    const [results, focusedIndex] = [
      this.getResultElements(),
      this.getFocusedIndex(),
    ];
    // Focus on last index moving to first
    if (results.length) {
      if (focusedIndex < 1) {
        results[results.length - 1].focus();
      } else {
        results[focusedIndex - 1].focus();
      }
    }
  }

  // Focus on next item in results list
  focusNextChild(event: UIEvent) {
    this.open();
    event.preventDefault(); // Negate normal keyscroll
    const [results, focusedIndex] = [
      this.getResultElements(),
      this.getFocusedIndex(),
    ];
    // Focus on first index moving to last
    if (results.length) {
      if (focusedIndex >= results.length - 1) {
        results[0].focus();
      } else {
        results[focusedIndex + 1].focus();
      }
    }
  }

  /**
   * Opens the PLP with the given query.
   *
   * TODO: if there's a single product match, we could open the PDP.
   */
  launchSearchResult(event: UIEvent, query: string): void {
    if (!query || query.trim().length === 0) {
      return;
    }
    this.close(event);
    this.searchBoxComponentService.launchSearchPage(query);
  }

  /**
   * Disables closing the search result list.
   */
  disableClose(): void {
    this.ignoreCloseEvent = true;
  }

  preventDefault(ev: UIEvent): void {
    ev.preventDefault();
  }

  /**
   * Clears the search box input field
   */
  clear(el: HTMLInputElement): void {
    this.disableClose();
    el.value = '';
    this.searchBoxComponentService.clearResults();

    // Use Timeout to run after blur event to prevent the searchbox from closing on mobile
    setTimeout(() => {
      // Retain focus on input lost by clicking on icon
      el.focus();
      this.ignoreCloseEvent = false;
    });
  }

  ngOnDestroy(): void {
    this.subscription?.unsubscribe();
  }
}
<label class="searchbox" [class.dirty]="!!searchInput.value">
  <input
    #searchInput
    [placeholder]="'searchBox.placeholder' | cxTranslate"
    autocomplete="off"
    aria-describedby="initialDescription"
    aria-controls="results"
    [attr.aria-label]="'common.search' | cxTranslate"
    (focus)="open()"
    (click)="open()"
    (input)="search(searchInput.value)"
    (blur)="close($event)"
    (keydown.escape)="close($event)"
    (keydown.enter)="
      close($event, true);
      launchSearchResult($event, searchInput.value);
      updateChosenWord(searchInput.value)
    "
    (keydown.arrowup)="focusPreviousChild($event)"
    (keydown.arrowdown)="focusNextChild($event)"
    value="{{ chosenWord }}"
  />

  <button
    [attr.aria-label]="'common.reset' | cxTranslate"
    (mousedown)="clear(searchInput)"
    (keydown.enter)="clear(searchInput)"
    class="reset"
  >
    <cx-icon [type]="iconTypes.RESET"></cx-icon>
  </button>

  <div role="presentation" class="search-icon">
    <cx-icon [type]="iconTypes.SEARCH"></cx-icon>
  </div>

  <button
    [attr.aria-label]="'common.search' | cxTranslate"
    class="search"
    (click)="open()"
  >
    <cx-icon [type]="iconTypes.SEARCH"></cx-icon>
  </button>
</label>

<div
  *ngIf="results$ | async as result"
  class="results"
  id="results"
  (click)="close($event, true)"
  role="listbox"
>
  <div
    *ngIf="result.message"
    class="message"
    [innerHTML]="result.message"
  ></div>

  <ul
    class="suggestions"
    attr.aria-label="{{ 'searchBox.ariaLabelSuggestions' | cxTranslate }}"
    tabindex="0"
  >
    <li *ngFor="let suggestion of result.suggestions">
      <a
        [innerHTML]="suggestion | cxHighlight: searchInput.value"
        [routerLink]="
          {
            cxRoute: 'search',
            params: { query: suggestion }
          } | cxUrl
        "
        (keydown.arrowup)="focusPreviousChild($event)"
        (keydown.arrowdown)="focusNextChild($event)"
        (keydown.enter)="close($event, true)"
        (keydown.escape)="close($event, true)"
        (blur)="close($event)"
        (mousedown)="preventDefault($event)"
        (click)="
          dispatchSuggestionEvent({
            freeText: searchInput.value,
            selectedSuggestion: suggestion,
            searchSuggestions: result.suggestions
          });
          updateChosenWord(suggestion)
        "
      >
      </a>
    </li>
  </ul>

  <ul
    class="products"
    *ngIf="result.products"
    attr.aria-label="{{ 'searchBox.ariaLabelProducts' | cxTranslate }}"
    tabindex="0"
  >
    <li *ngFor="let product of result.products">
      <a
        [routerLink]="
          {
            cxRoute: 'product',
            params: product
          } | cxUrl
        "
        [class.has-media]="config.displayProductImages"
        (keydown.arrowup)="focusPreviousChild($event)"
        (keydown.arrowdown)="focusNextChild($event)"
        (keydown.enter)="close($event, true)"
        (keydown.escape)="close($event, true)"
        (blur)="close($event)"
        (mousedown)="preventDefault($event)"
        (click)="
          dispatchProductEvent({
            freeText: searchInput.value,
            productCode: product.code
          })
        "
      >
        <cx-media
          *ngIf="config.displayProductImages"
          [container]="product.images?.PRIMARY"
          format="thumbnail"
          role="presentation"
        ></cx-media>
        <div class="name" [innerHTML]="product.nameHtml"></div>
        <span class="price">{{ product.price?.formattedValue }}</span>
      </a>
    </li>
  </ul>
  <span id="initialDescription" class="cx-visually-hidden">
    {{ 'searchBox.initialDescription' | cxTranslate }}
  </span>
  <div
    *ngIf="result.suggestions?.length || result.products?.length"
    aria-live="assertive"
    class="cx-visually-hidden"
  >
    {{
      'searchBox.suggestionsResult'
        | cxTranslate: { count: result.suggestions?.length }
    }}
    {{
      'searchBox.productsResult'
        | cxTranslate: { count: result.products?.length }
    }}
    {{ 'searchBox.initialDescription' | cxTranslate }}
  </div>
</div>
Legend
Html element
Component
Html element with directive

result-matching ""

    No results matching ""