Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Sparque (advanced) suggest IAD and VD #1736

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading