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 f32f4b1423a3..1f9abc891ac9 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -164,7 +164,7 @@ describe('TreeKeyManager', () => { }); it('should maintain the active item if the amount of items changes', () => { - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); expect(keyManager.getActiveItem()?.getLabel()) @@ -199,7 +199,7 @@ describe('TreeKeyManager', () => { }); it('should emit an event whenever the active item changes', () => { - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -214,14 +214,14 @@ describe('TreeKeyManager', () => { }); it('should emit if the active item changed, but not the active index', () => { - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(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.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); expect(spy).toHaveBeenCalledTimes(1); subscription.unsubscribe(); @@ -248,7 +248,7 @@ describe('TreeKeyManager', () => { }); it('should not do anything for unsupported key presses', () => { - keyManager.onClick(itemList.get(1)!); + keyManager.setActiveItem(itemList.get(1)!); expect(keyManager.getActiveItemIndex()).toBe(1); expect(fakeKeyEvents.unsupported.defaultPrevented).toBe(false); @@ -260,7 +260,7 @@ describe('TreeKeyManager', () => { }); it('should focus the first item when Home is pressed', () => { - keyManager.onClick(itemList.get(1)!); + keyManager.setActiveItem(itemList.get(1)!); expect(keyManager.getActiveItemIndex()).toBe(1); keyManager.onKeydown(fakeKeyEvents.home); @@ -270,7 +270,7 @@ describe('TreeKeyManager', () => { it('should focus the first non-disabled item when Home is pressed', () => { itemList.get(0)!.isDisabled = true; - keyManager.onClick(itemList.get(2)!); + keyManager.setActiveItem(itemList.get(2)!); expect(keyManager.getActiveItemIndex()).toBe(2); keyManager.onKeydown(fakeKeyEvents.home); @@ -279,7 +279,7 @@ describe('TreeKeyManager', () => { }); it('should focus the last item when End is pressed', () => { - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).toBe(0); keyManager.onKeydown(fakeKeyEvents.end); @@ -288,7 +288,7 @@ describe('TreeKeyManager', () => { it('should focus the last non-disabled item when End is pressed', () => { itemList.get(itemList.length - 1)!.isDisabled = true; - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).toBe(0); keyManager.onKeydown(fakeKeyEvents.end); @@ -299,7 +299,7 @@ describe('TreeKeyManager', () => { describe('up/down key events', () => { it('should set subsequent items as active when the down key is pressed', () => { - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -330,7 +330,7 @@ describe('TreeKeyManager', () => { }); it('should set previous item as active when the up key is pressed', () => { - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -365,7 +365,7 @@ describe('TreeKeyManager', () => { it('should skip disabled items', () => { itemList.get(1)!.isDisabled = true; - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -393,7 +393,7 @@ describe('TreeKeyManager', () => { itemList.get(0)!.isDisabled = undefined; itemList.get(1)!.isDisabled = undefined; itemList.get(2)!.isDisabled = undefined; - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); const spy = jasmine.createSpy('change spy'); const subscription = keyManager.change.subscribe(spy); @@ -416,7 +416,7 @@ describe('TreeKeyManager', () => { }); it('should not move active item past either end of the list', () => { - keyManager.onClick(itemList.get(itemList.length - 1)!); + keyManager.setActiveItem(itemList.get(itemList.length - 1)!); expect(keyManager.getActiveItemIndex()) .withContext('active item index, selecting the last item') @@ -428,7 +428,7 @@ describe('TreeKeyManager', () => { .withContext('active item index, last item still selected after a down event') .toBe(itemList.length - 1); - keyManager.onClick(itemList.get(0)!); + keyManager.setActiveItem(itemList.get(0)!); keyManager.onKeydown(fakeKeyEvents.upArrow); expect(keyManager.getActiveItemIndex()) .withContext('active item index, selecting the first item') @@ -444,7 +444,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.onClick(itemList.get(itemList.length - 2)!); + keyManager.setActiveItem(itemList.get(itemList.length - 2)!); expect(keyManager.getActiveItemIndex()) .withContext('active item index, last non-disabled item selected') .toBe(itemList.length - 2); @@ -555,7 +555,7 @@ describe('TreeKeyManager', () => { let subscription: Subscription; beforeEach(() => { - keyManager.onClick(parentItem); + keyManager.setActiveItem(parentItem); parentItem._isExpanded = true; spy = jasmine.createSpy('change spy'); @@ -640,7 +640,7 @@ describe('TreeKeyManager', () => { let subscription: Subscription; beforeEach(() => { - keyManager.onClick(childItemWithNoChildren); + keyManager.setActiveItem(childItemWithNoChildren); childItemWithNoChildren._isExpanded = true; spy = jasmine.createSpy('change spy'); @@ -666,7 +666,7 @@ describe('TreeKeyManager', () => { let subscription: Subscription; beforeEach(() => { - keyManager.onClick(childItem); + keyManager.setActiveItem(childItem); childItem._isExpanded = false; spy = jasmine.createSpy('change spy'); @@ -733,7 +733,7 @@ describe('TreeKeyManager', () => { let subscription: Subscription; beforeEach(() => { - keyManager.onClick(parentItem); + keyManager.setActiveItem(parentItem); parentItem._isExpanded = false; spy = jasmine.createSpy('change spy'); @@ -924,7 +924,7 @@ describe('TreeKeyManager', () => { ]); itemList.notifyOnChanges(); - keyManager.onClick(frodo); + keyManager.setActiveItem(frodo); keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); tick(debounceInterval); @@ -942,7 +942,7 @@ describe('TreeKeyManager', () => { ]); itemList.notifyOnChanges(); - keyManager.onClick(boromir); + keyManager.setActiveItem(boromir); keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); tick(debounceInterval); @@ -950,7 +950,7 @@ describe('TreeKeyManager', () => { })); it('should wrap back around if the last item is active', fakeAsync(() => { - keyManager.onClick(lastItem); + keyManager.setActiveItem(lastItem); keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); tick(debounceInterval); diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index f15ef34fda52..0de04aabba4b 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -261,14 +261,6 @@ export class TreeKeyManager { event.preventDefault(); } - /** - * Handles a mouse click on a particular tree item. - * @param treeItem The item that was clicked by the user. - */ - onClick(treeItem: T) { - this._setActiveItem(treeItem); - } - /** Index of the currently active item. */ getActiveItemIndex(): number | null { return this._activeItemIndex; @@ -300,7 +292,7 @@ export class TreeKeyManager { */ focusItem(item: T, options?: {emitChangeEvent?: boolean}): void; focusItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void { - this._setActiveItem(itemOrIndex, options); + this.setActiveItem(itemOrIndex, options); } /** Focus the first available item. */ @@ -323,10 +315,10 @@ export class TreeKeyManager { this._focusPreviousItem(); } - private _setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void; - private _setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void; - private _setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void; - private _setActiveItem(itemOrIndex: number | T, options: {emitChangeEvent?: boolean} = {}) { + 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} = {}) { // Set default options options.emitChangeEvent ??= true; @@ -406,7 +398,7 @@ export class TreeKeyManager { !this._skipPredicateFn(item) && item.getLabel?.().toLocaleUpperCase().trim().indexOf(inputString) === 0 ) { - this._setActiveItem(index); + this.setActiveItem(index); break; } } @@ -418,19 +410,19 @@ export class TreeKeyManager { //// Navigational methods private _focusFirstItem() { - this._setActiveItem(this._findNextAvailableItemIndex(-1)); + this.setActiveItem(this._findNextAvailableItemIndex(-1)); } private _focusLastItem() { - this._setActiveItem(this._findPreviousAvailableItemIndex(this._items.length)); + this.setActiveItem(this._findPreviousAvailableItemIndex(this._items.length)); } private _focusPreviousItem() { - this._setActiveItem(this._findPreviousAvailableItemIndex(this._activeItemIndex)); + this.setActiveItem(this._findPreviousAvailableItemIndex(this._activeItemIndex)); } private _focusNextItem() { - this._setActiveItem(this._findNextAvailableItemIndex(this._activeItemIndex)); + this.setActiveItem(this._findNextAvailableItemIndex(this._activeItemIndex)); } private _findNextAvailableItemIndex(startingIndex: number) { @@ -466,7 +458,7 @@ export class TreeKeyManager { if (!parent || this._skipPredicateFn(parent as T)) { return; } - this._setActiveItem(parent as T); + this.setActiveItem(parent as T); } } @@ -488,7 +480,7 @@ export class TreeKeyManager { if (!firstChild) { return; } - this._setActiveItem(firstChild as T); + this.setActiveItem(firstChild as T); }); } } diff --git a/src/cdk/tree/tree.spec.ts b/src/cdk/tree/tree.spec.ts index a43a1dc4e999..2670e331bf84 100644 --- a/src/cdk/tree/tree.spec.ts +++ b/src/cdk/tree/tree.spec.ts @@ -1174,6 +1174,18 @@ describe('CdkTree redesign', () => { expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['0', '-1', '-1', '-1', '-1', '-1']); }); + it('maintains tabindex when a node is programatically focused', () => { + // activate the second child by programatically focusing it + nodes[1].focus(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']); + + // activate the first child by programatically focusing it + nodes[0].focus(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['0', '-1', '-1', '-1', '-1', '-1']); + }); + it('maintains tabindex when component is blurred', () => { // activate the second child by clicking on it nodes[1].click(); diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 422eb7796c3b..a3070428cb44 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -1085,6 +1085,7 @@ export class CdkTree 'tabindex': '-1', 'role': 'treeitem', '(click)': '_setActiveItem()', + '(focus)': '_setActiveItem()', }, }) export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { @@ -1290,7 +1291,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI if (this.isDisabled) { return; } - this._tree._keyManager.onClick(this); + this._tree._keyManager.setActiveItem(this); } _emitExpansionState(expanded: boolean) { diff --git a/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.html b/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.html index 9322c426514b..9e7feb6b0e9e 100644 --- a/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.html +++ b/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.html @@ -2,25 +2,25 @@ - {{node.item}} + {{node.name}} - {{node.item}} + {{node.name}} - Load more... + role="button" (click)="loadOnClick($event, node)" + (keydown)="loadOnKeypress($event, node)"> + Load more of {{node.parent}}... diff --git a/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.ts b/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.ts index 8f7ff159c9fc..cd24458c1654 100644 --- a/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.ts +++ b/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.ts @@ -14,84 +14,109 @@ import {MatButtonModule} from '@angular/material/button'; import {ENTER, SPACE} from '@angular/cdk/keycodes'; const LOAD_MORE = 'LOAD_MORE'; +let loadMoreId = 1; /** Nested node */ -export class LoadmoreNode { - childrenChange = new BehaviorSubject([]); +export class NestedNode { + childrenChange = new BehaviorSubject([]); - get children(): LoadmoreNode[] { + get children(): NestedNode[] { return this.childrenChange.value; } constructor( - public item: string, + public name: string, public hasChildren = false, - public loadMoreParentItem: string | null = null, + public parent: string | null = null, + public isLoadMore = false, ) {} } /** Flat node with expandable and level information */ -export class LoadmoreFlatNode { +export class FlatNode { constructor( - public item: string, + public name: string, public level = 1, public expandable = false, - public loadMoreParentItem: string | null = null, + public parent: string | null = null, + public isLoadMore = false, ) {} } +/** Number of nodes loaded at a time */ +const batchSize = 3; + /** * A database that only load part of the data initially. After user clicks on the `Load more` * button, more data will be loaded. */ @Injectable() export class LoadmoreDatabase { - batchNumber = 5; - dataChange = new BehaviorSubject([]); - nodeMap = new Map(); + /** Map of node name to node */ + nodes = new Map(); + + dataChange = new BehaviorSubject([]); - /** The data */ - rootLevelNodes: string[] = ['Vegetables', 'Fruits']; - dataMap = new Map([ + /** Example data */ + rootNodes: string[] = ['Vegetables', 'Fruits']; + childMap = new Map([ ['Fruits', ['Apple', 'Orange', 'Banana']], ['Vegetables', ['Tomato', 'Potato', 'Onion']], - ['Apple', ['Fuji', 'Macintosh']], + [ + 'Apple', + [ + 'Gala', + 'Braeburn', + 'Fuji', + 'Macintosh', + 'Golden Delicious', + 'Red Delicious', + 'Empire', + 'Granny Smith', + 'Cameo', + 'Baldwin', + 'Jonagold', + ], + ], ['Onion', ['Yellow', 'White', 'Purple', 'Green', 'Shallot', 'Sweet', 'Red', 'Leek']], ]); initialize() { - const data = this.rootLevelNodes.map(name => this._generateNode(name)); + const data = this.rootNodes.map(name => this._generateNode(name, null)); this.dataChange.next(data); } /** Expand a node whose children are not loaded */ - loadMore(item: string, onlyFirstTime = false) { - if (!this.nodeMap.has(item) || !this.dataMap.has(item)) { + loadChildren(name: string, onlyFirstTime = false) { + if (!this.nodes.has(name) || !this.childMap.has(name)) { return; } - const parent = this.nodeMap.get(item)!; - const children = this.dataMap.get(item)!; + const parent = this.nodes.get(name)!; + const children = this.childMap.get(name)!; + if (onlyFirstTime && parent.children!.length > 0) { return; } - const newChildrenNumber = parent.children!.length + this.batchNumber; - const nodes = children.slice(0, newChildrenNumber).map(name => this._generateNode(name)); + + const newChildrenNumber = parent.children!.length + batchSize; + const nodes = children + .slice(0, newChildrenNumber) + .map(name => this._generateNode(name, parent.name)); if (newChildrenNumber < children.length) { - // Need a new load more node - nodes.push(new LoadmoreNode(LOAD_MORE, false, item)); + // Need a new "Load More" node + nodes.push(new NestedNode(`LOAD_MORE-${loadMoreId++}`, false, name, true)); } parent.childrenChange.next(nodes); this.dataChange.next(this.dataChange.value); } - private _generateNode(item: string): LoadmoreNode { - if (this.nodeMap.has(item)) { - return this.nodeMap.get(item)!; + private _generateNode(name: string, parent: string | null): NestedNode { + if (!this.nodes.has(name)) { + this.nodes.set(name, new NestedNode(name, this.childMap.has(name), parent)); } - const result = new LoadmoreNode(item, this.dataMap.has(item)); - this.nodeMap.set(item, result); - return result; + + return this.nodes.get(name)!; } } @@ -107,11 +132,11 @@ export class LoadmoreDatabase { imports: [MatTreeModule, MatButtonModule, MatIconModule], }) export class TreeLoadmoreExample { - nodeMap = new Map(); - treeControl: FlatTreeControl; - treeFlattener: MatTreeFlattener; + nodeMap = new Map(); + treeControl: FlatTreeControl; + treeFlattener: MatTreeFlattener; // Flat tree data source - dataSource: MatTreeFlatDataSource; + dataSource: MatTreeFlatDataSource; constructor(private _database: LoadmoreDatabase) { this.treeFlattener = new MatTreeFlattener( @@ -121,7 +146,8 @@ export class TreeLoadmoreExample { this.getChildren, ); - this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + // TODO(#27626): Remove treeControl. Adopt either levelAccessor or childrenAccessor. + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); @@ -132,57 +158,58 @@ export class TreeLoadmoreExample { _database.initialize(); } - getChildren = (node: LoadmoreNode): Observable => node.childrenChange; + getChildren = (node: NestedNode): Observable => node.childrenChange; - transformer = (node: LoadmoreNode, level: number) => { - const existingNode = this.nodeMap.get(node.item); + transformer = (node: NestedNode, level: number) => { + const existingNode = this.nodeMap.get(node.name); if (existingNode) { return existingNode; } - const newNode = new LoadmoreFlatNode( - node.item, - level, - node.hasChildren, - node.loadMoreParentItem, - ); - this.nodeMap.set(node.item, newNode); + const newNode = new FlatNode(node.name, level, node.hasChildren, node.parent, node.isLoadMore); + this.nodeMap.set(node.name, newNode); return newNode; }; - getLevel = (node: LoadmoreFlatNode) => node.level; + getLevel = (node: FlatNode) => node.level; - isExpandable = (node: LoadmoreFlatNode) => node.expandable; + isExpandable = (node: FlatNode) => node.expandable; - hasChild = (_: number, _nodeData: LoadmoreFlatNode) => _nodeData.expandable; + hasChild = (_: number, node: FlatNode) => node.expandable; - isLoadMore = (_: number, _nodeData: LoadmoreFlatNode) => _nodeData.item === LOAD_MORE; + isLoadMore = (_: number, node: FlatNode) => node.isLoadMore; - private loadMoreData(node: LoadmoreFlatNode) { - // TODO: set focus to appropriate location - if (node.loadMoreParentItem) { - this._database.loadMore(node.loadMoreParentItem); - } + loadChildren(node: FlatNode) { + this._database.loadChildren(node.name, true); } - /** Load more nodes from data source */ - loadMoreOnClick(event: MouseEvent, node: LoadmoreFlatNode) { - this.loadMoreData(node); + /** Load more nodes when clicking on "Load more" node. */ + loadOnClick(event: MouseEvent, node: FlatNode) { + this.loadSiblings(event.target as HTMLElement, node); } - loadMoreOnEnterOrSpace(event: KeyboardEvent, node: LoadmoreFlatNode) { + /** Load more nodes on keyboardpress when focused on "Load more" node */ + loadOnKeypress(event: KeyboardEvent, node: FlatNode) { if (event.keyCode === ENTER || event.keyCode === SPACE) { - // TODO: set focus to an appropriate location - this.loadMoreData(node); - - // Prevent default behavior so that the tree node doesn't handle the keypress instead of this - // button. - event.preventDefault(); + this.loadSiblings(event.target as HTMLElement, node); } } - loadChildren(node: LoadmoreFlatNode) { - this._database.loadMore(node.item, true); + private loadSiblings(nodeElement: HTMLElement, node: FlatNode) { + if (node.parent) { + // Store a reference to the sibling of the "Load More" node before it is removed from the DOM + const previousSibling = nodeElement.previousElementSibling; + + // Synchronously load data. + this._database.loadChildren(node.parent); + + const focusDesination = previousSibling?.nextElementSibling || previousSibling; + + if (focusDesination) { + // Restore focus. + (focusDesination as HTMLElement).focus(); + } + } } } diff --git a/tools/public_api_guard/cdk/a11y.md b/tools/public_api_guard/cdk/a11y.md index 3e12818295b6..72f9b3069698 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -442,9 +442,20 @@ export class TreeKeyManager { focusPreviousItem(): void; getActiveItem(): T | null; getActiveItemIndex(): number | null; - onClick(treeItem: T): void; 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; }