Skip to content

Commit a7ab227

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 aa3360a commit a7ab227

File tree

5 files changed

+146
-33
lines changed

5 files changed

+146
-33
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: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import {QueryList} from '@angular/core';
22
import {fakeAsync, tick} from '@angular/core/testing';
3-
import {FocusKeyManager} from './focus-key-manager';
3+
import {FocusKeyManager, FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL} from './focus-key-manager';
44
import {DOWN_ARROW, UP_ARROW, TAB, HOME, END} from '../keyboard/keycodes';
55
import {ListKeyManager} from './list-key-manager';
66
import {ActiveDescendantKeyManager} from './activedescendant-key-manager';
77

88
class FakeFocusable {
99
disabled = false;
1010
focus() {}
11+
12+
getFocusableLabel() {
13+
return this._label;
14+
}
15+
16+
constructor(private _label?: string) { }
1117
}
1218

1319
class FakeHighlightable {
@@ -22,6 +28,7 @@ class FakeQueryList<T> extends QueryList<T> {
2228
toArray() {
2329
return this.items;
2430
}
31+
get first() { return this.items[0]; }
2532
}
2633

2734
export class FakeEvent {
@@ -405,9 +412,9 @@ describe('Key managers', () => {
405412

406413
beforeEach(() => {
407414
itemList.items = [
408-
new FakeFocusable(),
409-
new FakeFocusable(),
410-
new FakeFocusable()
415+
new FakeFocusable('one'),
416+
new FakeFocusable('two'),
417+
new FakeFocusable('three')
411418
];
412419

413420
keyManager = new FocusKeyManager(itemList);
@@ -420,40 +427,81 @@ describe('Key managers', () => {
420427
spyOn(itemList.items[2], 'focus');
421428
});
422429

423-
it('should focus subsequent items when down arrow is pressed', () => {
424-
keyManager.onKeydown(DOWN_ARROW_EVENT);
430+
it('should focus subsequent items when down arrow is pressed', () => {
431+
keyManager.onKeydown(DOWN_ARROW_EVENT);
425432

426-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
427-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
428-
expect(itemList.items[2].focus).not.toHaveBeenCalled();
433+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
434+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
435+
expect(itemList.items[2].focus).not.toHaveBeenCalled();
429436

430-
keyManager.onKeydown(DOWN_ARROW_EVENT);
431-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
432-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
433-
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
434-
});
437+
keyManager.onKeydown(DOWN_ARROW_EVENT);
438+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
439+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
440+
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
441+
});
435442

436-
it('should focus previous items when up arrow is pressed', () => {
437-
keyManager.onKeydown(DOWN_ARROW_EVENT);
443+
it('should focus previous items when up arrow is pressed', () => {
444+
keyManager.onKeydown(DOWN_ARROW_EVENT);
438445

439-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
440-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
446+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
447+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
441448

442-
keyManager.onKeydown(UP_ARROW_EVENT);
449+
keyManager.onKeydown(UP_ARROW_EVENT);
443450

444-
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
445-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
446-
});
451+
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
452+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
453+
});
447454

448-
it('should allow setting the focused item without calling focus', () => {
449-
expect(keyManager.activeItemIndex)
450-
.toBe(0, `Expected first item of the list to be active.`);
455+
it('should allow setting the focused item without calling focus', () => {
456+
expect(keyManager.activeItemIndex)
457+
.toBe(0, `Expected first item of the list to be active.`);
451458

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

458506
});
459507

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
@@ -141,6 +141,11 @@ export class MdOption {
141141
this._active = false;
142142
}
143143

144+
/** Fetches the label to be used when determining whether the option should be focused. */
145+
getFocusableLabel(): string {
146+
return this.viewValue;
147+
}
148+
144149
/** Ensures the option is selected when activated from the keyboard. */
145150
_handleKeydown(event: KeyboardEvent): void {
146151
if (event.keyCode === ENTER || event.keyCode === SPACE) {

0 commit comments

Comments
 (0)