Skip to content

Commit c5a9148

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

File tree

5 files changed

+245
-28
lines changed

5 files changed

+245
-28
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: 167 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
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;
1315
let dir: LayoutDirection;
16+
let fixture: ComponentFixture<SimpleAutocomplete>;
17+
let input: HTMLInputElement;
1418

1519
beforeEach(async(() => {
1620
dir = 'ltr';
@@ -39,17 +43,14 @@ describe('MdAutocomplete', () => {
3943
TestBed.compileComponents();
4044
}));
4145

42-
describe('panel toggling', () => {
43-
let fixture: ComponentFixture<SimpleAutocomplete>;
44-
let input: HTMLInputElement;
45-
46-
beforeEach(() => {
47-
fixture = TestBed.createComponent(SimpleAutocomplete);
48-
fixture.detectChanges();
46+
beforeEach(() => {
47+
fixture = TestBed.createComponent(SimpleAutocomplete);
48+
fixture.detectChanges();
4949

50-
input = fixture.debugElement.query(By.css('input')).nativeElement;
51-
});
50+
input = fixture.debugElement.query(By.css('input')).nativeElement;
51+
});
5252

53+
describe('panel toggling', () => {
5354
it('should open the panel when the input is focused', () => {
5455
expect(fixture.componentInstance.trigger.panelOpen).toBe(false);
5556
dispatchEvent('focus', input);
@@ -185,15 +186,6 @@ describe('MdAutocomplete', () => {
185186
});
186187

187188
describe('forms integration', () => {
188-
let fixture: ComponentFixture<SimpleAutocomplete>;
189-
let input: HTMLInputElement;
190-
191-
beforeEach(() => {
192-
fixture = TestBed.createComponent(SimpleAutocomplete);
193-
fixture.detectChanges();
194-
195-
input = fixture.debugElement.query(By.css('input')).nativeElement;
196-
});
197189

198190
it('should fill the text field when an option is selected', () => {
199191
fixture.componentInstance.trigger.openPanel();
@@ -205,7 +197,7 @@ describe('MdAutocomplete', () => {
205197
fixture.detectChanges();
206198

207199
expect(input.value)
208-
.toContain('California', `Expected text field to be filled with selected value.`);
200+
.toContain('California', `Expected text field to fill with selected value.`);
209201
});
210202

211203
it('should mark the autocomplete control as dirty when an option is selected', () => {
@@ -236,6 +228,153 @@ describe('MdAutocomplete', () => {
236228

237229
});
238230

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

241380
@Component({
@@ -257,6 +396,7 @@ class SimpleAutocomplete implements OnDestroy {
257396
valueSub: Subscription;
258397

259398
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
399+
@ViewChildren(MdOption) options: QueryList<MdOption>;
260400

261401
states = [
262402
{code: 'AL', name: 'Alabama'},
@@ -301,4 +441,11 @@ function dispatchEvent(eventName: string, element: HTMLElement): void {
301441
element.dispatchEvent(event);
302442
}
303443

444+
/** This is a mock keyboard event to test keyboard events in the autocomplete. */
445+
class FakeKeyboardEvent {
446+
constructor(public keyCode: number) {}
447+
preventDefault() {}
448+
}
449+
450+
304451

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)