From c1fb19f9b277beb95ad24c58030187f7ee1a7551 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Tue, 18 Apr 2023 21:38:03 +0000 Subject: [PATCH 01/18] feat(cdk/a11y): add some missing focus functions to TreeKeyManager, fix tests --- .../a11y/key-manager/tree-key-manager.spec.ts | 210 ++++++++++++++++++ src/cdk/a11y/key-manager/tree-key-manager.ts | 53 ++++- 2 files changed, 259 insertions(+), 4 deletions(-) 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 cceea2b40dec..17abfa81ea76 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -23,6 +23,7 @@ class FakeBaseTreeKeyManagerItem { _children: FakeBaseTreeKeyManagerItem[] = []; isDisabled?: boolean = false; + skipItem?: boolean = false; constructor(private _label: string) {} @@ -263,6 +264,16 @@ describe('TreeKeyManager', () => { expect(keyManager.getActiveItemIndex()).toBe(0); }); + it('should focus the first non-disabled item when Home is pressed', () => { + itemList.get(0)!.isDisabled = true; + keyManager.onClick(itemList.get(2)!); + expect(keyManager.getActiveItemIndex()).toBe(2); + + keyManager.onKeydown(fakeKeyEvents.home); + + expect(keyManager.getActiveItemIndex()).toBe(1); + }); + it('should focus the last item when End is pressed', () => { keyManager.onClick(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).toBe(0); @@ -270,6 +281,16 @@ describe('TreeKeyManager', () => { keyManager.onKeydown(fakeKeyEvents.end); expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 1); }); + + it('should focus the last non-disabled item when End is pressed', () => { + itemList.get(itemList.length - 1)!.isDisabled = true; + keyManager.onClick(itemList.get(0)!); + expect(keyManager.getActiveItemIndex()).toBe(0); + + keyManager.onKeydown(fakeKeyEvents.end); + + expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 2); + }); }); describe('up/down key events', () => { @@ -946,6 +967,195 @@ describe('TreeKeyManager', () => { expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(-1); })); }); + + describe('focusItem', () => { + beforeEach(() => { + keyManager.onInitialFocus(); + }); + + it('should focus the provided index', () => { + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + + keyManager.focusItem(1); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + }); + + it('should be able to set the active item by reference', () => { + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + + keyManager.focusItem(itemList.get(2)!); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + }); + + it('should be able to set the active item without emitting an event', () => { + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + + expect(keyManager.getActiveItemIndex()).toBe(0); + + keyManager.focusItem(2, {emitChangeEvent: false}); + + expect(keyManager.getActiveItemIndex()).toBe(2); + expect(spy).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + }); + + it('should not emit an event if the item did not change', () => { + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + + keyManager.focusItem(2); + keyManager.focusItem(2); + + expect(spy).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + }); + }); + + 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, + skipPredicate: item => item.skipItem ?? false, + }); + keyManager.onInitialFocus(); + }); + + it('should be able to skip items with a custom predicate', () => { + itemList.get(1)!.skipItem = true; + expect(keyManager.getActiveItemIndex()).toBe(0); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()).toBe(2); + }); + }); + + describe('focus', () => { + beforeEach(() => { + keyManager.onInitialFocus(); + + for (const item of itemList) { + spyOn(item, 'focus'); + } + }); + + it('calls .focus() on focused items', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(itemList.get(0)!.focus).not.toHaveBeenCalled(); + expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1); + expect(itemList.get(2)!.focus).not.toHaveBeenCalled(); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(itemList.get(0)!.focus).not.toHaveBeenCalled(); + expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1); + expect(itemList.get(2)!.focus).toHaveBeenCalledTimes(1); + }); + + it('calls .focus() on focused items, when pressing up key', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(itemList.get(0)!.focus).not.toHaveBeenCalled(); + expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1); + + keyManager.onKeydown(fakeKeyEvents.upArrow); + + expect(itemList.get(0)!.focus).toHaveBeenCalledTimes(1); + expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1); + }); + }); }); } }); diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index f5103d05b42b..3cd975ee3d1c 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -66,6 +66,9 @@ export interface TreeKeyManagerItem { focus(): void; } +/** + * Configuration for the TreeKeyManager. + */ export interface TreeKeyManagerOptions { items: Observable | QueryList | T[]; @@ -284,9 +287,49 @@ export class TreeKeyManager { this._focusFirstItem(); } - private _setActiveItem(index: number): void; - private _setActiveItem(item: T): void; - private _setActiveItem(itemOrIndex: number | T) { + /** + * 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(); + } + + /** Focus the last available item. */ + focusLastItem(): void { + this._focusLastItem(); + } + + /** Focus the next available item. */ + focusNextItem(): void { + this._focusNextItem(); + } + + /** Focus the previous available item. */ + focusPreviousItem(): void { + 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} = {}) { + // Set default options + options.emitChangeEvent ??= true; + let index = typeof itemOrIndex === 'number' ? itemOrIndex @@ -307,7 +350,9 @@ export class TreeKeyManager { this._activeItem = activeItem ?? null; this._activeItemIndex = index; - this.change.next(this._activeItem); + if (options.emitChangeEvent) { + this.change.next(this._activeItem); + } this._activeItem?.focus(); if (this._activationFollowsFocus) { this._activateCurrentItem(); From d3ac29dc954b646605c9aa28504aeae5ddc68e49 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Wed, 14 Jun 2023 18:37:48 +0000 Subject: [PATCH 02/18] feat(cdk/tree): report an error when the API consumer tries to expand a non-expandable node. --- src/cdk/tree/tree-errors.ts | 12 ++++++++++++ src/cdk/tree/tree.ts | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/src/cdk/tree/tree-errors.ts b/src/cdk/tree/tree-errors.ts index d07879aa5e69..a4dcd42f999e 100644 --- a/src/cdk/tree/tree-errors.ts +++ b/src/cdk/tree/tree-errors.ts @@ -54,3 +54,15 @@ export function getMultipleTreeControlsError() { export function getTreeControlNodeTypeUnspecifiedError() { return Error(`The nodeType was not specified for the tree.`); } + +/** + * Returns an error to be thrown when a node is attempted to be expanded or collapsed when + * it's not expandable. + * @docs-private + */ +export function getNodeNotExpandableError() { + return Error( + `The node that was attempted to be expanded or collapsed is not expandable; you might ` + + `need to provide 'isExpandable'.`, + ); +} diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 0513ec1d06f0..bcc0c97115e1 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -61,6 +61,7 @@ import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node'; import {CdkTreeNodeOutlet} from './outlet'; import { getMultipleTreeControlsError, + getNodeNotExpandableError, getTreeControlMissingError, getTreeControlNodeTypeUnspecifiedError, getTreeMissingMatchingNodeDefError, @@ -1097,12 +1098,18 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Collapses this data node. Implemented for TreeKeyManagerItem. */ collapse(): void { + if (typeof ngDevMode === 'undefined' || (ngDevMode && !this._isExpandable())) { + throw getNodeNotExpandableError(); + } this._tree.collapse(this._data); this.expandedChange.emit(this.isExpanded); } /** Expands this data node. Implemented for TreeKeyManagerItem. */ expand(): void { + if (typeof ngDevMode === 'undefined' || (ngDevMode && !this._isExpandable())) { + throw getNodeNotExpandableError(); + } this._tree.expand(this._data); this.expandedChange.emit(this.isExpanded); } From 467c42702cc3632c36965e5624a570b01e8d3f74 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Wed, 14 Jun 2023 18:49:22 +0000 Subject: [PATCH 03/18] fix(cdk/tree): set node role through component host --- src/cdk/tree/tree.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index bcc0c97115e1..bbbe3a4d0dc6 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -923,6 +923,7 @@ export class CdkTree '[attr.aria-posinset]': '_getPositionInSet()', '[attr.aria-setsize]': '_getSetSize()', 'tabindex': '-1', + 'role': 'treeitem', '(click)': '_setActiveItem()', }, }) @@ -940,7 +941,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI set role(_role: 'treeitem' | 'group') { // ignore any role setting, we handle this internally. - this._setRoleFromData(); } /** @@ -998,7 +998,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI set data(value: T) { if (value !== this._data) { this._data = value; - this._setRoleFromData(); this._dataChanges.next(); } } @@ -1055,7 +1054,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI public _changeDetectorRef: ChangeDetectorRef, ) { CdkTreeNode.mostRecentTreeNode = this as CdkTreeNode; - this.role = 'treeitem'; } ngOnInit(): void { @@ -1128,11 +1126,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI } this._tree._keyManager.onClick(this); } - - // TODO: role should eventually just be set in the component host - protected _setRoleFromData(): void { - this._elementRef.nativeElement.setAttribute('role', 'treeitem'); - } } function getParentNodeAriaLevel(nodeElement: HTMLElement): number { From 5e6d92fa28bef1f55ed38f307a971ccddb5071eb Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Wed, 14 Jun 2023 19:12:25 +0000 Subject: [PATCH 04/18] fix(material/tree): fix duplicate keydown events --- src/material/tree/tree.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/material/tree/tree.ts b/src/material/tree/tree.ts index 1a6c2c609877..70bf55328846 100644 --- a/src/material/tree/tree.ts +++ b/src/material/tree/tree.ts @@ -19,9 +19,6 @@ import {MatTreeNodeOutlet} from './outlet'; template: ``, host: { 'class': 'mat-tree', - 'role': 'tree', - '(keydown)': '_sendKeydownToKeyManager($event)', - '(focus)': '_focusInitialTreeItem()', }, styleUrls: ['tree.css'], encapsulation: ViewEncapsulation.None, From e8fbf4e22a249af44bfb4b674598161a16906ed0 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 16 Jun 2023 22:08:32 +0000 Subject: [PATCH 05/18] fix(cdk/tree): make keyboard behaviour consistent across all configurations This also removes the need for specifying `nodeType` manually. --- src/cdk/tree/nested-node.ts | 1 + src/cdk/tree/tree-errors.ts | 8 - src/cdk/tree/tree.ts | 320 ++++++++++++------ ...k-tree-flat-children-accessor-example.html | 2 +- .../cdk-tree-flat-level-accessor-example.html | 2 +- ...dk-tree-nested-level-accessor-example.html | 2 +- .../cdk-tree-nested-example.html | 6 +- .../tree-nested-overview-example.html | 34 +- 8 files changed, 248 insertions(+), 127 deletions(-) diff --git a/src/cdk/tree/nested-node.ts b/src/cdk/tree/nested-node.ts index 0f6d5ddd863e..a79be60b1b5e 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -81,6 +81,7 @@ export class CdkNestedTreeNode // This is a workaround for https://github.com/angular/angular/issues/23091 // In aot mode, the lifecycle hooks from parent class are not called. override ngOnInit() { + this._tree._setNodeTypeIfUnset('nested'); super.ngOnInit(); } diff --git a/src/cdk/tree/tree-errors.ts b/src/cdk/tree/tree-errors.ts index a4dcd42f999e..6c234f5e2bc7 100644 --- a/src/cdk/tree/tree-errors.ts +++ b/src/cdk/tree/tree-errors.ts @@ -47,14 +47,6 @@ export function getMultipleTreeControlsError() { return Error(`More than one of tree control, levelAccessor, or childrenAccessor were provided.`); } -/** - * Returns an error to be thrown when the node type is not specified. - * @docs-private - */ -export function getTreeControlNodeTypeUnspecifiedError() { - return Error(`The nodeType was not specified for the tree.`); -} - /** * Returns an error to be thrown when a node is attempted to be expanded or collapsed when * it's not expandable. diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index bbbe3a4d0dc6..66abca21a4f5 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -7,7 +7,7 @@ */ import {TreeKeyManager, TreeKeyManagerItem} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; -import {coerceNumberProperty} from '@angular/cdk/coercion'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; import {CollectionViewer, DataSource, isDataSource, SelectionModel} from '@angular/cdk/collections'; import { AfterContentChecked, @@ -39,6 +39,7 @@ import { EMPTY, isObservable, merge, + NEVER, Observable, of as observableOf, Subject, @@ -63,7 +64,6 @@ import { getMultipleTreeControlsError, getNodeNotExpandableError, getTreeControlMissingError, - getTreeControlNodeTypeUnspecifiedError, getTreeMissingMatchingNodeDefError, getTreeMultipleDefaultNodeDefsError, getTreeNoValidDataSourceError, @@ -80,6 +80,8 @@ function isNotNullish(val: T | null | undefined): val is T { return val != null; } +type NodeGroup = Map; + /** * CDK tree component that connects with a data source to retrieve data of type `T` and renders * dataNodes with hierarchy. Updates the dataNodes when new data is provided by the data source. @@ -132,7 +134,7 @@ export class CdkTree * - the inner index is the parent node for this particular group. If there is no parent node, we * use `null`. */ - private _groups: Map> = new Map>(); + private _groups: Map> = new Map>(); /** * Provides a stream containing the latest data array to render. Influenced by the tree's @@ -187,16 +189,6 @@ export class CdkTree */ @Input() expansionKey?: (dataNode: T) => K; - /** - * What type of node is being used in the tree. This must be provided if either of - * `levelAccessor` or `childrenAccessor` are provided. - * - * This controls what selection of data the tree will render. - */ - // NB: we're unable to determine this ourselves; Angular's ContentChildren - // unfortunately does not pick up the necessary information. - @Input() nodeType?: 'flat' | 'nested'; - // Outlets within the tree's template where the dataNodes will be inserted. @ViewChild(CdkTreeNodeOutlet, {static: true}) _nodeOutlet: CdkTreeNodeOutlet; @@ -223,11 +215,25 @@ export class CdkTree private _expansionModel?: SelectionModel; /** - * Maintain a synchronous cache of the currently known data nodes. In the + * Maintain a synchronous cache of flattened data nodes. In the * case of nested nodes (i.e. if `nodeType` is 'nested'), this will - * only contain the root nodes. + * not contain any data. */ - private _dataNodes: BehaviorSubject = new BehaviorSubject([]); + private _preFlattenedNodes: BehaviorSubject = new BehaviorSubject([]); + + private _flattenedNodes: BehaviorSubject = new BehaviorSubject([]); + + /** + * The automatically determined node type for the tree. + */ + private _nodeType: BehaviorSubject<'flat' | 'nested' | null> = new BehaviorSubject< + 'flat' | 'nested' | null + >(null); + + /** + * The root nodes of the tree. + */ + private _rootNodes: BehaviorSubject = new BehaviorSubject([]); /** The mapping between data and the node that is rendered. */ private _nodes: BehaviorSubject>> = new BehaviorSubject( @@ -237,6 +243,8 @@ export class CdkTree /** The key manager for this tree. Handles focus and activation based on user keyboard input. */ _keyManager: TreeKeyManager>; + private _inInitialRender = true; + constructor( private _differs: IterableDiffers, private _changeDetectorRef: ChangeDetectorRef, @@ -255,11 +263,6 @@ export class CdkTree } else if (provided === 0) { throw getTreeControlMissingError(); } - - // Check that the node type is also provided if treeControl is not. - if (!this.treeControl && !this.nodeType) { - throw getTreeControlNodeTypeUnspecifiedError(); - } } let expansionModel; @@ -282,6 +285,23 @@ export class CdkTree nodes.get(removed)?._changeDetectorRef.detectChanges(); } }); + + combineLatest([this._preFlattenedNodes, this._rootNodes]) + .pipe( + switchMap(([preFlattened, rootNodes]) => { + if (preFlattened.length) { + return observableOf(preFlattened); + } else if (rootNodes.length) { + return this._flattenRootNodes(rootNodes); + } + return NEVER; + }), + takeUntil(this._onDestroy), + ) + .subscribe(flattenedNodes => { + this._flattenedNodes.next(flattenedNodes); + this._recalculateGroupsForLevelAccessor(); + }); } ngOnDestroy() { @@ -303,12 +323,13 @@ export class CdkTree ngAfterContentInit() { this._keyManager = new TreeKeyManager({ - items: combineLatest([this._dataNodes, this._nodes]).pipe( - map(([dataNodes, nodes]) => - dataNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish), + items: combineLatest([this._flattenedNodes, this._nodes]).pipe( + map(([flattenedNodes, nodes]) => + flattenedNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish), ), ), trackBy: node => this._getExpansionKey(node.data), + skipPredicate: node => node.isDisabled || !this._hasAllParentsExpanded(node.data), typeAheadDebounceInterval: true, horizontalOrientation: this._dir.value, }); @@ -338,6 +359,12 @@ export class CdkTree } } + _setNodeTypeIfUnset(nodeType: 'flat' | 'nested') { + if (this._nodeType.value === null) { + this._nodeType.next(nodeType); + } + } + /** * Sets the tabIndex on the host element. * @@ -394,12 +421,15 @@ export class CdkTree } if (dataStream) { - this._dataSubscription = dataStream + this._dataSubscription = combineLatest([dataStream, this._nodeType]) .pipe( - switchMap(data => this._convertChildren(data)), + switchMap(([data, nodeType]) => this._convertChildren(data, nodeType)), takeUntil(this._onDestroy), ) - .subscribe(data => this._renderNodeChanges(data)); + .subscribe(data => { + this._renderNodeChanges(data); + this._inInitialRender = false; + }); } else if (typeof ngDevMode === 'undefined' || ngDevMode) { throw getTreeNoValidDataSourceError(); } @@ -467,15 +497,20 @@ export class CdkTree * within the data node view container. */ insertNode(nodeData: T, index: number, viewContainer?: ViewContainerRef, parentData?: T) { + const levelAccessor = this._getLevelAccessor(); + // On the first render, we don't yet have a cache of flattenedNodes to determine parent data. + // Skip setting this until we have it saved, then recalculate it later. + const shouldSetGroupData = !levelAccessor || !this._inInitialRender; + const node = this._getNodeDef(nodeData, index); + const key = this._getExpansionKey(nodeData); // Node context that will be provided to created embedded view const context = new CdkTreeNodeOutletContext(nodeData); - parentData ??= this._parents.get(this._getExpansionKey(nodeData)) ?? undefined; + parentData ??= this._parents.get(key) ?? undefined; // If the tree is flat tree, then use the `getLevel` function in flat tree control // Otherwise, use the level of parent node. - const levelAccessor = this._getLevelAccessor(); if (levelAccessor) { context.level = levelAccessor(nodeData); } else if ( @@ -486,17 +521,21 @@ export class CdkTree } else { context.level = 0; } - this._levels.set(this._getExpansionKey(nodeData), context.level); - const parent = parentData ?? this._findParentForNode(nodeData, index); - this._parents.set(this._getExpansionKey(nodeData), parent); - - // We're essentially replicating the tree structure within each `group`; - // we insert the node into the group at the specified index. - const currentGroup = this._groups.get(context.level) ?? new Map(); - const group = currentGroup.get(parent) ?? []; - group.splice(index, 0, nodeData); - currentGroup.set(parent, group); - this._groups.set(context.level, currentGroup); + this._levels.set(key, context.level); + + if (shouldSetGroupData) { + const parent = parentData ?? this._findParentForNode(nodeData, index); + const parentKey = parent ? this._getExpansionKey(parent) : null; + this._parents.set(key, parent); + + // We're essentially replicating the tree structure within each `group`; + // we insert the node into the group at the specified index. + const currentGroup = this._groups.get(context.level) ?? new Map(); + const group = currentGroup.get(parentKey) ?? []; + group.splice(index, 0, nodeData); + currentGroup.set(parentKey, group); + this._groups.set(context.level, currentGroup); + } // Use default tree nodeOutlet, or nested node's nodeOutlet const container = viewContainer ? viewContainer : this._nodeOutlet.viewContainer; @@ -570,6 +609,7 @@ export class CdkTree this.treeControl.expandDescendants(dataNode); } else if (this._expansionModel) { const expansionModel = this._expansionModel; + expansionModel.select(this._getExpansionKey(dataNode)); this._getDescendants(dataNode) .pipe(take(1), takeUntil(this._onDestroy)) .subscribe(children => { @@ -584,6 +624,7 @@ export class CdkTree this.treeControl.collapseDescendants(dataNode); } else if (this._expansionModel) { const expansionModel = this._expansionModel; + expansionModel.deselect(this._getExpansionKey(dataNode)); this._getDescendants(dataNode) .pipe(take(1), takeUntil(this._onDestroy)) .subscribe(children => { @@ -636,9 +677,10 @@ export class CdkTree */ _getDirectChildren(dataNode: T): Observable { const levelAccessor = this._getLevelAccessor(); - if (levelAccessor && this._expansionModel) { + const expansionModel = this._expansionModel ?? this.treeControl?.expansionModel; + if (levelAccessor && expansionModel) { const key = this._getExpansionKey(dataNode); - const isExpanded = this._expansionModel.changed.pipe( + const isExpanded = expansionModel.changed.pipe( switchMap(changes => { if (changes.added.includes(key)) { return observableOf(true); @@ -650,28 +692,28 @@ export class CdkTree startWith(this.isExpanded(dataNode)), ); - return combineLatest([isExpanded, this._dataNodes]).pipe( - map(([expanded, dataNodes]) => { + return combineLatest([isExpanded, this._flattenedNodes]).pipe( + map(([expanded, flattenedNodes]) => { if (!expanded) { return []; } - const startIndex = dataNodes.indexOf(dataNode); + const startIndex = flattenedNodes.indexOf(dataNode); const level = levelAccessor(dataNode) + 1; const results: T[] = []; - // Goes through flattened tree nodes in the `dataNodes` array, and get all direct descendants. - // The level of descendants of a tree node must be equal to the level of the given - // tree node + 1. + // Goes through flattened tree nodes in the `flattenedNodes` array, and get all direct + // descendants. The level of descendants of a tree node must be equal to the level of the + // given tree node + 1. // If we reach a node whose level is equal to the level of the tree node, we hit a sibling. // If we reach a node whose level is greater than the level of the tree node, we hit a // sibling of an ancestor. - for (let i = startIndex + 1; i < dataNodes.length; i++) { - const currentLevel = levelAccessor(dataNodes[i]); + for (let i = startIndex + 1; i < flattenedNodes.length; i++) { + const currentLevel = levelAccessor(flattenedNodes[i]); if (level > currentLevel) { break; } if (level === currentLevel) { - results.push(dataNodes[i]); + results.push(flattenedNodes[i]); } } return results; @@ -738,7 +780,7 @@ export class CdkTree /** Given a CdkTreeNode, gets the nodes that renders that node's child data. */ _getNodeChildren(node: CdkTreeNode) { - return coerceObservable(this._getChildrenAccessor()?.(node.data) ?? []).pipe( + return this._getDirectChildren(node.data).pipe( map(children => children .map(child => this._nodes.value.get(this._getExpansionKey(child))) @@ -760,19 +802,12 @@ export class CdkTree this._keyManager.onInitialFocus(); } - /** - * Gets all nodes in the tree, through recursive expansion. - * - * NB: this will emit multiple times; the collective sum of the emissions - * will encompass the entire tree. This is done so that `expandAll` and - * `collapseAll` can incrementally expand/collapse instead of waiting for an - * all asynchronous operations to complete before expanding. - * - * Note also that this does not capture continual changes to descendants in - * the tree. - */ - private _getAllDescendants(): Observable { - return merge(...this._dataNodes.value.map(dataNode => this._getDescendants(dataNode))); + /** Gets all nodes in the tree, using the cached nodes. */ + private _getAllDescendants(): Observable { + if (this._flattenedNodes.value.length) { + return this._flattenedNodes; + } + return observableOf([]); } private _getDescendants(dataNode: T): Observable { @@ -780,8 +815,8 @@ export class CdkTree return observableOf(this.treeControl.getDescendants(dataNode)); } if (this.levelAccessor) { - const startIndex = this._dataNodes.value.indexOf(dataNode); - const results: T[] = [dataNode]; + const startIndex = this._flattenedNodes.value.indexOf(dataNode); + const results: T[] = []; // Goes through flattened tree nodes in the `dataNodes` array, and get all descendants. // The level of descendants of a tree node must be greater than the level of the given @@ -792,23 +827,20 @@ export class CdkTree const currentLevel = this.levelAccessor(dataNode); for ( let i = startIndex + 1; - i < this._dataNodes.value.length && - currentLevel < this.levelAccessor(this._dataNodes.value[i]); + i < this._flattenedNodes.value.length && + currentLevel < this.levelAccessor(this._flattenedNodes.value[i]); i++ ) { - results.push(this._dataNodes.value[i]); + results.push(this._flattenedNodes.value[i]); } return observableOf(results); } if (this.childrenAccessor) { return this._getAllChildrenRecursively(dataNode).pipe( - reduce( - (allChildren: T[], nextChildren) => { - allChildren.push(...nextChildren); - return allChildren; - }, - [dataNode], - ), + reduce((allChildren: T[], nextChildren) => { + allChildren.push(...nextChildren); + return allChildren; + }, []), ); } throw getTreeControlMissingError(); @@ -853,20 +885,30 @@ export class CdkTree private _getNodeGroup(node: T) { const level = this._levels.get(this._getExpansionKey(node)); const parent = this._parents.get(this._getExpansionKey(node)); - const group = this._groups.get(level ?? 0)?.get(parent ?? null); + const parentKey = parent ? this._getExpansionKey(parent) : null; + const group = this._groups.get(level ?? 0)?.get(parentKey); return group ?? [node]; } - private _findParentForNode(node: T, index: number) { + /** + * Finds the parent for the given node. If this is a root node, this + * returns null. If we're unable to determine the parent, for example, + * if we don't have cached node data, this returns undefined. + */ + private _findParentForNode(node: T, index: number): T | null { // In all cases, we have a mapping from node to level; all we need to do here is backtrack in // our flattened list of nodes to determine the first node that's of a level lower than the // provided node. - if (!this._dataNodes) { + let cachedNodes = this._flattenedNodes.value; + if (!cachedNodes.length) { + cachedNodes = this._preFlattenedNodes.value; + } + if (!cachedNodes.length) { return null; } const currentLevel = this._levels.get(this._getExpansionKey(node)) ?? 0; for (let parentIndex = index; parentIndex >= 0; parentIndex--) { - const parentNode = this._dataNodes.value[parentIndex]; + const parentNode = cachedNodes[parentIndex]; const parentLevel = this._levels.get(this._getExpansionKey(parentNode)) ?? 0; if (parentLevel < currentLevel) { @@ -880,34 +922,112 @@ export class CdkTree * Converts children for certain tree configurations. Note also that this * caches the known nodes for use in other parts of the tree. */ - private _convertChildren(nodes: readonly T[]): Observable { + private _convertChildren( + nodes: readonly T[], + nodeType: 'flat' | 'nested' | null, + ): Observable { + // Initially, we pass through the data directly to the renderer, until + // we can determine how to format the nodes for consumption by the actual + // node component. + if (nodeType === null) { + return observableOf(nodes); + } + // The only situations where we have to convert children types is when // they're mismatched; i.e. if the tree is using a childrenAccessor and the // nodes are flat, or if the tree is using a levelAccessor and the nodes are // nested. - if (this.childrenAccessor && this.nodeType === 'flat') { + if (this.childrenAccessor && nodeType === 'flat') { // This flattens children into a single array. - return observableOf(...nodes).pipe( - concatMap(node => concat(observableOf([node]), this._getAllChildrenRecursively(node))), - reduce((results, children) => { - results.push(...children); - return results; - }, [] as T[]), + return this._flattenRootNodes(nodes).pipe( tap(allNodes => { - this._dataNodes.next(allNodes); + this._preFlattenedNodes.next(allNodes); }), ); - } else if (this.levelAccessor && this.nodeType === 'nested') { - this._dataNodes.next(nodes); + } else if (this.levelAccessor && nodeType === 'nested') { // In the nested case, we only look for root nodes. The CdkNestedNode // itself will handle rendering each individual node's children. const levelAccessor = this.levelAccessor; - return observableOf(nodes.filter(node => levelAccessor(node) === 0)); + return observableOf(nodes.filter(node => levelAccessor(node) === 0)).pipe( + tap(rootNodes => { + this._preFlattenedNodes.next(nodes); + }), + ); } else { - this._dataNodes.next(nodes); + // In the case of a TreeControl, we know that the node type matches up + // with the TreeControl, and so no conversions are necessary. + if (nodeType === 'flat') { + this._preFlattenedNodes.next(nodes); + } else { + this._rootNodes.next(nodes); + } return observableOf(nodes); } } + + private _flattenRootNodes(rootNodes: readonly T[]): Observable { + return observableOf(...rootNodes).pipe( + concatMap(node => concat(observableOf([node]), this._getDescendants(node))), + reduce((results, children) => { + results.push(...children); + return results; + }, [] as T[]), + ); + } + + private _isExpanded(dataNode: T): boolean { + return ( + this._expansionModel?.isSelected(this._getExpansionKey(dataNode)) ?? + this.treeControl?.isExpanded(dataNode) ?? + false + ); + } + + private _recalculateGroupsForLevelAccessor(): void { + // Check that we're using level accessor. Levels have already been cached; + // this is just here to prevent unnecessary work. + if (!this._getLevelAccessor()) { + return; + } + + const flattenedNodes = this._flattenedNodes.value; + for (let index = 0; index < flattenedNodes.length; index++) { + const dataNode = flattenedNodes[index]; + const key = this._getExpansionKey(dataNode); + const parent = this._findParentForNode(dataNode, index); + this._parents.set(key, parent); + const parentKey = parent ? this._getExpansionKey(parent) : null; + + const level = this._getLevel(dataNode) ?? 0; + + const currentGroup = this._groups.get(level) ?? new Map(); + const group = currentGroup.get(parentKey) ?? []; + group.splice(index, 0, dataNode); + currentGroup.set(parentKey, group); + this._groups.set(level, currentGroup); + } + + this._refreshAllNodes(); + } + + private _refreshAllNodes(): void { + for (const [, node] of this._nodes.value) { + node._changeDetectorRef.detectChanges(); + } + } + + private _hasAllParentsExpanded(dataNode: T): boolean { + const parent = this._parents.get(this._getExpansionKey(dataNode)); + if (parent === null) { + return true; + } + // If we don't have any record of a parent here, this means the node is likely + // removed from the DOM entirely and therefore cannot have parents expanded. + if (parent === undefined) { + return false; + } + return this._isExpanded(parent) && this._hasAllParentsExpanded(parent); + } } /** @@ -949,7 +1069,13 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI * If not using `FlatTreeControl`, or if `isExpandable` is not provided to * `NestedTreeControl`, this should be provided for correct node a11y. */ - @Input() isExpandable: boolean = false; + @Input() + get isExpandable() { + return this._isExpandable(); + } + set isExpandable(isExpandable: boolean | '' | null) { + this._inputIsExpandable = coerceBooleanProperty(isExpandable); + } @Input() get isExpanded(): boolean { @@ -989,6 +1115,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Emits when the node's data has changed. */ readonly _dataChanges = new Subject(); + private _inputIsExpandable: boolean = false; private _parentNodeAriaLevel: number; /** The tree node's data. */ @@ -1015,7 +1142,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI if (typeof this._tree.treeControl?.isExpandable === 'function') { return this._tree.treeControl.isExpandable(this._data); } - return this.isExpandable; + return this._inputIsExpandable; } /** @@ -1058,6 +1185,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI ngOnInit(): void { this._parentNodeAriaLevel = getParentNodeAriaLevel(this._elementRef.nativeElement); + this._tree._setNodeTypeIfUnset('flat'); this._tree._registerNode(this); } diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html index 6976db2de744..b16da4c8e2db 100644 --- a/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html @@ -1,4 +1,4 @@ - + + + {{node.name}} diff --git a/src/components-examples/cdk/tree/cdk-tree-nested/cdk-tree-nested-example.html b/src/components-examples/cdk/tree/cdk-tree-nested/cdk-tree-nested-example.html index ec03afc6a8fa..403cb3b7a13e 100644 --- a/src/components-examples/cdk/tree/cdk-tree-nested/cdk-tree-nested-example.html +++ b/src/components-examples/cdk/tree/cdk-tree-nested/cdk-tree-nested-example.html @@ -1,8 +1,6 @@ - @@ -11,7 +9,7 @@ - {{node.name}} - - -
- + +
+ + {{node.name}} +
+ +
+
From c9a2a36d48ad4e130bcdc74e2221d291b5d8e56a Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 16 Jun 2023 22:23:33 +0000 Subject: [PATCH 06/18] fix(cdk/tree): remove unnecessary change detection --- src/cdk/tree/tree.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 66abca21a4f5..2c06273f8a1e 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -990,6 +990,9 @@ export class CdkTree return; } + this._parents.clear(); + this._groups.clear(); + const flattenedNodes = this._flattenedNodes.value; for (let index = 0; index < flattenedNodes.length; index++) { const dataNode = flattenedNodes[index]; @@ -1006,14 +1009,6 @@ export class CdkTree currentGroup.set(parentKey, group); this._groups.set(level, currentGroup); } - - this._refreshAllNodes(); - } - - private _refreshAllNodes(): void { - for (const [, node] of this._nodes.value) { - node._changeDetectorRef.detectChanges(); - } } private _hasAllParentsExpanded(dataNode: T): boolean { From dead1750dc78cc0627363e51a15b7a37b0ec2db4 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 16 Jun 2023 22:24:57 +0000 Subject: [PATCH 07/18] fix(cdk/tree): update API goldens --- tools/public_api_guard/cdk/tree.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tools/public_api_guard/cdk/tree.md b/tools/public_api_guard/cdk/tree.md index b9bb20eb1b93..24c3ca2a03bd 100644 --- a/tools/public_api_guard/cdk/tree.md +++ b/tools/public_api_guard/cdk/tree.md @@ -115,10 +115,11 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, _nodeDefs: QueryList>; // (undocumented) _nodeOutlet: CdkTreeNodeOutlet; - nodeType?: 'flat' | 'nested'; _registerNode(node: CdkTreeNode): void; _renderNodeChanges(data: readonly T[], dataDiffer?: IterableDiffer, viewContainer?: ViewContainerRef, parentData?: T): void; _sendKeydownToKeyManager(event: KeyboardEvent): void; + // (undocumented) + _setNodeTypeIfUnset(nodeType: 'flat' | 'nested'): void; _setTabIndex(): void; toggle(dataNode: T): void; toggleDescendants(dataNode: T): void; @@ -131,7 +132,7 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, end: number; }>; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-tree", ["cdkTree"], { "dataSource": { "alias": "dataSource"; "required": false; }; "treeControl": { "alias": "treeControl"; "required": false; }; "levelAccessor": { "alias": "levelAccessor"; "required": false; }; "childrenAccessor": { "alias": "childrenAccessor"; "required": false; }; "trackBy": { "alias": "trackBy"; "required": false; }; "expansionKey": { "alias": "expansionKey"; "required": false; }; "nodeType": { "alias": "nodeType"; "required": false; }; }, {}, ["_nodeDefs"], never, false, never, false>; + static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-tree", ["cdkTree"], { "dataSource": { "alias": "dataSource"; "required": false; }; "treeControl": { "alias": "treeControl"; "required": false; }; "levelAccessor": { "alias": "levelAccessor"; "required": false; }; "childrenAccessor": { "alias": "childrenAccessor"; "required": false; }; "trackBy": { "alias": "trackBy"; "required": false; }; "expansionKey": { "alias": "expansionKey"; "required": false; }; }, {}, ["_nodeDefs"], never, false, never, false>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -173,7 +174,8 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI _getPositionInSet(): number; _getSetSize(): number; isDisabled?: boolean; - isExpandable: boolean; + get isExpandable(): boolean | '' | null; + set isExpandable(isExpandable: boolean | '' | null); _isExpandable(): boolean; // (undocumented) get isExpanded(): boolean; @@ -191,8 +193,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI // (undocumented) _setActiveItem(): void; // (undocumented) - protected _setRoleFromData(): void; - // (undocumented) _setTabFocusable(): void; // (undocumented) _setTabUnfocusable(): void; @@ -305,10 +305,10 @@ export interface FlatTreeControlOptions { export function getMultipleTreeControlsError(): Error; // @public -export function getTreeControlMissingError(): Error; +export function getNodeNotExpandableError(): Error; // @public -export function getTreeControlNodeTypeUnspecifiedError(): Error; +export function getTreeControlMissingError(): Error; // @public export function getTreeMissingMatchingNodeDefError(): Error; From bb663a04cb87069b2545d30c2d807c8d8f592ffd Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 16 Jun 2023 22:25:52 +0000 Subject: [PATCH 08/18] refactor(cdk/tree): organize imports --- src/cdk/tree/tree.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 2c06273f8a1e..198cbcdc4512 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -38,7 +38,6 @@ import { concat, EMPTY, isObservable, - merge, NEVER, Observable, of as observableOf, From 669943897ade14a937162709961e41b6c222d24e Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 16 Jun 2023 22:30:10 +0000 Subject: [PATCH 09/18] fix(cdk/a11y): update API goldens --- tools/public_api_guard/cdk/a11y.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tools/public_api_guard/cdk/a11y.md b/tools/public_api_guard/cdk/a11y.md index dc134ebb51b5..85a8d2daff6d 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -428,6 +428,16 @@ export function removeAriaReferencedId(el: Element, attr: `aria-${string}`, id: export class TreeKeyManager { constructor({ items, skipPredicate, trackBy, horizontalOrientation, activationFollowsFocus, typeAheadDebounceInterval, }: TreeKeyManagerOptions); readonly change: Subject; + focusFirstItem(): void; + focusItem(index: number, options?: { + emitChangeEvent?: boolean; + }): void; + focusItem(item: T, options?: { + emitChangeEvent?: boolean; + }): void; + focusLastItem(): void; + focusNextItem(): void; + focusPreviousItem(): void; getActiveItem(): T | null; getActiveItemIndex(): number | null; onClick(treeItem: T): void; @@ -449,7 +459,7 @@ export interface TreeKeyManagerItem { isExpanded: (() => boolean) | boolean; } -// @public (undocumented) +// @public export interface TreeKeyManagerOptions { activationFollowsFocus?: boolean; horizontalOrientation?: 'rtl' | 'ltr'; From 408f5421567a513d80856c7c9dd76aa596d482cf Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Mon, 19 Jun 2023 20:52:23 +0000 Subject: [PATCH 10/18] fix(cdk/tree): remove `_preFlattenedNodes` --- src/cdk/tree/tree.ts | 49 +++++++++++++++++--------------------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 198cbcdc4512..f913313f090f 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -214,12 +214,10 @@ export class CdkTree private _expansionModel?: SelectionModel; /** - * Maintain a synchronous cache of flattened data nodes. In the - * case of nested nodes (i.e. if `nodeType` is 'nested'), this will - * not contain any data. + * Maintain a synchronous cache of flattened data nodes. This will only be + * populated after initial render, and in certain cases, will be delayed due to + * relying on Observable `getChildren` calls. */ - private _preFlattenedNodes: BehaviorSubject = new BehaviorSubject([]); - private _flattenedNodes: BehaviorSubject = new BehaviorSubject([]); /** @@ -230,7 +228,8 @@ export class CdkTree >(null); /** - * The root nodes of the tree. + * The root nodes of the tree. This will only be populated in the case where the user + * provides a `NestedTreeControl`. */ private _rootNodes: BehaviorSubject = new BehaviorSubject([]); @@ -285,22 +284,18 @@ export class CdkTree } }); - combineLatest([this._preFlattenedNodes, this._rootNodes]) + this._rootNodes .pipe( - switchMap(([preFlattened, rootNodes]) => { - if (preFlattened.length) { - return observableOf(preFlattened); - } else if (rootNodes.length) { - return this._flattenRootNodes(rootNodes); - } - return NEVER; - }), + switchMap(rootNodes => this._flattenRootNodes(rootNodes)), takeUntil(this._onDestroy), ) .subscribe(flattenedNodes => { this._flattenedNodes.next(flattenedNodes); - this._recalculateGroupsForLevelAccessor(); }); + + this._flattenedNodes.pipe(takeUntil(this._onDestroy)).subscribe(() => { + this._recalculateGroupsForLevelAccessor(); + }); } ngOnDestroy() { @@ -638,7 +633,7 @@ export class CdkTree this.treeControl.expandAll(); } else if (this._expansionModel) { const expansionModel = this._expansionModel; - this._getAllDescendants() + this._getAllNodes() .pipe(takeUntil(this._onDestroy)) .subscribe(children => { expansionModel.select(...children.map(child => this._getExpansionKey(child))); @@ -652,7 +647,7 @@ export class CdkTree this.treeControl.collapseAll(); } else if (this._expansionModel) { const expansionModel = this._expansionModel; - this._getAllDescendants() + this._getAllNodes() .pipe(takeUntil(this._onDestroy)) .subscribe(children => { expansionModel.deselect(...children.map(child => this._getExpansionKey(child))); @@ -802,11 +797,8 @@ export class CdkTree } /** Gets all nodes in the tree, using the cached nodes. */ - private _getAllDescendants(): Observable { - if (this._flattenedNodes.value.length) { - return this._flattenedNodes; - } - return observableOf([]); + private _getAllNodes(): Observable { + return this._flattenedNodes; } private _getDescendants(dataNode: T): Observable { @@ -899,9 +891,6 @@ export class CdkTree // our flattened list of nodes to determine the first node that's of a level lower than the // provided node. let cachedNodes = this._flattenedNodes.value; - if (!cachedNodes.length) { - cachedNodes = this._preFlattenedNodes.value; - } if (!cachedNodes.length) { return null; } @@ -940,7 +929,7 @@ export class CdkTree // This flattens children into a single array. return this._flattenRootNodes(nodes).pipe( tap(allNodes => { - this._preFlattenedNodes.next(allNodes); + this._flattenedNodes.next(allNodes); }), ); } else if (this.levelAccessor && nodeType === 'nested') { @@ -948,15 +937,15 @@ export class CdkTree // itself will handle rendering each individual node's children. const levelAccessor = this.levelAccessor; return observableOf(nodes.filter(node => levelAccessor(node) === 0)).pipe( - tap(rootNodes => { - this._preFlattenedNodes.next(nodes); + tap(() => { + this._flattenedNodes.next(nodes); }), ); } else { // In the case of a TreeControl, we know that the node type matches up // with the TreeControl, and so no conversions are necessary. if (nodeType === 'flat') { - this._preFlattenedNodes.next(nodes); + this._flattenedNodes.next(nodes); } else { this._rootNodes.next(nodes); } From db6dc6f1babae9c86773a16f853d44f8040b43e1 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Mon, 19 Jun 2023 21:24:42 +0000 Subject: [PATCH 11/18] fix(cdk/tree): lint --- src/cdk/tree/tree.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index f913313f090f..3891320e6afc 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -38,7 +38,6 @@ import { concat, EMPTY, isObservable, - NEVER, Observable, of as observableOf, Subject, From d58ff0466e99d0130423ae9ee2976aac42265ff3 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Tue, 20 Jun 2023 20:31:59 +0000 Subject: [PATCH 12/18] fix(cdk/tree): use `findIndex` instead of `indexOf`; fixes inconsistent aria-posinset --- src/cdk/tree/tree.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 3891320e6afc..746267e3edec 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -451,9 +451,13 @@ export class CdkTree } else if (currentIndex == null) { viewContainer.remove(adjustedPreviousIndex!); const group = this._getNodeGroup(item.item); - this._levels.delete(this._getExpansionKey(item.item)); - this._parents.delete(this._getExpansionKey(item.item)); - group.splice(group.indexOf(item.item), 1); + const key = this._getExpansionKey(item.item); + this._levels.delete(key); + this._parents.delete(key); + group.splice( + group.findIndex(groupItem => this._getExpansionKey(groupItem) === key), + 1, + ); } else { const view = viewContainer.get(adjustedPreviousIndex!); viewContainer.move(view!, currentIndex); @@ -690,7 +694,8 @@ export class CdkTree if (!expanded) { return []; } - const startIndex = flattenedNodes.indexOf(dataNode); + const key = this._getExpansionKey(dataNode); + const startIndex = flattenedNodes.findIndex(node => this._getExpansionKey(node) === key); const level = levelAccessor(dataNode) + 1; const results: T[] = []; @@ -762,7 +767,8 @@ export class CdkTree */ _getPositionInSet(dataNode: T) { const group = this._getNodeGroup(dataNode); - return group.indexOf(dataNode) + 1; + const key = this._getExpansionKey(dataNode); + return group.findIndex(node => this._getExpansionKey(node) === key) + 1; } /** Given a CdkTreeNode, gets the node that renders that node's parent's data. */ @@ -805,7 +811,10 @@ export class CdkTree return observableOf(this.treeControl.getDescendants(dataNode)); } if (this.levelAccessor) { - const startIndex = this._flattenedNodes.value.indexOf(dataNode); + const key = this._getExpansionKey(dataNode); + const startIndex = this._flattenedNodes.value.findIndex( + node => this._getExpansionKey(node) === key, + ); const results: T[] = []; // Goes through flattened tree nodes in the `dataNodes` array, and get all descendants. From 5efed26ae3de8a9360e6f8a552e1404c3348313b Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Wed, 26 Jul 2023 19:10:25 +0000 Subject: [PATCH 13/18] feat(cdk/tree): add complex redux-like demo --- .../cdk-tree-complex-example.css | 4 + .../cdk-tree-complex-example.html | 34 ++ .../cdk-tree-complex-example.ts | 304 ++++++++++++++++++ 3 files changed, 342 insertions(+) create mode 100644 src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.css create mode 100644 src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.html create mode 100644 src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts diff --git a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.css b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.css new file mode 100644 index 000000000000..00fa2d29167f --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.css @@ -0,0 +1,4 @@ +cdk-tree-node { + display: flex; + align-items: center; +} diff --git a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.html b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.html new file mode 100644 index 000000000000..3796809f4336 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.html @@ -0,0 +1,34 @@ + + + + + + + + + + + +
+ + {{node.raw.name}} +
+
+
diff --git a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts new file mode 100644 index 000000000000..29dc00ea57d6 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts @@ -0,0 +1,304 @@ +import {CdkTreeModule} from '@angular/cdk/tree'; +import {CommonModule} from '@angular/common'; +import {Component, OnInit} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {BehaviorSubject, Observable, combineLatest, of as observableOf} from 'rxjs'; +import {delay, map, shareReplay} from 'rxjs/operators'; + +interface BackendData { + id: string; + name: string; + parent?: string; + children?: string[]; +} + +const TREE_DATA: Map = new Map( + [ + { + id: '1', + name: 'Fruit', + children: ['1-1', '1-2', '1-3'], + }, + {id: '1-1', name: 'Apple', parent: '1'}, + {id: '1-2', name: 'Banana', parent: '1'}, + {id: '1-3', name: 'Fruit Loops', parent: '1'}, + { + id: '2', + name: 'Vegetables', + children: ['2-1', '2-2'], + }, + { + id: '2-1', + name: 'Green', + parent: '2', + children: ['2-1-1', '2-1-2'], + }, + { + id: '2-2', + name: 'Orange', + parent: '2', + children: ['2-2-1', '2-2-2'], + }, + {id: '2-1-1', name: 'Broccoli', parent: '2-1'}, + {id: '2-1-2', name: 'Brussel sprouts', parent: '2-1'}, + {id: '2-2-1', name: 'Pumpkins', parent: '2-2'}, + {id: '2-2-2', name: 'Carrots', parent: '2-2'}, + ].map(datum => [datum.id, datum]), +); + +class FakeDataBackend { + private getRandomDelayTime() { + // anywhere from 100 to 500ms. + return Math.floor(Math.random() * 400) + 100; + } + + getChildren(id: string): Observable { + // first, find the specified ID in our tree + const item = TREE_DATA.get(id); + const children = item?.children ?? []; + + return observableOf(children.map(childId => TREE_DATA.get(childId)!)).pipe( + delay(this.getRandomDelayTime()), + ); + } + + getRoots(): Observable { + return observableOf([...TREE_DATA.values()].filter(datum => !datum.parent)).pipe( + delay(this.getRandomDelayTime()), + ); + } +} + +type LoadingState = 'INIT' | 'LOADING' | 'LOADED'; + +interface RawData { + id: string; + name: string; + parentId?: string; + childrenIds?: string[]; + childrenLoading: LoadingState; +} + +class TransformedData { + constructor(public raw: RawData) {} + + areChildrenLoading() { + return this.raw.childrenLoading === 'LOADING'; + } + + isExpandable() { + return ( + (this.raw.childrenLoading === 'INIT' || this.raw.childrenLoading === 'LOADED') && + !!this.raw.childrenIds?.length + ); + } + + isLeaf() { + return !this.isExpandable() && !this.areChildrenLoading(); + } +} + +interface State { + rootIds: string[]; + rootsLoading: LoadingState; + allData: Map; + dataLoading: Map; +} + +type ObservedValueOf = T extends Observable ? U : never; + +type ObservedValuesOf>> = { + [K in keyof T]: ObservedValueOf; +}; + +type TransformFn>, U> = ( + ...args: [...ObservedValuesOf, State] +) => U; + +class ComplexDataStore { + private readonly backend = new FakeDataBackend(); + + private state = new BehaviorSubject({ + rootIds: [], + rootsLoading: 'INIT', + allData: new Map(), + dataLoading: new Map(), + }); + + private readonly rootIds = this.select(state => state.rootIds); + private readonly allData = this.select(state => state.allData); + private readonly loadingData = this.select(state => state.dataLoading); + private readonly rootsLoadingState = this.select(state => state.rootsLoading); + readonly areRootsLoading = this.select( + this.rootIds, + this.loadingData, + this.rootsLoadingState, + (rootIds, loading, rootsLoading) => + rootsLoading !== 'LOADED' || rootIds.some(id => loading.get(id) !== 'LOADED'), + ); + readonly roots = this.select( + this.areRootsLoading, + this.rootIds, + this.allData, + (rootsLoading, rootIds, data) => { + if (rootsLoading) { + return []; + } + return this.getDataByIds(rootIds, data); + }, + ); + + getChildren(parentId: string) { + return this.select(this.allData, this.loadingData, (data, loading) => { + const parentData = data.get(parentId); + if (parentData?.childrenLoading !== 'LOADED') { + return []; + } + const childIds = parentData.childrenIds ?? []; + if (childIds.some(id => loading.get(id) !== 'LOADED')) { + return []; + } + return this.getDataByIds(childIds, data); + }); + } + + loadRoots() { + this.setRootsLoading(); + this.backend.getRoots().subscribe(roots => { + this.setRoots(roots); + }); + } + + loadChildren(parentId: string) { + this.setChildrenLoading(parentId); + this.backend.getChildren(parentId).subscribe(children => { + this.addLoadedData(parentId, children); + }); + } + + private setRootsLoading() { + this.state.next({ + ...this.state.value, + rootsLoading: 'LOADING', + }); + } + + private setRoots(roots: BackendData[]) { + const currentState = this.state.value; + + this.state.next({ + ...currentState, + rootIds: roots.map(root => root.id), + rootsLoading: 'LOADED', + ...this.addData(currentState, roots), + }); + } + + private setChildrenLoading(parentId: string) { + const currentState = this.state.value; + const parentData = currentState.allData.get(parentId); + + this.state.next({ + ...currentState, + dataLoading: new Map([ + ...currentState.dataLoading, + ...(parentData?.childrenIds?.map(childId => [childId, 'LOADING'] as const) ?? []), + ]), + }); + } + + private addLoadedData(parentId: string, childData: BackendData[]) { + const currentState = this.state.value; + + this.state.next({ + ...currentState, + ...this.addData(currentState, childData, parentId), + }); + } + + private addData( + {allData, dataLoading}: State, + data: BackendData[], + parentId?: string, + ): Pick { + const parentData = parentId && allData.get(parentId); + const allChildren = data.flatMap(data => data.children ?? []); + return { + allData: new Map([ + ...allData, + ...data.map(datum => { + return [ + datum.id, + { + id: datum.id, + name: datum.name, + parentId, + childrenIds: datum.children, + childrenLoading: 'INIT', + }, + ] as const; + }), + ...(parentData ? ([[parentId, {...parentData, childrenLoading: 'LOADED'}]] as const) : []), + ]), + dataLoading: new Map([ + ...dataLoading, + ...data.map(datum => [datum.id, 'LOADED'] as const), + ...allChildren.map(childId => [childId, 'INIT'] as const), + ]), + }; + } + + private getDataByIds(ids: string[], data: State['allData']) { + return ids + .map(id => data.get(id)) + .filter((item: T | undefined): item is T => !!item) + .map(data => new TransformedData(data)); + } + + private select>, U>( + ...sourcesAndTransform: [...T, TransformFn] + ) { + const sources = sourcesAndTransform.slice(0, -1) as unknown as T; + const transformFn = sourcesAndTransform[sourcesAndTransform.length - 1] as TransformFn; + + return combineLatest([...sources, this.state]).pipe( + map(args => transformFn(...(args as [...ObservedValuesOf, State]))), + shareReplay({refCount: true, bufferSize: 1}), + ); + } +} + +/** + * @title Complex example making use of the redux pattern. + */ +@Component({ + selector: 'cdk-tree-complex-example', + templateUrl: 'cdk-tree-complex-example.html', + styleUrls: ['cdk-tree-complex-example.css'], + standalone: true, + imports: [CdkTreeModule, MatButtonModule, MatIconModule, CommonModule, MatProgressSpinnerModule], +}) +export class CdkTreeComplexExample implements OnInit { + private readonly dataStore = new ComplexDataStore(); + + areRootsLoading = this.dataStore.areRootsLoading; + roots = this.dataStore.roots; + + getChildren = (node: TransformedData) => this.dataStore.getChildren(node.raw.id); + trackBy = (index: number, node: TransformedData) => this.expansionKey(node); + expansionKey = (node: TransformedData) => node.raw.id; + + ngOnInit() { + this.dataStore.loadRoots(); + } + + onExpand(node: TransformedData, expanded: boolean) { + console.log('onExpand', node.raw.id); + if (expanded) { + // Only perform a load on expansion. + this.dataStore.loadChildren(node.raw.id); + } + } +} From 98a01e05ce78193b06b0b3763a7ddfb1ad0aa245 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Wed, 26 Jul 2023 20:06:30 +0000 Subject: [PATCH 14/18] fix(cdk/tree): refactor rendering pipeline This also refactors the parent/level/group data processing in order to make it significantly more consistent in all tree configurations. --- src/cdk/a11y/key-manager/tree-key-manager.ts | 7 +- src/cdk/tree/tree-errors.ts | 12 - src/cdk/tree/tree.ts | 391 ++++++++++-------- src/components-examples/cdk/tree/BUILD.bazel | 1 + .../cdk-tree-complex-example.ts | 1 - src/components-examples/cdk/tree/index.ts | 1 + src/dev-app/tree/tree-demo.html | 4 + src/dev-app/tree/tree-demo.ts | 2 + 8 files changed, 237 insertions(+), 182 deletions(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index 3cd975ee3d1c..f15ef34fda52 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -360,8 +360,11 @@ export class TreeKeyManager { } private _updateActiveItemIndex(newItems: T[]) { - if (this._activeItem) { - const newIndex = newItems.indexOf(this._activeItem); + const activeItem = this._activeItem; + if (activeItem) { + const newIndex = newItems.findIndex( + item => this._trackByFn(item) === this._trackByFn(activeItem), + ); if (newIndex > -1 && newIndex !== this._activeItemIndex) { this._activeItemIndex = newIndex; diff --git a/src/cdk/tree/tree-errors.ts b/src/cdk/tree/tree-errors.ts index 6c234f5e2bc7..ba70ea5bfa80 100644 --- a/src/cdk/tree/tree-errors.ts +++ b/src/cdk/tree/tree-errors.ts @@ -46,15 +46,3 @@ export function getTreeControlMissingError() { export function getMultipleTreeControlsError() { return Error(`More than one of tree control, levelAccessor, or childrenAccessor were provided.`); } - -/** - * Returns an error to be thrown when a node is attempted to be expanded or collapsed when - * it's not expandable. - * @docs-private - */ -export function getNodeNotExpandableError() { - return Error( - `The node that was attempted to be expanded or collapsed is not expandable; you might ` + - `need to provide 'isExpandable'.`, - ); -} diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 746267e3edec..11890e719669 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -8,7 +8,13 @@ import {TreeKeyManager, TreeKeyManagerItem} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; -import {CollectionViewer, DataSource, isDataSource, SelectionModel} from '@angular/cdk/collections'; +import { + CollectionViewer, + DataSource, + isDataSource, + SelectionChange, + SelectionModel, +} from '@angular/cdk/collections'; import { AfterContentChecked, AfterContentInit, @@ -60,7 +66,6 @@ import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node'; import {CdkTreeNodeOutlet} from './outlet'; import { getMultipleTreeControlsError, - getNodeNotExpandableError, getTreeControlMissingError, getTreeMissingMatchingNodeDefError, getTreeMultipleDefaultNodeDefsError, @@ -124,15 +129,16 @@ export class CdkTree private _parents: Map = new Map(); /** - * The internal node groupings for each node; we use this, primarily for flattened trees, to - * determine where a particular node is within each group. + * The internal node groupings for each node; we use this to determine where + * a particular node is within each group. This allows us to compute the + * correct aria attribute values. * * The structure of this is that: * - the outer index is the level * - the inner index is the parent node for this particular group. If there is no parent node, we * use `null`. */ - private _groups: Map> = new Map>(); + private _groups: NodeGroup = new Map(); /** * Provides a stream containing the latest data array to render. Influenced by the tree's @@ -219,29 +225,26 @@ export class CdkTree */ private _flattenedNodes: BehaviorSubject = new BehaviorSubject([]); - /** - * The automatically determined node type for the tree. - */ + /** The automatically determined node type for the tree. */ private _nodeType: BehaviorSubject<'flat' | 'nested' | null> = new BehaviorSubject< 'flat' | 'nested' | null >(null); - /** - * The root nodes of the tree. This will only be populated in the case where the user - * provides a `NestedTreeControl`. - */ - private _rootNodes: BehaviorSubject = new BehaviorSubject([]); - /** The mapping between data and the node that is rendered. */ private _nodes: BehaviorSubject>> = new BehaviorSubject( new Map>(), ); + /** + * Synchronous cache of nodes for the `TreeKeyManager`. This is separate + * from `_flattenedNodes` so they can be independently updated at different + * times. + */ + private _keyManagerNodes: BehaviorSubject = new BehaviorSubject([]); + /** The key manager for this tree. Handles focus and activation based on user keyboard input. */ _keyManager: TreeKeyManager>; - private _inInitialRender = true; - constructor( private _differs: IterableDiffers, private _changeDetectorRef: ChangeDetectorRef, @@ -261,40 +264,6 @@ export class CdkTree throw getTreeControlMissingError(); } } - - let expansionModel; - if (!this.treeControl) { - expansionModel = new SelectionModel(true); - this._expansionModel = expansionModel; - } else { - expansionModel = this.treeControl.expansionModel; - } - - // We manually detect changes on all the children nodes when expansion - // status changes; otherwise, the various attributes won't be updated. - expansionModel.changed - .pipe(withLatestFrom(this._nodes), takeUntil(this._onDestroy)) - .subscribe(([changes, nodes]) => { - for (const added of changes.added) { - nodes.get(added)?._changeDetectorRef.detectChanges(); - } - for (const removed of changes.removed) { - nodes.get(removed)?._changeDetectorRef.detectChanges(); - } - }); - - this._rootNodes - .pipe( - switchMap(rootNodes => this._flattenRootNodes(rootNodes)), - takeUntil(this._onDestroy), - ) - .subscribe(flattenedNodes => { - this._flattenedNodes.next(flattenedNodes); - }); - - this._flattenedNodes.pipe(takeUntil(this._onDestroy)).subscribe(() => { - this._recalculateGroupsForLevelAccessor(); - }); } ngOnDestroy() { @@ -316,13 +285,13 @@ export class CdkTree ngAfterContentInit() { this._keyManager = new TreeKeyManager({ - items: combineLatest([this._flattenedNodes, this._nodes]).pipe( - map(([flattenedNodes, nodes]) => - flattenedNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish), + items: combineLatest([this._keyManagerNodes, this._nodes]).pipe( + map(([dataNodes, nodes]) => + dataNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish), ), ), trackBy: node => this._getExpansionKey(node.data), - skipPredicate: node => node.isDisabled || !this._hasAllParentsExpanded(node.data), + skipPredicate: node => !!node.isDisabled, typeAheadDebounceInterval: true, horizontalOrientation: this._dir.value, }); @@ -352,6 +321,12 @@ export class CdkTree } } + /** + * Sets the node type for the tree, if it hasn't been set yet. + * + * This will be called by the first node that's rendered in order for the tree + * to determine what data transformations are required. + */ _setNodeTypeIfUnset(nodeType: 'flat' | 'nested') { if (this._nodeType.value === null) { this._nodeType.next(nodeType); @@ -413,21 +388,74 @@ export class CdkTree dataStream = observableOf(this._dataSource); } + let expansionModel; + if (!this.treeControl) { + this._expansionModel = new SelectionModel(true); + expansionModel = this._expansionModel; + } else { + expansionModel = this.treeControl.expansionModel; + } + if (dataStream) { - this._dataSubscription = combineLatest([dataStream, this._nodeType]) + this._dataSubscription = combineLatest([ + dataStream, + this._nodeType, + // NB: the data is unused below, however we add it here to essentially + // trigger data rendering when expansion changes occur. + expansionModel.changed.pipe( + startWith(null), + tap(expansionChanges => { + this._emitExpansionChanges(expansionChanges); + }), + ), + ]) .pipe( - switchMap(([data, nodeType]) => this._convertChildren(data, nodeType)), takeUntil(this._onDestroy), + switchMap(([data, nodeType]) => { + if (nodeType === null) { + return observableOf([{renderNodes: data}, nodeType] as const); + } + + // If we're here, then we know what our node type is, and therefore can + // perform our usual rendering pipeline, which necessitates converting the data + const convertedData = this._convertData(data, nodeType); + return convertedData.pipe(map(data => [data, nodeType] as const)); + }), ) - .subscribe(data => { - this._renderNodeChanges(data); - this._inInitialRender = false; + .subscribe(([data, nodeType]) => { + if (nodeType === null) { + // Skip saving cached and key manager data. + this._renderNodeChanges(data.renderNodes); + return; + } + + // If we're here, then we know what our node type is, and therefore can + // perform our usual rendering pipeline. + this._updateCachedData(data.flattenedNodes); + this._renderNodeChanges(data.renderNodes); + this._updateKeyManagerItems(data.flattenedNodes); }); } else if (typeof ngDevMode === 'undefined' || ngDevMode) { throw getTreeNoValidDataSourceError(); } } + private _emitExpansionChanges(expansionChanges: SelectionChange | null) { + if (!expansionChanges) { + return; + } + + const nodes = this._nodes.value; + for (const added of expansionChanges.added) { + const node = nodes.get(added); + node?._emitExpansionState(true); + } + for (const removed of expansionChanges.removed) { + const node = nodes.get(removed); + node?._emitExpansionState(false); + } + } + /** Check for changes made in the data and render each change (node added/removed/moved). */ _renderNodeChanges( data: readonly T[], @@ -452,8 +480,6 @@ export class CdkTree viewContainer.remove(adjustedPreviousIndex!); const group = this._getNodeGroup(item.item); const key = this._getExpansionKey(item.item); - this._levels.delete(key); - this._parents.delete(key); group.splice( group.findIndex(groupItem => this._getExpansionKey(groupItem) === key), 1, @@ -495,9 +521,6 @@ export class CdkTree */ insertNode(nodeData: T, index: number, viewContainer?: ViewContainerRef, parentData?: T) { const levelAccessor = this._getLevelAccessor(); - // On the first render, we don't yet have a cache of flattenedNodes to determine parent data. - // Skip setting this until we have it saved, then recalculate it later. - const shouldSetGroupData = !levelAccessor || !this._inInitialRender; const node = this._getNodeDef(nodeData, index); const key = this._getExpansionKey(nodeData); @@ -520,20 +543,6 @@ export class CdkTree } this._levels.set(key, context.level); - if (shouldSetGroupData) { - const parent = parentData ?? this._findParentForNode(nodeData, index); - const parentKey = parent ? this._getExpansionKey(parent) : null; - this._parents.set(key, parent); - - // We're essentially replicating the tree structure within each `group`; - // we insert the node into the group at the specified index. - const currentGroup = this._groups.get(context.level) ?? new Map(); - const group = currentGroup.get(parentKey) ?? []; - group.splice(index, 0, nodeData); - currentGroup.set(parentKey, group); - this._groups.set(context.level, currentGroup); - } - // Use default tree nodeOutlet, or nested node's nodeOutlet const container = viewContainer ? viewContainer : this._nodeOutlet.viewContainer; container.createEmbeddedView(node.template, context, index); @@ -675,20 +684,25 @@ export class CdkTree _getDirectChildren(dataNode: T): Observable { const levelAccessor = this._getLevelAccessor(); const expansionModel = this._expansionModel ?? this.treeControl?.expansionModel; - if (levelAccessor && expansionModel) { - const key = this._getExpansionKey(dataNode); - const isExpanded = expansionModel.changed.pipe( - switchMap(changes => { - if (changes.added.includes(key)) { - return observableOf(true); - } else if (changes.removed.includes(key)) { - return observableOf(false); - } - return EMPTY; - }), - startWith(this.isExpanded(dataNode)), - ); + if (!expansionModel) { + return observableOf([]); + } + + const key = this._getExpansionKey(dataNode); + + const isExpanded = expansionModel.changed.pipe( + switchMap(changes => { + if (changes.added.includes(key)) { + return observableOf(true); + } else if (changes.removed.includes(key)) { + return observableOf(false); + } + return EMPTY; + }), + startWith(this.isExpanded(dataNode)), + ); + if (levelAccessor) { return combineLatest([isExpanded, this._flattenedNodes]).pipe( map(([expanded, flattenedNodes]) => { if (!expanded) { @@ -806,6 +820,7 @@ export class CdkTree return this._flattenedNodes; } + /** Gets all nested descendants of a given node. */ private _getDescendants(dataNode: T): Observable { if (this.treeControl) { return observableOf(this.treeControl.getDescendants(dataNode)); @@ -882,10 +897,10 @@ export class CdkTree } private _getNodeGroup(node: T) { - const level = this._levels.get(this._getExpansionKey(node)); - const parent = this._parents.get(this._getExpansionKey(node)); + const key = this._getExpansionKey(node); + const parent = this._parents.get(key); const parentKey = parent ? this._getExpansionKey(parent) : null; - const group = this._groups.get(level ?? 0)?.get(parentKey); + const group = this._groups.get(parentKey); return group ?? [node]; } @@ -894,16 +909,15 @@ export class CdkTree * returns null. If we're unable to determine the parent, for example, * if we don't have cached node data, this returns undefined. */ - private _findParentForNode(node: T, index: number): T | null { + private _findParentForNode(node: T, index: number, cachedNodes: readonly T[]): T | null { // In all cases, we have a mapping from node to level; all we need to do here is backtrack in // our flattened list of nodes to determine the first node that's of a level lower than the // provided node. - let cachedNodes = this._flattenedNodes.value; if (!cachedNodes.length) { return null; } const currentLevel = this._levels.get(this._getExpansionKey(node)) ?? 0; - for (let parentIndex = index; parentIndex >= 0; parentIndex--) { + for (let parentIndex = index - 1; parentIndex >= 0; parentIndex--) { const parentNode = cachedNodes[parentIndex]; const parentLevel = this._levels.get(this._getExpansionKey(parentNode)) ?? 0; @@ -915,109 +929,150 @@ export class CdkTree } /** - * Converts children for certain tree configurations. Note also that this - * caches the known nodes for use in other parts of the tree. + * Given a set of root nodes and the current node level, flattens any nested + * nodes into a single array. + * + * If any nodes are not expanded, then their children will not be added into the array. + * NB: this will still traverse all nested children in order to build up our + * internal data models, but will not include them in the returned array. */ - private _convertChildren( - nodes: readonly T[], - nodeType: 'flat' | 'nested' | null, - ): Observable { - // Initially, we pass through the data directly to the renderer, until - // we can determine how to format the nodes for consumption by the actual - // node component. - if (nodeType === null) { - return observableOf(nodes); + private _flattenNestedNodesWithExpansion(nodes: readonly T[], level = 0): Observable { + const childrenAccessor = this._getChildrenAccessor(); + // If we're using a level accessor, we don't need to flatten anything. + if (!childrenAccessor) { + return observableOf([...nodes]); } + return observableOf(...nodes).pipe( + concatMap(node => { + const parentKey = this._getExpansionKey(node); + if (!this._parents.has(parentKey)) { + this._parents.set(parentKey, null); + } + this._levels.set(parentKey, level); + + const children = coerceObservable(childrenAccessor(node)); + return concat( + observableOf([node]), + children.pipe( + take(1), + tap(childNodes => { + this._groups.set(parentKey, [...(childNodes ?? [])]); + for (const child of childNodes ?? []) { + const childKey = this._getExpansionKey(child); + this._parents.set(childKey, node); + this._levels.set(childKey, level + 1); + } + }), + switchMap(childNodes => { + if (!childNodes) { + return observableOf([]); + } + return this._flattenNestedNodesWithExpansion(childNodes, level + 1).pipe( + map(nestedNodes => (this.isExpanded(node) ? nestedNodes : [])), + ); + }), + ), + ); + }), + reduce((results, children) => { + results.push(...children); + return results; + }, [] as T[]), + ); + } + + /** + * Converts children for certain tree configurations. + * + * This also computes parent, level, and group data. + */ + private _convertData( + nodes: readonly T[], + nodeType: 'flat' | 'nested', + ): Observable<{ + renderNodes: readonly T[]; + flattenedNodes: readonly T[]; + }> { // The only situations where we have to convert children types is when // they're mismatched; i.e. if the tree is using a childrenAccessor and the // nodes are flat, or if the tree is using a levelAccessor and the nodes are // nested. if (this.childrenAccessor && nodeType === 'flat') { // This flattens children into a single array. - return this._flattenRootNodes(nodes).pipe( - tap(allNodes => { - this._flattenedNodes.next(allNodes); - }), + this._groups.set(null, [...nodes]); + return this._flattenNestedNodesWithExpansion(nodes).pipe( + map(flattenedNodes => ({ + renderNodes: flattenedNodes, + flattenedNodes, + })), ); } else if (this.levelAccessor && nodeType === 'nested') { // In the nested case, we only look for root nodes. The CdkNestedNode // itself will handle rendering each individual node's children. const levelAccessor = this.levelAccessor; return observableOf(nodes.filter(node => levelAccessor(node) === 0)).pipe( - tap(() => { - this._flattenedNodes.next(nodes); + map(rootNodes => ({ + renderNodes: rootNodes, + flattenedNodes: nodes, + })), + tap(({flattenedNodes}) => { + this._calculateParents(flattenedNodes); }), ); - } else { + } else if (nodeType === 'flat') { // In the case of a TreeControl, we know that the node type matches up - // with the TreeControl, and so no conversions are necessary. - if (nodeType === 'flat') { - this._flattenedNodes.next(nodes); - } else { - this._rootNodes.next(nodes); - } - return observableOf(nodes); + // with the TreeControl, and so no conversions are necessary. Otherwise, + // we've already confirmed that the data model matches up with the + // desired node type here. + return observableOf({renderNodes: nodes, flattenedNodes: nodes}).pipe( + tap(({flattenedNodes}) => { + this._calculateParents(flattenedNodes); + }), + ); + } else { + // For nested nodes, we still need to perform the node flattening in order + // to maintain our caches for various tree operations. + this._groups.set(null, [...nodes]); + return this._flattenNestedNodesWithExpansion(nodes).pipe( + map(flattenedNodes => ({ + renderNodes: nodes, + flattenedNodes, + })), + ); } } - private _flattenRootNodes(rootNodes: readonly T[]): Observable { - return observableOf(...rootNodes).pipe( - concatMap(node => concat(observableOf([node]), this._getDescendants(node))), - reduce((results, children) => { - results.push(...children); - return results; - }, [] as T[]), - ); + private _updateCachedData(flattenedNodes: readonly T[]) { + this._flattenedNodes.next(flattenedNodes); } - private _isExpanded(dataNode: T): boolean { - return ( - this._expansionModel?.isSelected(this._getExpansionKey(dataNode)) ?? - this.treeControl?.isExpanded(dataNode) ?? - false - ); + private _updateKeyManagerItems(flattenedNodes: readonly T[]) { + this._keyManagerNodes.next(flattenedNodes); } - private _recalculateGroupsForLevelAccessor(): void { - // Check that we're using level accessor. Levels have already been cached; - // this is just here to prevent unnecessary work. - if (!this._getLevelAccessor()) { + /** Traverse the flattened node data and compute parents, levels, and group data. */ + private _calculateParents(flattenedNodes: readonly T[]): void { + const levelAccessor = this._getLevelAccessor(); + if (!levelAccessor) { return; } this._parents.clear(); this._groups.clear(); - const flattenedNodes = this._flattenedNodes.value; for (let index = 0; index < flattenedNodes.length; index++) { const dataNode = flattenedNodes[index]; const key = this._getExpansionKey(dataNode); - const parent = this._findParentForNode(dataNode, index); + this._levels.set(key, levelAccessor(dataNode)); + const parent = this._findParentForNode(dataNode, index, flattenedNodes); this._parents.set(key, parent); const parentKey = parent ? this._getExpansionKey(parent) : null; - const level = this._getLevel(dataNode) ?? 0; - - const currentGroup = this._groups.get(level) ?? new Map(); - const group = currentGroup.get(parentKey) ?? []; + const group = this._groups.get(parentKey) ?? []; group.splice(index, 0, dataNode); - currentGroup.set(parentKey, group); - this._groups.set(level, currentGroup); - } - } - - private _hasAllParentsExpanded(dataNode: T): boolean { - const parent = this._parents.get(this._getExpansionKey(dataNode)); - if (parent === null) { - return true; - } - // If we don't have any record of a parent here, this means the node is likely - // removed from the DOM entirely and therefore cannot have parents expanded. - if (parent === undefined) { - return false; + this._groups.set(parentKey, group); } - return this._isExpanded(parent) && this._hasAllParentsExpanded(parent); } } @@ -1215,20 +1270,18 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Collapses this data node. Implemented for TreeKeyManagerItem. */ collapse(): void { - if (typeof ngDevMode === 'undefined' || (ngDevMode && !this._isExpandable())) { - throw getNodeNotExpandableError(); + if (!this._isExpandable()) { + return; } this._tree.collapse(this._data); - this.expandedChange.emit(this.isExpanded); } /** Expands this data node. Implemented for TreeKeyManagerItem. */ expand(): void { - if (typeof ngDevMode === 'undefined' || (ngDevMode && !this._isExpandable())) { - throw getNodeNotExpandableError(); + if (!this._isExpandable()) { + return; } this._tree.expand(this._data); - this.expandedChange.emit(this.isExpanded); } _setTabFocusable() { @@ -1245,6 +1298,10 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI } this._tree._keyManager.onClick(this); } + + _emitExpansionState(expanded: boolean) { + this.expandedChange.emit(expanded); + } } function getParentNodeAriaLevel(nodeElement: HTMLElement): number { diff --git a/src/components-examples/cdk/tree/BUILD.bazel b/src/components-examples/cdk/tree/BUILD.bazel index da97ed3380ec..46b61041e57a 100644 --- a/src/components-examples/cdk/tree/BUILD.bazel +++ b/src/components-examples/cdk/tree/BUILD.bazel @@ -13,6 +13,7 @@ ng_module( "//src/cdk/tree", "//src/material/button", "//src/material/icon", + "//src/material/progress-spinner", ], ) diff --git a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts index 29dc00ea57d6..643adcd47c41 100644 --- a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts +++ b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts @@ -295,7 +295,6 @@ export class CdkTreeComplexExample implements OnInit { } onExpand(node: TransformedData, expanded: boolean) { - console.log('onExpand', node.raw.id); if (expanded) { // Only perform a load on expansion. this.dataStore.loadChildren(node.raw.id); diff --git a/src/components-examples/cdk/tree/index.ts b/src/components-examples/cdk/tree/index.ts index e7dee857f12c..f4f931a4c8f6 100644 --- a/src/components-examples/cdk/tree/index.ts +++ b/src/components-examples/cdk/tree/index.ts @@ -3,3 +3,4 @@ export {CdkTreeFlatLevelAccessorExample} from './cdk-tree-flat-level-accessor/cd export {CdkTreeFlatExample} from './cdk-tree-flat/cdk-tree-flat-example'; export {CdkTreeNestedLevelAccessorExample} from './cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example'; export {CdkTreeNestedExample} from './cdk-tree-nested/cdk-tree-nested-example'; +export {CdkTreeComplexExample} from './cdk-tree-complex/cdk-tree-complex-example'; diff --git a/src/dev-app/tree/tree-demo.html b/src/dev-app/tree/tree-demo.html index 21fb0e5d8196..53228d32d04b 100644 --- a/src/dev-app/tree/tree-demo.html +++ b/src/dev-app/tree/tree-demo.html @@ -39,4 +39,8 @@ Load more flat tree + + Complex tree (Redux pattern) + + diff --git a/src/dev-app/tree/tree-demo.ts b/src/dev-app/tree/tree-demo.ts index 5bacdc861850..80d535bfa70c 100644 --- a/src/dev-app/tree/tree-demo.ts +++ b/src/dev-app/tree/tree-demo.ts @@ -14,6 +14,7 @@ import { CdkTreeFlatLevelAccessorExample, CdkTreeNestedLevelAccessorExample, CdkTreeFlatChildrenAccessorExample, + CdkTreeComplexExample, } from '@angular/components-examples/cdk/tree'; import { TreeChecklistExample, @@ -45,6 +46,7 @@ import {MatTreeModule} from '@angular/material/tree'; CdkTreeFlatChildrenAccessorExample, CdkTreeFlatLevelAccessorExample, CdkTreeNestedLevelAccessorExample, + CdkTreeComplexExample, CommonModule, FormsModule, TreeChecklistExample, From c6a889245517522e715c7ba8cfed145be2ef6121 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Wed, 26 Jul 2023 20:48:50 +0000 Subject: [PATCH 15/18] feat(cdk/tree): update tree documentation --- src/cdk/tree/tree.md | 109 +++++++++++++--------- src/cdk/tree/tree.ts | 2 +- src/material/tree/tree.md | 187 ++++++++++++++++++++++++++++++-------- 3 files changed, 216 insertions(+), 82 deletions(-) diff --git a/src/cdk/tree/tree.md b/src/cdk/tree/tree.md index 6e9da076bab1..4d1061d50b91 100644 --- a/src/cdk/tree/tree.md +++ b/src/cdk/tree/tree.md @@ -2,19 +2,13 @@ The `` enables developers to build a customized tree experience for st `` provides a foundation to build other features such as filtering on top of tree. For a Material Design styled tree, see `` which builds on top of the ``. -There are two types of trees: flat tree and nested Tree. The DOM structures are different for +There are two types of trees: flat and nested. The DOM structures are different for these these two types of trees. #### Flat tree - - - In a flat tree, the hierarchy is flattened; nodes are not rendered inside of each other, but instead -are rendered as siblings in sequence. An instance of `TreeFlattener` is used to generate the flat -list of items from hierarchical data. The "level" of each tree node is read through the `getLevel` -method of the `TreeControl`; this level can be used to style the node such that it is indented to -the appropriate level. +are rendered as siblings in sequence. ```html @@ -25,16 +19,16 @@ the appropriate level. ``` + + Flat trees are generally easier to style and inspect. They are also more friendly to scrolling variations, such as infinite or virtual scrolling. #### Nested tree - - -In nested tree, children nodes are placed inside their parent node in DOM. The parent node contains -a node outlet into which children are projected. +In a nested tree, children nodes are placed inside their parent node in DOM. The parent node +contains a node outlet into which children are projected. ```html @@ -46,15 +40,18 @@ a node outlet into which children are projected. ``` + + Nested trees are easier to work with when hierarchical relationships are visually represented in ways that would be difficult to accomplish with flat nodes. -### Using the CDK tree + +### Usage #### Writing your tree template -The only thing you need to define is the tree node template. There are two types of tree nodes, -`` for flat tree and `` for nested tree. The tree node +In order to use the tree, you must define a tree node template. There are two types of tree nodes, +`` for flat tree and `` for nested tree. The tree node template defines the look of the tree node, expansion/collapsing control and the structure for nested children nodes. @@ -69,9 +66,12 @@ data to be used in any bindings in the node template. ##### Flat tree node template -Flat tree uses each node's `level` to render the hierarchy of the nodes. -The "indent" for a given node is accomplished by adding spacing to each node based on its level. -Spacing can be added either by applying the `cdkNodePadding` directive or by applying custom styles. +Flat trees use the `level` of a node to both render and determine hierarchy of the nodes for screen +readers. This may be provided either via `levelAccessor`, or will be calculated by `CdkTree` if +`childrenAccessor` is provided. + +Spacing can be added either by applying the `cdkNodePadding` directive or by applying custom styles +based on the `aria-level` attribute. ##### Nested tree node template @@ -84,24 +84,16 @@ where the children of the node will be rendered. {{node.value}} - ``` #### Adding expand/collapse -A `cdkTreeNodeToggle` can be added in the tree node template to expand/collapse the tree node. -The toggle toggles the expand/collapse functions in TreeControl and is able to expand/collapse +The `cdkTreeNodeToggle` directive can be used to add expand/collapse functionality for tree nodes. +The toggle calls the expand/collapse functions in the `CdkTree` and is able to expand/collapse a tree node recursively by setting `[cdkTreeNodeToggleRecursive]` to true. -```html - - {{node.value}} - -``` - -The toggle can be placed anywhere in the tree node, and is only toggled by click action. -For best accessibility, `cdkTreeNodeToggle` should be on a button element and have an appropriate -`aria-label`. +`cdkTreeNodeToggle` should be attached to button elements, and will trigger upon click or keyboard +activation. For icon buttons, ensure that `aria-label` is provided. ```html @@ -114,25 +106,24 @@ For best accessibility, `cdkTreeNodeToggle` should be on a button element and ha #### Padding (Flat tree only) -The cdkTreeNodePadding can be placed in a flat tree's node template to display the level +The `cdkTreeNodePadding` directive can be placed in a flat tree's node template to display the level information of a flat tree node. ```html {{node.value}} - ``` -Nested tree does not need this padding since padding can be easily added to the hierarchy structure -in DOM. +This is unnecessary for a nested tree, since the hierarchical structure of the DOM allows for +padding to be added via CSS. #### Conditional template + The tree may include multiple node templates, where a template is chosen for a particular data node via the `when` predicate of the template. - ```html {{node.value}} @@ -154,20 +145,30 @@ Because the data source provides this stream, it bears the responsibility of tog updates. This can be based on anything: tree node expansion change, websocket connections, user interaction, model updates, time-based intervals, etc. +There are two main methods of providing data to the tree: -#### Flat tree +* flattened data, combined with `levelAccessor`. This should be used if the data source already + flattens the nested data structure into a single array. +* only root data, combined with `childrenAccessor`. This should be used if the data source is + already provided as a nested data structure. -The flat tree data source is responsible for the node expansion/collapsing events, since when -the expansion status changes, the data nodes feed to the tree are changed. A new list of visible -nodes should be sent to tree component based on current expansion status. +#### `levelAccessor` +`levelAccessor` is a function that when provided a datum, returns the level the data sits at in the +tree structure. If `levelAccessor` is provided, the data provided by `dataSource` should contain all +renderable nodes in a single array. -#### Nested tree +The data source is responsible for handling node expand/collapse events and providing an updated +array of renderable nodes, if applicable. This can be listened to via the `(expansionChange)` event +on `cdk-tree-node` and `cdk-nested-tree-node`. + +#### `childrenAccessor` -The data source for nested tree has an option to leave the node expansion/collapsing event for each -tree node component to handle. +`childrenAccessor` is a function that when provided a datum, returns the children of that particular +datum. If `childrenAccessor` is provided, the data provided by `dataSource` should _only_ contain +the root nodes of the tree. -##### `trackBy` +#### `trackBy` To improve performance, a `trackBy` function can be provided to the tree similar to Angular’s [`ngFor` `trackBy`](https://angular.io/api/common/NgForOf#change-propagation). This informs the @@ -176,3 +177,25 @@ tree how to uniquely identify nodes to track how the data changes with each upda ```html ``` + +### Accessibility + +The `` implements the [`tree` widget](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/), +including keyboard navigation and appropriate roles and ARIA attributes. + +#### Activation actions + +For trees with nodes that have actions upon activation or click, `` will emit +`(activation)` events that can be listened to when the user activates a node via keyboard +interaction. + +```html + + +``` + +In this example, `$event` contains the node's data and is equivalent to the implicit data passed in +the `cdkNodeDef` context. diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 11890e719669..01be2527944f 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -1141,7 +1141,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI */ @Input() isDisabled?: boolean; - /** This emits when the node has been programatically activated. */ + /** This emits when the node has been programatically activated or activated by keyboard. */ @Output() readonly activation: EventEmitter = new EventEmitter(); diff --git a/src/material/tree/tree.md b/src/material/tree/tree.md index 21c030f9a27c..0f9bab5e8eb7 100644 --- a/src/material/tree/tree.md +++ b/src/material/tree/tree.md @@ -1,21 +1,17 @@ -The `mat-tree` provides a Material Design styled tree that can be used to display hierarchy +The `mat-tree` provides a Material Design styled tree that can be used to display hierarchical data. This tree builds on the foundation of the CDK tree and uses a similar interface for its data source input and template, except that its element and attribute selectors will be prefixed with `mat-` instead of `cdk-`. -There are two types of trees: Flat tree and nested tree. The DOM structures are different for these -two types of trees. Flat trees generally offer better performance, while nested trees provide -flexibility. +There are two types of trees: flat and nested. The DOM structures are different for these +two types of trees. #### Flat tree -In a flat tree, the hierarchy is flattened; nodes are not rendered inside of each other, -but instead are rendered as siblings in sequence. An instance of `TreeFlattener` is -used to generate the flat list of items from hierarchical data. The "level" of each tree -node is read through the `getLevel` method of the `TreeControl`; this level can be -used to style the node such that it is indented to the appropriate level. +In a flat tree, the hierarchy is flattened; nodes are not rendered inside of each other, +but instead are rendered as siblings in sequence. ```html @@ -28,14 +24,12 @@ used to style the node such that it is indented to the appropriate level. Flat trees are generally easier to style and inspect. They are also more friendly to scrolling -variations, such as infinite or virtual scrolling. Flat trees -generally offer better performance. - - +variations, such as infinite or virtual scrolling. #### Nested tree -In Nested tree, children nodes are placed inside their parent node in DOM. The parent node has an -outlet to keep all the children nodes. + +In a nested tree, children nodes are placed inside their parent node in DOM. The parent node +contains a node outlet into which children are projected. ```html @@ -49,26 +43,66 @@ outlet to keep all the children nodes. -Nested trees are easier to work with when hierarchical relationships are visually -represented in ways that would be difficult to accomplish with flat nodes. +Nested trees are easier to work with when hierarchical relationships are visually represented in +ways that would be difficult to accomplish with flat nodes. - +### Usage -### Features +#### Writing your tree template -The `` itself only deals with the rendering of a tree structure. -Additional features can be built on top of the tree by adding behavior inside node templates -(e.g., padding and toggle). Interactions that affect the -rendered data (such as expand/collapse) should be propagated through the table's data source. +In order to use the tree, you must define a tree node template. There are two types of tree nodes, +`` for flat tree and `` for nested tree. The tree node +template defines the look of the tree node, expansion/collapsing control and the structure for +nested children nodes. -### TreeControl +A node definition is specified via any element with `matNodeDef`. This directive exports the node +data to be used in any bindings in the node template. + +```html + + {{node.key}}: {{node.value}} + +``` -The `TreeControl` controls the expand/collapse state of tree nodes. Users can expand/collapse a tree -node recursively through tree control. For nested tree node, `getChildren` function need to pass to -the `NestedTreeControl` to make it work recursively. The `getChildren` function may return an -observable of children for a given node, or an array of children. -For flattened tree node, `getLevel` and `isExpandable` functions need to pass to the -`FlatTreeControl` to make it work recursively. +##### Flat tree node template + +Flat trees use the `level` of a node to both render and determine hierarchy of the nodes for screen +readers. This may be provided either via `levelAccessor`, or will be calculated by `MatTree` if +`childrenAccessor` is provided. + +Spacing can be added either by applying the `matNodePadding` directive or by applying custom styles +based on the `aria-level` attribute. + + +##### Nested tree node template + +When using nested tree nodes, the node template must contain a `matTreeNodeOutlet`, which marks +where the children of the node will be rendered. + +```html + + {{node.value}} + + +``` + +#### Adding expand/collapse + +The `matTreeNodeToggle` directive can be used to add expand/collapse functionality for tree nodes. +The toggle calls the expand/collapse functions in the `matTree` and is able to expand/collapse +a tree node recursively by setting `[matTreeNodeToggleRecursive]` to true. + +`matTreeNodeToggle` should be attached to button elements, and will trigger upon click or keyboard +activation. For icon buttons, ensure that `aria-label` is provided. + +```html + + + {{node.value}} + +``` ### Toggle @@ -84,16 +118,93 @@ The toggle can be placed anywhere in the tree node, and is only toggled by `clic The `matTreeNodePadding` can be placed in a flat tree's node template to display the `level` information of a flat tree node. -Nested tree does not need this padding since padding can be easily added to the hierarchy -structure in DOM. +```html + + {{node.value}} + +``` + +This is unnecessary for a nested tree, since the hierarchical structure of the DOM allows for +padding to be added via CSS. + +#### Conditional template + +The tree may include multiple node templates, where a template is chosen +for a particular data node via the `when` predicate of the template. + +```html + + {{node.value}} + + + [ A special node {{node.value}} ] + +``` + +### Data Source + +#### Connecting the tree to a data source + +Similar to `mat-table`, data is provided to the tree through a `DataSource`. When the tree receives +a `DataSource` it will call its `connect()` method which returns an observable that emits an array +of data. Whenever the data source emits data to this stream, the tree will render an update. + +Because the data source provides this stream, it bears the responsibility of toggling tree +updates. This can be based on anything: tree node expansion change, websocket connections, user +interaction, model updates, time-based intervals, etc. + +There are two main methods of providing data to the tree: + +* flattened data, combined with `levelAccessor`. This should be used if the data source already + flattens the nested data structure into a single array. +* only root data, combined with `childrenAccessor`. This should be used if the data source is + already provided as a nested data structure. + +#### `levelAccessor` + +`levelAccessor` is a function that when provided a datum, returns the level the data sits at in the +tree structure. If `levelAccessor` is provided, the data provided by `dataSource` should contain all +renderable nodes in a single array. + +The data source is responsible for handling node expand/collapse events and providing an updated +array of renderable nodes, if applicable. This can be listened to via the `(expansionChange)` event +on `mat-tree-node` and `mat-nested-tree-node`. + +#### `childrenAccessor` + +`childrenAccessor` is a function that when provided a datum, returns the children of that particular +datum. If `childrenAccessor` is provided, the data provided by `dataSource` should _only_ contain +the root nodes of the tree. + +#### `trackBy` + +To improve performance, a `trackBy` function can be provided to the tree similar to Angular’s +[`ngFor` `trackBy`](https://angular.io/api/common/NgForOf#change-propagation). This informs the +tree how to uniquely identify nodes to track how the data changes with each update. + +```html + +``` ### Accessibility -Trees without text or labels should be given a meaningful label via `aria-label` or -`aria-labelledby`. The `aria-readonly` defaults to `true` if it's not set. -Tree's role is `tree`. -Parent nodes are given `role="group"`, while leaf nodes are given `role="treeitem"` +The `` implements the [`tree` widget](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/), +including keyboard navigation and appropriate roles and ARIA attributes. + +#### Activation actions + +For trees with nodes that have actions upon activation or click, `` will emit +`(activation)` events that can be listened to when the user activates a node via keyboard +interaction. + +```html + + +``` -`mat-tree` does not manage any focus/keyboard interaction on its own. Users can add desired -focus/keyboard interactions in their application. +In this example, `$event` contains the node's data and is equivalent to the implicit data passed in +the `matNodeDef` context. From f0d949c30a9f95b4512731493547160da3ac5113 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Wed, 26 Jul 2023 21:00:07 +0000 Subject: [PATCH 16/18] feat(cdk/a11y): update docs for `TreeKeyManager`. --- src/cdk/a11y/a11y.md | 60 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/cdk/a11y/a11y.md b/src/cdk/a11y/a11y.md index 6724827b019d..64464a5cb7a0 100644 --- a/src/cdk/a11y/a11y.md +++ b/src/cdk/a11y/a11y.md @@ -27,7 +27,7 @@ Navigation through options can be made to wrap via the `withWrap` method this.keyManager = new FocusKeyManager(...).withWrap(); ``` -#### Types of key managers +#### Types of list key managers There are two varieties of `ListKeyManager`, `FocusKeyManager` and `ActiveDescendantKeyManager`. @@ -55,6 +55,64 @@ interface Highlightable extends ListKeyManagerOption { Each item must also have an ID bound to the listbox's or menu's `aria-activedescendant`. +### TreeKeyManager + +`TreeKeyManager` manages the active option in a tree view. This is intended to be used with +components that correspond to a `role="tree"` pattern. + +#### Basic usage + +Any component that uses a `TreeKeyManager` will generally do three things: +* Create a `@ViewChildren` query for the tree items being managed. +* Initialize the `TreeKeyManager`, passing in the options. +* Forward keyboard events from the managed component to the `TreeKeyManager` via `onKeydown`. + +Each tree item should implement the `TreeKeyManagerItem` interface: +```ts +interface TreeKeyManagerItem { + /** Whether the item is disabled. */ + isDisabled?: (() => boolean) | boolean; + + /** The user-facing label for this item. */ + getLabel?(): string; + + /** Perform the main action (i.e. selection) for this item. */ + activate(): void; + + /** Retrieves the parent for this item. This is `null` if there is no parent. */ + getParent(): TreeKeyManagerItem | null; + + /** Retrieves the children for this item. */ + getChildren(): TreeKeyManagerItem[] | Observable; + + /** Determines if the item is currently expanded. */ + isExpanded: (() => boolean) | boolean; + + /** Collapses the item, hiding its children. */ + collapse(): void; + + /** Expands the item, showing its children. */ + expand(): void; + + /** + * Focuses the item. This should provide some indication to the user that this item is focused. + */ + focus(): void; +} +``` + +#### Focus management + +The `TreeKeyManager` will handle focusing the appropriate item on keyboard interactions. However, +the component should call `onInitialFocus` when the component is focused for the first time (i.e. +when there is no active item). + +`tabindex` should also be set by the component when the active item changes. This can be listened to +via the `change` property on the `TreeKeyManager`. In particular, the tree should only have a +`tabindex` set if there is no active item, and should not have a `tabindex` set if there is an +active item. Only the HTML node corresponding to the active item should have a `tabindex` set to +`0`, with all other items set to `-1`. + ### FocusTrap From 6cf8176867bf4784982abc8323ad9bc20bc0fc36 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Wed, 26 Jul 2023 21:26:06 +0000 Subject: [PATCH 17/18] fix(cdk/tree): update API goldens, fix lint errors --- src/cdk/tree/tree.ts | 13 +-- .../cdk-tree-complex-example.ts | 102 +++++++++--------- tools/public_api_guard/cdk/tree.md | 6 +- 3 files changed, 58 insertions(+), 63 deletions(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 01be2527944f..296e124245d2 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -59,7 +59,6 @@ import { take, takeUntil, tap, - withLatestFrom, } from 'rxjs/operators'; import {TreeControl} from './control/tree-control'; import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node'; @@ -83,8 +82,6 @@ function isNotNullish(val: T | null | undefined): val is T { return val != null; } -type NodeGroup = Map; - /** * CDK tree component that connects with a data source to retrieve data of type `T` and renders * dataNodes with hierarchy. Updates the dataNodes when new data is provided by the data source. @@ -138,7 +135,7 @@ export class CdkTree * - the inner index is the parent node for this particular group. If there is no parent node, we * use `null`. */ - private _groups: NodeGroup = new Map(); + private _groups: Map = new Map(); /** * Provides a stream containing the latest data array to render. Influenced by the tree's @@ -410,7 +407,6 @@ export class CdkTree ), ]) .pipe( - takeUntil(this._onDestroy), switchMap(([data, nodeType]) => { if (nodeType === null) { return observableOf([{renderNodes: data}, nodeType] as const); @@ -418,9 +414,11 @@ export class CdkTree // If we're here, then we know what our node type is, and therefore can // perform our usual rendering pipeline, which necessitates converting the data - const convertedData = this._convertData(data, nodeType); - return convertedData.pipe(map(data => [data, nodeType] as const)); + return this._convertData(data, nodeType).pipe( + map(convertedData => [convertedData, nodeType] as const), + ); }), + takeUntil(this._onDestroy), ) .subscribe(([data, nodeType]) => { if (nodeType === null) { @@ -708,7 +706,6 @@ export class CdkTree if (!expanded) { return []; } - const key = this._getExpansionKey(dataNode); const startIndex = flattenedNodes.findIndex(node => this._getExpansionKey(node) === key); const level = levelAccessor(dataNode) + 1; const results: T[] = []; diff --git a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts index 643adcd47c41..eb4f34017d2c 100644 --- a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts +++ b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts @@ -49,7 +49,7 @@ const TREE_DATA: Map = new Map( ); class FakeDataBackend { - private getRandomDelayTime() { + private _getRandomDelayTime() { // anywhere from 100 to 500ms. return Math.floor(Math.random() * 400) + 100; } @@ -60,13 +60,13 @@ class FakeDataBackend { const children = item?.children ?? []; return observableOf(children.map(childId => TREE_DATA.get(childId)!)).pipe( - delay(this.getRandomDelayTime()), + delay(this._getRandomDelayTime()), ); } getRoots(): Observable { return observableOf([...TREE_DATA.values()].filter(datum => !datum.parent)).pipe( - delay(this.getRandomDelayTime()), + delay(this._getRandomDelayTime()), ); } } @@ -109,49 +109,49 @@ interface State { type ObservedValueOf = T extends Observable ? U : never; -type ObservedValuesOf>> = { +type ObservedValuesOf[]> = { [K in keyof T]: ObservedValueOf; }; -type TransformFn>, U> = ( +type TransformFn[], U> = ( ...args: [...ObservedValuesOf, State] ) => U; class ComplexDataStore { - private readonly backend = new FakeDataBackend(); + private readonly _backend = new FakeDataBackend(); - private state = new BehaviorSubject({ + private _state = new BehaviorSubject({ rootIds: [], rootsLoading: 'INIT', allData: new Map(), dataLoading: new Map(), }); - private readonly rootIds = this.select(state => state.rootIds); - private readonly allData = this.select(state => state.allData); - private readonly loadingData = this.select(state => state.dataLoading); - private readonly rootsLoadingState = this.select(state => state.rootsLoading); + private readonly _rootIds = this.select(state => state.rootIds); + private readonly _allData = this.select(state => state.allData); + private readonly _loadingData = this.select(state => state.dataLoading); + private readonly _rootsLoadingState = this.select(state => state.rootsLoading); readonly areRootsLoading = this.select( - this.rootIds, - this.loadingData, - this.rootsLoadingState, + this._rootIds, + this._loadingData, + this._rootsLoadingState, (rootIds, loading, rootsLoading) => rootsLoading !== 'LOADED' || rootIds.some(id => loading.get(id) !== 'LOADED'), ); readonly roots = this.select( this.areRootsLoading, - this.rootIds, - this.allData, + this._rootIds, + this._allData, (rootsLoading, rootIds, data) => { if (rootsLoading) { return []; } - return this.getDataByIds(rootIds, data); + return this._getDataByIds(rootIds, data); }, ); getChildren(parentId: string) { - return this.select(this.allData, this.loadingData, (data, loading) => { + return this.select(this._allData, this._loadingData, (data, loading) => { const parentData = data.get(parentId); if (parentData?.childrenLoading !== 'LOADED') { return []; @@ -160,47 +160,47 @@ class ComplexDataStore { if (childIds.some(id => loading.get(id) !== 'LOADED')) { return []; } - return this.getDataByIds(childIds, data); + return this._getDataByIds(childIds, data); }); } loadRoots() { - this.setRootsLoading(); - this.backend.getRoots().subscribe(roots => { - this.setRoots(roots); + this._setRootsLoading(); + this._backend.getRoots().subscribe(roots => { + this._setRoots(roots); }); } loadChildren(parentId: string) { - this.setChildrenLoading(parentId); - this.backend.getChildren(parentId).subscribe(children => { - this.addLoadedData(parentId, children); + this._setChildrenLoading(parentId); + this._backend.getChildren(parentId).subscribe(children => { + this._addLoadedData(parentId, children); }); } - private setRootsLoading() { - this.state.next({ - ...this.state.value, + private _setRootsLoading() { + this._state.next({ + ...this._state.value, rootsLoading: 'LOADING', }); } - private setRoots(roots: BackendData[]) { - const currentState = this.state.value; + private _setRoots(roots: BackendData[]) { + const currentState = this._state.value; - this.state.next({ + this._state.next({ ...currentState, rootIds: roots.map(root => root.id), rootsLoading: 'LOADED', - ...this.addData(currentState, roots), + ...this._addData(currentState, roots), }); } - private setChildrenLoading(parentId: string) { - const currentState = this.state.value; + private _setChildrenLoading(parentId: string) { + const currentState = this._state.value; const parentData = currentState.allData.get(parentId); - this.state.next({ + this._state.next({ ...currentState, dataLoading: new Map([ ...currentState.dataLoading, @@ -209,22 +209,22 @@ class ComplexDataStore { }); } - private addLoadedData(parentId: string, childData: BackendData[]) { - const currentState = this.state.value; + private _addLoadedData(parentId: string, childData: BackendData[]) { + const currentState = this._state.value; - this.state.next({ + this._state.next({ ...currentState, - ...this.addData(currentState, childData, parentId), + ...this._addData(currentState, childData, parentId), }); } - private addData( + private _addData( {allData, dataLoading}: State, data: BackendData[], parentId?: string, ): Pick { const parentData = parentId && allData.get(parentId); - const allChildren = data.flatMap(data => data.children ?? []); + const allChildren = data.flatMap(datum => datum.children ?? []); return { allData: new Map([ ...allData, @@ -250,20 +250,20 @@ class ComplexDataStore { }; } - private getDataByIds(ids: string[], data: State['allData']) { + private _getDataByIds(ids: string[], data: State['allData']) { return ids .map(id => data.get(id)) .filter((item: T | undefined): item is T => !!item) - .map(data => new TransformedData(data)); + .map(datum => new TransformedData(datum)); } - private select>, U>( + select[], U>( ...sourcesAndTransform: [...T, TransformFn] ) { const sources = sourcesAndTransform.slice(0, -1) as unknown as T; const transformFn = sourcesAndTransform[sourcesAndTransform.length - 1] as TransformFn; - return combineLatest([...sources, this.state]).pipe( + return combineLatest([...sources, this._state]).pipe( map(args => transformFn(...(args as [...ObservedValuesOf, State]))), shareReplay({refCount: true, bufferSize: 1}), ); @@ -281,23 +281,23 @@ class ComplexDataStore { imports: [CdkTreeModule, MatButtonModule, MatIconModule, CommonModule, MatProgressSpinnerModule], }) export class CdkTreeComplexExample implements OnInit { - private readonly dataStore = new ComplexDataStore(); + private readonly _dataStore = new ComplexDataStore(); - areRootsLoading = this.dataStore.areRootsLoading; - roots = this.dataStore.roots; + areRootsLoading = this._dataStore.areRootsLoading; + roots = this._dataStore.roots; - getChildren = (node: TransformedData) => this.dataStore.getChildren(node.raw.id); + getChildren = (node: TransformedData) => this._dataStore.getChildren(node.raw.id); trackBy = (index: number, node: TransformedData) => this.expansionKey(node); expansionKey = (node: TransformedData) => node.raw.id; ngOnInit() { - this.dataStore.loadRoots(); + this._dataStore.loadRoots(); } onExpand(node: TransformedData, expanded: boolean) { if (expanded) { // Only perform a load on expansion. - this.dataStore.loadChildren(node.raw.id); + this._dataStore.loadChildren(node.raw.id); } } } diff --git a/tools/public_api_guard/cdk/tree.md b/tools/public_api_guard/cdk/tree.md index 24c3ca2a03bd..27d682fed7f1 100644 --- a/tools/public_api_guard/cdk/tree.md +++ b/tools/public_api_guard/cdk/tree.md @@ -118,7 +118,6 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, _registerNode(node: CdkTreeNode): void; _renderNodeChanges(data: readonly T[], dataDiffer?: IterableDiffer, viewContainer?: ViewContainerRef, parentData?: T): void; _sendKeydownToKeyManager(event: KeyboardEvent): void; - // (undocumented) _setNodeTypeIfUnset(nodeType: 'flat' | 'nested'): void; _setTabIndex(): void; toggle(dataNode: T): void; @@ -163,6 +162,8 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI protected readonly _destroyed: Subject; // (undocumented) protected _elementRef: ElementRef; + // (undocumented) + _emitExpansionState(expanded: boolean): void; expand(): void; readonly expandedChange: EventEmitter; focus(): void; @@ -304,9 +305,6 @@ export interface FlatTreeControlOptions { // @public export function getMultipleTreeControlsError(): Error; -// @public -export function getNodeNotExpandableError(): Error; - // @public export function getTreeControlMissingError(): Error; From fe7e437f882e57530dda71b514565ac048301b83 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 28 Jul 2023 19:00:20 +0000 Subject: [PATCH 18/18] fix(cdk/tree): empty commit; retry ci actions