From eba4399d6cbcef8b5922fc22ac0aa20e35cb3312 Mon Sep 17 00:00:00 2001 From: Vito Date: Fri, 24 Nov 2017 23:50:26 +0000 Subject: [PATCH] [ADF-2002] added ARROW support for search autocomplete results (#2732) * [ADF-2002] added arrow up and down feature * [ADF-2002] fixed search bar animation and added arrow support * [ADF-2002] added some test for arrow manage --- .../components/search-control.component.html | 6 +- .../components/search-control.component.scss | 1 + .../search-control.component.spec.ts | 140 +++++++++++------- .../components/search-control.component.ts | 55 ++++++- .../components/search-trigger.directive.ts | 15 +- 5 files changed, 145 insertions(+), 72 deletions(-) diff --git a/lib/content-services/search/components/search-control.component.html b/lib/content-services/search/components/search-control.component.html index 01333d2da4..a1efbae3e4 100644 --- a/lib/content-services/search/components/search-control.component.html +++ b/lib/content-services/search/components/search-control.component.html @@ -10,13 +10,15 @@ @@ -36,6 +38,8 @@ [tabindex]="0" (focus)="onFocus($event)" (blur)="onBlur($event)" + (keyup.arrowdown)="onRowArrowDown($event)" + (keyup.arrowup)="onRowArrowUp($event)" class="adf-search-autocomplete-item" (click)="elementClicked(item)" (keyup.enter)="elementClicked(item)"> diff --git a/lib/content-services/search/components/search-control.component.scss b/lib/content-services/search/components/search-control.component.scss index bc99bc9fb1..7cb8b6c9ba 100644 --- a/lib/content-services/search/components/search-control.component.scss +++ b/lib/content-services/search/components/search-control.component.scss @@ -55,6 +55,7 @@ } &-search-autocomplete-item { + &:hover { background-color: mat-color($background, 'hover'); opacity: 1; diff --git a/lib/content-services/search/components/search-control.component.spec.ts b/lib/content-services/search/components/search-control.component.spec.ts index e5cdb60399..720929d436 100644 --- a/lib/content-services/search/components/search-control.component.spec.ts +++ b/lib/content-services/search/components/search-control.component.spec.ts @@ -69,6 +69,13 @@ describe('SearchControlComponent', () => { TestBed.resetTestingModule(); })); + function typeWordIntoSearchInput(word: string): void { + let inputDebugElement = debugElement.query(By.css('#adf-control-input')); + inputDebugElement.nativeElement.value = word; + inputDebugElement.nativeElement.focus(); + inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + } + describe('when input values are inserted', () => { beforeEach(async(() => { @@ -83,18 +90,12 @@ describe('SearchControlComponent', () => { expect(value).toBe('customSearchTerm'); }); - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'customSearchTerm'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('customSearchTerm'); fixture.detectChanges(); })); it('should update FAYT search when user inputs a valid term', async(() => { - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'customSearchTerm'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('customSearchTerm'); spyOn(component, 'isSearchBarActive').and.returnValue(true); spyOn(searchService, 'search').and.returnValue(Observable.of(results)); @@ -108,10 +109,7 @@ describe('SearchControlComponent', () => { })); it('should NOT update FAYT term when user inputs an empty string as search term ', async(() => { - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = ''; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput(''); spyOn(component, 'isSearchBarActive').and.returnValue(true); spyOn(searchService, 'search').and.returnValue(Observable.of(results)); @@ -126,10 +124,7 @@ describe('SearchControlComponent', () => { component.searchChange.subscribe(value => { expect(value).toBe('cu'); }); - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'cu'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('cu'); fixture.detectChanges(); })); }); @@ -188,9 +183,7 @@ describe('SearchControlComponent', () => { fixture.detectChanges(); let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); let enterKeyEvent: any = new Event('keyup'); enterKeyEvent.keyCode = '13'; inputDebugElement.nativeElement.dispatchEvent(enterKeyEvent); @@ -209,10 +202,7 @@ describe('SearchControlComponent', () => { spyOn(searchService, 'search').and.returnValue(Observable.of(results)); fixture.detectChanges(); - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); @@ -227,10 +217,7 @@ describe('SearchControlComponent', () => { spyOn(searchService, 'search').and.returnValue(Observable.of(noResult)); fixture.detectChanges(); - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'NO RES'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('NO RES'); fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); @@ -245,9 +232,7 @@ describe('SearchControlComponent', () => { fixture.detectChanges(); let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'NO RES'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('NO RES'); fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); @@ -268,9 +253,7 @@ describe('SearchControlComponent', () => { fixture.detectChanges(); let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); @@ -290,9 +273,7 @@ describe('SearchControlComponent', () => { fixture.detectChanges(); let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); @@ -315,9 +296,7 @@ describe('SearchControlComponent', () => { fixture.detectChanges(); let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); @@ -356,15 +335,75 @@ describe('SearchControlComponent', () => { component.liveSearchEnabled = false; fixture.detectChanges(); - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); fixture.whenStable().then(() => { fixture.detectChanges(); expect(element.querySelector('#autocomplete-search-result-list')).toBeNull(); }); })); + + it('should select the first item on autocomplete list when ARROW DOWN is pressed on input', async(() => { + spyOn(searchService, 'search').and.returnValue(Observable.of(results)); + fixture.detectChanges(); + typeWordIntoSearchInput('TEST'); + let inputDebugElement = debugElement.query(By.css('#adf-control-input')); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(element.querySelector('#autocomplete-search-result-list')).not.toBeNull(); + + inputDebugElement.triggerEventHandler('keyup.arrowdown', {}); + fixture.detectChanges(); + expect(document.activeElement.id).toBe('result_option_0'); + }); + })); + + it('should select the second item on autocomplete list when ARROW DOWN is pressed on list', async(() => { + spyOn(searchService, 'search').and.returnValue(Observable.of(results)); + fixture.detectChanges(); + let inputDebugElement = debugElement.query(By.css('#adf-control-input')); + typeWordIntoSearchInput('TEST'); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(element.querySelector('#autocomplete-search-result-list')).not.toBeNull(); + + inputDebugElement.triggerEventHandler('keyup.arrowdown', {}); + fixture.detectChanges(); + expect(document.activeElement.id).toBe('result_option_0'); + + let firstElement = debugElement.query(By.css('#result_option_0')); + firstElement.triggerEventHandler('keyup.arrowdown', { target : firstElement.nativeElement}); + fixture.detectChanges(); + expect(document.activeElement.id).toBe('result_option_1'); + }); + })); + + it('should focus the input search when ARROW UP is pressed on the first list item', async(() => { + spyOn(searchService, 'search').and.returnValue(Observable.of(results)); + fixture.detectChanges(); + let inputDebugElement = debugElement.query(By.css('#adf-control-input')); + typeWordIntoSearchInput('TEST'); + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(element.querySelector('#autocomplete-search-result-list')).not.toBeNull(); + + inputDebugElement.triggerEventHandler('keyup.arrowdown', {}); + fixture.detectChanges(); + expect(document.activeElement.id).toBe('result_option_0'); + + let firstElement = debugElement.query(By.css('#result_option_0')); + firstElement.triggerEventHandler('keyup.arrowup', { target : firstElement.nativeElement}); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(document.activeElement.id).toBe('adf-control-input'); + }); + }); + })); + }); describe('search button', () => { @@ -434,10 +473,7 @@ describe('SearchControlComponent', () => { expect(item.entry.id).toBe('123'); }); fixture.detectChanges(); - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); fixture.detectChanges(); fixture.whenStable().then(() => { fixture.detectChanges(); @@ -456,10 +492,7 @@ describe('SearchControlComponent', () => { }); fixture.detectChanges(); - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); fixture.whenStable().then(() => { fixture.detectChanges(); @@ -476,10 +509,7 @@ describe('SearchControlComponent', () => { expect(component.searchTerm).toBe('TEST'); }); fixture.detectChanges(); - let inputDebugElement = debugElement.query(By.css('#adf-control-input')); - inputDebugElement.nativeElement.value = 'TEST'; - inputDebugElement.nativeElement.focus(); - inputDebugElement.nativeElement.dispatchEvent(new Event('input')); + typeWordIntoSearchInput('TEST'); fixture.detectChanges(); fixture.whenStable().then(() => { diff --git a/lib/content-services/search/components/search-control.component.ts b/lib/content-services/search/components/search-control.component.ts index b351ae2a83..7ada1a9c95 100644 --- a/lib/content-services/search/components/search-control.component.ts +++ b/lib/content-services/search/components/search-control.component.ts @@ -17,11 +17,13 @@ import { AuthenticationService, ThumbnailService } from '@alfresco/adf-core'; import { animate, state, style, transition, trigger } from '@angular/animations'; -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, + QueryList, ViewEncapsulation, ViewChild, ViewChildren, ElementRef } from '@angular/core'; import { MinimalNodeEntity, QueryBody } from 'alfresco-js-api'; import { Observable } from 'rxjs/Observable'; import { Subject } from 'rxjs/Subject'; -import 'rxjs/add/operator/distinctUntilChanged'; +import { SearchComponent } from './search.component'; +import { MatListItem } from '@angular/material'; @Component({ selector: 'adf-search-control', @@ -73,6 +75,15 @@ export class SearchControlComponent implements OnInit, OnDestroy { @Output() optionClicked: EventEmitter = new EventEmitter(); + @ViewChild(SearchComponent) + searchAutocomplete: SearchComponent; + + @ViewChild('inputSearch') + inputSearch: ElementRef; + + @ViewChildren(MatListItem) + private listResultElement: QueryList; + searchTerm: string = ''; subscriptAnimationState: string; @@ -88,6 +99,10 @@ export class SearchControlComponent implements OnInit, OnDestroy { if (this.subscriptAnimationState === 'inactive') { this.searchTerm = ''; + this.searchAutocomplete.resetResults(); + if ( document.activeElement.id === this.inputSearch.nativeElement.id) { + this.inputSearch.nativeElement.blur(); + } } } }); @@ -165,15 +180,39 @@ export class SearchControlComponent implements OnInit, OnDestroy { this.focusSubject.next($event); } - activateToolbar($event) { + activateToolbar() { if (!this.isSearchBarActive()) { this.toggleSearchBar(); } } + selectFirstResult() { + if ( this.listResultElement && this.listResultElement.length > 0) { + let firstElement: MatListItem = this.listResultElement.first; + firstElement._getHostElement().focus(); + } + } + + onRowArrowDown($event: KeyboardEvent): void { + let nextElement: any = this.getNextElementSibling( $event.target); + if (nextElement) { + nextElement.focus(); + } + } + + onRowArrowUp($event: KeyboardEvent): void { + let previousElement: any = this.getPreviousElementSibling( $event.target); + if (previousElement) { + previousElement.focus(); + }else { + this.inputSearch.nativeElement.focus(); + this.focusSubject.next(new FocusEvent('focus')); + } + } + private setupFocusEventHandlers() { let focusEvents: Observable = this.focusSubject.asObservable() - .distinctUntilChanged().debounceTime(50); + .debounceTime(50); focusEvents.filter(($event: any) => { return this.isSearchBarActive() && ($event.type === 'blur' || $event.type === 'focusout'); }).subscribe(() => { @@ -181,4 +220,12 @@ export class SearchControlComponent implements OnInit, OnDestroy { }); } + private getNextElementSibling(node: Element): Element { + return node.nextElementSibling; + } + + private getPreviousElementSibling(node: Element): Element { + return node.previousElementSibling; + } + } diff --git a/lib/content-services/search/components/search-trigger.directive.ts b/lib/content-services/search/components/search-trigger.directive.ts index 9eb94c13e5..c9c6df7df9 100644 --- a/lib/content-services/search/components/search-trigger.directive.ts +++ b/lib/content-services/search/components/search-trigger.directive.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { DOWN_ARROW, ENTER, ESCAPE, UP_ARROW } from '@angular/cdk/keycodes'; +import { ENTER, ESCAPE } from '@angular/cdk/keycodes'; import { ChangeDetectorRef, Directive, @@ -36,10 +36,6 @@ import { Subject } from 'rxjs/Subject'; import { Subscription } from 'rxjs/Subscription'; import { SearchComponent } from './search.component'; -export const AUTOCOMPLETE_OPTION_HEIGHT = 48; - -export const AUTOCOMPLETE_PANEL_HEIGHT = 256; - export const SEARCH_AUTOCOMPLETE_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SearchTriggerDirective), @@ -150,14 +146,8 @@ export class SearchTriggerDirective implements ControlValueAccessor, OnDestroy { } else if (keyCode === ENTER) { this.escapeEventStream.next(); event.preventDefault(); - }else { - let isArrowKey = keyCode === UP_ARROW || keyCode === DOWN_ARROW; - if ( isArrowKey ) { - if ( !this.panelOpen ) { - this.openPanel(); - } - } } + } handleInput(event: KeyboardEvent): void { @@ -168,6 +158,7 @@ export class SearchTriggerDirective implements ControlValueAccessor, OnDestroy { this.searchPanel.keyPressedStream.next(inputValue); this.openPanel(); } else { + this.searchPanel.resetResults(); this.closePanel(); } }