Skip to content

Commit 4a18ddb

Browse files
committed
feat(autocomplete): add keyboard events to autocomplete
1 parent 8d0d22a commit 4a18ddb

File tree

5 files changed

+237
-28
lines changed

5 files changed

+237
-28
lines changed

src/lib/autocomplete/_autocomplete-theme.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
}
1111

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

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 'rxjs/add/operator/startWith';
1214
import 'rxjs/add/operator/switchMap';
@@ -17,21 +19,30 @@ export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6;
1719
@Directive({
1820
selector: 'input[mdAutocomplete], input[matAutocomplete]',
1921
host: {
20-
'(focus)': 'openPanel()'
22+
'(focus)': 'openPanel()',
23+
'(keydown)': '_handleKeydown($event)',
24+
'autocomplete': 'off'
2125
}
2226
})
23-
export class MdAutocompleteTrigger implements OnDestroy {
27+
export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
2428
private _overlayRef: OverlayRef;
2529
private _portal: TemplatePortal;
2630
private _panelOpen: boolean = false;
2731

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

3138
constructor(private _element: ElementRef, private _overlay: Overlay,
3239
private _viewContainerRef: ViewContainerRef,
3340
@Optional() private _controlDir: NgControl) {}
3441

42+
ngAfterContentInit() {
43+
this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options);
44+
}
45+
3546
ngOnDestroy() { this._destroyPanel(); }
3647

3748
/* Whether or not the autocomplete panel is open. */
@@ -67,15 +78,31 @@ export class MdAutocompleteTrigger implements OnDestroy {
6778
* when an option is selected and when the backdrop is clicked.
6879
*/
6980
get panelClosingActions(): Observable<any> {
70-
// TODO(kara): add tab event observable with keyboard event PR
71-
return Observable.merge(...this.optionSelections, this._overlayRef.backdropClick());
81+
return Observable.merge(
82+
...this.optionSelections,
83+
this._overlayRef.backdropClick(),
84+
this._keyManager.tabOut
85+
);
7286
}
7387

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

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

80107
/**
81108
* This method listens to a stream of panel closing actions and resets the
@@ -88,7 +115,10 @@ export class MdAutocompleteTrigger implements OnDestroy {
88115
.startWith(null)
89116
// create a new stream of panelClosingActions, replacing any previous streams
90117
// that were created, and flatten it so our stream only emits closing events...
91-
.switchMap(() => this.panelClosingActions)
118+
.switchMap(() => {
119+
this._resetActiveItem();
120+
return this.panelClosingActions;
121+
})
92122
// when the first closing event occurs...
93123
.first()
94124
// set the value, close the panel, and complete.
@@ -146,5 +176,10 @@ export class MdAutocompleteTrigger implements OnDestroy {
146176
return this._element.nativeElement.getBoundingClientRect().width;
147177
}
148178

179+
/** Reset active item to -1 so DOWN_ARROW event will activate the first option.*/
180+
private _resetActiveItem(): void {
181+
this._keyManager.setActiveItem(-1);
182+
}
183+
149184
}
150185

src/lib/autocomplete/autocomplete.spec.ts

Lines changed: 159 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
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 {FormControl, ReactiveFormsModule} from '@angular/forms';
88
import {Subscription} from 'rxjs/Subscription';
9+
import {ENTER, DOWN_ARROW, SPACE} from '../core/keyboard/keycodes';
10+
import {MdOption} from '../core/option/option';
911

