diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index 1e4bfa2a4122..077ab258caa6 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -298,6 +298,12 @@ export class TreeKeyManager implements TreeKeyMana if (newIndex > -1 && newIndex !== this._activeItemIndex) { this._activeItemIndex = newIndex; this._typeahead?.setCurrentSelectedItemIndex(newIndex); + } else if (newIndex === -1) { + // if there's no new matching element, then we reset the state of this + // key manager + this._activeItem = null; + this._activeItemIndex = -1; + this._hasInitialFocused = false; } } diff --git a/src/cdk/tree/BUILD.bazel b/src/cdk/tree/BUILD.bazel index 4434c52a254b..9c5450e70e59 100644 --- a/src/cdk/tree/BUILD.bazel +++ b/src/cdk/tree/BUILD.bazel @@ -39,6 +39,7 @@ ng_test_library( "//src/cdk/collections", "//src/cdk/keycodes", "//src/cdk/testing/testbed", + "@npm//@angular/common", "@npm//rxjs", ], ) diff --git a/src/cdk/tree/nested-node.ts b/src/cdk/tree/nested-node.ts index 6d7a93b88264..9ceb69a5835f 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -65,7 +65,7 @@ export class CdkNestedTreeNode } ngAfterContentInit() { - this._dataDiffer = this._differs.find([]).create(this._tree.trackBy); + this._dataDiffer = this._differs.find([]).create(this._tree._trackByFn()); this._tree ._getDirectChildren(this.data) .pipe(takeUntil(this._destroyed)) diff --git a/src/cdk/tree/tree-using-legacy-key-manager.spec.ts b/src/cdk/tree/tree-using-legacy-key-manager.spec.ts index 00b6afd22767..a31060f7a932 100644 --- a/src/cdk/tree/tree-using-legacy-key-manager.spec.ts +++ b/src/cdk/tree/tree-using-legacy-key-manager.spec.ts @@ -1,4 +1,4 @@ -import {Component, ElementRef, QueryList, ViewChild, ViewChildren} from '@angular/core'; +import {Component, QueryList, ViewChildren, ElementRef} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {of} from 'rxjs'; import {CdkTreeModule} from './tree-module'; @@ -9,8 +9,7 @@ describe('CdkTree when provided LegacyTreeKeyManager', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [CdkTreeModule], - declarations: [SimpleCdkTreeApp], + imports: [SimpleCdkTreeApp], providers: [NOOP_TREE_KEY_MANAGER_FACTORY_PROVIDER], }); @@ -74,13 +73,14 @@ class MinimalTestData { @Component({ template: ` - + {{node.name}} `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class SimpleCdkTreeApp { isExpandable = (node: MinimalTestData) => node.children.length > 0; @@ -88,6 +88,5 @@ class SimpleCdkTreeApp { dataSource = of([new MinimalTestData('apple'), new MinimalTestData('banana')]); - @ViewChild('tree', {read: ElementRef}) tree: ElementRef; @ViewChildren('node') treeNodes: QueryList>; } diff --git a/src/cdk/tree/tree-with-tree-control.spec.ts b/src/cdk/tree/tree-with-tree-control.spec.ts index 2ec6401edd32..a879d1094c6d 100644 --- a/src/cdk/tree/tree-with-tree-control.spec.ts +++ b/src/cdk/tree/tree-with-tree-control.spec.ts @@ -28,6 +28,7 @@ import {FlatTreeControl} from './control/flat-tree-control'; import {NestedTreeControl} from './control/nested-tree-control'; import {CdkTreeModule, CdkTreeNodePadding} from './index'; import {CdkTree, CdkTreeNode} from './tree'; +import {NgIf, NgSwitch} from '@angular/common'; describe('CdkTree with TreeControl', () => { /** Represents an indent for expectNestedTreeToMatch */ @@ -37,9 +38,9 @@ describe('CdkTree with TreeControl', () => { let tree: CdkTree; let dir: {value: Direction; readonly change: EventEmitter}; - function configureCdkTreeTestingModule(declarations: Type[]) { + function configureCdkTreeTestingModule(imports: Type[]) { TestBed.configureTestingModule({ - imports: [CdkTreeModule], + imports, providers: [ { provide: Directionality, @@ -58,7 +59,6 @@ describe('CdkTree with TreeControl', () => { }, }, ], - declarations: declarations, }); } @@ -216,9 +216,8 @@ describe('CdkTree with TreeControl', () => { }); it('should be able to set zero as the indent level', () => { - component.paddingNodes.forEach(node => (node.level = 0)); - fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); + component.paddingNodes.forEach(node => (node.level = 0)); const data = dataSource.data; @@ -532,7 +531,9 @@ describe('CdkTree with TreeControl', () => { // Add new data component.dataSource.data = copiedData; + fixture.detectChanges(); component.dataSource.addData(); + fixture.detectChanges(); } it('should add/remove/move nodes with reference-based trackBy', () => { @@ -1462,7 +1463,8 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class SimpleCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1488,7 +1490,8 @@ class SimpleCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule, NgSwitch], }) class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} @@ -1501,7 +1504,8 @@ class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class NestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1525,7 +1529,8 @@ class NestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class StaticNestedCdkTreeApp { getChildren = (node: TestData) => node.children; @@ -1562,7 +1567,8 @@ class StaticNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class WhenNodeNestedCdkTreeApp { isSecondNode = (_: number, node: TestData) => node.pizzaBase.indexOf('2') > 0; @@ -1586,7 +1592,8 @@ class WhenNodeNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class CdkTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1613,7 +1620,8 @@ class CdkTreeAppWithToggle { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule, NgIf], }) class NestedCdkTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1643,7 +1651,8 @@ class NestedCdkTreeAppWithToggle { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class WhenNodeCdkTreeApp { isOddNode = (_: number, node: TestData) => node.level % 2 === 1; @@ -1667,7 +1676,8 @@ class WhenNodeCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class ArrayDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1694,7 +1704,8 @@ class ArrayDataSourceCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class ObservableDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1720,7 +1731,8 @@ class ObservableDataSourceCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class ArrayDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1745,7 +1757,8 @@ class ArrayDataSourceNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class ObservableDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1771,7 +1784,8 @@ class ObservableDataSourceNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class DepthNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1795,7 +1809,8 @@ class DepthNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class CdkTreeAppWithTrackBy { trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; @@ -1829,7 +1844,8 @@ class CdkTreeAppWithTrackBy { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class NestedCdkTreeAppWithTrackBy { trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; diff --git a/src/cdk/tree/tree.spec.ts b/src/cdk/tree/tree.spec.ts index 96321621afb9..43f3f430dead 100644 --- a/src/cdk/tree/tree.spec.ts +++ b/src/cdk/tree/tree.spec.ts @@ -28,6 +28,7 @@ import {map} from 'rxjs/operators'; import {CdkTreeModule, CdkTreeNodePadding} from './index'; import {CdkTree, CdkTreeNode} from './tree'; import {createKeyboardEvent} from '@angular/cdk/testing/testbed/fake-events'; +import {AsyncPipe} from '@angular/common'; /** * This is a cloned version of `tree.spec.ts` that contains all the same tests, @@ -41,9 +42,9 @@ describe('CdkTree', () => { let tree: CdkTree; let dir: {value: Direction; readonly change: EventEmitter}; - function configureCdkTreeTestingModule(declarations: Type[]) { + function configureCdkTreeTestingModule(imports: Type[]) { TestBed.configureTestingModule({ - imports: [CdkTreeModule], + imports, providers: [ { provide: Directionality, @@ -62,7 +63,6 @@ describe('CdkTree', () => { }, }, ], - declarations: declarations, }); } @@ -151,7 +151,7 @@ describe('CdkTree', () => { let ariaExpandedStates = getNodes(treeElement).map(n => n.getAttribute('aria-expanded')); expect(ariaExpandedStates).toEqual([null, null, 'false', null]); - component.tree.expandAll(); + component.expandAll(); fixture.detectChanges(); ariaExpandedStates = getNodes(treeElement).map(n => n.getAttribute('aria-expanded')); @@ -222,8 +222,8 @@ describe('CdkTree', () => { }); it('should be able to set zero as the indent level', () => { - component.paddingNodes.forEach(node => (node.level = 0)); fixture.detectChanges(); + component.paddingNodes.forEach(node => (node.level = 0)); const data = dataSource.data; @@ -583,7 +583,9 @@ describe('CdkTree', () => { // Add new data component.dataSource.data = copiedData; + fixture.detectChanges(); component.dataSource.addData(); + fixture.detectChanges(); } function mutateProperties() { @@ -634,6 +636,7 @@ describe('CdkTree', () => { component.dataSource.data = component.dataSource.data.map( item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase), ); + fixture.detectChanges(); // Expect first two to be the same since they were swapped but indicies are consistent. // The third element was removed and caught by the tree so it was removed before another @@ -1244,6 +1247,7 @@ describe('CdkTree', () => { fixture.destroy(); fixture = TestBed.createComponent(StaticNestedCdkTreeApp); + fixture.detectChanges(); component = fixture.componentInstance; dataSource = component.dataSource as FakeDataSource; @@ -1270,7 +1274,6 @@ describe('CdkTree', () => { it('the tree does not have a tabindex when an element is active', () => { // activate the second child by clicking on it nodes[1].click(); - fixture.detectChanges(); expect(treeElement.hasAttribute('tabindex')).toBeFalse(); }); @@ -1481,6 +1484,17 @@ describe('CdkTree', () => { .withContext(`expect an expanded node`) .toBe(1); }); + + it('statically renders nested children', () => { + configureCdkTreeTestingModule([NestedChildrenExpansionTest]); + const fixture = TestBed.createComponent(NestedChildrenExpansionTest); + fixture.detectChanges(); + + const component = fixture.componentInstance; + expect(getExpandedNodes(component.allNodes, component.tree).length) + .withContext(`expect all expanded nodes`) + .toBe(3); + }); }); export class TestData { @@ -1687,7 +1701,8 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class SimpleCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1719,7 +1734,8 @@ class SimpleCdkTreeApp { } `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} @@ -1733,7 +1749,8 @@ class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class NestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1757,7 +1774,8 @@ class NestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class StaticNestedCdkTreeApp { getChildren = (node: TestData) => node.children; @@ -1791,7 +1809,8 @@ class StaticNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class WhenNodeNestedCdkTreeApp { isSecondNode = (_: number, node: TestData) => node.pizzaBase.indexOf('2') > 0; @@ -1815,7 +1834,8 @@ class WhenNodeNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class CdkTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1845,7 +1865,8 @@ class CdkTreeAppWithToggle { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule, AsyncPipe], }) class NestedCdkTreeAppWithToggle { toggleRecursively: boolean = true; @@ -1877,7 +1898,8 @@ class NestedCdkTreeAppWithToggle { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class WhenNodeCdkTreeApp { isOddNode = (_: number, node: TestData) => node.level % 2 === 1; @@ -1901,7 +1923,8 @@ class WhenNodeCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class ArrayDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1936,7 +1959,8 @@ class ArrayDataSourceCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class ObservableDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; @@ -1961,7 +1985,8 @@ class ObservableDataSourceCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class ArrayDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -1985,7 +2010,8 @@ class ArrayDataSourceNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class ObservableDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -2010,7 +2036,8 @@ class ObservableDataSourceNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class DepthNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; @@ -2033,7 +2060,8 @@ class DepthNestedCdkTreeApp { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class CdkTreeAppWithTrackBy { trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; @@ -2067,7 +2095,8 @@ class CdkTreeAppWithTrackBy { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class NestedCdkTreeAppWithTrackBy { trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; @@ -2111,7 +2140,8 @@ class MinimalTestData { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class TypeaheadLabelFlatTreeWithThreeNodes { isExpandable = (node: MinimalTestData) => node.children.length > 0; @@ -2135,7 +2165,8 @@ class TypeaheadLabelFlatTreeWithThreeNodes { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class FlatTreeWithThreeNodes { isExpandable = (node: MinimalTestData) => node.children.length > 0; @@ -2162,7 +2193,8 @@ class FlatTreeWithThreeNodes { `, - standalone: false, + standalone: true, + imports: [CdkTreeModule], }) class IsExpandableOrderingTest { getChildren = (node: MinimalTestData) => node.children; @@ -2179,3 +2211,38 @@ class IsExpandableOrderingTest { this.dataSource = data; } } + +@Component({ + template: ` + + + {{node.name}} + + + `, + standalone: true, + imports: [CdkTreeModule], +}) +class NestedChildrenExpansionTest { + getChildren = (node: MinimalTestData) => node.children; + + @ViewChild(CdkTree) tree: CdkTree; + + dataSource: MinimalTestData[]; + + allNodes: MinimalTestData[]; + + constructor() { + const nestedChildren = [new MinimalTestData('subchild')]; + const children = [new MinimalTestData('child')]; + children[0].children = nestedChildren; + const data = [new MinimalTestData('parent')]; + data[0].children = children; + + this.dataSource = data; + this.allNodes = [...data, ...children, ...nestedChildren]; + } +} diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index c7daf68f9fd1..248f03a55fe0 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -41,22 +41,28 @@ import { Output, QueryList, TrackByFunction, + input, + computed, + Signal, + signal, ViewChild, ViewContainerRef, ViewEncapsulation, numberAttribute, inject, booleanAttribute, + TemplateRef, } from '@angular/core'; +import {toObservable, toSignal} from '@angular/core/rxjs-interop'; import {coerceObservable} from '@angular/cdk/coercion/private'; import { BehaviorSubject, combineLatest, + Subscriber, concat, EMPTY, Observable, Subject, - Subscription, isObservable, of as observableOf, } from 'rxjs'; @@ -69,6 +75,7 @@ import { switchMap, take, takeUntil, + shareReplay, tap, } from 'rxjs/operators'; import {TreeControl} from './control/tree-control'; @@ -81,6 +88,7 @@ import { getTreeMultipleDefaultNodeDefsError, getTreeNoValidDataSourceError, } from './tree-errors'; +import {NgTemplateOutlet} from '@angular/common'; type RenderingData = | { @@ -94,6 +102,72 @@ type RenderingData = renderNodes: readonly T[]; }; +interface RenderNode { + context: CdkTreeNodeOutletContext; + template: TemplateRef>; + key: K; +} + +class NodeOutletTemplateContext { + $implicit: Signal[]>; + + constructor(nodes: Signal[]>) { + this.$implicit = nodes; + } +} + +/** @docs-private */ +@Directive({ + selector: '[cdkTreeNodeRenderer]', + hostDirectives: [ + { + directive: NgTemplateOutlet, + inputs: ['ngTemplateOutlet: template', 'ngTemplateOutletContext: context'], + }, + ], +}) +export class CdkTreeNodeRenderer implements OnInit { + readonly node = input.required>(); + + ngOnInit() { + if (CdkTreeNode.mostRecentTreeNode) { + CdkTreeNode.mostRecentTreeNode.data = this.node().context.$implicit; + } + } +} + +/** + * Invisible component that holds a template to be rendered inside the CdkTreeNodeOutlet. + * + * @docs-private + */ +@Component({ + selector: 'cdk-tree-node-outlet-template', + template: ` + + @for (node of nodes(); track node.key) { + + } + + `, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CdkTreeNodeRenderer], + host: { + 'style': 'display: none;', + }, +}) +export class CdkTreeNodeOutletTemplate { + @ViewChild('outletTemplate', {static: true, read: TemplateRef}) _nodeOutletTemplate: TemplateRef< + NodeOutletTemplateContext + >; +} + /** * 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. @@ -129,6 +203,7 @@ export class CdkTree private _elementRef = inject(ElementRef); private _dir = inject(Directionality); + private _viewContainer = inject(ViewContainerRef); /** Subject that emits when the component has been destroyed. */ private readonly _onDestroy = new Subject(); @@ -139,9 +214,6 @@ export class CdkTree /** Stores the node definition that does not have a when predicate. */ private _defaultNodeDef: CdkTreeNodeDef | null; - /** Data subscription */ - private _dataSubscription: Subscription | null; - /** Level of nodes */ private _levels: Map = new Map(); @@ -165,14 +237,35 @@ export class CdkTree */ @Input() get dataSource(): DataSource | Observable | T[] { - return this._dataSource; + return this._dataSource.value; } - set dataSource(dataSource: DataSource | Observable | T[]) { - if (this._dataSource !== dataSource) { - this._switchDataSource(dataSource); + set dataSource(dataSource: DataSource | Observable | T[] | null) { + if (this._dataSource.value !== dataSource) { + this._dataSource.next(dataSource!); } } - private _dataSource: DataSource | Observable | T[]; + private readonly _dataSource = new BehaviorSubject | Observable | T[]>([]); + private readonly _transformedDataSource = this._dataSource.pipe( + tap(dataSource => { + if (!dataSource) { + this._nodeOutlet.viewContainer.clear(); + } + }), + switchMap(dataSource => { + if (isDataSource(dataSource)) { + return this._fromDataSource(dataSource); + } else if (isObservable(dataSource)) { + return dataSource; + } else if (Array.isArray(dataSource)) { + return observableOf(dataSource); + } + + if (typeof ngDevMode === 'undefined' || ngDevMode) { + throw getTreeNoValidDataSourceError(); + } + return EMPTY; + }), + ); /** * The tree controller @@ -205,12 +298,28 @@ export class CdkTree * relative to the function to know if a node should be added/removed/moved. * Accepts a function that takes two parameters, `index` and `item`. */ - @Input() trackBy: TrackByFunction; + @Input() + get trackBy(): TrackByFunction | undefined { + return this._trackBy(); + } + set trackBy(trackBy: TrackByFunction) { + this._trackBy.set(trackBy); + } /** * Given a data node, determines the key by which we determine whether or not this node is expanded. */ - @Input() expansionKey?: (dataNode: T) => K; + @Input() + get expansionKey(): ((dataNode: T) => K) | undefined { + return this._expansionKey(); + } + set expansionKey(expansionKey: (dataNode: T) => K) { + this._expansionKey.set(expansionKey); + } + + private readonly _trackBy = signal | undefined>(undefined); + + private readonly _expansionKey = signal<((dataNode: T) => K) | undefined>(undefined); // Outlets within the tree's template where the dataNodes will be inserted. @ViewChild(CdkTreeNodeOutlet, {static: true}) _nodeOutlet: CdkTreeNodeOutlet; @@ -223,6 +332,8 @@ export class CdkTree }) _nodeDefs: QueryList>; + private readonly _selection = new BehaviorSubject([]); + // TODO(tinayuangao): Setup a listener for scrolling, emit the calculated view to viewChange. // Remove the MAX_VALUE in viewChange /** @@ -237,36 +348,90 @@ export class CdkTree /** Keep track of which nodes are expanded. */ private _expansionModel?: SelectionModel; - /** - * 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 _flattenedNodes: BehaviorSubject = new BehaviorSubject([]); - /** The automatically determined node type for the tree. */ - private _nodeType: BehaviorSubject<'flat' | 'nested' | null> = new BehaviorSubject< - 'flat' | 'nested' | null - >(null); + private _nodeType = new BehaviorSubject<'flat' | 'nested' | null>(null); /** 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([]); - private _keyManagerFactory = inject(TREE_KEY_MANAGER) as TreeKeyManagerFactory>; /** The key manager for this tree. Handles focus and activation based on user keyboard input. */ _keyManager: TreeKeyManagerStrategy>; private _viewInit = false; + private readonly _expansionKeyFn = computed(() => { + // In the case that a key accessor function was not provided by the + // tree user, we'll default to using the node object itself as the key. + // + // This cast is safe since: + // - if an expansionKey is provided, TS will infer the type of K to be + // the return type. + // - if it's not, then K will be defaulted to T. + return this._expansionKey() ?? ((item: T) => item as unknown as K); + }); + readonly _trackByFn = computed(() => { + const trackBy = this._trackBy(); + if (trackBy) return trackBy; + const expansionKey = this._expansionKeyFn(); + // Provide a default trackBy based on `_ExpansionKey` if one isn't provided. + return (_index: number, item: T) => expansionKey(item); + }); + + private readonly _renderData = toSignal( + combineLatest([this._transformedDataSource, this._nodeType, this._selection]).pipe( + switchMap(([data, nodeType, expandedKeys]) => + this._getRenderData(data, nodeType, expandedKeys), + ), + ), + {initialValue: null}, + ); + /** + * 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 readonly _flattenedNodes = computed(() => { + return this._renderData()?.flattenedNodes ?? []; + }); + private readonly _flatNodesObs = toObservable(this._flattenedNodes); + private readonly _renderNodes = computed((): RenderNode[] => { + const nodes = this._renderData()?.renderNodes ?? []; + const levelAccessor = this._getLevelAccessor(); + const trackBy = this._trackByFn(); + + return nodes.map((nodeData, index) => { + const node = this._getNodeDef(nodeData, index); + const key = this._getExpansionKey(nodeData); + const parentData = this._parents.get(key) ?? undefined; + + const context = new CdkTreeNodeOutletContext(nodeData); + // If the tree is flat tree, then use the `getLevel` function in flat tree control + // Otherwise, use the level of parent node. + if (levelAccessor) { + context.level = levelAccessor(nodeData); + } else if (parentData !== undefined && this._levels.has(this._getExpansionKey(parentData))) { + context.level = this._levels.get(this._getExpansionKey(parentData))! + 1; + } else { + context.level = 0; + } + this._levels.set(key, context.level); + return { + context, + template: node.template, + key: trackBy(index, nodeData), + }; + }); + }); + /** + * Synchronous cache of nodes for the `TreeKeyManager`. This is separate + * from `_flattenedNodes` so they can be independently updated at different + * times. + */ + private readonly _keyManagerNodes = toObservable(this._flattenedNodes); + constructor(...args: unknown[]); constructor() {} @@ -276,7 +441,6 @@ export class CdkTree ngAfterContentChecked() { this._updateDefaultNodeDefinition(); - this._subscribeToDataChanges(); } ngOnDestroy() { @@ -286,15 +450,6 @@ export class CdkTree this._onDestroy.next(); this._onDestroy.complete(); - if (this._dataSource && typeof (this._dataSource as DataSource).disconnect === 'function') { - (this.dataSource as DataSource).disconnect(this); - } - - if (this._dataSubscription) { - this._dataSubscription.unsubscribe(); - this._dataSubscription = null; - } - // In certain tests, the tree might be destroyed before this is initialized // in `ngAfterContentInit`. this._keyManager?.destroy(); @@ -303,10 +458,16 @@ export class CdkTree ngOnInit() { this._checkTreeControlUsage(); this._initializeDataDiffer(); + this._subscribeToExpansionChanges(); } ngAfterViewInit() { this._viewInit = true; + const componentRef = this._viewContainer.createComponent(CdkTreeNodeOutletTemplate); + this._nodeOutlet.viewContainer.createEmbeddedView( + componentRef.instance._nodeOutletTemplate, + new NodeOutletTemplateContext(this._renderNodes), + ); } private _updateDefaultNodeDefinition() { @@ -337,30 +498,13 @@ export class CdkTree } } - /** - * Switch to the provided data source by resetting the data and unsubscribing from the current - * render change subscription if one exists. If the data source is null, interpret this by - * clearing the node outlet. Otherwise start listening for new data. - */ - private _switchDataSource(dataSource: DataSource | Observable | T[]) { - if (this._dataSource && typeof (this._dataSource as DataSource).disconnect === 'function') { - (this.dataSource as DataSource).disconnect(this); - } - - if (this._dataSubscription) { - this._dataSubscription.unsubscribe(); - this._dataSubscription = null; - } - - // Remove the all dataNodes if there is now no data source - if (!dataSource) { - this._nodeOutlet.viewContainer.clear(); - } - - this._dataSource = dataSource; - if (this._nodeDefs) { - this._subscribeToDataChanges(); - } + private _subscribeToExpansionChanges() { + const model = this._getExpansionModel(); + this._selection.next([...model.selected]); + model.changed.pipe(takeUntil(this._onDestroy)).subscribe(changes => { + this._emitExpansionChanges(changes); + this._selection.next([...model.selected]); + }); } _getExpansionModel() { @@ -371,76 +515,21 @@ export class CdkTree return this.treeControl.expansionModel; } - /** Set up a subscription for the data provided by the data source. */ - private _subscribeToDataChanges() { - if (this._dataSubscription) { - return; - } - - let dataStream: Observable | undefined; - - if (isDataSource(this._dataSource)) { - dataStream = this._dataSource.connect(this); - } else if (isObservable(this._dataSource)) { - dataStream = this._dataSource; - } else if (Array.isArray(this._dataSource)) { - dataStream = observableOf(this._dataSource); - } - - if (!dataStream) { - if (typeof ngDevMode === 'undefined' || ngDevMode) { - throw getTreeNoValidDataSourceError(); - } - return; - } - - this._dataSubscription = this._getRenderData(dataStream) - .pipe(takeUntil(this._onDestroy)) - .subscribe(renderingData => { - this._renderDataChanges(renderingData); - }); - } - - /** Given an Observable containing a stream of the raw data, returns an Observable containing the RenderingData */ - private _getRenderData(dataStream: Observable): Observable> { - const expansionModel = this._getExpansionModel(); - return combineLatest([ - dataStream, - this._nodeType, - // We don't use the expansion data directly, 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]) => { - if (nodeType === null) { - return observableOf({renderNodes: data, flattenedNodes: null, 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 - return this._computeRenderingData(data, nodeType).pipe( - map(convertedData => ({...convertedData, nodeType}) as const), - ); - }), - ); - } - - private _renderDataChanges(data: RenderingData) { - if (data.nodeType === null) { - this.renderNodeChanges(data.renderNodes); - return; + /** Given the raw data, returns an Observable containing the RenderingData */ + private _getRenderData( + data: readonly T[], + nodeType: 'flat' | 'nested' | null, + selection: readonly K[], + ): Observable> { + if (nodeType === null) { + return observableOf({renderNodes: data, flattenedNodes: null, nodeType} as const); } // 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); + // perform our usual rendering pipeline, which necessitates converting the data + return this._computeRenderingData(data, nodeType, selection).pipe( + map(convertedData => ({...convertedData, nodeType}) as const), + ); } private _emitExpansionChanges(expansionChanges: SelectionChange | null) { @@ -483,9 +572,7 @@ export class CdkTree } private _initializeDataDiffer() { - // Provide a default trackBy based on `_getExpansionKey` if one isn't provided. - const trackBy = this.trackBy ?? ((_index: number, item: T) => this._getExpansionKey(item)); - this._dataDiffer = this._differs.find([]).create(trackBy); + this._dataDiffer = this._differs.find([]).create(this._trackByFn()); } private _checkTreeControlUsage() { @@ -559,15 +646,7 @@ export class CdkTree } }); - // Note: we only `detectChanges` from a top-level call, otherwise we risk overflowing - // the call stack since this method is called recursively (see #29733.) - // TODO: change to `this._changeDetectorRef.markForCheck()`, - // or just switch this component to use signals. - if (parentData) { - this._changeDetectorRef.markForCheck(); - } else { - this._changeDetectorRef.detectChanges(); - } + this._changeDetectorRef.markForCheck(); } /** @@ -717,9 +796,7 @@ export class CdkTree this.treeControl.expandAll(); } else if (this._expansionModel) { const expansionModel = this._expansionModel; - expansionModel.select( - ...this._flattenedNodes.value.map(child => this._getExpansionKey(child)), - ); + expansionModel.select(...this._flattenedNodes().map(child => this._getExpansionKey(child))); } } @@ -729,9 +806,7 @@ export class CdkTree this.treeControl.collapseAll(); } else if (this._expansionModel) { const expansionModel = this._expansionModel; - expansionModel.deselect( - ...this._flattenedNodes.value.map(child => this._getExpansionKey(child)), - ); + expansionModel.deselect(...this._flattenedNodes().map(child => this._getExpansionKey(child))); } } @@ -771,18 +846,12 @@ export class CdkTree ); if (levelAccessor) { - return combineLatest([isExpanded, this._flattenedNodes]).pipe( + return combineLatest([isExpanded, this._flatNodesObs]).pipe( map(([expanded, flattenedNodes]) => { if (!expanded) { return []; } - return this._findChildrenByLevel( - levelAccessor, - flattenedNodes, - - dataNode, - 1, - ); + return this._findChildrenByLevel(levelAccessor, flattenedNodes, dataNode, 1); }), ); } @@ -922,7 +991,7 @@ export class CdkTree if (this.levelAccessor) { const results = this._findChildrenByLevel( this.levelAccessor, - this._flattenedNodes.value, + this._flattenedNodes(), dataNode, Infinity, ); @@ -965,14 +1034,7 @@ export class CdkTree } private _getExpansionKey(dataNode: T): K { - // In the case that a key accessor function was not provided by the - // tree user, we'll default to using the node object itself as the key. - // - // This cast is safe since: - // - if an expansionKey is provided, TS will infer the type of K to be - // the return type. - // - if it's not, then K will be defaulted to T. - return this.expansionKey?.(dataNode) ?? (dataNode as unknown as K); + return this._expansionKeyFn()(dataNode); } private _getAriaSet(node: T) { @@ -1015,7 +1077,11 @@ export class CdkTree * 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 _flattenNestedNodesWithExpansion(nodes: readonly T[], level = 0): Observable { + private _flattenNestedNodesWithExpansion( + nodes: readonly T[], + selection: readonly K[], + level = 0, + ): Observable { const childrenAccessor = this._getChildrenAccessor(); // If we're using a level accessor, we don't need to flatten anything. if (!childrenAccessor) { @@ -1047,8 +1113,8 @@ export class CdkTree if (!childNodes) { return observableOf([]); } - return this._flattenNestedNodesWithExpansion(childNodes, level + 1).pipe( - map(nestedNodes => (this.isExpanded(node) ? nestedNodes : [])), + return this._flattenNestedNodesWithExpansion(childNodes, selection, level + 1).pipe( + map(nestedNodes => (selection.includes(parentKey) ? nestedNodes : [])), ); }), ), @@ -1069,6 +1135,7 @@ export class CdkTree private _computeRenderingData( nodes: readonly T[], nodeType: 'flat' | 'nested', + selection: readonly K[], ): Observable<{ renderNodes: readonly T[]; flattenedNodes: readonly T[]; @@ -1080,7 +1147,7 @@ export class CdkTree if (this.childrenAccessor && nodeType === 'flat') { // This flattens children into a single array. this._ariaSets.set(null, [...nodes]); - return this._flattenNestedNodesWithExpansion(nodes).pipe( + return this._flattenNestedNodesWithExpansion(nodes, selection).pipe( map(flattenedNodes => ({ renderNodes: flattenedNodes, flattenedNodes, @@ -1113,7 +1180,7 @@ export class CdkTree // For nested nodes, we still need to perform the node flattening in order // to maintain our caches for various tree operations. this._ariaSets.set(null, [...nodes]); - return this._flattenNestedNodesWithExpansion(nodes).pipe( + return this._flattenNestedNodesWithExpansion(nodes, selection).pipe( map(flattenedNodes => ({ renderNodes: nodes, flattenedNodes, @@ -1122,14 +1189,6 @@ export class CdkTree } } - private _updateCachedData(flattenedNodes: readonly T[]) { - this._flattenedNodes.next(flattenedNodes); - } - - private _updateKeyManagerItems(flattenedNodes: readonly T[]) { - this._keyManagerNodes.next(flattenedNodes); - } - /** Traverse the flattened node data and compute parents, levels, and group data. */ private _calculateParents(flattenedNodes: readonly T[]): void { const levelAccessor = this._getLevelAccessor(); @@ -1153,6 +1212,27 @@ export class CdkTree this._ariaSets.set(parentKey, group); } } + + private _fromDataSource(dataSource: DataSource): Observable { + return new Observable((subscriber: Subscriber) => { + const subscription = dataSource.connect(this).subscribe({ + next(val) { + subscriber.next(val); + }, + error(err) { + subscriber.next(err); + }, + complete() { + subscriber.complete(); + }, + }); + + return () => { + dataSource.disconnect(this); + subscription.unsubscribe(); + }; + }).pipe(shareReplay({bufferSize: 1, refCount: true})); + } } /** @@ -1167,7 +1247,7 @@ export class CdkTree '[attr.aria-level]': 'level + 1', '[attr.aria-posinset]': '_getPositionInSet()', '[attr.aria-setsize]': '_getSetSize()', - '[tabindex]': '_tabindex', + '[tabindex]': '_tabindex()', 'role': 'treeitem', '(click)': '_setActiveItem()', '(focus)': '_focusItem()', @@ -1176,7 +1256,7 @@ export class CdkTree export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { _elementRef = inject>(ElementRef); protected _tree = inject>(CdkTree); - protected _tabindex: number | null = -1; + protected readonly _tabindex = signal(-1); protected readonly _type: 'flat' | 'nested' = 'flat'; /** @@ -1404,19 +1484,15 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Focuses this data node. Implemented for TreeKeyManagerItem. */ focus(): void { - this._tabindex = 0; + this._tabindex.set(0); if (this._shouldFocus) { this._elementRef.nativeElement.focus(); } - - this._changeDetectorRef.markForCheck(); } /** Defocus this data node. */ unfocus(): void { - this._tabindex = -1; - - this._changeDetectorRef.markForCheck(); + this._tabindex.set(-1); } /** Emits an activation event. Implemented for TreeKeyManagerItem. */ @@ -1443,8 +1519,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Makes the node focusable. Implemented for TreeKeyManagerItem. */ makeFocusable(): void { - this._tabindex = 0; - this._changeDetectorRef.markForCheck(); + this._tabindex.set(0); } _focusItem() { diff --git a/src/material/tree/tree.spec.ts b/src/material/tree/tree.spec.ts index 1a62d6b1505b..12ddc40fb656 100644 --- a/src/material/tree/tree.spec.ts +++ b/src/material/tree/tree.spec.ts @@ -60,6 +60,7 @@ describe('MatTree', () => { // add a child to the first node const data = underlyingDataSource.data; underlyingDataSource.addChild(data[2]); + fixture.detectChanges(); component.tree.expandAll(); fixture.detectChanges(); diff --git a/tools/public_api_guard/cdk/tree.md b/tools/public_api_guard/cdk/tree.md index dda4541d9306..186c04979d9c 100644 --- a/tools/public_api_guard/cdk/tree.md +++ b/tools/public_api_guard/cdk/tree.md @@ -13,7 +13,9 @@ import { DataSource } from '@angular/cdk/collections'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; import * as i0 from '@angular/core'; +import * as i1 from '@angular/common'; import { InjectionToken } from '@angular/core'; +import { InputSignal } from '@angular/core'; import { IterableDiffer } from '@angular/core'; import { IterableDiffers } from '@angular/core'; import { Observable } from 'rxjs'; @@ -21,12 +23,14 @@ import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { QueryList } from '@angular/core'; import { SelectionModel } from '@angular/cdk/collections'; +import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; import { TemplateRef } from '@angular/core'; import { TrackByFunction } from '@angular/core'; import { TreeKeyManagerItem } from '@angular/cdk/a11y'; import { TreeKeyManagerStrategy } from '@angular/cdk/a11y'; import { ViewContainerRef } from '@angular/core'; +import { WritableSignal } from '@angular/core'; // @public @deprecated export abstract class BaseTreeControl implements TreeControl { @@ -82,11 +86,12 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, collapseAll(): void; collapseDescendants(dataNode: T): void; get dataSource(): DataSource | Observable | T[]; - set dataSource(dataSource: DataSource | Observable | T[]); + set dataSource(dataSource: DataSource | Observable | T[] | null); expand(dataNode: T): void; expandAll(): void; expandDescendants(dataNode: T): void; - expansionKey?: (dataNode: T) => K; + get expansionKey(): ((dataNode: T) => K) | undefined; + set expansionKey(expansionKey: (dataNode: T) => K); _getChildrenAccessor(): ((dataNode: T) => T[] | Observable | null | undefined) | undefined; _getDirectChildren(dataNode: T): Observable; // (undocumented) @@ -121,7 +126,10 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, _setNodeTypeIfUnset(newType: 'flat' | 'nested'): void; toggle(dataNode: T): void; toggleDescendants(dataNode: T): void; - trackBy: TrackByFunction; + get trackBy(): TrackByFunction | undefined; + set trackBy(trackBy: TrackByFunction); + // (undocumented) + readonly _trackByFn: Signal>; // @deprecated treeControl?: TreeControl; _unregisterNode(node: CdkTreeNode): void; @@ -142,7 +150,7 @@ export class CdkTreeModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public @@ -202,7 +210,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI // (undocumented) _setActiveItem(): void; // (undocumented) - protected _tabindex: number | null; + protected readonly _tabindex: WritableSignal; // (undocumented) protected _tree: CdkTree; // (undocumented) @@ -248,6 +256,16 @@ export class CdkTreeNodeOutletContext { level: number; } +// @public +export class CdkTreeNodeOutletTemplate { + // (undocumented) + _nodeOutletTemplate: TemplateRef>; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-tree-node-outlet-template", never, {}, {}, never, never, true, never>; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; +} + // @public export class CdkTreeNodePadding implements OnDestroy { constructor(...args: unknown[]); @@ -275,6 +293,18 @@ export class CdkTreeNodePadding implements OnDestroy { static ɵfac: i0.ɵɵFactoryDeclaration, never>; } +// @public +export class CdkTreeNodeRenderer implements OnInit { + // (undocumented) + ngOnInit(): void; + // (undocumented) + readonly node: InputSignal>; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkTreeNodeRenderer]", never, { "node": { "alias": "node"; "required": true; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof i1.NgTemplateOutlet; inputs: { "ngTemplateOutlet": "template"; "ngTemplateOutletContext": "context"; }; outputs: {}; }]>; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; +} + // @public export class CdkTreeNodeToggle { constructor(...args: unknown[]); diff --git a/tools/public_api_guard/material/tree.md b/tools/public_api_guard/material/tree.md index d63d1c0ee9ca..cf95d3552a23 100644 --- a/tools/public_api_guard/material/tree.md +++ b/tools/public_api_guard/material/tree.md @@ -23,6 +23,7 @@ import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { TreeControl } from '@angular/cdk/tree'; import { ViewContainerRef } from '@angular/core'; +import { WritableSignal } from '@angular/core'; // @public export class MatNestedTreeNode extends CdkNestedTreeNode implements AfterContentInit, OnDestroy, OnInit { @@ -119,7 +120,7 @@ export class MatTreeNode extends CdkTreeNode implements OnInit, get disabled(): boolean; set disabled(value: boolean); // (undocumented) - protected _getTabindexAttribute(): number | null; + protected _getTabindexAttribute(): number | WritableSignal; // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented)