From cec975f810c439e55781bdac494714489fec687b Mon Sep 17 00:00:00 2001 From: Kara Date: Mon, 23 Jan 2017 11:57:00 -0800 Subject: [PATCH] feat(autocomplete): add keyboard events to autocomplete (#2723) --- src/lib/autocomplete/_autocomplete-theme.scss | 2 +- src/lib/autocomplete/autocomplete-trigger.ts | 49 ++++- src/lib/autocomplete/autocomplete.spec.ts | 167 +++++++++++++++++- src/lib/core/option/_option-theme.scss | 5 + src/lib/core/option/option.ts | 30 ++++ 5 files changed, 243 insertions(+), 10 deletions(-) diff --git a/src/lib/autocomplete/_autocomplete-theme.scss b/src/lib/autocomplete/_autocomplete-theme.scss index 3e231a63148d..cf5bc6e609a1 100644 --- a/src/lib/autocomplete/_autocomplete-theme.scss +++ b/src/lib/autocomplete/_autocomplete-theme.scss @@ -9,7 +9,7 @@ color: md-color($foreground, text); md-option { - &.md-selected { + &.md-selected:not(.md-active) { background: md-color($background, card); color: md-color($foreground, text); } diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index 42d623b88da1..4e87b4eea70b 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -1,12 +1,14 @@ import { - Directive, ElementRef, Input, ViewContainerRef, Optional, OnDestroy + AfterContentInit, Directive, ElementRef, Input, ViewContainerRef, Optional, OnDestroy } from '@angular/core'; import {NgControl} from '@angular/forms'; import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core'; import {MdAutocomplete} from './autocomplete'; import {PositionStrategy} from '../core/overlay/position/position-strategy'; import {Observable} from 'rxjs/Observable'; -import {MdOptionSelectEvent} from '../core/option/option'; +import {MdOptionSelectEvent, MdOption} from '../core/option/option'; +import {ActiveDescendantKeyManager} from '../core/a11y/activedescendant-key-manager'; +import {ENTER} from '../core/keyboard/keycodes'; import 'rxjs/add/observable/merge'; import {Dir} from '../core/rtl/dir'; import 'rxjs/add/operator/startWith'; @@ -19,14 +21,19 @@ export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6; @Directive({ selector: 'input[mdAutocomplete], input[matAutocomplete]', host: { - '(focus)': 'openPanel()' + '(focus)': 'openPanel()', + '(keydown)': '_handleKeydown($event)', + 'autocomplete': 'off' } }) -export class MdAutocompleteTrigger implements OnDestroy { +export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy { private _overlayRef: OverlayRef; private _portal: TemplatePortal; private _panelOpen: boolean = false; + /** Manages active item in option list based on key events. */ + private _keyManager: ActiveDescendantKeyManager; + /* The autocomplete panel to be attached to this trigger. */ @Input('mdAutocomplete') autocomplete: MdAutocomplete; @@ -34,6 +41,10 @@ export class MdAutocompleteTrigger implements OnDestroy { private _viewContainerRef: ViewContainerRef, @Optional() private _controlDir: NgControl, @Optional() private _dir: Dir) {} + ngAfterContentInit() { + this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options); + } + ngOnDestroy() { this._destroyPanel(); } /* Whether or not the autocomplete panel is open. */ @@ -69,8 +80,11 @@ export class MdAutocompleteTrigger implements OnDestroy { * when an option is selected and when the backdrop is clicked. */ get panelClosingActions(): Observable { - // TODO(kara): add tab event observable with keyboard event PR - return Observable.merge(...this.optionSelections, this._overlayRef.backdropClick()); + return Observable.merge( + ...this.optionSelections, + this._overlayRef.backdropClick(), + this._keyManager.tabOut + ); } /** Stream of autocomplete option selections. */ @@ -78,6 +92,19 @@ export class MdAutocompleteTrigger implements OnDestroy { return this.autocomplete.options.map(option => option.onSelect); } + /** The currently active option, coerced to MdOption type. */ + get activeOption(): MdOption { + return this._keyManager.activeItem as MdOption; + } + + _handleKeydown(event: KeyboardEvent): void { + if (this.activeOption && event.keyCode === ENTER) { + this.activeOption._selectViaInteraction(); + } else { + this.openPanel(); + this._keyManager.onKeydown(event); + } + } /** * This method listens to a stream of panel closing actions and resets the @@ -90,7 +117,10 @@ export class MdAutocompleteTrigger implements OnDestroy { .startWith(null) // create a new stream of panelClosingActions, replacing any previous streams // that were created, and flatten it so our stream only emits closing events... - .switchMap(() => this.panelClosingActions) + .switchMap(() => { + this._resetActiveItem(); + return this.panelClosingActions; + }) // when the first closing event occurs... .first() // set the value, close the panel, and complete. @@ -149,5 +179,10 @@ export class MdAutocompleteTrigger implements OnDestroy { return this._element.nativeElement.getBoundingClientRect().width; } + /** Reset active item to -1 so DOWN_ARROW event will activate the first option.*/ + private _resetActiveItem(): void { + this._keyManager.setActiveItem(-1); + } + } diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index bc02469a4d43..c2d540df343d 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -1,5 +1,5 @@ import {TestBed, async, ComponentFixture} from '@angular/core/testing'; -import {Component, OnDestroy, ViewChild} from '@angular/core'; +import {Component, OnDestroy, QueryList, ViewChild, ViewChildren} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdAutocompleteModule, MdAutocompleteTrigger} from './index'; import {OverlayContainer} from '../core/overlay/overlay-container'; @@ -7,6 +7,8 @@ import {MdInputModule} from '../input/index'; import {Dir, LayoutDirection} from '../core/rtl/dir'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {Subscription} from 'rxjs/Subscription'; +import {ENTER, DOWN_ARROW, SPACE} from '../core/keyboard/keycodes'; +import {MdOption} from '../core/option/option'; describe('MdAutocomplete', () => { let overlayContainerElement: HTMLElement; @@ -205,7 +207,7 @@ describe('MdAutocomplete', () => { fixture.detectChanges(); expect(input.value) - .toContain('California', `Expected text field to be filled with selected value.`); + .toContain('California', `Expected text field to fill with selected value.`); }); it('should mark the autocomplete control as dirty when an option is selected', () => { @@ -236,6 +238,159 @@ describe('MdAutocomplete', () => { }); + describe('keyboard events', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + let DOWN_ARROW_EVENT: KeyboardEvent; + let ENTER_EVENT: KeyboardEvent; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleAutocomplete); + fixture.detectChanges(); + + input = fixture.debugElement.query(By.css('input')).nativeElement; + DOWN_ARROW_EVENT = new FakeKeyboardEvent(DOWN_ARROW) as KeyboardEvent; + ENTER_EVENT = new FakeKeyboardEvent(ENTER) as KeyboardEvent; + }); + + it('should should not focus the option when DOWN key is pressed', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + spyOn(fixture.componentInstance.options.first, 'focus'); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + expect(fixture.componentInstance.options.first.focus).not.toHaveBeenCalled(); + }); + + it('should set the active item to the first option when DOWN key is pressed', async(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + const optionEls = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.componentInstance.trigger.activeOption) + .toBe(fixture.componentInstance.options.first, 'Expected first option to be active.'); + expect(optionEls[0].classList).toContain('md-active'); + expect(optionEls[1].classList).not.toContain('md-active'); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.componentInstance.trigger.activeOption) + .toBe(fixture.componentInstance.options.toArray()[1], + 'Expected second option to be active.'); + expect(optionEls[0].classList).not.toContain('md-active'); + expect(optionEls[1].classList).toContain('md-active'); + }); + }); + })); + + it('should set the active item properly after filtering', async(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + input.value = 'o'; + dispatchEvent('input', input); + fixture.detectChanges(); + + const optionEls = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + expect(fixture.componentInstance.trigger.activeOption) + .toBe(fixture.componentInstance.options.first, 'Expected first option to be active.'); + expect(optionEls[0].classList).toContain('md-active'); + expect(optionEls[1].classList).not.toContain('md-active'); + }); + }); + })); + + it('should fill the text field when an option is selected with ENTER', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + + expect(input.value) + .toContain('Alabama', `Expected text field to fill with selected value on ENTER.`); + }); + + it('should fill the text field, not select an option, when SPACE is entered', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + input.value = 'New'; + dispatchEvent('input', input); + fixture.detectChanges(); + + const SPACE_EVENT = new FakeKeyboardEvent(SPACE) as KeyboardEvent; + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.componentInstance.trigger._handleKeydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(input.value) + .not.toContain('New York', `Expected option not to be selected on SPACE.`); + }); + + it('should mark the control as dirty when an option is selected from the keyboard', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(true, `Expected control to become dirty when option was selected by ENTER.`); + }); + + it('should open the panel again when typing after making a selection', async(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to read closed after ENTER key.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected panel to close after ENTER key.`); + + // 65 is the keycode for "a" + const A_KEY = new FakeKeyboardEvent(65) as KeyboardEvent; + fixture.componentInstance.trigger._handleKeydown(A_KEY); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to read open when typing in input.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to display when typing in input.`); + }); + })); + + }); + }); @Component({ @@ -257,6 +412,7 @@ class SimpleAutocomplete implements OnDestroy { valueSub: Subscription; @ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger; + @ViewChildren(MdOption) options: QueryList; states = [ {code: 'AL', name: 'Alabama'}, @@ -301,4 +457,11 @@ function dispatchEvent(eventName: string, element: HTMLElement): void { element.dispatchEvent(event); } +/** This is a mock keyboard event to test keyboard events in the autocomplete. */ +class FakeKeyboardEvent { + constructor(public keyCode: number) {} + preventDefault() {} +} + + diff --git a/src/lib/core/option/_option-theme.scss b/src/lib/core/option/_option-theme.scss index f639cdfb42f2..2b22c8ff4f58 100644 --- a/src/lib/core/option/_option-theme.scss +++ b/src/lib/core/option/_option-theme.scss @@ -16,6 +16,11 @@ color: md-color($primary); } + &.md-active { + background: md-color($background, hover); + color: md-color($foreground, text); + } + &.md-option-disabled { color: md-color($foreground, hint-text); } diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts index 2c7e0f9e85c7..9d714fafc99d 100644 --- a/src/lib/core/option/option.ts +++ b/src/lib/core/option/option.ts @@ -36,6 +36,7 @@ export class MdOptionSelectEvent { 'role': 'option', '[attr.tabindex]': '_getTabIndex()', '[class.md-selected]': 'selected', + '[class.md-active]': 'active', '[id]': 'id', '[attr.aria-selected]': 'selected.toString()', '[attr.aria-disabled]': 'disabled.toString()', @@ -48,6 +49,7 @@ export class MdOptionSelectEvent { }) export class MdOption { private _selected: boolean = false; + private _active: boolean = false; /** Whether the option is disabled. */ private _disabled: boolean = false; @@ -75,6 +77,16 @@ export class MdOption { return this._selected; } + /** + * Whether or not the option is currently active and ready to be selected. + * An active option displays styles as if it is focused, but the + * focus is actually retained somewhere else. This comes in handy + * for components like autocomplete where focus must remain on the input. + */ + get active(): boolean { + return this._active; + } + /** * The displayed value of the option. It is necessary to show the selected option in the * select's trigger. @@ -100,6 +112,24 @@ export class MdOption { this._renderer.invokeElementMethod(this._getHostElement(), 'focus'); } + /** + * This method sets display styles on the option to make it appear + * active. This is used by the ActiveDescendantKeyManager so key + * events will display the proper options as active on arrow key events. + */ + setActiveStyles() { + Promise.resolve(null).then(() => this._active = true); + } + + /** + * This method removes display styles on the option that made it appear + * active. This is used by the ActiveDescendantKeyManager so key + * events will display the proper options as active on arrow key events. + */ + setInactiveStyles() { + Promise.resolve(null).then(() => this._active = false); + } + /** Ensures the option is selected when activated from the keyboard. */ _handleKeydown(event: KeyboardEvent): void { if (event.keyCode === ENTER || event.keyCode === SPACE) {