projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | cx-searchbox |
| templateUrl | ./search-box.component.html |
Properties |
|
Methods |
|
Inputs |
Accessors |
constructor(searchBoxComponentService: SearchBoxComponentService, componentData: CmsComponentData<CmsSearchBoxComponent>, winRef: WindowRef, routingService: RoutingService)
|
|||||||||||||||
|
Parameters :
|
| config | |
Type : SearchBoxConfig
|
|
| queryText | |
Type : string
|
|
|
Sets the search box input field |
|
| 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 :
Returns :
void
|
| Protected blurSearchBox | ||||||
blurSearchBox(event: UIEvent)
|
||||||
|
Parameters :
Returns :
void
|
| clear | ||||||
clear(el: HTMLInputElement)
|
||||||
|
Clears the search box input field
Parameters :
Returns :
void
|
| close | |||||||||
close(event: UIEvent, force?: boolean)
|
|||||||||
|
Closes the type-ahead searchBox.
Parameters :
Returns :
void
|
| disableClose |
disableClose()
|
|
Disables closing the search result list.
Returns :
void
|
| dispatchProductEvent | ||||||||
dispatchProductEvent(eventData: SearchBoxProductSelectedEvent)
|
||||||||
|
Dispatch UI events for Product selected
Parameters :
Returns :
void
|
| dispatchSuggestionEvent | ||||||||
dispatchSuggestionEvent(eventData: SearchBoxSuggestionSelectedEvent)
|
||||||||
|
Dispatch UI events for Suggestion selected
Parameters :
Returns :
void
|
| focusNextChild | ||||||
focusNextChild(event: UIEvent)
|
||||||
|
Parameters :
Returns :
void
|
| focusPreviousChild | ||||||
focusPreviousChild(event: UIEvent)
|
||||||
|
Parameters :
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 :
Returns :
void
|
| ngOnDestroy |
ngOnDestroy()
|
|
Returns :
void
|
| ngOnInit |
ngOnInit()
|
|
Returns :
void
|
| open |
open()
|
|
Opens the type-ahead searchBox
Returns :
void
|
| preventDefault | ||||||
preventDefault(ev: UIEvent)
|
||||||
|
Parameters :
Returns :
void
|
| search | ||||||
search(query: string)
|
||||||
|
Closes the searchBox and opens the search result page.
Parameters :
Returns :
void
|
| updateChosenWord | ||||||
updateChosenWord(chosenWord: string)
|
||||||
|
Parameters :
Returns :
void
|
| 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
|
| queryText | ||||||
setqueryText(value: string)
|
||||||
|
Sets the search box input field
Parameters :
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>