feat(select): allow focusing items by typing (#2907)

* 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.

* refactor: address feedback

* chore: linting errors
This commit is contained in:
Kristiyan Kostadinov
2017-08-08 22:21:03 +02:00
committed by mmalerba
parent 504b5df88d
commit 5ebca5ee81
13 changed files with 191 additions and 95 deletions
+2 -2
View File
@@ -6,14 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ListKeyManager, CanDisable} from './list-key-manager';
import {ListKeyManager, ListKeyManagerOption} from './list-key-manager';
/**
* This is the interface for highlightable items (used by the ActiveDescendantKeyManager).
* Each item must know how to style itself as active or inactive and whether or not it is
* currently disabled.
*/
export interface Highlightable extends CanDisable {
export interface Highlightable extends ListKeyManagerOption {
setActiveStyles(): void;
setInactiveStyles(): void;
}
+5 -12
View File
@@ -6,24 +6,18 @@
* found in the LICENSE file at https://angular.io/license
*/
import {QueryList} from '@angular/core';
import {ListKeyManager, CanDisable} from './list-key-manager';
import {ListKeyManager, ListKeyManagerOption} 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 {
export interface FocusableOption extends ListKeyManagerOption {
focus(): void;
}
export class FocusKeyManager extends ListKeyManager<Focusable> {
constructor(items: QueryList<Focusable>) {
super(items);
}
export class FocusKeyManager extends ListKeyManager<FocusableOption> {
/**
* This method sets the active item to the item at the specified index.
* It also adds focuses the newly active item.
@@ -35,5 +29,4 @@ export class FocusKeyManager extends ListKeyManager<Focusable> {
this.activeItem.focus();
}
}
}
+97 -32
View File
@@ -9,8 +9,10 @@ import {first} from '../rxjs/index';
class FakeFocusable {
constructor(private _label = '') { }
disabled = false;
focus() {}
getLabel() { return this._label; }
}
class FakeHighlightable {
@@ -20,11 +22,11 @@ class FakeHighlightable {
}
class FakeQueryList<T> extends QueryList<T> {
get length() { return this.items.length; }
items: T[];
toArray() {
return this.items;
}
get length() { return this.items.length; }
get first() { return this.items[0]; }
toArray() { return this.items; }
some() { return this.items.some.apply(this.items, arguments); }
}
@@ -43,7 +45,7 @@ describe('Key managers', () => {
downArrow: createKeyboardEvent('keydown', DOWN_ARROW),
upArrow: createKeyboardEvent('keydown', UP_ARROW),
tab: createKeyboardEvent('keydown', TAB),
unsupported: createKeyboardEvent('keydown', 65) // corresponds to the letter "a"
unsupported: createKeyboardEvent('keydown', 192) // corresponds to the tilde character (~)
};
});
@@ -52,7 +54,11 @@ describe('Key managers', () => {
let keyManager: ListKeyManager<FakeFocusable>;
beforeEach(() => {
itemList.items = [new FakeFocusable(), new FakeFocusable(), new FakeFocusable()];
itemList.items = [
new FakeFocusable('one'),
new FakeFocusable('two'),
new FakeFocusable('three')
];
keyManager = new ListKeyManager<FakeFocusable>(itemList);
// first item is already focused
@@ -383,6 +389,65 @@ describe('Key managers', () => {
});
describe('typeahead mode', () => {
const debounceInterval = 300;
beforeEach(() => {
keyManager.withTypeAhead(debounceInterval);
keyManager.setActiveItem(-1);
});
it('should throw if the items do not implement the getLabel method', () => {
const invalidQueryList = new FakeQueryList();
invalidQueryList.items = [{ disabled: false }];
const invalidManager = new ListKeyManager(invalidQueryList);
expect(() => invalidManager.withTypeAhead()).toThrowError(/must implement/);
});
it('should debounce the input key presses', fakeAsync(() => {
keyManager.onKeydown(createKeyboardEvent('keydown', 79)); // types "o"
keyManager.onKeydown(createKeyboardEvent('keydown', 78)); // types "n"
keyManager.onKeydown(createKeyboardEvent('keydown', 69)); // types "e"
expect(keyManager.activeItem).not.toBe(itemList.items[0]);
tick(debounceInterval);
expect(keyManager.activeItem).toBe(itemList.items[0]);
}));
it('should focus the first item that starts with a letter', fakeAsync(() => {
keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t"
tick(debounceInterval);
expect(keyManager.activeItem).toBe(itemList.items[1]);
}));
it('should focus the first item that starts with sequence of letters', fakeAsync(() => {
keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t"
keyManager.onKeydown(createKeyboardEvent('keydown', 72)); // types "h"
tick(debounceInterval);
expect(keyManager.activeItem).toBe(itemList.items[2]);
}));
it('should cancel any pending timers if a navigation key is pressed', fakeAsync(() => {
keyManager.onKeydown(createKeyboardEvent('keydown', 84)); // types "t"
keyManager.onKeydown(createKeyboardEvent('keydown', 72)); // types "h"
keyManager.onKeydown(fakeKeyEvents.downArrow);
tick(debounceInterval);
expect(keyManager.activeItem).toBe(itemList.items[0]);
}));
});
});
describe('FocusKeyManager', () => {
@@ -400,40 +465,40 @@ describe('Key managers', () => {
spyOn(itemList.items[2], 'focus');
});
it('should focus subsequent items when down arrow is pressed', () => {
keyManager.onKeydown(fakeKeyEvents.downArrow);
it('should focus subsequent items when down arrow is pressed', () => {
keyManager.onKeydown(fakeKeyEvents.downArrow);
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(fakeKeyEvents.downArrow);
expect(itemList.items[0].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1);
});
keyManager.onKeydown(fakeKeyEvents.downArrow);
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(fakeKeyEvents.downArrow);
it('should focus previous items when up arrow is pressed', () => {
keyManager.onKeydown(fakeKeyEvents.downArrow);
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(fakeKeyEvents.upArrow);
keyManager.onKeydown(fakeKeyEvents.upArrow);
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);
});
});
+60 -25
View File
@@ -9,42 +9,83 @@
import {QueryList} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import {UP_ARROW, DOWN_ARROW, TAB} from '@angular/cdk/keyboard';
import {Subscription} from 'rxjs/Subscription';
import {UP_ARROW, DOWN_ARROW, TAB, A, Z} from '@angular/cdk/keyboard';
import {RxChain, debounceTime, filter, map, doOperator} from '@angular/cdk/rxjs';
/**
* This interface is for items that can be disabled. The type passed into
* ListKeyManager must extend this interface.
* This interface is for items that can be passed to a ListKeyManager.
*/
export interface CanDisable {
export interface ListKeyManagerOption {
disabled?: boolean;
getLabel?(): string;
}
/**
* This class manages keyboard events for selectable lists. If you pass it a query list
* of items, it will set the active item correctly when arrow events occur.
*/
export class ListKeyManager<T extends CanDisable> {
private _activeItemIndex: number = -1;
export class ListKeyManager<T extends ListKeyManagerOption> {
private _activeItemIndex = -1;
private _activeItem: T;
private _tabOut = new Subject<void>();
private _wrap: boolean = false;
private _wrap = false;
private _nonNavigationKeyStream = new Subject<number>();
private _typeaheadSubscription: Subscription;
// Buffer for the letters that the user has pressed when the typeahead option is turned on.
private _pressedInputKeys: number[] = [];
constructor(private _items: QueryList<T>) { }
/**
* Turns on wrapping mode, which ensures that the active item will wrap to
* the other end of list when there are no more items in the given direction.
*
* @returns The ListKeyManager that the method was called on.
*/
withWrap(): this {
this._wrap = true;
return this;
}
/**
* Turns on typeahead mode which allows users to set the active item by typing.
* @param debounceInterval Time to wait after the last keystroke before setting the active item.
*/
withTypeAhead(debounceInterval = 200): this {
if (this._items.length && this._items.some(item => typeof item.getLabel !== 'function')) {
throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
}
if (this._typeaheadSubscription) {
this._typeaheadSubscription.unsubscribe();
}
// Debounce the presses of non-navigational keys, collect the ones that correspond to letters
// and convert those letters back into a string. Afterwards find the first item that starts
// with that string and select it.
this._typeaheadSubscription = RxChain.from(this._nonNavigationKeyStream)
.call(filter, keyCode => keyCode >= A && keyCode <= Z)
.call(doOperator, keyCode => this._pressedInputKeys.push(keyCode))
.call(debounceTime, debounceInterval)
.call(filter, () => this._pressedInputKeys.length > 0)
.call(map, () => String.fromCharCode(...this._pressedInputKeys))
.subscribe(inputString => {
const items = this._items.toArray();
for (let i = 0; i < items.length; i++) {
if (items[i].getLabel!().toUpperCase().trim().indexOf(inputString) === 0) {
this.setActiveItem(i);
break;
}
}
this._pressedInputKeys = [];
});
return this;
}
/**
* Sets the active item to the item at the index specified.
*
* @param index The index of the item to be set as active.
*/
setActiveItem(index: number): void {
@@ -58,20 +99,15 @@ export class ListKeyManager<T extends CanDisable> {
*/
onKeydown(event: KeyboardEvent): void {
switch (event.keyCode) {
case DOWN_ARROW:
this.setNextItemActive();
break;
case UP_ARROW:
this.setPreviousItemActive();
break;
case TAB:
// Note that we shouldn't prevent the default action on tab.
this._tabOut.next();
return;
default:
return;
case DOWN_ARROW: this.setNextItemActive(); break;
case UP_ARROW: this.setPreviousItemActive(); break;
// Note that we return here, in order to avoid preventing
// the default action of unsupported keys.
default: this._nonNavigationKeyStream.next(event.keyCode); return;
}
this._pressedInputKeys = [];
event.preventDefault();
}
@@ -119,7 +155,7 @@ export class ListKeyManager<T extends CanDisable> {
* when focus is shifted off of the list.
*/
get tabOut(): Observable<void> {
return this._tabOut.asObservable();
return filter.call(this._nonNavigationKeyStream, keyCode => keyCode === TAB);
}
/**
@@ -173,5 +209,4 @@ export class ListKeyManager<T extends CanDisable> {
}
this.setActiveItem(index);
}
}
+2
View File
@@ -20,3 +20,5 @@ export const TAB = 9;
export const ESCAPE = 27;
export const BACKSPACE = 8;
export const DELETE = 46;
export const A = 65;
export const Z = 90;
+3 -2
View File
@@ -18,7 +18,7 @@ import {
forwardRef,
} from '@angular/core';
import {Focusable} from '../core/a11y/focus-key-manager';
import {FocusableOption} from '../core/a11y/focus-key-manager';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {CanColor, mixinColor} from '../core/common-behaviors/color';
import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled';
@@ -68,7 +68,8 @@ export class MdBasicChip { }
'(blur)': '_hasFocus = false',
}
})
export class MdChip extends _MdChipMixinBase implements Focusable, OnDestroy, CanColor, CanDisable {
export class MdChip extends _MdChipMixinBase implements FocusableOption, OnDestroy, CanColor,
CanDisable {
@ContentChild(forwardRef(() => MdChipRemove)) _chipRemove: MdChipRemove;
@@ -6,14 +6,14 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ListKeyManager, CanDisable} from './list-key-manager';
import {ListKeyManager, ListKeyManagerOption} from './list-key-manager';
/**
* This is the interface for highlightable items (used by the ActiveDescendantKeyManager).
* Each item must know how to style itself as active or inactive and whether or not it is
* currently disabled.
*/
export interface Highlightable extends CanDisable {
export interface Highlightable extends ListKeyManagerOption {
setActiveStyles(): void;
setInactiveStyles(): void;
}
+5 -12
View File
@@ -6,24 +6,18 @@
* found in the LICENSE file at https://angular.io/license
*/
import {QueryList} from '@angular/core';
import {ListKeyManager, CanDisable} from './list-key-manager';
import {ListKeyManager, ListKeyManagerOption} 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 {
export interface FocusableOption extends ListKeyManagerOption {
focus(): void;
}
export class FocusKeyManager extends ListKeyManager<Focusable> {
constructor(items: QueryList<Focusable>) {
super(items);
}
export class FocusKeyManager extends ListKeyManager<FocusableOption> {
/**
* This method sets the active item to the item at the specified index.
* It also adds focuses the newly active item.
@@ -35,5 +29,4 @@ export class FocusKeyManager extends ListKeyManager<Focusable> {
this.activeItem.focus();
}
}
}
+1 -3
View File
@@ -6,6 +6,4 @@
* found in the LICENSE file at https://angular.io/license
*/
export {CanDisable, ListKeyManager} from '@angular/cdk/a11y';
export {ListKeyManagerOption, ListKeyManager} from '@angular/cdk/a11y';
+3 -1
View File
@@ -21,5 +21,7 @@ export {
TAB,
ESCAPE,
BACKSPACE,
DELETE
DELETE,
A,
Z,
} from '@angular/cdk/keyboard';
+6 -1
View File
@@ -174,6 +174,11 @@ export class MdOption {
}
}
/** Gets the label to be used when determining whether the option should be focused. */
getLabel(): 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) {
@@ -201,7 +206,7 @@ export class MdOption {
return this.disabled ? '-1' : '0';
}
/** Fetches the host DOM element. */
/** Gets the host DOM element. */
_getHostElement(): HTMLElement {
return this._element.nativeElement;
}
+4 -2
View File
@@ -7,7 +7,7 @@
*/
import {Component, ElementRef, OnDestroy, ChangeDetectionStrategy} from '@angular/core';
import {Focusable} from '../core/a11y/focus-key-manager';
import {FocusableOption} from '../core/a11y/focus-key-manager';
import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled';
import {Subject} from 'rxjs/Subject';
@@ -39,7 +39,9 @@ export const _MdMenuItemMixinBase = mixinDisabled(MdMenuItemBase);
templateUrl: 'menu-item.html',
exportAs: 'mdMenuItem',
})
export class MdMenuItem extends _MdMenuItemMixinBase implements Focusable, CanDisable, OnDestroy {
export class MdMenuItem extends _MdMenuItemMixinBase implements FocusableOption, CanDisable,
OnDestroy {
/** Stream that emits when the menu item is hovered. */
hover: Subject<MdMenuItem> = new Subject();
+1 -1
View File
@@ -719,7 +719,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
private _initKeyManager() {
this._keyManager = new FocusKeyManager(this.options);
this._keyManager = new FocusKeyManager(this.options).withTypeAhead();
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.close());
}