Skip to content

Commit 425223a

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 f73cc97 commit 425223a

File tree

5 files changed

+151
-30
lines changed

5 files changed

+151
-30
lines changed

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,33 @@
77
*/
88

99
import {QueryList} from '@angular/core';
10+
import {INPUT_KEY_RANGE_START, INPUT_KEY_RANGE_END} from '../core';
1011
import {ListKeyManager, CanDisable} from './list-key-manager';
1112

1213
/**
1314
* This is the interface for focusable items (used by the FocusKeyManager).
14-
* Each item must know how to focus itself and whether or not it is currently disabled.
15+
* Each item must know how to focus itself, whether or not it is currently disabled
16+
* and be able to supply it's label.
1517
*/
1618
export interface Focusable extends CanDisable {
1719
focus(): void;
20+
getFocusableLabel?(): string;
1821
}
1922

23+
/**
24+
* Time in ms to wait after the last keypress to focus the active item.
25+
*/
26+
export const FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL = 200;
27+
2028

2129
export class FocusKeyManager extends ListKeyManager<Focusable> {
30+
private _timer: number | null;
31+
private _pressedInputKeys: number[] = [];
32+
private _hasLabelFn: boolean;
2233

2334
constructor(items: QueryList<Focusable>) {
2435
super(items);
36+
this._hasLabelFn = items && items.first && 'getFocusableLabel' in items.first;
2537
}
2638

2739
/**
@@ -36,4 +48,52 @@ export class FocusKeyManager extends ListKeyManager<Focusable> {
3648
}
3749
}
3850

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

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

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
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} from '../keyboard/keycodes';
55
import {ListKeyManager} from './list-key-manager';
66
import {ActiveDescendantKeyManager} from './activedescendant-key-manager';
@@ -11,6 +11,12 @@ import {first} from '../rxjs/index';
1111
class FakeFocusable {
1212
disabled = false;
1313
focus() {}
14+
15+
getFocusableLabel() {
16+
return this._label;
17+
}
18+
19+
constructor(private _label?: string) { }
1420
}
1521

1622
class FakeHighlightable {
@@ -25,6 +31,7 @@ class FakeQueryList<T> extends QueryList<T> {
2531
toArray() {
2632
return this.items;
2733
}
34+
get first() { return this.items[0]; }
2835
}
2936

3037

@@ -389,7 +396,12 @@ describe('Key managers', () => {
389396
let keyManager: FocusKeyManager;
390397

391398
beforeEach(() => {
392-
itemList.items = [new FakeFocusable(), new FakeFocusable(), new FakeFocusable()];
399+
itemList.items = [
400+
new FakeFocusable('one'),
401+
new FakeFocusable('two'),
402+
new FakeFocusable('three')
403+
];
404+
393405
keyManager = new FocusKeyManager(itemList);
394406

395407
// first item is already focused
@@ -400,40 +412,81 @@ describe('Key managers', () => {
400412
spyOn(itemList.items[2], 'focus');
401413
});
402414

403-
it('should focus subsequent items when down arrow is pressed', () => {
404-
keyManager.onKeydown(fakeKeyEvents.downArrow);
415+
it('should focus subsequent items when down arrow is pressed', () => {
416+
keyManager.onKeydown(fakeKeyEvents.downArrow);
405417

406-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
407-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
408-
expect(itemList.items[2].focus).not.toHaveBeenCalled();
418+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
419+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
420+
expect(itemList.items[2].focus).not.toHaveBeenCalled();
409421

410-
keyManager.onKeydown(fakeKeyEvents.downArrow);
411-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
412-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
413-
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
414-
});
422+
keyManager.onKeydown(fakeKeyEvents.downArrow);
423+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
424+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
425+
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
426+
});
415427

416-
it('should focus previous items when up arrow is pressed', () => {
417-
keyManager.onKeydown(fakeKeyEvents.downArrow);
428+
it('should focus previous items when up arrow is pressed', () => {
429+
keyManager.onKeydown(fakeKeyEvents.downArrow);
418430

419-
expect(itemList.items[0].focus).not.toHaveBeenCalled();
420-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
431+
expect(itemList.items[0].focus).not.toHaveBeenCalled();
432+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
421433

422-
keyManager.onKeydown(fakeKeyEvents.upArrow);
434+
keyManager.onKeydown(fakeKeyEvents.upArrow);
423435

424-
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
425-
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
426-
});
436+
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
437+
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
438+
});
427439

428-
it('should allow setting the focused item without calling focus', () => {
429-
expect(keyManager.activeItemIndex)
430-
.toBe(0, `Expected first item of the list to be active.`);
440+
it('should allow setting the focused item without calling focus', () => {
441+
expect(keyManager.activeItemIndex)
442+
.toBe(0, `Expected first item of the list to be active.`);
431443

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

438491
});
439492

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export class ListKeyManager<T extends CanDisable> {
2929
private _tabOut = new Subject<null>();
3030
private _wrap: boolean = false;
3131

32-
constructor(private _items: QueryList<T>) { }
32+
constructor(protected _items: QueryList<T>) { }
3333

3434
/**
3535
* Turns on wrapping mode, which ensures that the active item will wrap to

src/lib/core/keyboard/keycodes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ export const TAB = 9;
2929
export const ESCAPE = 27;
3030
export const BACKSPACE = 8;
3131
export const DELETE = 46;
32+
33+
export const INPUT_KEY_RANGE_START = 65;
34+
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
@@ -145,6 +145,11 @@ export class MdOption {
145145
this._active = false;
146146
}
147147

148+
/** Fetches the label to be used when determining whether the option should be focused. */
149+
getFocusableLabel(): string {
150+
return this.viewValue;
151+
}
152+
148153
/** Ensures the option is selected when activated from the keyboard. */
149154
_handleKeydown(event: KeyboardEvent): void {
150155
if (event.keyCode === ENTER || event.keyCode === SPACE) {

0 commit comments

Comments
 (0)