Skip to content

Commit 9f70cbc

Browse files
committed
feat(autocomplete): add keyboard events to autocomplete
1 parent 5def001 commit 9f70cbc

File tree

5 files changed

+243
-10
lines changed

5 files changed

+243
-10
lines changed

src/lib/autocomplete/_autocomplete-theme.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
color: md-color($foreground, text);
1010

1111
md-option {
12-
&.md-selected {
12+
&.md-selected:not(.md-active) {
1313
background: md-color($background, card);
1414
color: md-color($foreground, text);
1515
}

src/lib/autocomplete/autocomplete-trigger.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {
2-
Directive, ElementRef, Input, ViewContainerRef, Optional, OnDestroy
2+
AfterContentInit, Directive, ElementRef, Input, ViewContainerRef, Optional, OnDestroy
33
} from '@angular/core';
44
import {NgControl} from '@angular/forms';
55
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
66
import {MdAutocomplete} from './autocomplete';
77
import {PositionStrategy} from '../core/overlay/position/position-strategy';
88
import {Observable} from 'rxjs/Observable';
9-
import {MdOptionSelectEvent} from '../core/option/option';
9+
import {MdOptionSelectEvent, MdOption} from '../core/option/option';
10+
import {ActiveDescendantKeyManager} from '../core/a11y/activedescendant-key-manager';
11+
import {ENTER} from '../core/keyboard/keycodes';
1012
import 'rxjs/add/observable/merge';
1113
import {Dir} from '../core/rtl/dir';
1214
import 'rxjs/add/operator/startWith';
@@ -19,21 +21,30 @@ export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6;
1921
@Directive({
2022
selector: 'input[mdAutocomplete], input[matAutocomplete]',
2123
host: {
22-
'(focus)': 'openPanel()'
24+
'(focus)': 'openPanel()',
25+
'(keydown)': '_handleKeydown($event)',
26+
'autocomplete': 'off'
2327
}
2428
})
25-
export class MdAutocompleteTrigger implements OnDestroy {
29+
export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
2630
private _overlayRef: OverlayRef;
2731
private _portal: TemplatePortal;
2832
private _panelOpen: boolean = false;
2933

34+
/** Manages active item in option list based on key events. */
35+
private _keyManager: ActiveDescendantKeyManager;
36+
3037
/* The autocomplete panel to be attached to this trigger. */
3138
@Input('mdAutocomplete') autocomplete: MdAutocomplete;
3239

3340
constructor(private _element: ElementRef, private _overlay: Overlay,
3441
private _viewContainerRef: ViewContainerRef,
3542
@Optional() private _controlDir: NgControl, @Optional() private _dir: Dir) {}
3643

44+
ngAfterContentInit() {
45+
this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options);
46+
}
47+
3748
ngOnDestroy() { this._destroyPanel(); }
3849

3950
/* Whether or not the autocomplete panel is open. */
@@ -69,15 +80,31 @@ export class MdAutocompleteTrigger implements OnDestroy {
6980
* when an option is selected and when the backdrop is clicked.
7081
*/
7182
get panelClosingActions(): Observable<any> {
72-
// TODO(kara): add tab event observable with keyboard event PR
73-
return Observable.merge(...this.optionSelections, this._overlayRef.backdropClick());
83+
return Observable.merge(
84+
...this.optionSelections,
85+
this._overlayRef.backdropClick(),
86+
this._keyManager.tabOut
87+
);
7488
}
7589

7690
/** Stream of autocomplete option selections. */
7791
get optionSelections(): Observable<any>[] {
7892
return this.autocomplete.options.map(option => option.onSelect);
7993
}
8094

95+
/** The currently active option, coerced to MdOption type. */
96+
get activeOption(): MdOption {
97+
return this._keyManager.activeItem as MdOption;
98+
}
99+
100+
_handleKeydown(event: KeyboardEvent): void {
101+
if (this.activeOption && event.keyCode === ENTER) {
102+
this.activeOption._selectViaInteraction();
103+
} else {
104+
this.openPanel();
105+
this._keyManager.onKeydown(event);
106+
}
107+
}
81108

82109
/**
83110
* This method listens to a stream of panel closing actions and resets the
@@ -90,7 +117,10 @@ export class MdAutocompleteTrigger implements OnDestroy {
90117
.startWith(null)
91118
// create a new stream of panelClosingActions, replacing any previous streams
92119
// that were created, and flatten it so our stream only emits closing events...
93-
.switchMap(() => this.panelClosingActions)
120+
.switchMap(() => {
121+
this._resetActiveItem();
122+
return this.panelClosingActions;
123+
})
94124
// when the first closing event occurs...
95125
.first()
96126
// set the value, close the panel, and complete.
@@ -149,5 +179,10 @@ export class MdAutocompleteTrigger implements OnDestroy {
149179
return this._element.nativeElement.getBoundingClientRect().width;
150180
}
151181

182+
/** Reset active item to -1 so DOWN_ARROW event will activate the first option.*/
183+
private _resetActiveItem(): void {
184+
this._keyManager.setActiveItem(-1);
185+
}
186+
152187
}
153188

src/lib/autocomplete/autocomplete.spec.ts

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
2-
import {Component, OnDestroy, ViewChild} from '@angular/core';
2+
import {Component, OnDestroy, QueryList, ViewChild, ViewChildren} from '@angular/core';
33
import {By} from '@angular/platform-browser';
44
import {MdAutocompleteModule, MdAutocompleteTrigger} from './index';
55
import {OverlayContainer} from '../core/overlay/overlay-container';
66
import {MdInputModule} from '../input/index';
77
import {Dir, LayoutDirection} from '../core/rtl/dir';
88
import {FormControl, ReactiveFormsModule} from '@angular/forms';
99
import {Subscription} from 'rxjs/Subscription';
10+
import {ENTER, DOWN_ARROW, SPACE} from '../core/keyboard/keycodes';
11+
import {MdOption} from '../core/option/option';
1012

1113
describe('MdAutocomplete', () => {
1214
let overlayContainerElement: HTMLElement;
@@ -205,7 +207,7 @@ describe('MdAutocomplete', () => {
205207
fixture.detectChanges();
206208

207209
expect(input.value)
208-
.toContain('California', `Expected text field to be filled with selected value.`);
210+
.toContain('California', `Expected text field to fill with selected value.`);
209211
});
210212

211213
it('should mark the autocomplete control as dirty when an option is selected', () => {
@@ -236,6 +238,159 @@ describe('MdAutocomplete', () => {
236238

237239
});
238240

241+
describe('keyboard events', () => {
242+
let fixture: ComponentFixture<SimpleAutocomplete>;
243+
let input: HTMLInputElement;
244+
let DOWN_ARROW_EVENT: KeyboardEvent;
245+
let ENTER_EVENT: KeyboardEvent;
246+
247+
beforeEach(() => {
248+
fixture = TestBed.createComponent(SimpleAutocomplete);
249+
fixture.detectChanges();
250+
251+
input = fixture.debugElement.query(By.css('input')).nativeElement;
252+
DOWN_ARROW_EVENT = new FakeKeyboardEvent(DOWN_ARROW) as KeyboardEvent;
253+
ENTER_EVENT = new FakeKeyboardEvent(ENTER) as KeyboardEvent;
254+
});
255+
256+
it('should should not focus the option when DOWN key is pressed', () => {
257+
fixture.componentInstance.trigger.openPanel();
258+
fixture.detectChanges();
259+
260+
spyOn(fixture.componentInstance.options.first, 'focus');
261+
262+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
263+
expect(fixture.componentInstance.options.first.focus).not.toHaveBeenCalled();
264+
});
265+
266+
it('should set the active item to the first option when DOWN key is pressed', async(() => {
267+
fixture.componentInstance.trigger.openPanel();
268+
fixture.detectChanges();
269+
270+
const optionEls =
271+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
272+
273+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
274+
275+
fixture.whenStable().then(() => {
276+
fixture.detectChanges();
277+
expect(fixture.componentInstance.trigger.activeOption)
278+
.toBe(fixture.componentInstance.options.first, 'Expected first option to be active.');
279+
expect(optionEls[0].classList).toContain('md-active');
280+
expect(optionEls[1].classList).not.toContain('md-active');
281+
282+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
283+
284+
fixture.whenStable().then(() => {
285+
fixture.detectChanges();
286+
expect(fixture.componentInstance.trigger.activeOption)
287+
.toBe(fixture.componentInstance.options.toArray()[1],
288+
'Expected second option to be active.');
289+
expect(optionEls[0].classList).not.toContain('md-active');
290+
expect(optionEls[1].classList).toContain('md-active');
291+
});
292+
});
293+
}));
294+
295+
it('should set the active item properly after filtering', async(() => {
296+
fixture.componentInstance.trigger.openPanel();
297+
fixture.detectChanges();
298+
299+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
300+
301+
fixture.whenStable().then(() => {
302+
fixture.detectChanges();
303+
input.value = 'o';
304+
dispatchEvent('input', input);
305+
fixture.detectChanges();
306+
307+
const optionEls =
308+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
309+
310+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
311+
312+
fixture.whenStable().then(() => {
313+
fixture.detectChanges();
314+
expect(fixture.componentInstance.trigger.activeOption)
315+
.toBe(fixture.componentInstance.options.first, 'Expected first option to be active.');
316+
expect(optionEls[0].classList).toContain('md-active');
317+
expect(optionEls[1].classList).not.toContain('md-active');
318+
});
319+
});
320+
}));
321+
322+
it('should fill the text field when an option is selected with ENTER', () => {
323+
fixture.componentInstance.trigger.openPanel();
324+
fixture.detectChanges();
325+
326+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
327+
fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
328+
fixture.detectChanges();
329+
330+
expect(input.value)
331+
.toContain('Alabama', `Expected text field to fill with selected value on ENTER.`);
332+
});
333+
334+
it('should fill the text field, not select an option, when SPACE is entered', () => {
335+
fixture.componentInstance.trigger.openPanel();
336+
fixture.detectChanges();
337+
338+
input.value = 'New';
339+
dispatchEvent('input', input);
340+
fixture.detectChanges();
341+
342+
const SPACE_EVENT = new FakeKeyboardEvent(SPACE) as KeyboardEvent;
343+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
344+
fixture.componentInstance.trigger._handleKeydown(SPACE_EVENT);
345+
fixture.detectChanges();
346+
347+
expect(input.value)
348+
.not.toContain('New York', `Expected option not to be selected on SPACE.`);
349+
});
350+
351+
it('should mark the control as dirty when an option is selected from the keyboard', () => {
352+
fixture.componentInstance.trigger.openPanel();
353+
fixture.detectChanges();
354+
355+
expect(fixture.componentInstance.stateCtrl.dirty)
356+
.toBe(false, `Expected control to start out pristine.`);
357+
358+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
359+
fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
360+
fixture.detectChanges();
361+
362+
expect(fixture.componentInstance.stateCtrl.dirty)
363+
.toBe(true, `Expected control to become dirty when option was selected by ENTER.`);
364+
});
365+
366+
it('should open the panel again when typing after making a selection', async(() => {
367+
fixture.componentInstance.trigger.openPanel();
368+
fixture.detectChanges();
369+
370+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
371+
fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
372+
fixture.detectChanges();
373+
374+
fixture.whenStable().then(() => {
375+
expect(fixture.componentInstance.trigger.panelOpen)
376+
.toBe(false, `Expected panel state to read closed after ENTER key.`);
377+
expect(overlayContainerElement.textContent)
378+
.toEqual('', `Expected panel to close after ENTER key.`);
379+
380+
// 65 is the keycode for "a"
381+
const A_KEY = new FakeKeyboardEvent(65) as KeyboardEvent;
382+
fixture.componentInstance.trigger._handleKeydown(A_KEY);
383+
fixture.detectChanges();
384+
385+
expect(fixture.componentInstance.trigger.panelOpen)
386+
.toBe(true, `Expected panel state to read open when typing in input.`);
387+
expect(overlayContainerElement.textContent)
388+
.toContain('Alabama', `Expected panel to display when typing in input.`);
389+
});
390+
}));
391+
392+
});
393+
239394
});
240395

241396
@Component({
@@ -257,6 +412,7 @@ class SimpleAutocomplete implements OnDestroy {
257412
valueSub: Subscription;
258413

259414
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
415+
@ViewChildren(MdOption) options: QueryList<MdOption>;
260416

261417
states = [
262418
{code: 'AL', name: 'Alabama'},
@@ -301,4 +457,11 @@ function dispatchEvent(eventName: string, element: HTMLElement): void {
301457
element.dispatchEvent(event);
302458
}
303459

460+
/** This is a mock keyboard event to test keyboard events in the autocomplete. */
461+
class FakeKeyboardEvent {
462+
constructor(public keyCode: number) {}
463+
preventDefault() {}
464+
}
465+
466+
304467

src/lib/core/option/_option-theme.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
color: md-color($primary);
1717
}
1818

19+
&.md-active {
20+
background: md-color($background, hover);
21+
color: md-color($foreground, text);
22+
}
23+
1924
&.md-option-disabled {
2025
color: md-color($foreground, hint-text);
2126
}

src/lib/core/option/option.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export class MdOptionSelectEvent {
3636
'role': 'option',
3737
'[attr.tabindex]': '_getTabIndex()',
3838
'[class.md-selected]': 'selected',
39+
'[class.md-active]': 'active',
3940
'[id]': 'id',
4041
'[attr.aria-selected]': 'selected.toString()',
4142
'[attr.aria-disabled]': 'disabled.toString()',
@@ -48,6 +49,7 @@ export class MdOptionSelectEvent {
4849
})
4950
export class MdOption {
5051
private _selected: boolean = false;
52+
private _active: boolean = false;
5153

5254
/** Whether the option is disabled. */
5355
private _disabled: boolean = false;
@@ -75,6 +77,16 @@ export class MdOption {
7577
return this._selected;
7678
}
7779

80+
/**
81+
* Whether or not the option is currently active and ready to be selected.
82+
* An active option displays styles as if it is focused, but the
83+
* focus is actually retained somewhere else. This comes in handy
84+
* for components like autocomplete where focus must remain on the input.
85+
*/
86+
get active(): boolean {
87+
return this._active;
88+
}
89+
7890
/**
7991
* The displayed value of the option. It is necessary to show the selected option in the
8092
* select's trigger.
@@ -100,6 +112,24 @@ export class MdOption {
100112
this._renderer.invokeElementMethod(this._getHostElement(), 'focus');
101113
}
102114

115+
/**
116+
* This method sets display styles on the option to make it appear
117+
* active. This is used by the ActiveDescendantKeyManager so key
118+
* events will display the proper options as active on arrow key events.
119+
*/
120+
setActiveStyles() {
121+
Promise.resolve(null).then(() => this._active = true);
122+
}
123+
124+
/**
125+
* This method removes display styles on the option that made it appear
126+
* active. This is used by the ActiveDescendantKeyManager so key
127+
* events will display the proper options as active on arrow key events.
128+
*/
129+
setInactiveStyles() {
130+
Promise.resolve(null).then(() => this._active = false);
131+
}
132+
103133
/** Ensures the option is selected when activated from the keyboard. */
104134
_handleKeydown(event: KeyboardEvent): void {
105135
if (event.keyCode === ENTER || event.keyCode === SPACE) {

0 commit comments

Comments
 (0)