Skip to content

Commit

Permalink
refactor(cdk/a11y): factor out a typeahead class for key managers (#2…
Browse files Browse the repository at this point in the history
…8142)

factor out a typeahead class for key managers
  • Loading branch information
BobobUnicorn authored Dec 6, 2023
1 parent 5356d69 commit 4bc94e0
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 128 deletions.
16 changes: 16 additions & 0 deletions src/cdk/a11y/key-manager/list-key-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ describe('Key managers', () => {

keyManager.setActiveItem(0);
itemList.reset([new FakeFocusable('zero'), ...itemList.toArray()]);
itemList.notifyOnChanges();
keyManager.setActiveItem(0);

expect(spy).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -342,6 +343,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

// Next event should skip past disabled item from 0 to 2
keyManager.onKeydown(this.nextKeyEvent);
Expand All @@ -367,6 +369,7 @@ describe('Key managers', () => {
items[1].disabled = undefined;
items[2].disabled = undefined;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(this.nextKeyEvent);
expect(keyManager.activeItemIndex)
Expand Down Expand Up @@ -416,6 +419,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[2].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(this.nextKeyEvent);
expect(keyManager.activeItemIndex)
Expand Down Expand Up @@ -558,6 +562,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[0].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.setFirstItemActive();
expect(keyManager.activeItemIndex)
Expand All @@ -580,6 +585,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[2].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.setLastItemActive();
expect(keyManager.activeItemIndex)
Expand All @@ -602,6 +608,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

expect(keyManager.activeItemIndex)
.withContext(`Expected first item of the list to be active.`)
Expand Down Expand Up @@ -629,6 +636,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(fakeKeyEvents.downArrow);
keyManager.onKeydown(fakeKeyEvents.downArrow);
Expand Down Expand Up @@ -706,6 +714,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items.forEach(item => (item.disabled = true));
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(fakeKeyEvents.downArrow);
});
Expand All @@ -730,6 +739,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

expect(keyManager.activeItemIndex).toBe(0);

Expand All @@ -744,6 +754,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[1].skipItem = true;
itemList.reset(items);
itemList.notifyOnChanges();

expect(keyManager.activeItemIndex).toBe(0);

Expand Down Expand Up @@ -839,6 +850,7 @@ describe('Key managers', () => {
new FakeFocusable('две'),
new FakeFocusable('три'),
]);
itemList.notifyOnChanges();

const keyboardEvent = createKeyboardEvent('keydown', 68, 'д');

Expand All @@ -854,6 +866,7 @@ describe('Key managers', () => {
new FakeFocusable('321'),
new FakeFocusable('`!?'),
]);
itemList.notifyOnChanges();

keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`"
tick(debounceInterval);
Expand All @@ -874,6 +887,7 @@ describe('Key managers', () => {
const items = itemList.toArray();
items[0].disabled = true;
itemList.reset(items);
itemList.notifyOnChanges();

keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o"
tick(debounceInterval);
Expand All @@ -889,6 +903,7 @@ describe('Key managers', () => {
new FakeFocusable('Boromir'),
new FakeFocusable('Aragorn'),
]);
itemList.notifyOnChanges();

keyManager.setActiveItem(1);
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
Expand All @@ -905,6 +920,7 @@ describe('Key managers', () => {
new FakeFocusable('Boromir'),
new FakeFocusable('Aragorn'),
]);
itemList.notifyOnChanges();

keyManager.setActiveItem(3);
keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b'));
Expand Down
78 changes: 19 additions & 59 deletions src/cdk/a11y/key-manager/list-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,13 @@ import {
LEFT_ARROW,
RIGHT_ARROW,
TAB,
A,
Z,
ZERO,
NINE,
hasModifierKey,
HOME,
END,
PAGE_UP,
PAGE_DOWN,
} from '@angular/cdk/keycodes';
import {debounceTime, filter, map, tap} from 'rxjs/operators';
import {Typeahead} from './typeahead';

/** This interface is for items that can be passed to a ListKeyManager. */
export interface ListKeyManagerOption {
Expand All @@ -46,36 +42,35 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
private _activeItemIndex = -1;
private _activeItem: T | null = null;
private _wrap = false;
private readonly _letterKeyStream = new Subject<string>();
private _typeaheadSubscription = Subscription.EMPTY;
private _itemChangesSubscription?: Subscription;
private _vertical = true;
private _horizontal: 'ltr' | 'rtl' | null;
private _allowedModifierKeys: ListKeyManagerModifierKey[] = [];
private _homeAndEnd = false;
private _pageUpAndDown = {enabled: false, delta: 10};
private _typeahead?: Typeahead<T>;

/**
* Predicate function that can be used to check whether an item should be skipped
* by the key manager. By default, disabled items are skipped.
*/
private _skipPredicateFn = (item: T) => item.disabled;

// Buffer for the letters that the user has pressed when the typeahead option is turned on.
private _pressedLetters: string[] = [];

constructor(private _items: QueryList<T> | T[]) {
// We allow for the items to be an array because, in some cases, the consumer may
// not have access to a QueryList of the items they want to manage (e.g. when the
// items aren't being collected via `ViewChildren` or `ContentChildren`).
if (_items instanceof QueryList) {
this._itemChangesSubscription = _items.changes.subscribe((newItems: QueryList<T>) => {
const itemArray = newItems.toArray();
this._typeahead?.setItems(itemArray);
if (this._activeItem) {
const itemArray = newItems.toArray();
const newIndex = itemArray.indexOf(this._activeItem);

if (newIndex > -1 && newIndex !== this._activeItemIndex) {
this._activeItemIndex = newIndex;
this._typeahead?.setCurrentSelectedItemIndex(newIndex);
}
}
});
Expand Down Expand Up @@ -144,53 +139,24 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
* @param debounceInterval Time to wait after the last keystroke before setting the active item.
*/
withTypeAhead(debounceInterval: number = 200): this {
if (
(typeof ngDevMode === 'undefined' || ngDevMode) &&
this._items.length &&
this._items.some(item => typeof item.getLabel !== 'function')
) {
throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
}

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 = this._letterKeyStream
.pipe(
tap(letter => this._pressedLetters.push(letter)),
debounceTime(debounceInterval),
filter(() => this._pressedLetters.length > 0),
map(() => this._pressedLetters.join('')),
)
.subscribe(inputString => {
const items = this._getItemsArray();

// Start at 1 because we want to start searching at the item immediately
// following the current active item.
for (let i = 1; i < items.length + 1; i++) {
const index = (this._activeItemIndex + i) % items.length;
const item = items[index];

if (
!this._skipPredicateFn(item) &&
item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0
) {
this.setActiveItem(index);
break;
}
}
const items = this._getItemsArray();
this._typeahead = new Typeahead(items, {
debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
skipPredicate: item => this._skipPredicateFn(item),
});

this._pressedLetters = [];
});
this._typeaheadSubscription = this._typeahead.selectedItem.subscribe(item => {
this.setActiveItem(item);
});

return this;
}

/** Cancels the current typeahead sequence. */
cancelTypeahead(): this {
this._pressedLetters = [];
this._typeahead?.reset();
return this;
}

Expand Down Expand Up @@ -322,21 +288,15 @@ export class ListKeyManager<T extends ListKeyManagerOption> {

default:
if (isModifierAllowed || hasModifierKey(event, 'shiftKey')) {
// Attempt to use the `event.key` which also maps it to the user's keyboard language,
// otherwise fall back to resolving alphanumeric characters via the keyCode.
if (event.key && event.key.length === 1) {
this._letterKeyStream.next(event.key.toLocaleUpperCase());
} else if ((keyCode >= A && keyCode <= Z) || (keyCode >= ZERO && keyCode <= NINE)) {
this._letterKeyStream.next(String.fromCharCode(keyCode));
}
this._typeahead?.handleKey(event);
}

// Note that we return here, in order to avoid preventing
// the default action of non-navigational keys.
return;
}

this._pressedLetters = [];
this._typeahead?.reset();
event.preventDefault();
}

Expand All @@ -352,7 +312,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {

/** Gets whether the user is currently typing into the manager using the typeahead feature. */
isTyping(): boolean {
return this._pressedLetters.length > 0;
return !!this._typeahead && this._typeahead.isTyping();
}

/** Sets the active item to the first enabled item in the list. */
Expand Down Expand Up @@ -397,16 +357,16 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
// Explicitly check for `null` and `undefined` because other falsy values are valid.
this._activeItem = activeItem == null ? null : activeItem;
this._activeItemIndex = index;
this._typeahead?.setCurrentSelectedItemIndex(index);
}

/** Cleans up the key manager. */
destroy() {
this._typeaheadSubscription.unsubscribe();
this._itemChangesSubscription?.unsubscribe();
this._letterKeyStream.complete();
this._typeahead?.destroy();
this.tabOut.complete();
this.change.complete();
this._pressedLetters = [];
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/cdk/a11y/key-manager/noop-tree-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export class NoopTreeKeyManager<T extends TreeKeyManagerItem> implements TreeKey
// implementation that does not emit to streams.
readonly change = new Subject<T | null>();

destroy() {
this.change.complete();
}

onKeydown() {
// noop
}
Expand Down
5 changes: 5 additions & 0 deletions src/cdk/a11y/key-manager/tree-key-manager-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ export interface TreeKeyManagerStrategy<T extends TreeKeyManagerItem> {
/** Stream that emits any time the focused item changes. */
readonly change: Subject<T | null>;

/**
* Cleans up the key manager.
*/
destroy(): void;

/**
* Handles a keyboard event on the tree.
*
Expand Down
Loading

0 comments on commit 4bc94e0

Please sign in to comment.