Skip to content

Commit 81c090f

Browse files
committed
feat(select): allow focusing items by typing
Allows for users to skip to a select item by typing, similar to the native select. Fixes #2668.
1 parent 8d900e0 commit 81c090f

File tree

6 files changed

+155
-35
lines changed

6 files changed

+155
-35
lines changed

src/lib/core/a11y/focus-key-manager.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
1-
21
import {QueryList} from '@angular/core';
2+
import {INPUT_KEY_RANGE_START, INPUT_KEY_RANGE_END} from '../core';
33
import {ListKeyManager, CanDisable} from './list-key-manager';
44

55
/**
66
* This is the interface for focusable items (used by the FocusKeyManager).
7-
* Each item must know how to focus itself and whether or not it is currently disabled.
7+
* Each item must know how to focus itself, whether or not it is currently disabled
8+
* and be able to supply it's label.
89
*/
910
export interface Focusable extends CanDisable {
1011
focus(): void;
12+
getFocusableLabel?(): string;
1113
}
1214

15+
/**
16+
* Time in ms to wait after the last keypress to focus the active item.
17+
*/
18+
export const FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL = 200;
19+
1320

1421
export class FocusKeyManager extends ListKeyManager<Focusable> {
22+
private _timer: number;
23+
private _pressedInputKeys: number[] = [];
24+
private _hasLabelFn: boolean;
1525

1626
constructor(items: QueryList<Focusable>) {
1727
super(items);
28+
this._hasLabelFn = items && items.first && 'getFocusableLabel' in items.first;
1829
}
1930

2031
/**
@@ -26,4 +37,50 @@ export class FocusKeyManager extends ListKeyManager<Focusable> {
2637
this.activeItem.focus();
2738
}
2839

40+
/**
41+
* Overrides the key event handling from the ListKeyManager, in order
42+
* to add the ability to type to focus an item.
43+
*/
44+
onKeydown(event: KeyboardEvent): void {
45+
let keyCode = event.keyCode;
46+
47+
if (this._hasLabelFn && keyCode >= INPUT_KEY_RANGE_START && keyCode <= INPUT_KEY_RANGE_END) {
48+
this._debounceInputEvent(keyCode);
49+
} else {
50+
this._clearTimeout();
51+
super.onKeydown(event);
52+
}
53+
}
54+
55+
/** Debounces the input key events and focuses the proper item after the last keystroke. */
56+
private _debounceInputEvent(keyCode: number): void {
57+
this._pressedInputKeys.push(keyCode);
58+
59+
this._clearTimeout();
60+
61+
this._timer = setTimeout(() => {
62+
if (this._pressedInputKeys.length) {
63+
let inputString = String.fromCharCode.apply(String, this._pressedInputKeys);
64+
let items = this._items.toArray();
65+
66+
this._pressedInputKeys.length = 0;
67+
68+
for (let i = 0; i < items.length; i++) {
69+
// Note that fromCharCode returns uppercase letters.
70+
if (items[i].getFocusableLabel().toUpperCase().trim().startsWith(inputString)) {
71+
this.setActiveItem(i);
72+
break;
73+
}
74+
}
75+
}
76+
}, FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);
77+
}
78+
79+
/** Clears the currently-running timeout. */
80+
private _clearTimeout(): void {
81+
if (this._timer) {
82+
clearTimeout(this._timer);
83+
this._timer = null;
84+
}
85+
}
2986
}

src/lib/core/a11y/list-key-manager.spec.ts

Lines changed: 79 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import {QueryList} from '@angular/core';
2-
import {FocusKeyManager} from './focus-key-manager';
2+
import {fakeAsync, tick} from '@angular/core/testing';
3+
import {FocusKeyManager, FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL} from './focus-key-manager';
34
import {DOWN_ARROW, UP_ARROW, TAB, HOME, END} from '../keyboard/keycodes';
45
import {ListKeyManager} from './list-key-manager';
56
import {ActiveDescendantKeyManager} from './activedescendant-key-manager';
67

78
class FakeFocusable {
89
disabled = false;
910
focus() {}
11+
12+
getFocusableLabel() {
13+
return this._label;
14+
}
15+
16+
constructor(private _label?: string) { }
1017
}
1118