1012
describe('MdAutocomplete', () => {
1113
let overlayContainerElement: HTMLElement;
14+
let fixture: ComponentFixture<SimpleAutocomplete>;
15+
let input: HTMLInputElement;
1216

1317
beforeEach(async(() => {
1418
TestBed.configureTestingModule({
@@ -33,17 +37,14 @@ describe('MdAutocomplete', () => {
3337
TestBed.compileComponents();
3438
}));
3539

36-
describe('panel toggling', () => {
37-
let fixture: ComponentFixture<SimpleAutocomplete>;
38-
let input: HTMLInputElement;
39-
40-
beforeEach(() => {
41-
fixture = TestBed.createComponent(SimpleAutocomplete);
42-
fixture.detectChanges();
40+
beforeEach(() => {
41+
fixture = TestBed.createComponent(SimpleAutocomplete);
42+
fixture.detectChanges();
4343

44-
input = fixture.debugElement.query(By.css('input')).nativeElement;
45-
});
44+
input = fixture.debugElement.query(By.css('input')).nativeElement;
45+
});
4646

47+
describe('panel toggling', () => {
4748
it('should open the panel when the input is focused', () => {
4849
expect(fixture.componentInstance.trigger.panelOpen).toBe(false);
4950
dispatchEvent('focus', input);
@@ -165,15 +166,6 @@ describe('MdAutocomplete', () => {
165166
});
166167

167168
describe('forms integration', () => {
168-
let fixture: ComponentFixture<SimpleAutocomplete>;
169-
let input: HTMLInputElement;
170-
171-
beforeEach(() => {
172-
fixture = TestBed.createComponent(SimpleAutocomplete);
173-
fixture.detectChanges();
174-
175-
input = fixture.debugElement.query(By.css('input')).nativeElement;
176-
});
177169

178170
it('should fill the text field when an option is selected', () => {
179171
fixture.componentInstance.trigger.openPanel();
@@ -185,7 +177,7 @@ describe('MdAutocomplete', () => {
185177
fixture.detectChanges();
186178

187179
expect(input.value)
188-
.toContain('California', `Expected text field to be filled with selected value.`);
180+
.toContain('California', `Expected text field to fill with selected value.`);
189181
});
190182

191183
it('should mark the autocomplete control as dirty when an option is selected', () => {
@@ -216,6 +208,145 @@ describe('MdAutocomplete', () => {
216208

217209
});
218210

211+
describe('keyboard events', () => {
212+
let DOWN_ARROW_EVENT: KeyboardEvent;
213+
let ENTER_EVENT: KeyboardEvent;
214+
215+
beforeEach(() => {
216+
DOWN_ARROW_EVENT = new FakeKeyboardEvent(DOWN_ARROW) as KeyboardEvent;
217+
ENTER_EVENT = new FakeKeyboardEvent(ENTER) as KeyboardEvent;
218+
});
219+
220+
it('should should not focus the option when DOWN key is pressed', () => {
221+
fixture.componentInstance.trigger.openPanel();
222+
fixture.detectChanges();
223+
224+
spyOn(fixture.componentInstance.options.first, 'focus');
225+
226+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
227+
expect(fixture.componentInstance.options.first.focus).not.toHaveBeenCalled();
228+
});
229+
230+
it('should set the active item to the first option when DOWN key is pressed', () => {
231+
fixture.componentInstance.trigger.openPanel();
232+
fixture.detectChanges();
233+
234+
const optionEls =
235+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
236+
237+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
238+
fixture.detectChanges();
239+
240+
expect(fixture.componentInstance.trigger.activeOption)
241+
.toBe(fixture.componentInstance.options.first, 'Expected first option to be active.');
242+
expect(optionEls[0].classList).toContain('md-active');
243+
expect(optionEls[1].classList).not.toContain('md-active');
244+
245+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
246+
fixture.detectChanges();
247+
248+
expect(fixture.componentInstance.trigger.activeOption)
249+
.toBe(fixture.componentInstance.options.toArray()[1],
250+
'Expected second option to be active.');
251+
expect(optionEls[0].classList).not.toContain('md-active');
252+
expect(optionEls[1].classList).toContain('md-active');
253+
});
254+
255+
it('should set the active item properly after filtering', () => {
256+
fixture.componentInstance.trigger.openPanel();
257+
fixture.detectChanges();
258+
259+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
260+
fixture.detectChanges();
261+
262+
input.value = 'o';
263+
dispatchEvent('input', input);
264+
fixture.detectChanges();
265+
266+
const optionEls =
267+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
268+
269+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
270+
fixture.detectChanges();
271+
272+
expect(fixture.componentInstance.trigger.activeOption)
273+
.toBe(fixture.componentInstance.options.first, 'Expected first option to be active.');
274+
expect(optionEls[0].classList).toContain('md-active');
275+
expect(optionEls[1].classList).not.toContain('md-active');
276+
});
277+
278+
it('should fill the text field when an option is selected with ENTER', () => {
279+
fixture.componentInstance.trigger.openPanel();
280+
fixture.detectChanges();
281+
282+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
283+
fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
284+
fixture.detectChanges();
285+
286+
expect(input.value)
287+
.toContain('Alabama', `Expected text field to fill with selected value on ENTER.`);
288+
});
289+
290+
it('should fill the text field, not select an option, when SPACE is entered', () => {
291+
fixture.componentInstance.trigger.openPanel();
292+
fixture.detectChanges();
293+
294+
input.value = 'New';
295+
dispatchEvent('input', input);
296+
fixture.detectChanges();
297+
298+
const SPACE_EVENT = new FakeKeyboardEvent(SPACE) as KeyboardEvent;
299+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
300+
fixture.componentInstance.trigger._handleKeydown(SPACE_EVENT);
301+
fixture.detectChanges();
302+
303+
expect(input.value)
304+
.not.toContain('New York', `Expected option not to be selected on SPACE.`);
305+
});
306+
307+
it('should mark the control as dirty when an option is selected from the keyboard', () => {
308+
fixture.componentInstance.trigger.openPanel();
309+
fixture.detectChanges();
310+
311+
expect(fixture.componentInstance.stateCtrl.dirty)
312+
.toBe(false, `Expected control to start out pristine.`);
313+
314+
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
315+
fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
316+
fixture.detectChanges();
317+
318+
expect(fixture.componentInstance.stateCtrl.dirty)
319+
.toBe(true, `Expected control to become dirty when option was selected by ENTER.`);
320+
});
321+
322+
it('should open the panel again when typing after making a selection', async(() => {
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+
fixture.whenStable().then(() => {
331+
expect(fixture.componentInstance.trigger.panelOpen)
332+
.toBe(false, `Expected panel state to read closed after ENTER key.`);
333+
expect(overlayContainerElement.textContent)
334+
.toEqual('', `Expected panel to close after ENTER key.`);
335+
336+
// 65 is the keycode for "a"
337+
const A_KEY = new FakeKeyboardEvent(65) as KeyboardEvent;
338+
fixture.componentInstance.trigger._handleKeydown(A_KEY);
339+
fixture.detectChanges();
340+
341+
expect(fixture.componentInstance.trigger.panelOpen)
342+
.toBe(true, `Expected panel state to read open when typing in input.`);
343+
expect(overlayContainerElement.textContent)
344+
.toContain('Alabama', `Expected panel to display when typing in input.`);
345+
});
346+
}));
347+
348+
});
349+
219350
});
220351

221352
@Component({
@@ -237,6 +368,7 @@ class SimpleAutocomplete implements OnDestroy {
237368
valueSub: Subscription;
238369

239370
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
371+
@ViewChildren(MdOption) options: QueryList<MdOption>;
240372

241373
states = [
242374
{code: 'AL', name: 'Alabama'},
@@ -281,4 +413,11 @@ function dispatchEvent(eventName: string, element: HTMLElement): void {
281413
element.dispatchEvent(event);
282414
}
283415

416+
/** This is a mock keyboard event to test keyboard events in the autocomplete. */
417+
class FakeKeyboardEvent {
418+
constructor(public keyCode: number) {}
419+
preventDefault() {}
420+
}
421+
422+
284423

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
}

0 commit comments

Comments
 (0)