mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
mmalerba
parent
504b5df88d
commit
5ebca5ee81
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -21,5 +21,7 @@ export {
|
||||
TAB,
|
||||
ESCAPE,
|
||||
BACKSPACE,
|
||||
DELETE
|
||||
DELETE,
|
||||
A,
|
||||
Z,
|
||||
} from '@angular/cdk/keyboard';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user