import { CommonModule, DOCUMENT } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
HostListener,
Inject,
Input,
OnDestroy,
OnInit,
ViewChild,
} from '@angular/core';
import { Router } from '@angular/router';
import { IconName } from '@fortawesome/fontawesome-svg-core';
import { TranslateModule } from '@ngx-translate/core';
import { Observable, ReplaySubject, map, shareReplay } from 'rxjs';
import { ShoppingFacade } from 'ish-core/facades/shopping.facade';
import { IconModule } from 'ish-core/icon.module';
import { SearchBoxConfiguration } from 'ish-core/models/search-box-configuration/search-box-configuration.model';
import { Suggestions } from 'ish-core/models/suggestions/suggestions.model';
import { DeviceType } from 'ish-core/models/viewtype/viewtype.types';
import { PipesModule } from 'ish-core/pipes.module';
import { SuggestBrandsComponent } from 'ish-shared/components/search/suggest-brands/suggest-brands.component';
import { SuggestCategoriesComponent } from 'ish-shared/components/search/suggest-categories/suggest-categories.component';
import { SuggestKeywordsComponent } from 'ish-shared/components/search/suggest-keywords/suggest-keywords.component';
import { SuggestProductsComponent } from 'ish-shared/components/search/suggest-products/suggest-products.component';
import { SuggestSearchTermsComponent } from 'ish-shared/components/search/suggest-search-terms/suggest-search-terms.component';
/**
* The SearchBoxComponent is responsible for handling the search box functionality,
* including managing the search input, handling focus and blur events, and interacting
* with the shopping facade to fetch search suggestions and results.
*
* @remarks
* This component uses Angular's lifecycle hooks to initialize and manage the search box.
* It also listens to various events such as transition end and window scroll to handle
* the search box's behavior appropriately.
*
* @example
*
*
* @publicApi
*/
@Component({
selector: 'ish-search-box',
templateUrl: './search-box.component.html',
standalone: true,
imports: [
CommonModule,
IconModule,
PipesModule,
TranslateModule,
SuggestBrandsComponent,
SuggestCategoriesComponent,
SuggestKeywordsComponent,
SuggestProductsComponent,
SuggestSearchTermsComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchBoxComponent implements OnInit, AfterViewInit, OnDestroy {
/**
* the search box configuration for this component
*/
@Input() configuration: SearchBoxConfiguration;
@Input() deviceType: DeviceType;
@ViewChild('searchBox') searchBox: ElementRef;
@ViewChild('searchInput') searchInput: ElementRef;
searchSuggestLoading$: Observable;
inputSearchTerms$ = new ReplaySubject(1);
suggestions$: Observable;
// check if suggest has results to show the suggest layer
searchBoxResults$: Observable;
// check if there are recent searched terms
searchedTerms$: Observable;
// check if the input search term has more than 3 characters
hasMinimumCharCount$: Observable;
// searchbox focus handling
searchBoxFocus = false;
searchBoxScaledUp = false;
private searchBoxInitialWidth: number;
// search suggest layer height
private resizeTimeout: ReturnType;
constructor(
private shoppingFacade: ShoppingFacade,
private router: Router,
@Inject(DOCUMENT) private document: Document
) {}
get usedIcon(): IconName {
return this.configuration?.icon || 'search';
}
ngOnInit() {
this.searchSuggestLoading$ = this.shoppingFacade.searchSuggestLoading$;
// suggests are triggered solely via stream
this.suggestions$ = this.shoppingFacade
.suggestResults$(this.inputSearchTerms$)
.pipe(shareReplay(1)) as Observable;
this.searchBoxResults$ = this.suggestions$.pipe(
map(
results =>
!!(
results &&
(results.keywords?.length ||
results.categories?.length ||
results.brands?.length ||
results.products?.length)
)
),
shareReplay(1)
);
this.searchedTerms$ = this.shoppingFacade.recentSearchTerms$.pipe(
map(terms => terms.length > 0),
shareReplay(1)
);
this.hasMinimumCharCount$ = this.inputSearchTerms$.pipe(
map(value => value.length > 2),
shareReplay(1)
);
}
ngAfterViewInit() {
this.searchBoxInitialWidth = this.searchBox.nativeElement.offsetWidth;
}
ngOnDestroy() {
clearTimeout(this.resizeTimeout);
}
// add event listener for transition end to check if search box has scaled up
// to show the suggest layer only if the input has scaled up
@HostListener('transitionend', ['$event'])
onTransitionEnd(event: TransitionEvent) {
if (event.propertyName === 'width' && event.target === this.searchBox.nativeElement) {
const newWidth = this.searchInput.nativeElement.offsetWidth;
if (newWidth > this.searchBoxInitialWidth) {
// check if search box has scaled up
this.searchBoxScaledUp = true;
} else {
this.searchBoxScaledUp = false;
}
}
}
// if searchbox has focus - scale down and remove focus when scrolling the document
@HostListener('window:scroll', [])
onWindowScroll() {
if (this.searchBoxFocus) {
this.blur();
}
}
// reset input when ESC key is pressed and element is focused within the search box
@HostListener('document:keydown.escape', ['$event'])
onEscape(event: KeyboardEvent) {
if (this.searchBox.nativeElement.contains(event.target)) {
event.preventDefault(); // Optional: Prevent default behavior
this.resetInput();
}
}
// remove focus when clicking outside the search box
@HostListener('document:click', ['$event.target'])
onClick(targetElement: undefined): void {
this.blurIfOutside(targetElement);
}
// remove focus when focused outside the search box
@HostListener('document:focusin', ['$event.target'])
onFocusIn(targetElement: undefined): void {
this.blurIfOutside(targetElement);
}
// check if the target element is outside the search box
private blurIfOutside(targetElement: undefined): void {
const clickedOrFocusedInside = this.searchBox.nativeElement.contains(targetElement);
if (!clickedOrFocusedInside) {
this.blur();
}
}
// simple blur method to remove focus from search input
private blur() {
this.handleFocus(false);
this.searchInput.nativeElement.blur();
}
// handle focus status of search box
handleFocus(scaleUp: boolean) {
this.updateMobileSuggestLayerHeight();
if (scaleUp) {
this.searchBoxFocus = true;
// this.searchBoxScaledUp is set using transitionend event
} else {
this.searchBoxFocus = false;
this.searchBoxScaledUp = false;
}
}
// manually set focus on search input
// the explicit function call in the component is needed to get the focus working in iOS devices
setFocusOnSearchInput() {
this.searchInput.nativeElement.focus();
}
// handle the user input
handleInput(source: EventTarget) {
const inputValue = (source as HTMLInputElement).value;
this.inputSearchTerms$.next(inputValue);
if (inputValue === '') {
// clear suggestions in state when input is set to empty
this.shoppingFacade.clearSuggestSearchSuggestions();
}
}
// reset all and blur the input
resetInput(term?: string) {
this.blur();
this.inputSearchTerms$.next(term || '');
this.shoppingFacade.clearSuggestSearchSuggestions();
}
// handle the reset button
handleResetButton(event: Event) {
event.stopPropagation(); // important to prevent any other event listeners from firing
this.inputSearchTerms$.next('');
this.shoppingFacade.clearSuggestSearchSuggestions();
}
// submit the search form
submitSearch(suggestedTerm: string) {
if (!suggestedTerm) {
this.setFocusOnSearchInput();
return false;
}
// add the suggested term to the input field
this.inputSearchTerms$.next(suggestedTerm);
this.router.navigate(['/search', suggestedTerm]);
this.blur();
return false; // prevent form submission
}
// set CSS variable for suggest layer height on mobile devices to prevent keyboard overlay issues
private updateMobileSuggestLayerHeight = () => {
if (!SSR && this.deviceType === 'mobile') {
clearTimeout(this.resizeTimeout);
// timeout to wait for keyboard animation to finish
this.resizeTimeout = setTimeout(() => {
const remainingHeight = window.visualViewport?.height || window.innerHeight;
this.document.documentElement.style.setProperty('--viewport-remaining-height', `${remainingHeight}px`);
}, 300);
}
};
}