1219
class FakeHighlightable {
@@ -21,6 +28,7 @@ class FakeQueryList<T> extends QueryList<T> {
2128
toArray() {
2229
return this.items;
2330
}
31+
get first() { return this.items[0]; }
2432
}
2533

2634
export class FakeEvent {
@@ -394,9 +402,9 @@ describe('Key managers', () => {
394402

395403
beforeEach(() => {
396404
itemList.items = [
397-
new FakeFocusable(),
398-
new FakeFocusable(),
399-
new FakeFocusable()
405+
new FakeFocusable('one'),
406+
new FakeFocusable('two'),
407+
new FakeFocusable('three')
400408
];
401409

402410
keyManager = new FocusKeyManager(itemList);
@@ -409,40 +417,81 @@ describe('Key managers', () => {
409417
spyOn(itemList.items[2], 'focus');
410418
});
411419

412-
it('should focus subsequent items when down arrow is pressed', () => {
413-
keyManager.onKeydown(DOWN_ARROW_EVENT);
420+
it('should focus subsequent items when down arrow is pressed', () => {
421+
keyManager.onKeydown(DOWN_ARROW_EVENT);
414422

415-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
416-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
417-
expect(itemList.items[2].focus).not.toHaveBeenCalled();
423+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
424+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
425+
expect(itemList.items[2].focus).not.toHaveBeenCalled();
418426

419-
keyManager.onKeydown(DOWN_ARROW_EVENT);
420-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
421-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
422-
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
423-
});
427+
keyManager.onKeydown(DOWN_ARROW_EVENT);
428+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
429+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
430+
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
431+
});
424432

425-
it('should focus previous items when up arrow is pressed', () => {
426-
keyManager.onKeydown(DOWN_ARROW_EVENT);
433+
it('should focus previous items when up arrow is pressed', () => {
434+
keyManager.onKeydown(DOWN_ARROW_EVENT);
427435

428-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
429-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
436+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
437+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
430438

431-
keyManager.onKeydown(UP_ARROW_EVENT);
439+
keyManager.onKeydown(UP_ARROW_EVENT);
432440

433-
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
434-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
435-
});
441+
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
442+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
443+
});
436444

437-
it('should allow setting the focused item without calling focus', () => {
438-
expect(keyManager.activeItemIndex)
439-
.toBe(0, `Expected first item of the list to be active.`);
445+
it('should allow setting the focused item without calling focus', () => {
446+
expect(keyManager.activeItemIndex)
447+
.toBe(0, `Expected first item of the list to be active.`);
440448

441-
keyManager.updateActiveItemIndex(1);
442-
expect(keyManager.activeItemIndex)
443-
.toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`);
444-
expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1);
445-
});
449+
keyManager.updateActiveItemIndex(1);
450+
expect(keyManager.activeItemIndex)
451+
.toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`);
452+
expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1);
453+
});
454+
455+
it('should debounce the input key presses', fakeAsync(() => {
456+
keyManager.onKeydown(new FakeEvent(79) as KeyboardEvent); // types "o"
457+
keyManager.onKeydown(new FakeEvent(78) as KeyboardEvent); // types "n"
458+
keyManager.onKeydown(new FakeEvent(69) as KeyboardEvent); // types "e"
459+
460+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
461+
462+
tick(FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);
463+
464+
expect(itemList.items[0].focus).toHaveBeenCalled();
465+
}));
466+
467+
it('should focus the first item that starts with a letter', fakeAsync(() => {
468+
keyManager.onKeydown(new FakeEvent(84) as KeyboardEvent); // types "t"
469+
470+
tick(FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);
471+
472+
expect(itemList.items[1].focus).toHaveBeenCalled();
473+
}));
474+
475+
it('should focus the first item that starts with sequence of letters', fakeAsync(() => {
476+
keyManager.onKeydown(new FakeEvent(84) as KeyboardEvent); // types "t"
477+
keyManager.onKeydown(new FakeEvent(72) as KeyboardEvent); // types "h"
478+
479+
tick(FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);
480+
481+
expect(itemList.items[1].focus).not.toHaveBeenCalled();
482+
expect(itemList.items[2].focus).toHaveBeenCalled();
483+
}));
484+
485+
it('should cancel any pending timers if a non-input key is pressed', fakeAsync(() => {
486+
keyManager.onKeydown(new FakeEvent(84) as KeyboardEvent); // types "t"
487+
keyManager.onKeydown(new FakeEvent(72) as KeyboardEvent); // types "h"
488+
keyManager.onKeydown(DOWN_ARROW_EVENT);
489+
490+
tick(FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);
491+
492+
expect(itemList.items[2].focus).not.toHaveBeenCalled();
493+
expect(itemList.items[1].focus).toHaveBeenCalled();
494+
}));
446495

447496
});
448497

src/lib/core/a11y/list-key-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export class ListKeyManager<T extends CanDisable> {
2121
private _tabOut: Subject<any> = new Subject();
2222
private _wrap: boolean = false;
2323

24-
constructor(private _items: QueryList<T>) {
24+
constructor(protected _items: QueryList<T>) {
2525
}
2626

2727
/**

src/lib/core/keyboard/keycodes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ export const TAB = 9;
2222
export const ESCAPE = 27;
2323
export const BACKSPACE = 8;
2424
export const DELETE = 46;
25+
26+
export const INPUT_KEY_RANGE_START = 65;
27+
export const INPUT_KEY_RANGE_END = 90;

src/lib/core/option/option.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ export class MdOption {
130130
Promise.resolve(null).then(() => this._active = false);
131131
}
132132

133+
/** Fetches the label to be used when determining whether the option should be focused. */
134+
getFocusableLabel(): string {
135+
return this.viewValue;
136+
}
137+
133138
/** Ensures the option is selected when activated from the keyboard. */
134139
_handleKeydown(event: KeyboardEvent): void {
135140
if (event.keyCode === ENTER || event.keyCode === SPACE) {

src/lib/select/select.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,14 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
255255

256256
ngOnDestroy() {
257257
this._dropSubscriptions();
258-
this._changeSubscription.unsubscribe();
259-
this._tabSubscription.unsubscribe();
258+
259+
if (this._changeSubscription) {
260+
this._changeSubscription.unsubscribe();
261+
}
262+
263+
if (this._tabSubscription) {
264+
this._tabSubscription.unsubscribe();
265+
}
260266
}
261267

262268
/** Toggles the overlay panel open or closed. */

0 commit comments

Comments
 (0)