Skip to content

Commit

Permalink
fix(cdk/tree): add injectable key manager and opt-out
Browse files Browse the repository at this point in the history
Make backwards compatibility improvements to cdk-tree-revamp regarding
focus management, the key manager and tabindex attribute.

 * Add TreeKeyMangerStrategy interface
 * Add injection toekn for tree key manager
 * Add LegacyTreeKeyManager

Provide LegacyTreeKeyManager to use legacy tabindex behavior from before
TreeKeyManager was introducted.

This commit message will be squashed away.
  • Loading branch information
zarend committed Oct 26, 2023
1 parent 732665d commit 95fd972
Show file tree
Hide file tree
Showing 15 changed files with 870 additions and 64 deletions.
91 changes: 91 additions & 0 deletions src/cdk/a11y/key-manager/legacy-tree-key-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {Subject} from 'rxjs';
import {
TREE_KEY_MANAGER,
TreeKeyManagerFactory,
TreeKeyManagerItem,
TreeKeyManagerStrategy,
} from './tree-key-manager';

/**
* @docs-private
*
* @deprecated LegacyTreeKeyManager deprecated. Use TreeKeyManager or inject a
* TreeKeyManagerStrategy instead. To be removed in a future version.
*
* @breaking-change 19.0.0
*/
// LegacyTreeKeyManager is a "noop" implementation of TreeKeyMangerStrategy. Methods are noops. Does
// not emit to streams.
//
// Used for applications built before TreeKeyManager to opt-out of TreeKeyManager and revert to
// legacy behavior.
export class LegacyTreeKeyManager<T extends TreeKeyManagerItem>
implements TreeKeyManagerStrategy<T>
{
get _isLegacyTreeKeyManager() {
return true;
}

// Provide tabOut as required by TreeKeyManagerStrategy. LegacyTreeKeyManager is a "noop"
// implementation that does not emit to streams.
readonly tabOut = new Subject<void>();

// Provide change as required by TreeKeyManagerStrategy. LegacyTreeKeyManager is a "noop"
// implementation that does not emit to streams.
readonly change = new Subject<T | null>();

onKeydown() {
// noop
}

getActiveItemIndex() {
// Always return null. LegacyTreeKeyManager is a "noop" implementation that does not maintain
// the active item.
return null;
}

getActiveItem() {
// Always return null. LegacyTreeKeyManager is a "noop" implementation that does not maintain
// the active item.
return null;
}

onInitialFocus() {
// noop
}

focusItem() {
// noop
}

setActiveItem() {
// noop
}
}

/**
* @docs-private
*
* @deprecated LegacyTreeKeyManager deprecated. Use TreeKeyManager or inject a
* TreeKeyManagerStrategy instead. To be removed in a future version.
*
* @breaking-change 19.0.0
*/
export function LEGACY_TREE_KEY_MANAGER_FACTORY<
T extends TreeKeyManagerItem,
>(): TreeKeyManagerFactory<T> {
return () => new LegacyTreeKeyManager<T>();
}

/**
* @docs-private
*
* @deprecated LegacyTreeKeyManager deprecated. Use TreeKeyManager or inject a
* TreeKeyManagerStrategy instead. To be removed in a future version.
*
* @breaking-change 19.0.0
*/
export const LEGACY_TREE_KEY_MANAGER_FACTORY_PROVIDER = {
provide: TREE_KEY_MANAGER,
useFactory: LEGACY_TREE_KEY_MANAGER_FACTORY,
};
155 changes: 112 additions & 43 deletions src/cdk/a11y/key-manager/tree-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
ZERO,
NINE,
} from '@angular/cdk/keycodes';
import {QueryList} from '@angular/core';
import {InjectionToken, QueryList} from '@angular/core';
import {of as observableOf, isObservable, Observable, Subject, Subscription} from 'rxjs';
import {debounceTime, filter, map, take, tap} from 'rxjs/operators';

