Skip to content

Commit

Permalink
refactor(cdk/a11y): factor out a typeahead class for key managers
Browse files Browse the repository at this point in the history
  • Loading branch information
BobobUnicorn committed Nov 16, 2023
1 parent 372f9df commit 4aefcc8
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 126 deletions.
76 changes: 18 additions & 58 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,24 +42,21 @@ 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
Expand All @@ -73,9 +66,11 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
if (this._activeItem) {
const itemArray = newItems.toArray();
const newIndex = itemArray.indexOf(this._activeItem);
this._typeahead?.setItems(itemArray);

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
2 changes: 1 addition & 1 deletion src/cdk/a11y/key-manager/tree-key-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ describe('TreeKeyManager', () => {
expect(fakeKeyEvents.tab.defaultPrevented).toBe(false);
});

it('should not do anything for unsupported key presses', () => {
fit('should not do anything for unsupported key presses', () => {
keyManager.focusItem(itemList.get(1)!);

expect(keyManager.getActiveItemIndex()).toBe(1);
Expand Down
87 changes: 20 additions & 67 deletions src/cdk/a11y/key-manager/tree-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ import {
TreeKeyManagerOptions,
TreeKeyManagerStrategy,
} from './tree-key-manager-strategy';

const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200;
import {Typeahead} from './typeahead';

function coerceObservable<T>(data: T | Observable<T>): Observable<T> {
if (!isObservable(data)) {
Expand All @@ -50,8 +49,6 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
private _activeItem: T | null = null;
private _activationFollowsFocus = false;
private _horizontal: 'ltr' | 'rtl' = 'ltr';
private readonly _letterKeyStream = new Subject<string>();
private _typeaheadSubscription = Subscription.EMPTY;

/**
* Predicate function that can be used to check whether an item should be skipped
Expand All @@ -62,11 +59,11 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
/** Function to determine equivalent items. */
private _trackByFn: (item: T) => unknown = (item: T) => item;

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

private _items: T[] = [];

private _typeahead?: Typeahead<T>;
private _typeaheadSubscription = Subscription.EMPTY;

constructor(items: Observable<T[]> | QueryList<T> | T[], config: TreeKeyManagerOptions<T>) {
// We allow for the items to be an array or Observable because, in some cases, the consumer may
// not have access to a QueryList of the items they want to manage (e.g. when the
Expand All @@ -75,11 +72,13 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
this._items = items.toArray();
items.changes.subscribe((newItems: QueryList<T>) => {
this._items = newItems.toArray();
this._typeahead?.setItems(this._items);
this._updateActiveItemIndex(this._items);
});
} else if (isObservable(items)) {
items.subscribe(newItems => {
this._items = newItems;
this._typeahead?.setItems(newItems);
this._updateActiveItemIndex(newItems);
});
} else {
Expand All @@ -99,12 +98,7 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
this._trackByFn = config.trackBy;
}
if (typeof config.typeAheadDebounceInterval !== 'undefined') {
const typeAheadInterval =
typeof config.typeAheadDebounceInterval === 'number'
? config.typeAheadDebounceInterval
: DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS;

this._setTypeAhead(typeAheadInterval);
this._setTypeAhead(config.typeAheadDebounceInterval);
}
}

Expand Down Expand Up @@ -160,21 +154,14 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
break;
}

// 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));
}

// NB: return here, in order to avoid preventing the default action of non-navigational
this._typeahead?.handleKey(event);
// Return here, in order to avoid preventing the default action of non-navigational
// keys or resetting the buffer of pressed letters.
return;
}

// Reset the typeahead since the user has used a navigational key.
this._pressedLetters = [];
this._typeahead?.reset();
event.preventDefault();
}

Expand Down Expand Up @@ -247,6 +234,7 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana

this._activeItem = activeItem ?? null;
this._activeItemIndex = index;
this._typeahead?.setCurrentSelectedItemIndex(index);

if (options.emitChangeEvent) {
// Emit to `change` stream as required by TreeKeyManagerStrategy interface.
Expand All @@ -270,50 +258,19 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana

if (newIndex > -1 && newIndex !== this._activeItemIndex) {
this._activeItemIndex = newIndex;
this._typeahead?.setCurrentSelectedItemIndex(newIndex);
}
}

private _setTypeAhead(debounceInterval: number) {
this._typeaheadSubscription.unsubscribe();

if (
(typeof ngDevMode === 'undefined' || ngDevMode) &&
this._items.length &&
this._items.some(item => typeof item.getLabel !== 'function')
) {
throw new Error(
'TreeKeyManager items in typeahead mode must implement the `getLabel` method.',
);
}

// 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('').toLocaleUpperCase()),
)
.subscribe(inputString => {
// Start at 1 because we want to start searching at the item immediately
// following the current active item.
for (let i = 1; i < this._items.length + 1; i++) {
const index = (this._activeItemIndex + i) % this._items.length;
const item = this._items[index];

if (
!this._skipPredicateFn(item) &&
item.getLabel?.().toLocaleUpperCase().trim().indexOf(inputString) === 0
) {
this.focusItem(index);
break;
}
}
private _setTypeAhead(debounceInterval: number | boolean) {
this._typeahead = new Typeahead(this._items, {
debounceInterval: typeof debounceInterval === 'number' ? debounceInterval : undefined,
skipPredicate: item => this._skipPredicateFn(item),
});

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

private _findNextAvailableItemIndex(startingIndex: number) {
Expand Down Expand Up @@ -364,10 +321,6 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyMana
if (!this._isCurrentItemExpanded()) {
this._activeItem.expand();
} else {
const children = this._activeItem.getChildren();

const children2 = isObservable(children) ? children : observableOf(children);

coerceObservable(this._activeItem.getChildren())
.pipe(take(1))
.subscribe(children => {
Expand Down
Loading

0 comments on commit 4aefcc8

Please sign in to comment.