Skip to content

Commit

Permalink
feat(select): allow focusing items by typing
Browse files Browse the repository at this point in the history
Allows for users to skip to a select item by typing, similar to the native select.

Fixes angular#2668.
  • Loading branch information
crisbeto committed Mar 16, 2017
1 parent aa3360a commit a7ab227
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 33 deletions.
61 changes: 59 additions & 2 deletions src/lib/core/a11y/focus-key-manager.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@

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

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

/**
* Time in ms to wait after the last keypress to focus the active item.
*/
export const FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL = 200;


export class FocusKeyManager extends ListKeyManager<Focusable> {
private _timer: number;
private _pressedInputKeys: number[] = [];
private _hasLabelFn: boolean;

constructor(items: QueryList<Focusable>) {
super(items);
this._hasLabelFn = items && items.first && 'getFocusableLabel' in items.first;
}

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

/**
* Overrides the key event handling from the ListKeyManager, in order
* to add the ability to type to focus an item.
*/
onKeydown(event: KeyboardEvent): void {
let keyCode = event.keyCode;

if (this._hasLabelFn && keyCode >= INPUT_KEY_RANGE_START && keyCode <= INPUT_KEY_RANGE_END) {
this._debounceInputEvent(keyCode);
} else {
this._clearTimeout();
super.onKeydown(event);
}
}

/** Debounces the input key events and focuses the proper item after the last keystroke. */
private _debounceInputEvent(keyCode: number): void {
this._pressedInputKeys.push(keyCode);

this._clearTimeout();

this._timer = setTimeout(() => {
if (this._pressedInputKeys.length) {
let inputString = String.fromCharCode.apply(String, this._pressedInputKeys);
let items = this._items.toArray();

this._pressedInputKeys.length = 0;

for (let i = 0; i < items.length; i++) {
// Note that fromCharCode returns uppercase letters.
if (items[i].getFocusableLabel().toUpperCase().trim().startsWith(inputString)) {
this.setActiveItem(i);
break;
}
}
}
}, FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);
}

/** Clears the currently-running timeout. */
private _clearTimeout(): void {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
}
}
108 changes: 78 additions & 30 deletions src/lib/core/a11y/list-key-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import {QueryList} from '@angular/core';
import {fakeAsync, tick} from '@angular/core/testing';
import {FocusKeyManager} from './focus-key-manager';
import {FocusKeyManager, FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL} from './focus-key-manager';
import {DOWN_ARROW, UP_ARROW, TAB, HOME, END} from '../keyboard/keycodes';
import {ListKeyManager} from './list-key-manager';
import {ActiveDescendantKeyManager} from './activedescendant-key-manager';

class FakeFocusable {
disabled = false;
focus() {}

getFocusableLabel() {
return this._label;
}

constructor(private _label?: string) { }
}

class FakeHighlightable {
Expand All @@ -22,6 +28,7 @@ class FakeQueryList<T> extends QueryList<T> {
toArray() {
return this.items;
}
get first() { return this.items[0]; }
}

export class FakeEvent {
Expand Down Expand Up @@ -405,9 +412,9 @@ describe('Key managers', () => {

beforeEach(() => {
itemList.items = [
new FakeFocusable(),
new FakeFocusable(),
new FakeFocusable()
new FakeFocusable('one'),
new FakeFocusable('two'),
new FakeFocusable('three')
];

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

it('should focus subsequent items when down arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);
it('should focus subsequent items when down arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);

expect(itemList.items[0].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[2].focus).not.toHaveBeenCalled();
expect(itemList.items[0].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[2].focus).not.toHaveBeenCalled();

keyManager.onKeydown(DOWN_ARROW_EVENT);
expect(itemList.items[0].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
});
keyManager.onKeydown(DOWN_ARROW_EVENT);
expect(itemList.items[0].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
});

it('should focus previous items when up arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);
it('should focus previous items when up arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);

expect(itemList.items[0].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[0].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);

keyManager.onKeydown(UP_ARROW_EVENT);
keyManager.onKeydown(UP_ARROW_EVENT);

expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
});
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
});

it('should allow setting the focused item without calling focus', () => {
expect(keyManager.activeItemIndex)
.toBe(0, `Expected first item of the list to be active.`);
it('should allow setting the focused item without calling focus', () => {
expect(keyManager.activeItemIndex)
.toBe(0, `Expected first item of the list to be active.`);

keyManager.updateActiveItemIndex(1);
expect(keyManager.activeItemIndex)
.toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`);
expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1);
});
keyManager.updateActiveItemIndex(1);
expect(keyManager.activeItemIndex)
.toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`);
expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1);
});

it('should debounce the input key presses', fakeAsync(() => {
keyManager.onKeydown(new FakeEvent(79) as KeyboardEvent); // types "o"
keyManager.onKeydown(new FakeEvent(78) as KeyboardEvent); // types "n"
keyManager.onKeydown(new FakeEvent(69) as KeyboardEvent); // types "e"

expect(itemList.items[0].focus).not.toHaveBeenCalled();

tick(FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);

expect(itemList.items[0].focus).toHaveBeenCalled();
}));

it('should focus the first item that starts with a letter', fakeAsync(() => {
keyManager.onKeydown(new FakeEvent(84) as KeyboardEvent); // types "t"

tick(FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);

expect(itemList.items[1].focus).toHaveBeenCalled();
}));

it('should focus the first item that starts with sequence of letters', fakeAsync(() => {
keyManager.onKeydown(new FakeEvent(84) as KeyboardEvent); // types "t"
keyManager.onKeydown(new FakeEvent(72) as KeyboardEvent); // types "h"

tick(FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);

expect(itemList.items[1].focus).not.toHaveBeenCalled();
expect(itemList.items[2].focus).toHaveBeenCalled();
}));

it('should cancel any pending timers if a non-input key is pressed', fakeAsync(() => {
keyManager.onKeydown(new FakeEvent(84) as KeyboardEvent); // types "t"
keyManager.onKeydown(new FakeEvent(72) as KeyboardEvent); // types "h"
keyManager.onKeydown(DOWN_ARROW_EVENT);

tick(FOCUS_KEY_MANAGER_DEBOUNCE_INTERVAL);

expect(itemList.items[2].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalled();
}));

});

Expand Down
2 changes: 1 addition & 1 deletion src/lib/core/a11y/list-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class ListKeyManager<T extends CanDisable> {
private _tabOut: Subject<any> = new Subject();
private _wrap: boolean = false;

constructor(private _items: QueryList<T>) {
constructor(protected _items: QueryList<T>) {
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/lib/core/keyboard/keycodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ export const TAB = 9;
export const ESCAPE = 27;
export const BACKSPACE = 8;
export const DELETE = 46;

export const INPUT_KEY_RANGE_START = 65;
export const INPUT_KEY_RANGE_END = 90;
5 changes: 5 additions & 0 deletions src/lib/core/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ export class MdOption {
this._active = false;
}

/** Fetches the label to be used when determining whether the option should be focused. */
getFocusableLabel(): string {
return this.viewValue;
}

/** Ensures the option is selected when activated from the keyboard. */
_handleKeydown(event: KeyboardEvent): void {
if (event.keyCode === ENTER || event.keyCode === SPACE) {
Expand Down

0 comments on commit a7ab227

Please sign in to comment.