Expand Down Expand Up @@ -70,8 +70,6 @@ export interface TreeKeyManagerItem {
* Configuration for the TreeKeyManager.
*/
export interface TreeKeyManagerOptions<T extends TreeKeyManagerItem> {
items: Observable<T[]> | QueryList<T> | T[];

/**
* Sets the predicate function that determines which items should be skipped by the tree key
* manager. By default, disabled items are skipped.
Expand Down Expand Up @@ -110,12 +108,80 @@ export interface TreeKeyManagerOptions<T extends TreeKeyManagerItem> {
typeAheadDebounceInterval?: true | number;
}

export interface TreeKeyManagerStrategy<T extends TreeKeyManagerItem> {
/**
* Stream that emits any time the TAB key is pressed, so components can react
* when focus is shifted off of the list.
*/
readonly tabOut: Subject<void>;

/** Stream that emits any time the focused item changes. */
readonly change: Subject<T | null>;

/**
* Handles a keyboard event on the tree.
*
* Emits to `tabOut` stream when tab key is pressed.
*
* @param event Keyboard event that represents the user interaction with the tree.
*/
onKeydown(event: KeyboardEvent): void;

/** Index of the currently active item. */
getActiveItemIndex(): number | null;

/** The currently active item. */
getActiveItem(): T | null;

/**
* Called the first time the Tree component is focused. This method will only be called once over
* the lifetime of the Tree component.
*
* Inteneded to be used to focus the first item in the tree.
*/
onInitialFocus(): void;

/**
* Focus the provided item by index.
*
* Updates the state of the currently active item. Emits to `change` stream if active item
* Changes.
* @param index The index of the item to focus.
* @param options Additional focusing options.
*/
focusItem(index: number, options?: {emitChangeEvent?: boolean}): void;
/**
* Focus the provided item.
*
* Updates the state of the currently active item. Emits to `change` stream if active item
* Changes.
* @param item The item to focus. Equality is determined via the trackBy function.
* @param options Additional focusing options.
*/
focusItem(item: T, options?: {emitChangeEvent?: boolean}): void;
focusItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void;

/**
* Focus the provided item by index.
* @param index The index of the item to focus.
* @param options Additional focusing options.
*/
setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void;
setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void;
setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void;
}

export type TreeKeyManagerFactory<T extends TreeKeyManagerItem> = (
items: Observable<T[]> | QueryList<T> | T[],
options: TreeKeyManagerOptions<T>,
) => TreeKeyManagerStrategy<T>;

/**
* This class manages keyboard events for trees. If you pass it a QueryList or other list of tree
* items, it will set the active item, focus, handle expansion and typeahead correctly when
* keyboard events occur.
*/
export class TreeKeyManager<T extends TreeKeyManagerItem> {
export class TreeKeyManager<T extends TreeKeyManagerItem> implements TreeKeyManagerStrategy<T> {
private _activeItemIndex = -1;
private _activeItem: T | null = null;
private _activationFollowsFocus = false;
Expand All @@ -137,14 +203,16 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {

private _items: T[] = [];

constructor({
items,
skipPredicate,
trackBy,
horizontalOrientation,
activationFollowsFocus,
typeAheadDebounceInterval,
}: TreeKeyManagerOptions<T>) {
constructor(
items: Observable<T[]> | QueryList<T> | T[],
{
skipPredicate,
trackBy,
horizontalOrientation,
activationFollowsFocus,
typeAheadDebounceInterval,
}: 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
// items aren't being collected via `ViewChildren` or `ContentChildren`).
Expand Down Expand Up @@ -202,6 +270,7 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {

switch (keyCode) {
case TAB:
// Emit to `tabOut` stream as required by TreeKeyManagerStratey interface.
this.tabOut.next();
// NB: return here, in order to allow Tab to actually tab out of the tree
return;
Expand Down Expand Up @@ -279,11 +348,6 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
this._focusFirstItem();
}

/**
* Focus the provided item by index.
* @param index The index of the item to focus.
* @param options Additional focusing options.
*/
focusItem(index: number, options?: {emitChangeEvent?: boolean}): void;
/**
* Focus the provided item.
Expand All @@ -296,25 +360,30 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
}

/** Focus the first available item. */
focusFirstItem(): void {
this._focusFirstItem();
private _focusFirstItem(): void {
this.setActiveItem(this._findNextAvailableItemIndex(-1));
}

/** Focus the last available item. */
focusLastItem(): void {
this._focusLastItem();
private _focusLastItem(): void {
this.setActiveItem(this._findPreviousAvailableItemIndex(this._items.length));
}

/** Focus the next available item. */
focusNextItem(): void {
this._focusNextItem();
private _focusNextItem(): void {
this.setActiveItem(this._findNextAvailableItemIndex(this._activeItemIndex));
}

/** Focus the previous available item. */
focusPreviousItem(): void {
this._focusPreviousItem();
private _focusPreviousItem(): void {
this.setActiveItem(this._findPreviousAvailableItemIndex(this._activeItemIndex));
}

/**
* Focus the provided item by index.
* @param index The index of the item to focus.
* @param options Additional focusing options.
*/
setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void;
setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void;
setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void;
Expand Down Expand Up @@ -343,6 +412,7 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
this._activeItemIndex = index;

if (options.emitChangeEvent) {
// Emit to `change` stream as required by TreeKeyManagerStrategy interface.
this.change.next(this._activeItem);
}
this._activeItem?.focus();
Expand Down Expand Up @@ -408,23 +478,6 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
}

//// Navigational methods

private _focusFirstItem() {
this.setActiveItem(this._findNextAvailableItemIndex(-1));
}

private _focusLastItem() {
this.setActiveItem(this._findPreviousAvailableItemIndex(this._items.length));
}

private _focusPreviousItem() {
this.setActiveItem(this._findPreviousAvailableItemIndex(this._activeItemIndex));
}

private _focusNextItem() {
this.setActiveItem(this._findNextAvailableItemIndex(this._activeItemIndex));
}

private _findNextAvailableItemIndex(startingIndex: number) {
for (let i = startingIndex + 1; i < this._items.length; i++) {
if (!this._skipPredicateFn(this._items[i])) {
Expand Down Expand Up @@ -465,7 +518,7 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
/**
* If the item is already collapsed, we expand the item. Otherwise, we will focus the first child.
*/
private _expandCurrentItem() {
protected _expandCurrentItem() {
if (!this._activeItem) {
return;
}
Expand Down Expand Up @@ -523,3 +576,19 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
this._activeItem?.activate();
}
}

/** Injection token that determines the key manager to use. */
export const TREE_KEY_MANAGER = new InjectionToken<TreeKeyManagerFactory<any>>(
'cdk-tree-key-manager',
);

/** @docs-private */
export function TREE_KEY_MANAGER_FACTORY<T extends TreeKeyManagerItem>(): TreeKeyManagerFactory<T> {
return (items, options) => new TreeKeyManager(items, options);
}

/** @docs-private */
export const TREE_KEY_MANAGER_FACTORY_PROVIDER = {
provide: TREE_KEY_MANAGER,
useFactory: TREE_KEY_MANAGER_FACTORY,
};
1 change: 1 addition & 0 deletions src/cdk/a11y/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './aria-describer/aria-describer';
export * from './aria-describer/aria-reference';
export * from './key-manager/activedescendant-key-manager';
export * from './key-manager/focus-key-manager';
export * from './key-manager/legacy-tree-key-manager';
export * from './key-manager/list-key-manager';
export * from './key-manager/tree-key-manager';
export * from './focus-trap/configurable-focus-trap';
Expand Down
2 changes: 2 additions & 0 deletions src/cdk/tree/tree-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {CdkTreeNodeToggle} from './toggle';
import {CdkTree, CdkTreeNode} from './tree';
import {CdkTreeNodeDef} from './node';
import {CdkNestedTreeNode} from './nested-node';
import {TREE_KEY_MANAGER_FACTORY_PROVIDER} from '../a11y';

const EXPORTED_DECLARATIONS = [
CdkNestedTreeNode,
Expand All @@ -27,5 +28,6 @@ const EXPORTED_DECLARATIONS = [
@NgModule({
exports: EXPORTED_DECLARATIONS,
declarations: EXPORTED_DECLARATIONS,
providers: [TREE_KEY_MANAGER_FACTORY_PROVIDER],
})
export class CdkTreeModule {}
Loading

0 comments on commit 95fd972

Please sign in to comment.