From 4e8bfd9bfa7ca1378b4760859593618283d26d97 Mon Sep 17 00:00:00 2001 From: Zach Arend Date: Tue, 24 Oct 2023 01:01:04 +0000 Subject: [PATCH] fix(cdk/tree): add injectable key manager and opt-out 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. --- .../key-manager/legacy-tree-key-manager.ts | 91 ++++ .../a11y/key-manager/tree-key-manager.spec.ts | 173 ++------ src/cdk/a11y/key-manager/tree-key-manager.ts | 167 +++++--- src/cdk/a11y/public-api.ts | 1 + src/cdk/tree/tree-module.ts | 2 + src/cdk/tree/tree.ts | 35 +- .../cdk-tree-custom-key-manager-example.css | 4 + .../cdk-tree-custom-key-manager-example.html | 28 ++ .../cdk-tree-custom-key-manager-example.ts | 399 ++++++++++++++++++ ...tree-legacy-keyboard-interface-example.css | 4 + ...ree-legacy-keyboard-interface-example.html | 18 + ...-tree-legacy-keyboard-interface-example.ts | 105 +++++ src/components-examples/cdk/tree/index.ts | 2 + src/dev-app/tree/tree-demo.html | 8 + src/dev-app/tree/tree-demo.ts | 4 + src/material/tree/node.ts | 62 ++- tools/public_api_guard/cdk/a11y.md | 89 +++- tools/public_api_guard/cdk/tree.md | 8 +- tools/public_api_guard/material/tree.md | 8 +- 19 files changed, 943 insertions(+), 265 deletions(-) create mode 100644 src/cdk/a11y/key-manager/legacy-tree-key-manager.ts create mode 100644 src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.css create mode 100644 src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.html create mode 100644 src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.ts create mode 100644 src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.css create mode 100644 src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.html create mode 100644 src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.ts diff --git a/src/cdk/a11y/key-manager/legacy-tree-key-manager.ts b/src/cdk/a11y/key-manager/legacy-tree-key-manager.ts new file mode 100644 index 000000000000..9c543bcff818 --- /dev/null +++ b/src/cdk/a11y/key-manager/legacy-tree-key-manager.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +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 + implements TreeKeyManagerStrategy +{ + get _isLegacyTreeKeyManager() { + return true; + } + + // Provide change as required by TreeKeyManagerStrategy. LegacyTreeKeyManager is a "noop" + // implementation that does not emit to streams. + readonly change = new Subject(); + + 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 + } +} + +/** + * @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 { + return () => new LegacyTreeKeyManager(); +} + +/** + * @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, +}; diff --git a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts index 1f9abc891ac9..86948d34afcb 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -12,7 +12,6 @@ import { } from '@angular/cdk/keycodes'; import {createKeyboardEvent} from '../../testing/private'; import {QueryList} from '@angular/core'; -import {take} from 'rxjs/operators'; import {TreeKeyManager, TreeKeyManagerItem} from './tree-key-manager'; import {Observable, of as observableOf, Subscription} from 'rxjs'; import {fakeAsync, tick} from '@angular/core/testing'; @@ -154,9 +153,7 @@ describe('TreeKeyManager', () => { ]); keyManager = new TreeKeyManager< FakeObservableTreeKeyManagerItem | FakeArrayTreeKeyManagerItem - >({ - items: itemList, - }); + >(itemList, {}); }); it('should start off the activeItem as null', () => { @@ -164,7 +161,7 @@ describe('TreeKeyManager', () => { }); it('should maintain the active item if the amount of items changes', () => { - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); expect(keyManager.getActiveItem()?.getLabel()) @@ -180,26 +177,8 @@ describe('TreeKeyManager', () => { }); describe('Key events', () => { - it('should emit tabOut when tab key is pressed', () => { - const spy = jasmine.createSpy('tabOut spy'); - keyManager.tabOut.pipe(take(1)).subscribe(spy); - keyManager.onKeydown(fakeKeyEvents.tab); - - expect(spy).toHaveBeenCalled(); - }); - - it('should emit tabOut when the tab key is pressed with a modifier', () => { - const spy = jasmine.createSpy('tabOut spy'); - keyManager.tabOut.pipe(take(1)).subscribe(spy); - - Object.defineProperty(fakeKeyEvents.tab, 'shiftKey', {get: () => true}); - keyManager.onKeydown(fakeKeyEvents.tab); - - expect(spy).toHaveBeenCalled(); - }); - it('should emit an event whenever the active item changes', () => { - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -214,14 +193,14 @@ describe('TreeKeyManager', () => { }); it('should emit if the active item changed, but not the active index', () => { - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); itemList.reset([new itemParam.constructor('zero'), ...itemList.toArray()]); itemList.notifyOnChanges(); - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); expect(spy).toHaveBeenCalledTimes(1); subscription.unsubscribe(); @@ -248,7 +227,7 @@ describe('TreeKeyManager', () => { }); it('should not do anything for unsupported key presses', () => { - keyManager.setActiveItem(itemList.get(1)!); + keyManager.focusItem(itemList.get(1)!); expect(keyManager.getActiveItemIndex()).toBe(1); expect(fakeKeyEvents.unsupported.defaultPrevented).toBe(false); @@ -260,7 +239,7 @@ describe('TreeKeyManager', () => { }); it('should focus the first item when Home is pressed', () => { - keyManager.setActiveItem(itemList.get(1)!); + keyManager.focusItem(itemList.get(1)!); expect(keyManager.getActiveItemIndex()).toBe(1); keyManager.onKeydown(fakeKeyEvents.home); @@ -270,7 +249,7 @@ describe('TreeKeyManager', () => { it('should focus the first non-disabled item when Home is pressed', () => { itemList.get(0)!.isDisabled = true; - keyManager.setActiveItem(itemList.get(2)!); + keyManager.focusItem(itemList.get(2)!); expect(keyManager.getActiveItemIndex()).toBe(2); keyManager.onKeydown(fakeKeyEvents.home); @@ -279,7 +258,7 @@ describe('TreeKeyManager', () => { }); it('should focus the last item when End is pressed', () => { - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).toBe(0); keyManager.onKeydown(fakeKeyEvents.end); @@ -288,7 +267,7 @@ describe('TreeKeyManager', () => { it('should focus the last non-disabled item when End is pressed', () => { itemList.get(itemList.length - 1)!.isDisabled = true; - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).toBe(0); keyManager.onKeydown(fakeKeyEvents.end); @@ -299,7 +278,7 @@ describe('TreeKeyManager', () => { describe('up/down key events', () => { it('should set subsequent items as active when the down key is pressed', () => { - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -330,7 +309,7 @@ describe('TreeKeyManager', () => { }); it('should set previous item as active when the up key is pressed', () => { - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -365,7 +344,7 @@ describe('TreeKeyManager', () => { it('should skip disabled items', () => { itemList.get(1)!.isDisabled = true; - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -393,7 +372,7 @@ describe('TreeKeyManager', () => { itemList.get(0)!.isDisabled = undefined; itemList.get(1)!.isDisabled = undefined; itemList.get(2)!.isDisabled = undefined; - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -416,7 +395,7 @@ describe('TreeKeyManager', () => { }); it('should not move active item past either end of the list', () => { - keyManager.setActiveItem(itemList.get(itemList.length - 1)!); + keyManager.focusItem(itemList.get(itemList.length - 1)!); expect(keyManager.getActiveItemIndex()) .withContext('active item index, selecting the last item') @@ -428,7 +407,7 @@ describe('TreeKeyManager', () => { .withContext('active item index, last item still selected after a down event') .toBe(itemList.length - 1); - keyManager.setActiveItem(itemList.get(0)!); + keyManager.focusItem(itemList.get(0)!); keyManager.onKeydown(fakeKeyEvents.upArrow); expect(keyManager.getActiveItemIndex()) .withContext('active item index, selecting the first item') @@ -444,7 +423,7 @@ describe('TreeKeyManager', () => { it('should not move active item to end when the last item is disabled', () => { itemList.get(itemList.length - 1)!.isDisabled = true; - keyManager.setActiveItem(itemList.get(itemList.length - 2)!); + keyManager.focusItem(itemList.get(itemList.length - 2)!); expect(keyManager.getActiveItemIndex()) .withContext('active item index, last non-disabled item selected') .toBe(itemList.length - 2); @@ -486,8 +465,7 @@ describe('TreeKeyManager', () => { for (const param of parameters) { describe(`in ${param.direction} mode`, () => { beforeEach(() => { - keyManager = new TreeKeyManager({ - items: itemList, + keyManager = new TreeKeyManager(itemList, { horizontalOrientation: param.direction, }); for (const item of itemList) { @@ -555,7 +533,7 @@ describe('TreeKeyManager', () => { let subscription: Subscription; beforeEach(() => { - keyManager.setActiveItem(parentItem); + keyManager.focusItem(parentItem); parentItem._isExpanded = true; spy = jasmine.createSpy('change spy'); @@ -640,7 +618,7 @@ describe('TreeKeyManager', () => { let subscription: Subscription; beforeEach(() => { - keyManager.setActiveItem(childItemWithNoChildren); + keyManager.focusItem(childItemWithNoChildren); childItemWithNoChildren._isExpanded = true; spy = jasmine.createSpy('change spy'); @@ -666,7 +644,7 @@ describe('TreeKeyManager', () => { let subscription: Subscription; beforeEach(() => { - keyManager.setActiveItem(childItem); + keyManager.focusItem(childItem); childItem._isExpanded = false; spy = jasmine.createSpy('change spy'); @@ -733,7 +711,7 @@ describe('TreeKeyManager', () => { let subscription: Subscription; beforeEach(() => { - keyManager.setActiveItem(parentItem); + keyManager.focusItem(parentItem); parentItem._isExpanded = false; spy = jasmine.createSpy('change spy'); @@ -765,8 +743,7 @@ describe('TreeKeyManager', () => { const debounceInterval = 300; beforeEach(() => { - keyManager = new TreeKeyManager({ - items: itemList, + keyManager = new TreeKeyManager(itemList, { typeAheadDebounceInterval: debounceInterval, }); }); @@ -777,8 +754,7 @@ describe('TreeKeyManager', () => { expect( () => - new TreeKeyManager({ - items: invalidQueryList, + new TreeKeyManager(invalidQueryList, { typeAheadDebounceInterval: true, }), ).toThrowError(/must implement/); @@ -810,8 +786,7 @@ describe('TreeKeyManager', () => { it('uses a default debounce interval', fakeAsync(() => { const defaultInterval = 200; - keyManager = new TreeKeyManager({ - items: itemList, + keyManager = new TreeKeyManager(itemList, { typeAheadDebounceInterval: true, }); @@ -924,7 +899,7 @@ describe('TreeKeyManager', () => { ]); itemList.notifyOnChanges(); - keyManager.setActiveItem(frodo); + keyManager.focusItem(frodo); keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); tick(debounceInterval); @@ -942,7 +917,7 @@ describe('TreeKeyManager', () => { ]); itemList.notifyOnChanges(); - keyManager.setActiveItem(boromir); + keyManager.focusItem(boromir); keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); tick(debounceInterval); @@ -950,7 +925,7 @@ describe('TreeKeyManager', () => { })); it('should wrap back around if the last item is active', fakeAsync(() => { - keyManager.setActiveItem(lastItem); + keyManager.focusItem(lastItem); keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); tick(debounceInterval); @@ -1018,99 +993,9 @@ describe('TreeKeyManager', () => { }); }); - describe('focusFirstItem', () => { - beforeEach(() => { - keyManager.onInitialFocus(); - }); - - it('should focus the first item', () => { - keyManager.onKeydown(fakeKeyEvents.downArrow); - keyManager.onKeydown(fakeKeyEvents.downArrow); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); - - keyManager.focusFirstItem(); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); - }); - - it('should set the active item to the second item if the first one is disabled', () => { - itemList.get(0)!.isDisabled = true; - - keyManager.focusFirstItem(); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); - }); - }); - - describe('focusLastItem', () => { - beforeEach(() => { - keyManager.onInitialFocus(); - }); - - it('should focus the last item', () => { - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); - - keyManager.focusLastItem(); - expect(keyManager.getActiveItemIndex()) - .withContext('active item index') - .toBe(itemList.length - 1); - }); - - it('should set the active item to the second-to-last item if the last is disabled', () => { - itemList.get(itemList.length - 1)!.isDisabled = true; - - keyManager.focusLastItem(); - expect(keyManager.getActiveItemIndex()) - .withContext('active item index') - .toBe(itemList.length - 2); - }); - }); - - describe('focusNextItem', () => { - beforeEach(() => { - keyManager.onInitialFocus(); - }); - - it('should focus the next item', () => { - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); - - keyManager.focusNextItem(); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); - }); - - it('should skip disabled items', () => { - itemList.get(1)!.isDisabled = true; - - keyManager.focusNextItem(); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); - }); - }); - - describe('focusPreviousItem', () => { - beforeEach(() => { - keyManager.onInitialFocus(); - }); - - it('should focus the previous item', () => { - keyManager.onKeydown(fakeKeyEvents.downArrow); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); - - keyManager.focusPreviousItem(); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); - }); - - it('should skip disabled items', () => { - itemList.get(1)!.isDisabled = true; - keyManager.onKeydown(fakeKeyEvents.downArrow); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); - - keyManager.focusPreviousItem(); - expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); - }); - }); - describe('skip predicate', () => { beforeEach(() => { - keyManager = new TreeKeyManager({ - items: itemList, + keyManager = new TreeKeyManager(itemList, { skipPredicate: item => item.skipItem ?? false, }); keyManager.onInitialFocus(); diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index 0de04aabba4b..6dc1721665ee 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -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'; @@ -70,8 +70,6 @@ export interface TreeKeyManagerItem { * Configuration for the TreeKeyManager. */ export interface TreeKeyManagerOptions { - items: Observable | QueryList | T[]; - /** * Sets the predicate function that determines which items should be skipped by the tree key * manager. By default, disabled items are skipped. @@ -110,12 +108,63 @@ export interface TreeKeyManagerOptions { typeAheadDebounceInterval?: true | number; } +export interface TreeKeyManagerStrategy { + /** Stream that emits any time the focused item changes. */ + readonly change: Subject; + + /** + * Handles a keyboard event on the tree. + * + * @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. + * + * Intended 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; +} + +export type TreeKeyManagerFactory = ( + items: Observable | QueryList | T[], + options: TreeKeyManagerOptions, +) => TreeKeyManagerStrategy; + /** * 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 { +export class TreeKeyManager implements TreeKeyManagerStrategy { private _activeItemIndex = -1; private _activeItem: T | null = null; private _activationFollowsFocus = false; @@ -137,14 +186,16 @@ export class TreeKeyManager { private _items: T[] = []; - constructor({ - items, - skipPredicate, - trackBy, - horizontalOrientation, - activationFollowsFocus, - typeAheadDebounceInterval, - }: TreeKeyManagerOptions) { + constructor( + items: Observable | QueryList | T[], + { + skipPredicate, + trackBy, + horizontalOrientation, + activationFollowsFocus, + typeAheadDebounceInterval, + }: TreeKeyManagerOptions, + ) { // 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`). @@ -184,12 +235,6 @@ export class TreeKeyManager { } } - /** - * Stream that emits any time the TAB key is pressed, so components can react - * when focus is shifted off of the list. - */ - readonly tabOut = new Subject(); - /** Stream that emits any time the focused item changes. */ readonly change = new Subject(); @@ -202,7 +247,6 @@ export class TreeKeyManager { switch (keyCode) { case TAB: - this.tabOut.next(); // NB: return here, in order to allow Tab to actually tab out of the tree return; @@ -279,46 +323,35 @@ export class TreeKeyManager { 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. - * @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 { - this.setActiveItem(itemOrIndex, options); - } - /** Focus the first available item. */ - focusFirstItem(): void { - this._focusFirstItem(); + private _focusFirstItem(): void { + this.focusItem(this._findNextAvailableItemIndex(-1)); } /** Focus the last available item. */ - focusLastItem(): void { - this._focusLastItem(); + private _focusLastItem(): void { + this.focusItem(this._findPreviousAvailableItemIndex(this._items.length)); } /** Focus the next available item. */ - focusNextItem(): void { - this._focusNextItem(); + private _focusNextItem(): void { + this.focusItem(this._findNextAvailableItemIndex(this._activeItemIndex)); } /** Focus the previous available item. */ - focusPreviousItem(): void { - this._focusPreviousItem(); + private _focusPreviousItem(): void { + this.focusItem(this._findPreviousAvailableItemIndex(this._activeItemIndex)); } - setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void; - setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void; - setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void; - setActiveItem(itemOrIndex: number | T, options: {emitChangeEvent?: boolean} = {}) { + /** + * 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; + focusItem(item: T, options?: {emitChangeEvent?: boolean}): void; + focusItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void; + focusItem(itemOrIndex: number | T, options: {emitChangeEvent?: boolean} = {}) { // Set default options options.emitChangeEvent ??= true; @@ -343,6 +376,7 @@ export class TreeKeyManager { this._activeItemIndex = index; if (options.emitChangeEvent) { + // Emit to `change` stream as required by TreeKeyManagerStrategy interface. this.change.next(this._activeItem); } this._activeItem?.focus(); @@ -398,7 +432,7 @@ export class TreeKeyManager { !this._skipPredicateFn(item) && item.getLabel?.().toLocaleUpperCase().trim().indexOf(inputString) === 0 ) { - this.setActiveItem(index); + this.focusItem(index); break; } } @@ -408,23 +442,6 @@ export class TreeKeyManager { } //// 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])) { @@ -458,7 +475,7 @@ export class TreeKeyManager { if (!parent || this._skipPredicateFn(parent as T)) { return; } - this.setActiveItem(parent as T); + this.focusItem(parent as T); } } @@ -480,7 +497,7 @@ export class TreeKeyManager { if (!firstChild) { return; } - this.setActiveItem(firstChild as T); + this.focusItem(firstChild as T); }); } } @@ -523,3 +540,19 @@ export class TreeKeyManager { this._activeItem?.activate(); } } + +/** Injection token that determines the key manager to use. */ +export const TREE_KEY_MANAGER = new InjectionToken>( + 'cdk-tree-key-manager', +); + +/** @docs-private */ +export function TREE_KEY_MANAGER_FACTORY(): TreeKeyManagerFactory { + 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, +}; diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index ea64d62578b7..38faed178a87 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -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'; diff --git a/src/cdk/tree/tree-module.ts b/src/cdk/tree/tree-module.ts index 6e2925e6a623..8fd91bedba2f 100644 --- a/src/cdk/tree/tree-module.ts +++ b/src/cdk/tree/tree-module.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {TREE_KEY_MANAGER_FACTORY_PROVIDER} from '@angular/cdk/a11y'; import {NgModule} from '@angular/core'; import {CdkTreeNodeOutlet} from './outlet'; import {CdkTreeNodePadding} from './padding'; @@ -27,5 +28,6 @@ const EXPORTED_DECLARATIONS = [ @NgModule({ exports: EXPORTED_DECLARATIONS, declarations: EXPORTED_DECLARATIONS, + providers: [TREE_KEY_MANAGER_FACTORY_PROVIDER], }) export class CdkTreeModule {} diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index a25fe8ef5497..275564af4ef1 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -5,7 +5,13 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {TreeKeyManager, TreeKeyManagerItem} from '@angular/cdk/a11y'; +import { + TREE_KEY_MANAGER, + TreeKeyManagerFactory, + TreeKeyManagerItem, + TreeKeyManagerOptions, + TreeKeyManagerStrategy, +} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; import { @@ -241,8 +247,10 @@ export class CdkTree */ private _keyManagerNodes: BehaviorSubject = new BehaviorSubject([]); + private _keyManagerFactory = inject(TREE_KEY_MANAGER) as TreeKeyManagerFactory>; + /** The key manager for this tree. Handles focus and activation based on user keyboard input. */ - _keyManager: TreeKeyManager>; + _keyManager: TreeKeyManagerStrategy>; constructor( private _differs: IterableDiffers, @@ -283,17 +291,20 @@ export class CdkTree } ngAfterContentInit() { - this._keyManager = new TreeKeyManager({ - items: combineLatest([this._keyManagerNodes, this._nodes]).pipe( - map(([dataNodes, nodes]) => - dataNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish), - ), + const items = combineLatest([this._keyManagerNodes, this._nodes]).pipe( + map(([dataNodes, nodes]) => + dataNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish), ), + ); + + const keyManagerOptions: TreeKeyManagerOptions> = { trackBy: node => this._getExpansionKey(node.data), skipPredicate: node => !!node.isDisabled, typeAheadDebounceInterval: true, horizontalOrientation: this._dir.value, - }); + }; + + this._keyManager = this._keyManagerFactory(items, keyManagerOptions); this._keyManager.change .pipe(startWith(null), pairwise(), takeUntil(this._onDestroy)) @@ -1080,8 +1091,8 @@ export class CdkTree '[attr.aria-setsize]': '_getSetSize()', 'tabindex': '-1', 'role': 'treeitem', - '(click)': '_setActiveItem()', - '(focus)': '_setActiveItem()', + '(click)': '_focusItem()', + '(focus)': '_focusItem()', }, }) export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { @@ -1288,11 +1299,11 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI this._elementRef.nativeElement.setAttribute('tabindex', '-1'); } - _setActiveItem() { + _focusItem() { if (this.isDisabled) { return; } - this._tree._keyManager.setActiveItem(this); + this._tree._keyManager.focusItem(this); } _emitExpansionState(expanded: boolean) { diff --git a/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.css b/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.css new file mode 100644 index 000000000000..a88255f0d954 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.css @@ -0,0 +1,4 @@ +.example-tree-node { + display: flex; + align-items: center; +} diff --git a/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.html b/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.html new file mode 100644 index 000000000000..faa9ba7490e1 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.html @@ -0,0 +1,28 @@ + + + + + + {{node.name}} + + + + + {{node.name}} + + diff --git a/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.ts b/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.ts new file mode 100644 index 000000000000..2bfab6336910 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example.ts @@ -0,0 +1,399 @@ +import {Component, QueryList} from '@angular/core'; +import {ArrayDataSource} from '@angular/cdk/collections'; +import {FlatTreeControl, CdkTreeModule} from '@angular/cdk/tree'; +import {MatIconModule} from '@angular/material/icon'; +import {MatButtonModule} from '@angular/material/button'; +import { + TREE_KEY_MANAGER, + TreeKeyManagerFactory, + TreeKeyManagerItem, + TreeKeyManagerStrategy, +} from '@angular/cdk/a11y'; +import { + DOWN_ARROW, + END, + ENTER, + H, + HOME, + J, + K, + L, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + TAB, + UP_ARROW, +} from '@angular/cdk/keycodes'; +import {of as observableOf, Subject, isObservable, Observable} from 'rxjs'; +import {take} from 'rxjs/operators'; + +const TREE_DATA: ExampleFlatNode[] = [ + { + name: 'Fruit', + expandable: true, + level: 0, + }, + { + name: 'Apple', + expandable: false, + level: 1, + }, + { + name: 'Banana', + expandable: false, + level: 1, + }, + { + name: 'Fruit loops', + expandable: false, + level: 1, + }, + { + name: 'Vegetables', + expandable: true, + level: 0, + }, + { + name: 'Green', + expandable: true, + level: 1, + }, + { + name: 'Broccoli', + expandable: false, + level: 2, + }, + { + name: 'Brussels sprouts', + expandable: false, + level: 2, + }, + { + name: 'Orange', + expandable: true, + level: 1, + }, + { + name: 'Pumpkins', + expandable: false, + level: 2, + }, + { + name: 'Carrots', + expandable: false, + level: 2, + }, +]; + +/** Flat node with expandable and level information */ +interface ExampleFlatNode { + expandable: boolean; + name: string; + level: number; + isExpanded?: boolean; +} + +function coerceObservable(data: T | Observable): Observable { + if (!isObservable(data)) { + return observableOf(data); + } + return data; +} + +/** + * 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 VimTreeKeyManager implements TreeKeyManagerStrategy { + private _activeItemIndex = -1; + private _activeItem: T | null = null; + + private _items: T[] = []; + + // TreeKeyManagerOptions not implemented. + constructor(items: Observable | QueryList | 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`). + if (items instanceof QueryList) { + this._items = items.toArray(); + items.changes.subscribe((newItems: QueryList) => { + this._items = newItems.toArray(); + this._updateActiveItemIndex(this._items); + }); + } else if (isObservable(items)) { + items.subscribe(newItems => { + this._items = newItems; + this._updateActiveItemIndex(newItems); + }); + } else { + this._items = items; + } + } + + /** Stream that emits any time the focused item changes. */ + readonly change = new Subject(); + + /** + * Handles a keyboard event on the tree. + * @param event Keyboard event that represents the user interaction with the tree. + */ + onKeydown(event: KeyboardEvent) { + const keyCode = event.keyCode; + + switch (keyCode) { + case TAB: + // NB: return here, in order to allow Tab to actually tab out of the tree + return; + + case DOWN_ARROW: + case J: + this._focusNextItem(); + break; + + case UP_ARROW: + case K: + this._focusPreviousItem(); + break; + + case RIGHT_ARROW: + case L: + this._expandCurrentItem(); + break; + + case LEFT_ARROW: + case H: + this._collapseCurrentItem(); + break; + + case HOME: + this._focusFirstItem(); + break; + + case END: + this._focusLastItem(); + break; + + case ENTER: + case SPACE: + this._activateCurrentItem(); + break; + } + } + + /** Index of the currently active item. */ + getActiveItemIndex(): number | null { + return this._activeItemIndex; + } + + /** The currently active item. */ + getActiveItem(): T | null { + return this._activeItem; + } + + /** + * Focus the initial element; this is intended to be called when the tree is focused for + * the first time. + */ + onInitialFocus(): void { + 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. + * @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; + focusItem(itemOrIndex: number | T, options: {emitChangeEvent?: boolean} = {}) { + // Set default options + options.emitChangeEvent ??= true; + + let index = + typeof itemOrIndex === 'number' + ? itemOrIndex + : this._items.findIndex(item => item === itemOrIndex); + if (index < 0 || index >= this._items.length) { + return; + } + const activeItem = this._items[index]; + + // If we're just setting the same item, don't re-call activate or focus + if (this._activeItem !== null && activeItem === this._activeItem) { + return; + } + + this._activeItem = activeItem ?? null; + this._activeItemIndex = index; + + if (options.emitChangeEvent) { + // Emit to `change` stream as required by TreeKeyManagerStrategy interface. + this.change.next(this._activeItem); + } + this._activeItem?.focus(); + this._activateCurrentItem(); + } + + private _updateActiveItemIndex(newItems: T[]) { + const activeItem = this._activeItem; + if (activeItem) { + const newIndex = newItems.findIndex(item => item === activeItem); + + if (newIndex > -1 && newIndex !== this._activeItemIndex) { + this._activeItemIndex = newIndex; + } + } + } + + /** Focus the first available item. */ + private _focusFirstItem(): void { + this.focusItem(this._findNextAvailableItemIndex(-1)); + } + + /** Focus the last available item. */ + private _focusLastItem(): void { + this.focusItem(this._findPreviousAvailableItemIndex(this._items.length)); + } + + /** Focus the next available item. */ + private _focusNextItem(): void { + this.focusItem(this._findNextAvailableItemIndex(this._activeItemIndex)); + } + + /** Focus the previous available item. */ + private _focusPreviousItem(): void { + this.focusItem(this._findPreviousAvailableItemIndex(this._activeItemIndex)); + } + + //// Navigational methods + private _findNextAvailableItemIndex(startingIndex: number) { + if (startingIndex + 1 < this._items.length) { + return startingIndex + 1; + } + return startingIndex; + } + + private _findPreviousAvailableItemIndex(startingIndex: number) { + if (startingIndex - 1 >= 0) { + return startingIndex - 1; + } + return startingIndex; + } + + /** + * If the item is already expanded, we collapse the item. Otherwise, we will focus the parent. + */ + private _collapseCurrentItem() { + if (!this._activeItem) { + return; + } + + if (this._isCurrentItemExpanded()) { + this._activeItem.collapse(); + } else { + const parent = this._activeItem.getParent(); + if (!parent) { + return; + } + this.focusItem(parent as T); + } + } + + /** + * If the item is already collapsed, we expand the item. Otherwise, we will focus the first child. + */ + private _expandCurrentItem() { + if (!this._activeItem) { + return; + } + + if (!this._isCurrentItemExpanded()) { + this._activeItem.expand(); + } else { + coerceObservable(this._activeItem.getChildren()) + .pipe(take(1)) + .subscribe(children => { + const firstChild = children[0]; + if (!firstChild) { + return; + } + this.focusItem(firstChild as T); + }); + } + } + + private _isCurrentItemExpanded() { + if (!this._activeItem) { + return false; + } + return typeof this._activeItem.isExpanded === 'boolean' + ? this._activeItem.isExpanded + : this._activeItem.isExpanded(); + } + + private _activateCurrentItem() { + this._activeItem?.activate(); + } +} + +function VimTreeKeyManagerFactory(): TreeKeyManagerFactory { + return items => new VimTreeKeyManager(items); +} + +const VIM_TREE_KEY_MANAGER_PROVIDER = { + provide: TREE_KEY_MANAGER, + useFactory: VimTreeKeyManagerFactory, +}; + +/** + * @title Tree with vim keyboard commands. + */ +@Component({ + selector: 'cdk-tree-custom-key-manager-example', + templateUrl: 'cdk-tree-custom-key-manager-example.html', + styleUrls: ['cdk-tree-custom-key-manager-example.css'], + standalone: true, + imports: [CdkTreeModule, MatButtonModule, MatIconModule], + providers: [VIM_TREE_KEY_MANAGER_PROVIDER], +}) +export class CdkTreeCustomKeyManagerExample { + treeControl = new FlatTreeControl( + node => node.level, + node => node.expandable, + ); + + dataSource = new ArrayDataSource(TREE_DATA); + + hasChild = (_: number, node: ExampleFlatNode) => node.expandable; + + getParentNode(node: ExampleFlatNode) { + const nodeIndex = TREE_DATA.indexOf(node); + + for (let i = nodeIndex - 1; i >= 0; i--) { + if (TREE_DATA[i].level === node.level - 1) { + return TREE_DATA[i]; + } + } + + return null; + } + + shouldRender(node: ExampleFlatNode) { + let parent = this.getParentNode(node); + while (parent) { + if (!parent.isExpanded) { + return false; + } + parent = this.getParentNode(parent); + } + return true; + } +} diff --git a/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.css b/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.css new file mode 100644 index 000000000000..a88255f0d954 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.css @@ -0,0 +1,4 @@ +.example-tree-node { + display: flex; + align-items: center; +} diff --git a/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.html b/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.html new file mode 100644 index 000000000000..26671ecd3381 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.html @@ -0,0 +1,18 @@ + + + + + + {{node.name}} + + + + {{node.name}} + + diff --git a/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.ts b/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.ts new file mode 100644 index 000000000000..0571ac6cdb8c --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example.ts @@ -0,0 +1,105 @@ +import {Component} from '@angular/core'; +import {ArrayDataSource} from '@angular/cdk/collections'; +import {FlatTreeControl, CdkTreeModule} from '@angular/cdk/tree'; +import {MatIconModule} from '@angular/material/icon'; +import {MatButtonModule} from '@angular/material/button'; +import {LEGACY_TREE_KEY_MANAGER_FACTORY_PROVIDER} from '@angular/cdk/a11y'; + +const TREE_DATA: ExampleFlatNode[] = [ + { + name: 'Fruit', + expandable: true, + level: 0, + }, + { + name: 'Apple', + expandable: false, + level: 1, + }, + { + name: 'Banana', + expandable: false, + level: 1, + }, + { + name: 'Fruit loops', + expandable: false, + level: 1, + }, + { + name: 'Vegetables', + expandable: true, + level: 0, + }, + { + name: 'Green', + expandable: true, + level: 1, + }, + { + name: 'Broccoli', + expandable: false, + level: 2, + }, + { + name: 'Brussels sprouts', + expandable: false, + level: 2, + }, + { + name: 'Orange', + expandable: true, + level: 1, + }, + { + name: 'Pumpkins', + expandable: false, + level: 2, + }, + { + name: 'Carrots', + expandable: false, + level: 2, + }, +]; + +/** Flat node with expandable and level information */ +interface ExampleFlatNode { + expandable: boolean; + name: string; + level: number; +} + +/** + * @title Tree with flat nodes + */ +@Component({ + selector: 'cdk-tree-legacy-keyboard-interface-example', + templateUrl: 'cdk-tree-legacy-keyboard-interface-example.html', + styleUrls: ['cdk-tree-legacy-keyboard-interface-example.css'], + standalone: true, + imports: [CdkTreeModule, MatButtonModule, MatIconModule], + providers: [LEGACY_TREE_KEY_MANAGER_FACTORY_PROVIDER], +}) +export class CdkTreeLegacyKeyboardInterfaceExample { + treeControl = new FlatTreeControl( + node => node.level, + node => node.expandable, + ); + + dataSource = new ArrayDataSource(TREE_DATA); + + hasChild = (_: number, node: ExampleFlatNode) => node.expandable; + + getParentNode(node: ExampleFlatNode) { + const nodeIndex = TREE_DATA.indexOf(node); + + for (let i = nodeIndex - 1; i >= 0; i--) { + if (TREE_DATA[i].level === node.level - 1) { + return TREE_DATA[i]; + } + } + + return null; + } +} diff --git a/src/components-examples/cdk/tree/index.ts b/src/components-examples/cdk/tree/index.ts index d5fa0c4d1b48..5fe7af83353d 100644 --- a/src/components-examples/cdk/tree/index.ts +++ b/src/components-examples/cdk/tree/index.ts @@ -5,3 +5,5 @@ export {CdkTreeNestedLevelAccessorExample} from './cdk-tree-nested-level-accesso export {CdkTreeNestedChildrenAccessorExample} from './cdk-tree-nested-children-accessor/cdk-tree-nested-children-accessor-example'; export {CdkTreeNestedExample} from './cdk-tree-nested/cdk-tree-nested-example'; export {CdkTreeComplexExample} from './cdk-tree-complex/cdk-tree-complex-example'; +export {CdkTreeCustomKeyManagerExample} from './cdk-tree-custom-key-manager/cdk-tree-custom-key-manager-example'; +export {CdkTreeLegacyKeyboardInterfaceExample} from './cdk-tree-legacy-keyboard-interface/cdk-tree-legacy-keyboard-interface-example'; diff --git a/src/dev-app/tree/tree-demo.html b/src/dev-app/tree/tree-demo.html index d3b846ceaa0a..55a59636d204 100644 --- a/src/dev-app/tree/tree-demo.html +++ b/src/dev-app/tree/tree-demo.html @@ -43,4 +43,12 @@ Complex tree (Redux pattern) + + Custom Key Manager + + + + Legacy Keyboard Interface + + diff --git a/src/dev-app/tree/tree-demo.ts b/src/dev-app/tree/tree-demo.ts index 40e0e5c02b40..695858753b12 100644 --- a/src/dev-app/tree/tree-demo.ts +++ b/src/dev-app/tree/tree-demo.ts @@ -16,6 +16,8 @@ import { CdkTreeNestedChildrenAccessorExample, CdkTreeFlatChildrenAccessorExample, CdkTreeComplexExample, + CdkTreeCustomKeyManagerExample, + CdkTreeLegacyKeyboardInterfaceExample, } from '@angular/components-examples/cdk/tree'; import { TreeDynamicExample, @@ -41,10 +43,12 @@ import {MatTreeModule} from '@angular/material/tree'; standalone: true, imports: [ CdkTreeModule, + CdkTreeCustomKeyManagerExample, CdkTreeFlatExample, CdkTreeNestedExample, CdkTreeFlatChildrenAccessorExample, CdkTreeFlatLevelAccessorExample, + CdkTreeLegacyKeyboardInterfaceExample, CdkTreeNestedChildrenAccessorExample, CdkTreeNestedLevelAccessorExample, CdkTreeComplexExample, diff --git a/src/material/tree/node.ts b/src/material/tree/node.ts index 0ff01547521e..6294f6459de0 100644 --- a/src/material/tree/node.ts +++ b/src/material/tree/node.ts @@ -24,7 +24,17 @@ import { OnInit, } from '@angular/core'; import {CanDisable, HasTabIndex} from '@angular/material/core'; -import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {BooleanInput, coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; +import {LegacyTreeKeyManager, TreeKeyManagerItem, TreeKeyManagerStrategy} from '@angular/cdk/a11y'; + +/** + * Determinte if argument TreeKeyManager is the LegacyTreeKeyManager. This function is safe to use with SSR. + */ +function isLegacyTreeKeyManager( + keyManager: TreeKeyManagerStrategy, +): keyManager is LegacyTreeKeyManager { + return !!(keyManager as any)._isLegacyTreeKeyManager; +} /** * Wrapper for the CdkTree node with Material design styles. @@ -41,33 +51,48 @@ import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; '[attr.aria-level]': 'level + 1', '[attr.aria-posinset]': '_getPositionInSet()', '[attr.aria-setsize]': '_getSetSize()', - 'tabindex': '-1', - '(click)': '_setActiveItem()', + '(click)': '_focusItem()', + 'tabindex': '_getTabindexAttribute()', }, }) export class MatTreeNode extends CdkTreeNode implements CanDisable, HasTabIndex, OnInit, OnDestroy { + private _tabIndex: number; + /** * The tabindex of the tree node. * - * @deprecated MatTreeNode ignores this proprety. Changing tabIndex has no affect. The tree - * automatically determines the appropriate tabindex for the tree node. To be removed in a - * future version. + * @deprecated By default MatTreeNode manages focus using TreeKeyManager instead of tabIndex. + * Recommend to avoid setting tabIndex directly to prevent TreeKeyManager form getting into + * an unexpected state. Tabindex to be removed in a future version. * @breaking-change 19.0.0 Remove this attribute. */ - tabIndex: number; + get tabIndex(): number { + return this.isDisabled ? -1 : this._tabIndex; + } + set tabIndex(value: number) { + // If the specified tabIndex value is null or undefined, fall back to the default value. + this._tabIndex = value != null ? coerceNumberProperty(value) : this.defaultTabIndex; + } /** - * The tabindex of the tree node. + * The default tabindex of the tree node. * - * @deprecated MatTreeNode ignores this proprety. Changing defaultTabIndex has no affect. The tree - * automatically determines the appropriate tabindex for the tree node. To be removed in a - * future version. + * @deprecated By default MatTreeNode manages focus using TreeKeyManager instead of tabIndex. + * Recommend to avoid setting tabIndex directly to prevent TreeKeyManager form getting into + * an unexpected state. Tabindex to be removed in a future version. * @breaking-change 19.0.0 Remove this attribute. */ - defaultTabIndex: number; + defaultTabIndex = 0; + + protected _getTabindexAttribute() { + if (isLegacyTreeKeyManager(this._tree._keyManager)) { + return this.tabIndex; + } + return -1; + } /** * Whether the component is disabled. @@ -85,12 +110,19 @@ export class MatTreeNode constructor( elementRef: ElementRef, tree: CdkTree, - // Ignore tabindex attribute. MatTree manages its own active state using TreeKeyManager. - // Keeping tabIndex in constructor for backwards compatibility with trees created before - // introducing TreeKeyManager. + /** + * The tabindex of the tree node. + * + * @deprecated By default MatTreeNode manages focus using TreeKeyManager instead of tabIndex. + * Recommend to avoid setting tabIndex directly to prevent TreeKeyManager form getting into + * an unexpected state. Tabindex to be removed in a future version. + * @breaking-change 19.0.0 Remove this attribute. + */ @Attribute('tabindex') tabIndex: string, ) { super(elementRef, tree); + + this._tabIndex = Number(tabIndex) || this.defaultTabIndex; } // This is a workaround for https://github.com/angular/angular/issues/23091 diff --git a/tools/public_api_guard/cdk/a11y.md b/tools/public_api_guard/cdk/a11y.md index 72f9b3069698..d865247b2abc 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -345,6 +345,33 @@ export class IsFocusableConfig { ignoreVisibility: boolean; } +// @public @deprecated +export function LEGACY_TREE_KEY_MANAGER_FACTORY(): TreeKeyManagerFactory; + +// @public @deprecated +export const LEGACY_TREE_KEY_MANAGER_FACTORY_PROVIDER: { + provide: InjectionToken>; + useFactory: typeof LEGACY_TREE_KEY_MANAGER_FACTORY; +}; + +// @public @deprecated +export class LegacyTreeKeyManager implements TreeKeyManagerStrategy { + // (undocumented) + readonly change: Subject; + // (undocumented) + focusItem(): void; + // (undocumented) + getActiveItem(): null; + // (undocumented) + getActiveItemIndex(): null; + // (undocumented) + get _isLegacyTreeKeyManager(): boolean; + // (undocumented) + onInitialFocus(): void; + // (undocumented) + onKeydown(): void; +} + // @public export class ListKeyManager { constructor(_items: QueryList | T[]); @@ -427,38 +454,41 @@ export interface RegisteredMessage { export function removeAriaReferencedId(el: Element, attr: `aria-${string}`, id: string): void; // @public -export class TreeKeyManager { - constructor({ items, skipPredicate, trackBy, horizontalOrientation, activationFollowsFocus, typeAheadDebounceInterval, }: TreeKeyManagerOptions); +export const TREE_KEY_MANAGER: InjectionToken>; + +// @public +export function TREE_KEY_MANAGER_FACTORY(): TreeKeyManagerFactory; + +// @public +export const TREE_KEY_MANAGER_FACTORY_PROVIDER: { + provide: InjectionToken>; + useFactory: typeof TREE_KEY_MANAGER_FACTORY; +}; + +// @public +export class TreeKeyManager implements TreeKeyManagerStrategy { + constructor(items: Observable | QueryList | T[], { skipPredicate, trackBy, horizontalOrientation, activationFollowsFocus, typeAheadDebounceInterval, }: TreeKeyManagerOptions); readonly change: Subject; - focusFirstItem(): void; focusItem(index: number, options?: { emitChangeEvent?: boolean; }): void; + // (undocumented) focusItem(item: T, options?: { emitChangeEvent?: boolean; }): void; - focusLastItem(): void; - focusNextItem(): void; - focusPreviousItem(): void; + // (undocumented) + focusItem(itemOrIndex: number | T, options?: { + emitChangeEvent?: boolean; + }): void; getActiveItem(): T | null; getActiveItemIndex(): number | null; onInitialFocus(): void; onKeydown(event: KeyboardEvent): void; - // (undocumented) - setActiveItem(index: number, options?: { - emitChangeEvent?: boolean; - }): void; - // (undocumented) - setActiveItem(item: T, options?: { - emitChangeEvent?: boolean; - }): void; - // (undocumented) - setActiveItem(itemOrIndex: number | T, options?: { - emitChangeEvent?: boolean; - }): void; - readonly tabOut: Subject; } +// @public (undocumented) +export type TreeKeyManagerFactory = (items: Observable | QueryList | T[], options: TreeKeyManagerOptions) => TreeKeyManagerStrategy; + // @public export interface TreeKeyManagerItem { activate(): void; @@ -476,13 +506,30 @@ export interface TreeKeyManagerItem { export interface TreeKeyManagerOptions { activationFollowsFocus?: boolean; horizontalOrientation?: 'rtl' | 'ltr'; - // (undocumented) - items: Observable | QueryList | T[]; skipPredicate?: (item: T) => boolean; trackBy?: (treeItem: T) => unknown; typeAheadDebounceInterval?: true | number; } +// @public (undocumented) +export interface TreeKeyManagerStrategy { + readonly change: Subject; + focusItem(index: number, options?: { + emitChangeEvent?: boolean; + }): void; + focusItem(item: T, options?: { + emitChangeEvent?: boolean; + }): void; + // (undocumented) + focusItem(itemOrIndex: number | T, options?: { + emitChangeEvent?: boolean; + }): void; + getActiveItem(): T | null; + getActiveItemIndex(): number | null; + onInitialFocus(): void; + onKeydown(event: KeyboardEvent): void; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/tools/public_api_guard/cdk/tree.md b/tools/public_api_guard/cdk/tree.md index 9b64647f75d1..f45a9476244d 100644 --- a/tools/public_api_guard/cdk/tree.md +++ b/tools/public_api_guard/cdk/tree.md @@ -27,8 +27,8 @@ import { SelectionModel } from '@angular/cdk/collections'; import { Subject } from 'rxjs'; import { TemplateRef } from '@angular/core'; import { TrackByFunction } from '@angular/core'; -import { TreeKeyManager } from '@angular/cdk/a11y'; import { TreeKeyManagerItem } from '@angular/cdk/a11y'; +import { TreeKeyManagerStrategy } from '@angular/cdk/a11y'; import { ViewContainerRef } from '@angular/core'; // @public @deprecated @@ -102,7 +102,7 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, _getSetSize(dataNode: T): number; insertNode(nodeData: T, index: number, viewContainer?: ViewContainerRef, parentData?: T): void; isExpanded(dataNode: T): boolean; - _keyManager: TreeKeyManager>; + _keyManager: TreeKeyManagerStrategy>; levelAccessor?: (dataNode: T) => number; // (undocumented) ngAfterContentChecked(): void; @@ -167,6 +167,8 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI expand(): void; readonly expandedChange: EventEmitter; focus(): void; + // (undocumented) + _focusItem(): void; _getAriaExpanded(): string | null; // (undocumented) getChildren(): CdkTreeNode[] | Observable[]>; @@ -192,8 +194,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI get role(): 'treeitem' | 'group'; set role(_role: 'treeitem' | 'group'); // (undocumented) - _setActiveItem(): void; - // (undocumented) _setTabFocusable(): void; // (undocumented) _setTabUnfocusable(): void; diff --git a/tools/public_api_guard/material/tree.md b/tools/public_api_guard/material/tree.md index 69c092f082a5..ae35d952fc6c 100644 --- a/tools/public_api_guard/material/tree.md +++ b/tools/public_api_guard/material/tree.md @@ -113,18 +113,22 @@ export class MatTreeNestedDataSource extends DataSource { // @public export class MatTreeNode extends CdkTreeNode implements CanDisable, HasTabIndex, OnInit, OnDestroy { - constructor(elementRef: ElementRef, tree: CdkTree, tabIndex: string); + constructor(elementRef: ElementRef, tree: CdkTree, + tabIndex: string); // @deprecated defaultTabIndex: number; // @deprecated get disabled(): boolean; set disabled(value: BooleanInput); // (undocumented) + protected _getTabindexAttribute(): number; + // (undocumented) ngOnDestroy(): void; // (undocumented) ngOnInit(): void; // @deprecated - tabIndex: number; + get tabIndex(): number; + set tabIndex(value: number); // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration, "mat-tree-node", ["matTreeNode"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "isExpandable": { "alias": "isExpandable"; "required": false; }; "isExpanded": { "alias": "isExpanded"; "required": false; }; "isDisabled": { "alias": "isDisabled"; "required": false; }; }, { "activation": "activation"; "expandedChange": "expandedChange"; }, never, never, false, never>; // (undocumented)