Skip to content

Commit

Permalink
feat: add Sparque (advanced) suggest IAD and VD (#1736)
Browse files Browse the repository at this point in the history
  • Loading branch information
andreassteinmann authored Jan 21, 2025
1 parent b36d79f commit cef3df4
Show file tree
Hide file tree
Showing 14 changed files with 431 additions and 165 deletions.
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
<div class="search clearfix">
<div
#searchBox
class="search advanced-search clearfix"
[ngClass]="{ focus: searchBoxFocus, 'scaled-up': searchBoxScaledUp }"
>
<input
#searchInput
[placeholder]="configuration?.placeholder || ''"
autocomplete="off"
type="search"
id="header-search-input"
class="form-control searchTerm"
(input)="searchSuggest($event.target)"
[value]="inputSearchTerms$ | async"
(blur)="blur()"
(keydown)="focus()"
(keydown.esc)="blur()"
(focus)="handleFocus()"
(blur)="handleBlur($event)"
(keydown)="handleFocus()"
(keydown.esc)="handleEscKey()"
(keydown.arrowleft)="selectSuggestedTerm(-1)"
(keydown.arrowright)="selectSuggestedTerm(-1)"
(keydown.arrowdown)="selectSuggestedTerm(activeIndex + 1)"
Expand All @@ -19,35 +25,42 @@

<div class="buttons">
<button
#searchInputReset
*ngIf="inputSearchTerms$ | async"
class="btn-reset btn btn-primary"
type="reset"
name="reset"
[title]="'search.searchbox.button.reset.title' | translate"
style="right: 40px"
(click)="searchSuggest(''); searchInput.focus()"
(focus)="handleFocus()"
(click)="handleReset()"
(blur)="handleBlur($event)"
(keydown.esc)="handleEscKey()"
>
<fa-icon [icon]="['fas', 'times-circle']" />
<fa-icon [icon]="['fas', 'times']" />
</button>

<button
#searchInputSubmit
class="btn-search btn btn-primary"
type="submit"
name="search"
[title]="'search.searchbox.button.title' | translate"
(keydown)="handleFocus()"
(click)="submitSearch(searchInput.value)"
(blur)="handleBlur($event)"
(keydown.esc)="handleEscKey()"
>
<!-- search button with icon -->
<ng-container *ngIf="!configuration?.buttonText; else textBlock">
<fa-icon [icon]="['fas', usedIcon]" />
<fa-icon [icon]="['fas', 'search']" />
</ng-container>
<!-- search button with text -->
<ng-template #textBlock> {{ configuration?.buttonText }} </ng-template>
</button>
</div>

<ng-container *ngIf="searchResults$ | async as results">
<ul *ngIf="results.length && inputFocused" class="search-suggest-results">
<ul *ngIf="results.length && searchBoxFocus" class="search-suggest-results">
<li
*ngFor="let result of results | slice : 0 : configuration?.maxAutoSuggests; let liIndex = index"
[class.active-suggestion]="activeIndex === liIndex"
Expand All @@ -62,4 +75,5 @@
</li>
</ul>
</ng-container>
<div class="search-suggest-backdrop" [ngClass]="{ show: searchBoxFocus }"></div>
</div>
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
/* eslint-disable ish-custom-rules/ban-imports-file-pattern */
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { ReplaySubject, Subject } from 'rxjs';

import { ShoppingFacade } from 'ish-core/facades/shopping.facade';
import { IconModule } from 'ish-core/icon.module';
import { PipesModule } from 'ish-core/pipes.module';

import { AdvancedSearchBoxComponent } from './advanced-search-box.component';

Expand All @@ -25,7 +21,7 @@ describe('Advanced Search Box Component', () => {
searchTerm$.next(undefined);

await TestBed.configureTestingModule({
imports: [CommonModule, IconModule, PipesModule, RouterTestingModule, TranslateModule.forRoot()],
imports: [TranslateModule.forRoot()],
providers: [
{
provide: ShoppingFacade,
Expand All @@ -41,7 +37,7 @@ describe('Advanced Search Box Component', () => {
element = fixture.nativeElement;

// activate
component.inputFocused = true;
component.searchBoxFocus = true;
component.configuration = { maxAutoSuggests: 4 };
});

Expand All @@ -53,7 +49,7 @@ describe('Advanced Search Box Component', () => {

describe('with no results', () => {
beforeEach(() => {
searchResults$.next(undefined);
searchResults$.next([]);
});

it('should show no results when no suggestions are found', () => {
Expand All @@ -70,13 +66,17 @@ describe('Advanced Search Box Component', () => {
});

it('should show results when suggestions are available', () => {
component.searchBoxFocus = true;
searchTerm$.next('ca');
fixture.detectChanges();

const ul = element.querySelector('.search-suggest-results');
expect(ul.querySelectorAll('li')).toHaveLength(2);
});

it('should show no results when suggestions are available but maxAutoSuggests is 0', () => {
component.searchBoxFocus = true;
searchTerm$.next('ca');
component.configuration.maxAutoSuggests = 0;
fixture.detectChanges();

Expand All @@ -85,11 +85,43 @@ describe('Advanced Search Box Component', () => {
});

it('should show no results when suggestions are available but input has no focus', () => {
component.inputFocused = false;
component.searchBoxFocus = false;
fixture.detectChanges();

expect(element.querySelector('.search-suggest-results')).toBeFalsy();
});

it('should show no results when input is less than 2 characters', () => {
component.searchBoxFocus = true;
component.inputSearchTerms$.next('a');
fixture.detectChanges();

const ul = fixture.nativeElement.querySelector('.search-suggest-results');
expect(ul).toBeFalsy();
});

it('should show results when input is 2 or more characters', () => {
component.searchBoxFocus = true;
searchTerm$.next('ca');
component.inputSearchTerms$.next('ca');
fixture.detectChanges();

const ul = fixture.nativeElement.querySelector('.search-suggest-results');
expect(ul.querySelectorAll('li')).toHaveLength(2);
});

it('should clear results when input is cleared', () => {
component.searchBoxFocus = true;

component.inputSearchTerms$.next('ca');
fixture.detectChanges();

component.inputSearchTerms$.next('');
fixture.detectChanges();

const ul = fixture.nativeElement.querySelector('.search-suggest-results');
expect(ul).toBeFalsy();
});
});

describe('with inputs', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, DestroyRef, Input, OnInit, inject } from '@angular/core';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
HostListener,
Input,
OnInit,
ViewChild,
inject,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { IconName } from '@fortawesome/fontawesome-svg-core';
import { TranslateModule } from '@ngx-translate/core';
import { Observable, ReplaySubject, map, take } from 'rxjs';
import { Observable, ReplaySubject, map, of, switchMap, take } from 'rxjs';

import { ShoppingFacade } from 'ish-core/facades/shopping.facade';
import { IconModule } from 'ish-core/icon.module';
Expand Down Expand Up @@ -46,26 +56,35 @@ import { PipesModule } from 'ish-core/pipes.module';
imports: [CommonModule, IconModule, PipesModule, TranslateModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdvancedSearchBoxComponent implements OnInit {
export class AdvancedSearchBoxComponent implements OnInit, AfterViewInit {
/**
* the search box configuration for this component
*/
@Input() configuration: SearchBoxConfiguration;

@ViewChild('searchBox') searchBox: ElementRef;
@ViewChild('searchInput') searchInput: ElementRef;
@ViewChild('searchInputReset') searchInputReset: ElementRef;
@ViewChild('searchInputSubmit') searchInputSubmit: ElementRef;

searchResults$: Observable<string[]>;
inputSearchTerms$ = new ReplaySubject<string>(1);

activeIndex = -1;
inputFocused: boolean;

// searchbox focus handling
searchBoxFocus = false;
private searchBoxInitialWidth: number;
searchBoxScaledUp = false;

// handle browser tab focus
// not-dead-code
isTabOut = false;

private destroyRef = inject(DestroyRef);

constructor(private shoppingFacade: ShoppingFacade, private router: Router) {}

get usedIcon(): IconName {
return this.configuration?.icon || 'search';
}

ngOnInit() {
// initialize with searchTerm when on search route
this.shoppingFacade.searchTerm$
Expand All @@ -76,28 +95,97 @@ export class AdvancedSearchBoxComponent implements OnInit {
.subscribe(term => this.inputSearchTerms$.next(term));

// suggests are triggered solely via stream
this.searchResults$ = this.shoppingFacade.searchResults$(this.inputSearchTerms$);
this.searchResults$ = this.inputSearchTerms$.pipe(
switchMap(term => {
if (term.length < 2) {
return of([]); // emit an empty array if the input is less than 2 characters
}
return this.shoppingFacade.searchResults$(of(term));
})
);
}

ngAfterViewInit() {
this.searchBoxInitialWidth = this.searchBox.nativeElement.offsetWidth;
}

@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;
}
}
}

@HostListener('window:scroll', [])
onWindowScroll() {
if (this.searchBoxFocus) {
// if searchbox has focus - scale down and remove focus when scrolling the document
this.blur();
}
}

blur() {
this.inputFocused = false;
this.handleFocus(false);
this.searchInput.nativeElement.blur();
}

handleBlur(event: FocusEvent) {
if (!SSR) {
if (!document.hasFocus()) {
this.isTabOut = true;
return; // skip handling blur if browser tab loses focus
}
this.isTabOut = false; // reset flag

const currentElement = event.relatedTarget as HTMLElement;
if (
currentElement !== this.searchInput.nativeElement &&
currentElement !== this.searchInputReset?.nativeElement &&
currentElement !== this.searchInputSubmit.nativeElement
) {
this.handleFocus(false); // do not scale down if one of the searchbox elements is focused
}
}
this.activeIndex = -1;
}

focus() {
this.inputFocused = true;
handleFocus(scaleUp: boolean = true) {
if (scaleUp) {
this.searchBoxFocus = true;
} else {
this.searchBoxFocus = false;
this.searchBoxScaledUp = false;
}
}

handleEscKey() {
this.blur();
this.inputSearchTerms$.next('');
}

handleReset() {
this.searchInput.nativeElement.focus(); // manually set focus to input to prevent blur event
this.inputSearchTerms$.next('');
}

searchSuggest(source: string | EventTarget) {
this.inputSearchTerms$.next(typeof source === 'string' ? source : (source as HTMLDataElement).value);
searchSuggest(source: EventTarget) {
const inputValue = (source as HTMLInputElement).value;
this.inputSearchTerms$.next(inputValue);
}

submitSearch(suggestedTerm: string) {
if (!suggestedTerm) {
this.searchInput.nativeElement.focus();
return false;
}

// remove focus when switching to search page
this.inputFocused = false;
this.blur();

if (this.activeIndex !== -1) {
// something was selected via keyboard
Expand All @@ -109,8 +197,7 @@ export class AdvancedSearchBoxComponent implements OnInit {
this.router.navigate(['/search', suggestedTerm]);
}

// prevent form submission
return false;
return false; // prevent form submission
}

selectSuggestedTerm(index: number) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
style="right: 40px"
(click)="searchSuggest(''); searchInput.focus()"
>
<fa-icon [icon]="['fas', 'times-circle']" />
<fa-icon [icon]="['fas', 'times']" />
</button>

<button
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
/* eslint-disable ish-custom-rules/ban-imports-file-pattern */
import { CommonModule } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { ReplaySubject, Subject } from 'rxjs';

import { ShoppingFacade } from 'ish-core/facades/shopping.facade';
import { IconModule } from 'ish-core/icon.module';
import { PipesModule } from 'ish-core/pipes.module';

import { SimpleSearchBoxComponent } from './simple-search-box.component';

Expand All @@ -25,7 +21,7 @@ describe('Simple Search Box Component', () => {
searchTerm$.next(undefined);

await TestBed.configureTestingModule({
imports: [CommonModule, IconModule, PipesModule, RouterTestingModule, TranslateModule.forRoot()],
imports: [TranslateModule.forRoot()],
providers: [
{
provide: ShoppingFacade,
Expand Down
Loading

0 comments on commit cef3df4

Please sign in to comment.