diff --git a/src/cdk/tree/nested-node.ts b/src/cdk/tree/nested-node.ts index 55c05c875167..91a328744e42 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -16,12 +16,10 @@ import { OnInit, QueryList, } from '@angular/core'; -import {isObservable} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {CDK_TREE_NODE_OUTLET_NODE, CdkTreeNodeOutlet} from './outlet'; import {CdkTree, CdkTreeNode} from './tree'; -import {getTreeControlFunctionsMissingError} from './tree-errors'; /** * Nested node is a child of ``. It works with nested tree. @@ -69,22 +67,13 @@ export class CdkNestedTreeNode ngAfterContentInit() { this._dataDiffer = this._differs.find([]).create(this._tree.trackBy); - const childrenAccessor = this._tree._getChildrenAccessor(); - if (!childrenAccessor && (typeof ngDevMode === 'undefined' || ngDevMode)) { - throw getTreeControlFunctionsMissingError(); - } else if (childrenAccessor) { - const childrenNodes = childrenAccessor(this.data); - if (Array.isArray(childrenNodes)) { - this.updateChildrenNodes(childrenNodes as T[]); - } else if (isObservable(childrenNodes)) { - childrenNodes - .pipe(takeUntil(this._destroyed)) - .subscribe(result => this.updateChildrenNodes(result)); - } - this.nodeOutlet.changes - .pipe(takeUntil(this._destroyed)) - .subscribe(() => this.updateChildrenNodes()); - } + this._tree + ._getDirectChildren(this.data) + .pipe(takeUntil(this._destroyed)) + .subscribe(result => this.updateChildrenNodes(result)); + this.nodeOutlet.changes + .pipe(takeUntil(this._destroyed)) + .subscribe(() => this.updateChildrenNodes()); } // This is a workaround for https://github.com/angular/angular/issues/23091 @@ -106,7 +95,7 @@ export class CdkNestedTreeNode } if (outlet && this._children) { const viewContainer = outlet.viewContainer; - this._tree.renderNodeChanges(this._children, this._dataDiffer, viewContainer, this._data); + this._tree._renderNodeChanges(this._children, this._dataDiffer, viewContainer, this._data); } else { // Reset the data differ if there's no children nodes displayed this._dataDiffer.diff([]); diff --git a/src/cdk/tree/padding.ts b/src/cdk/tree/padding.ts index 1953550bf0d3..54c5c51a0774 100644 --- a/src/cdk/tree/padding.ts +++ b/src/cdk/tree/padding.ts @@ -80,8 +80,7 @@ export class CdkTreeNodePadding implements OnDestroy { /** The padding indent value for the tree node. Returns a string with px numbers if not null. */ _paddingIndent(): string | null { - const nodeLevel = - (this._treeNode.data && this._tree._getLevelAccessor()?.(this._treeNode.data)) ?? null; + const nodeLevel = (this._treeNode.data && this._tree._getLevel(this._treeNode.data)) ?? null; const level = this._level == null ? nodeLevel : this._level; return typeof level === 'number' ? `${level * this._indent}${this.indentUnits}` : null; } diff --git a/src/cdk/tree/tree-errors.ts b/src/cdk/tree/tree-errors.ts index bb5ae840a31a..d07879aa5e69 100644 --- a/src/cdk/tree/tree-errors.ts +++ b/src/cdk/tree/tree-errors.ts @@ -48,9 +48,9 @@ export function getMultipleTreeControlsError() { } /** - * Returns an error to be thrown when tree control did not implement functions for flat/nested node. + * Returns an error to be thrown when the node type is not specified. * @docs-private */ -export function getTreeControlFunctionsMissingError() { - return Error(`Could not find functions for nested/flat tree in tree control.`); +export function getTreeControlNodeTypeUnspecifiedError() { + return Error(`The nodeType was not specified for the tree.`); } diff --git a/src/cdk/tree/tree-redesign.spec.ts b/src/cdk/tree/tree-redesign.spec.ts index 644d6ed03853..109d9b32a353 100644 --- a/src/cdk/tree/tree-redesign.spec.ts +++ b/src/cdk/tree/tree-redesign.spec.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import { Component, ErrorHandler, @@ -24,7 +24,6 @@ import {map} from 'rxjs/operators'; import {CdkTreeModule, CdkTreeNodePadding} from './index'; import {CdkTree, CdkTreeNode} from './tree'; -import {getTreeControlFunctionsMissingError} from './tree-errors'; /** * This is a cloned version of `tree.spec.ts` that contains all the same tests, @@ -1127,20 +1126,6 @@ describe('CdkTree redesign', () => { expect(changedNodes[5].getAttribute('initialIndex')).toBe('2'); }); }); - - it('should throw an error when missing function in nested tree', fakeAsync(() => { - configureCdkTreeTestingModule([NestedCdkErrorTreeApp]); - expect(() => { - try { - TestBed.createComponent(NestedCdkErrorTreeApp).detectChanges(); - flush(); - } catch { - flush(); - } finally { - flush(); - } - }).toThrowError(getTreeControlFunctionsMissingError().message); - })); }); describe('with depth', () => { @@ -1356,7 +1341,8 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { @Component({ template: ` - + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1417,7 +1405,8 @@ class NestedCdkTreeApp { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1445,7 +1434,8 @@ class StaticNestedCdkTreeApp { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1469,7 +1459,8 @@ class WhenNodeNestedCdkTreeApp { @Component({ template: ` - + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1515,7 +1507,8 @@ class NestedCdkTreeAppWithToggle { @Component({ template: ` - + + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1615,7 +1611,8 @@ class ArrayDataSourceNestedCdkTreeApp { @Component({ template: ` - + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1637,28 +1634,8 @@ class ObservableDataSourceNestedCdkTreeApp { @Component({ template: ` - - - {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} - - - - `, -}) -class NestedCdkErrorTreeApp { - getLevel = (node: TestData) => node.level; - - isExpandable = (node: TestData) => node.children.length > 0; - - dataSource: FakeDataSource | null = new FakeDataSource(); - - @ViewChild(CdkTree) tree: CdkTree; -} - -@Component({ - template: ` - + {{level}} [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1681,7 +1658,8 @@ class DepthNestedCdkTreeApp { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1712,7 +1690,8 @@ class CdkTreeAppWithTrackBy { @Component({ template: ` - + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] diff --git a/src/cdk/tree/tree.spec.ts b/src/cdk/tree/tree.spec.ts index 8c58ee0db1c8..3ba2ca114050 100644 --- a/src/cdk/tree/tree.spec.ts +++ b/src/cdk/tree/tree.spec.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import { Component, ErrorHandler, @@ -27,7 +27,6 @@ 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 {getTreeControlFunctionsMissingError} from './tree-errors'; describe('CdkTree', () => { /** Represents an indent for expectNestedTreeToMatch */ @@ -1126,20 +1125,6 @@ describe('CdkTree', () => { expect(changedNodes[5].getAttribute('initialIndex')).toBe('2'); }); }); - - it('should throw an error when missing function in nested tree', fakeAsync(() => { - configureCdkTreeTestingModule([NestedCdkErrorTreeApp]); - expect(() => { - try { - TestBed.createComponent(NestedCdkErrorTreeApp).detectChanges(); - flush(); - } catch { - flush(); - } finally { - flush(); - } - }).toThrowError(getTreeControlFunctionsMissingError().message); - })); }); describe('with depth', () => { @@ -1633,28 +1618,6 @@ class ObservableDataSourceNestedCdkTreeApp { @ViewChild(CdkTree) tree: CdkTree; } -@Component({ - template: ` - - - {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} - - - - `, -}) -class NestedCdkErrorTreeApp { - getLevel = (node: TestData) => node.level; - - isExpandable = (node: TestData) => node.children.length > 0; - - treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); - - @ViewChild(CdkTree) tree: CdkTree; -} - @Component({ template: ` diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 420826d6f504..4ce0ce26a8c9 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import {FocusableOption} from '@angular/cdk/a11y'; +import {coerceNumberProperty} from '@angular/cdk/coercion'; import {CollectionViewer, DataSource, isDataSource, SelectionModel} from '@angular/cdk/collections'; import { AfterContentChecked, @@ -29,7 +30,9 @@ import { } from '@angular/core'; import { BehaviorSubject, + combineLatest, concat, + EMPTY, isObservable, merge, Observable, @@ -37,18 +40,18 @@ import { Subject, Subscription, } from 'rxjs'; -import {reduce, switchMap, take, takeUntil} from 'rxjs/operators'; +import {concatMap, map, reduce, startWith, switchMap, take, takeUntil, tap} from 'rxjs/operators'; import {TreeControl} from './control/tree-control'; import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node'; import {CdkTreeNodeOutlet} from './outlet'; import { getMultipleTreeControlsError, getTreeControlMissingError, + getTreeControlNodeTypeUnspecifiedError, getTreeMissingMatchingNodeDefError, getTreeMultipleDefaultNodeDefsError, getTreeNoValidDataSourceError, } from './tree-errors'; -import {coerceNumberProperty} from '@angular/cdk/coercion'; function coerceObservable(data: T | Observable): Observable { if (!isObservable(data)) { @@ -146,6 +149,16 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, */ @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; @@ -170,8 +183,21 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, /** Keep track of which nodes are expanded. */ private _expansionModel?: SelectionModel; - /** Maintain a synchronous cache of the currently known data nodes. */ - private _dataNodes?: readonly T[]; + + /** + * Maintain a synchronous cache of the currently known data nodes. In the + * case of nested nodes (i.e. if `nodeType` is 'nested'), this will + * only contain the root nodes. + */ + private _dataNodes: BehaviorSubject = new BehaviorSubject([]); + + /** The mapping between data and the node that is rendered. */ + private _nodes: BehaviorSubject>> = new BehaviorSubject( + new Map>(), + ); + + /** The mapping between data nodes and the parent node. `null` if no parent. */ + private _parents: Map = new Map(); constructor(private _differs: IterableDiffers, private _changeDetectorRef: ChangeDetectorRef) {} @@ -186,6 +212,11 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, } 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(); + } } if (!this.treeControl) { @@ -265,21 +296,23 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, if (dataStream) { this._dataSubscription = dataStream - .pipe(takeUntil(this._onDestroy)) - .subscribe(data => this.renderNodeChanges(data)); + .pipe( + switchMap(data => this._convertChildren(data)), + takeUntil(this._onDestroy), + ) + .subscribe(data => this._renderNodeChanges(data)); } else if (typeof ngDevMode === 'undefined' || ngDevMode) { throw getTreeNoValidDataSourceError(); } } /** Check for changes made in the data and render each change (node added/removed/moved). */ - renderNodeChanges( + _renderNodeChanges( data: readonly T[], dataDiffer: IterableDiffer = this._dataDiffer, viewContainer: ViewContainerRef = this._nodeOutlet.viewContainer, parentData?: T, ) { - this._dataNodes = data; const changes = dataDiffer.diff(data); if (!changes) { return; @@ -337,6 +370,7 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, // Node context that will be provided to created embedded view const context = new CdkTreeNodeOutletContext(nodeData); + parentData ??= this._parents.get(this._getExpansionKey(nodeData)) ?? 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(); @@ -481,6 +515,81 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, return this.treeControl?.getChildren ?? this.childrenAccessor; } + /** + * Gets the direct children of a node; used for compatibility between the old tree and the + * new tree. + */ + _getDirectChildren(dataNode: T): Observable { + const levelAccessor = this._getLevelAccessor(); + if (levelAccessor && this._expansionModel) { + const key = this._getExpansionKey(dataNode); + const isExpanded = this._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)), + ); + + return combineLatest([isExpanded, this._dataNodes]).pipe( + map(([expanded, dataNodes]) => { + if (!expanded) { + return []; + } + const startIndex = dataNodes.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. + // 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]); + if (level > currentLevel) { + break; + } + if (level === currentLevel) { + results.push(dataNodes[i]); + } + } + return results; + }), + ); + } + const childrenAccessor = this._getChildrenAccessor(); + if (childrenAccessor) { + return coerceObservable(childrenAccessor(dataNode) ?? []); + } + throw getTreeControlMissingError(); + } + + /** + * Adds the specified node component to the tree's internal registry. + * + * This primarily facilitates keyboard navigation. + */ + _registerNode(node: CdkTreeNode) { + this._nodes.value.set(this._getExpansionKey(node.data), node); + this._nodes.next(this._nodes.value); + } + + /** Removes the specified node component from the tree's internal registry. */ + _unregisterNode(node: CdkTreeNode) { + this._nodes.value.delete(this._getExpansionKey(node.data)); + this._nodes.next(this._nodes.value); + } + + _getLevel(node: T) { + return this._levels.get(node); + } + /** * Gets all nodes in the tree, through recursive expansion. * @@ -493,7 +602,7 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, * the tree. */ private _getAllDescendants(): Observable { - return merge(...(this._dataNodes?.map(dataNode => this._getDescendants(dataNode)) ?? [])); + return merge(...this._dataNodes.value.map(dataNode => this._getDescendants(dataNode))); } private _getDescendants(dataNode: T): Observable { @@ -501,11 +610,7 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, return observableOf(this.treeControl.getDescendants(dataNode)); } if (this.levelAccessor) { - // If we have no known nodes, we wouldn't be able to determine descendants - if (!this._dataNodes) { - return observableOf([]); - } - const startIndex = this._dataNodes.indexOf(dataNode); + const startIndex = this._dataNodes.value.indexOf(dataNode); const results: T[] = [dataNode]; // Goes through flattened tree nodes in the `dataNodes` array, and get all descendants. @@ -517,15 +622,16 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, const currentLevel = this.levelAccessor(dataNode); for ( let i = startIndex + 1; - i < this._dataNodes.length && currentLevel < this.levelAccessor(this._dataNodes[i]); + i < this._dataNodes.value.length && + currentLevel < this.levelAccessor(this._dataNodes.value[i]); i++ ) { - results.push(this._dataNodes[i]); + results.push(this._dataNodes.value[i]); } return observableOf(results); } if (this.childrenAccessor) { - return this._getChildrenRecursively(dataNode).pipe( + return this._getAllChildrenRecursively(dataNode).pipe( reduce( (allChildren: T[], nextChildren) => { allChildren.push(...nextChildren); @@ -544,7 +650,7 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, * This will emit multiple times, in the order that the children will appear * in the tree, and can be combined with a `reduce` operator. */ - private _getChildrenRecursively(dataNode: T): Observable { + private _getAllChildrenRecursively(dataNode: T): Observable { if (!this.childrenAccessor) { return observableOf([]); } @@ -552,9 +658,12 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, return coerceObservable(this.childrenAccessor(dataNode)).pipe( take(1), switchMap(children => { - return concat( - observableOf(children), - ...children.map(child => this._getChildrenRecursively(child)), + // Here, we cache the parents of a particular child so that we can compute the levels. + for (const child of children) { + this._parents.set(this._getExpansionKey(child), dataNode); + } + return observableOf(...children).pipe( + concatMap(child => concat(observableOf([child]), this._getAllChildrenRecursively(child))), ); }), ); @@ -570,6 +679,39 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, // - if it's not, then K will be defaulted to T. return this.expansionKey?.(dataNode) ?? (dataNode as unknown as K); } + + /** + * 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 { + // 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') { + // 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[]), + tap(allNodes => { + this._dataNodes.next(allNodes); + }), + ); + } else if (this.levelAccessor && this.nodeType === 'nested') { + this._dataNodes.next(nodes); + // 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)); + } else { + this._dataNodes.next(nodes); + return observableOf(nodes); + } + } } /** @@ -645,7 +787,7 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit // If the tree has a levelAccessor, use it to get the level. Otherwise read the // aria-level off the parent node and use it as the level for this node (note aria-level is // 1-indexed, while this property is 0-indexed, so we don't need to increment). - return this._tree._getLevelAccessor()?.(this._data) ?? this._parentNodeAriaLevel; + return this._tree._getLevel(this._data) ?? this._parentNodeAriaLevel; } constructor(protected _elementRef: ElementRef, protected _tree: CdkTree) { @@ -656,6 +798,7 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit ngOnInit(): void { this._parentNodeAriaLevel = getParentNodeAriaLevel(this._elementRef.nativeElement); this._elementRef.nativeElement.setAttribute('aria-level', `${this.level + 1}`); + this._tree._registerNode(this); } ngOnDestroy() { diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.css b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.css new file mode 100644 index 000000000000..a88255f0d954 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.css @@ -0,0 +1,4 @@ +.example-tree-node { + display: flex; + align-items: center; +} 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 new file mode 100644 index 000000000000..6976db2de744 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html @@ -0,0 +1,22 @@ + + + + + + {{node.name}} + + + + + {{node.name}} + + diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts new file mode 100644 index 000000000000..48ed2152d27a --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts @@ -0,0 +1,57 @@ +import {ArrayDataSource} from '@angular/cdk/collections'; +import {CdkTree} from '@angular/cdk/tree'; +import {Component, ViewChild} from '@angular/core'; +import {timer} from 'rxjs'; +import {mapTo} from 'rxjs/operators'; +import {NestedFoodNode, NESTED_DATA} from '../tree-data'; + +function flattenNodes(nodes: NestedFoodNode[]): NestedFoodNode[] { + const flattenedNodes = []; + for (const node of nodes) { + flattenedNodes.push(node); + if (node.children) { + flattenedNodes.push(...flattenNodes(node.children)); + } + } + return flattenedNodes; +} + +/** + * @title Tree with flat nodes + */ +@Component({ + selector: 'cdk-tree-flat-children-accessor-example', + templateUrl: 'cdk-tree-flat-children-accessor-example.html', + styleUrls: ['cdk-tree-flat-children-accessor-example.css'], +}) +export class CdkTreeFlatChildrenAccessorExample { + @ViewChild(CdkTree) + tree!: CdkTree; + + childrenAccessor = (dataNode: NestedFoodNode) => timer(100).pipe(mapTo(dataNode.children ?? [])); + + dataSource = new ArrayDataSource(NESTED_DATA); + + hasChild = (_: number, node: NestedFoodNode) => !!node.children?.length; + + getParentNode(node: NestedFoodNode) { + for (const parent of flattenNodes(NESTED_DATA)) { + if (parent.children?.includes(node)) { + return parent; + } + } + + return null; + } + + shouldRender(node: NestedFoodNode) { + let parent = this.getParentNode(node); + while (parent) { + if (!this.tree.isExpanded(parent)) { + return false; + } + parent = this.getParentNode(parent); + } + return true; + } +} diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.css b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.css new file mode 100644 index 000000000000..a88255f0d954 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.css @@ -0,0 +1,4 @@ +.example-tree-node { + display: flex; + align-items: center; +} diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.html b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.html new file mode 100644 index 000000000000..207aae8a6f82 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.html @@ -0,0 +1,24 @@ + + + + + + {{node.name}} + + + + + {{node.name}} + + diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts new file mode 100644 index 000000000000..3484953f7abb --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts @@ -0,0 +1,43 @@ +import {ArrayDataSource} from '@angular/cdk/collections'; +import {CdkTree} from '@angular/cdk/tree'; +import {Component, ViewChild} from '@angular/core'; +import {FlatFoodNode, FLAT_DATA} from '../tree-data'; + +/** + * @title Tree with flat nodes + */ +@Component({ + selector: 'cdk-tree-flat-level-accessor-example', + templateUrl: 'cdk-tree-flat-level-accessor-example.html', + styleUrls: ['cdk-tree-flat-level-accessor-example.css'], +}) +export class CdkTreeFlatLevelAccessorExample { + @ViewChild(CdkTree) + tree: CdkTree; + + levelAccessor = (dataNode: FlatFoodNode) => dataNode.level; + + dataSource = new ArrayDataSource(FLAT_DATA); + + hasChild = (_: number, node: FlatFoodNode) => node.expandable; + + getParentNode(node: FlatFoodNode) { + const nodeIndex = FLAT_DATA.indexOf(node); + + // Determine the node's parent by finding the first preceding node that's + // one level shallower. + for (let i = nodeIndex - 1; i >= 0; i--) { + if (FLAT_DATA[i].level === node.level - 1) { + return FLAT_DATA[i]; + } + } + + return null; + } + + shouldRender(node: FlatFoodNode): boolean { + // This node should render if it is a root node or if all of its ancestors are expanded. + const parent = this.getParentNode(node); + return !parent || (!!this.tree?.isExpanded(parent) && this.shouldRender(parent)); + } +} diff --git a/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.css b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.css new file mode 100644 index 000000000000..988fa23745aa --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.css @@ -0,0 +1,18 @@ +.example-tree-invisible { + display: none; +} + +.example-tree ul, +.example-tree li { + margin-top: 0; + margin-bottom: 0; + list-style-type: none; +} +.example-tree-node { + display: block; + line-height: 40px; +} + +.example-tree-node .example-tree-node { + padding-left: 40px; +} diff --git a/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.html b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.html new file mode 100644 index 000000000000..71eef12b5f90 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.html @@ -0,0 +1,22 @@ + + + + {{node.name}} + + + + + {{node.name}} +
+ +
+
+
diff --git a/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.ts b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.ts new file mode 100644 index 000000000000..3a0ea4dd90b4 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.ts @@ -0,0 +1,19 @@ +import {ArrayDataSource} from '@angular/cdk/collections'; +import {Component} from '@angular/core'; +import {FlatFoodNode, FLAT_DATA} from '../tree-data'; + +/** + * @title Tree with nested nodes + */ +@Component({ + selector: 'cdk-tree-nested-level-accessor-example', + templateUrl: 'cdk-tree-nested-level-accessor-example.html', + styleUrls: ['cdk-tree-nested-level-accessor-example.css'], +}) +export class CdkTreeNestedLevelAccessorExample { + levelAccessor = (dataNode: FlatFoodNode) => dataNode.level; + + dataSource = new ArrayDataSource(FLAT_DATA); + + hasChild = (_: number, node: FlatFoodNode) => node.expandable; +} diff --git a/src/components-examples/cdk/tree/index.ts b/src/components-examples/cdk/tree/index.ts index a7f2305695de..fc8afe4a305d 100644 --- a/src/components-examples/cdk/tree/index.ts +++ b/src/components-examples/cdk/tree/index.ts @@ -2,12 +2,27 @@ import {CdkTreeModule} from '@angular/cdk/tree'; import {NgModule} from '@angular/core'; import {MatButtonModule} from '@angular/material/button'; import {MatIconModule} from '@angular/material/icon'; +import {CdkTreeFlatChildrenAccessorExample} from './cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example'; +import {CdkTreeFlatLevelAccessorExample} from './cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example'; import {CdkTreeFlatExample} from './cdk-tree-flat/cdk-tree-flat-example'; +import {CdkTreeNestedLevelAccessorExample} from './cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example'; import {CdkTreeNestedExample} from './cdk-tree-nested/cdk-tree-nested-example'; -export {CdkTreeFlatExample, CdkTreeNestedExample}; +export { + CdkTreeFlatExample, + CdkTreeNestedExample, + CdkTreeFlatLevelAccessorExample, + CdkTreeFlatChildrenAccessorExample, + CdkTreeNestedLevelAccessorExample, +}; -const EXAMPLES = [CdkTreeFlatExample, CdkTreeNestedExample]; +const EXAMPLES = [ + CdkTreeFlatExample, + CdkTreeNestedExample, + CdkTreeFlatLevelAccessorExample, + CdkTreeFlatChildrenAccessorExample, + CdkTreeNestedLevelAccessorExample, +]; @NgModule({ imports: [CdkTreeModule, MatButtonModule, MatIconModule], diff --git a/src/components-examples/cdk/tree/tree-data.ts b/src/components-examples/cdk/tree/tree-data.ts new file mode 100644 index 000000000000..780c969d532f --- /dev/null +++ b/src/components-examples/cdk/tree/tree-data.ts @@ -0,0 +1,94 @@ +/** Flat node with expandable and level information */ +export interface FlatFoodNode { + expandable: boolean; + name: string; + level: number; + isExpanded?: boolean; +} + +export const FLAT_DATA: FlatFoodNode[] = [ + { + name: 'Fruit', + expandable: true, + level: 0, + }, + { + name: 'Apple', + expandable: false, + level: 1, + }, + { + name: 'Banana', + expandable: false, + level: 1, + }, + { + name: 'Fruit loops', + expandable: false, + level: 1, + }, + { + name: 'Vegetables', + expandable: true, + level: 0, + }, + { + name: 'Green', + expandable: true, + level: 1, + }, + { + name: 'Broccoli', + expandable: false, + level: 2, + }, + { + name: 'Brussels sprouts', + expandable: false, + level: 2, + }, + { + name: 'Orange', + expandable: true, + level: 1, + }, + { + name: 'Pumpkins', + expandable: false, + level: 2, + }, + { + name: 'Carrots', + expandable: false, + level: 2, + }, +]; + +/** + * Food data with nested structure. + * Each node has a name and an optional list of children. + */ +export interface NestedFoodNode { + name: string; + children?: NestedFoodNode[]; +} + +export const NESTED_DATA: NestedFoodNode[] = [ + { + name: 'Fruit', + children: [{name: 'Apple'}, {name: 'Banana'}, {name: 'Fruit loops'}], + }, + { + name: 'Vegetables', + children: [ + { + name: 'Green', + children: [{name: 'Broccoli'}, {name: 'Brussels sprouts'}], + }, + { + name: 'Orange', + children: [{name: 'Pumpkins'}, {name: 'Carrots'}], + }, + ], + }, +]; diff --git a/src/dev-app/tree/tree-demo.html b/src/dev-app/tree/tree-demo.html index 97160db7c1cb..21fb0e5d8196 100644 --- a/src/dev-app/tree/tree-demo.html +++ b/src/dev-app/tree/tree-demo.html @@ -7,6 +7,14 @@ CDK Flat tree + + CDK Flat tree (levelAccessor) + + + + CDK Flat tree (childrenAccessor) + + Nested tree @@ -15,6 +23,10 @@ CDK Nested tree + + CDK Nested tree (levelAccessor) + + Todo list tree diff --git a/tools/public_api_guard/cdk/tree.md b/tools/public_api_guard/cdk/tree.md index 5bfcdd4a9269..ec83fed73824 100644 --- a/tools/public_api_guard/cdk/tree.md +++ b/tools/public_api_guard/cdk/tree.md @@ -70,7 +70,7 @@ export class CdkNestedTreeNode extends CdkTreeNode implements Af nodeOutlet: QueryList; protected updateChildrenNodes(children?: T[]): void; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "cdk-nested-tree-node", ["cdkNestedTreeNode"], { "role": "role"; "disabled": "disabled"; "tabIndex": "tabIndex"; }, {}, ["nodeOutlet"], never, false, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "cdk-nested-tree-node", ["cdkNestedTreeNode"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; }, {}, ["nodeOutlet"], never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -89,6 +89,9 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, expandDescendants(dataNode: T): void; expansionKey?: (dataNode: T) => K; _getChildrenAccessor(): ((dataNode: T) => T[] | Observable | null | undefined) | undefined; + _getDirectChildren(dataNode: T): Observable; + // (undocumented) + _getLevel(node: T): number | undefined; _getLevelAccessor(): ((dataNode: T) => number) | undefined; _getNodeDef(data: T, i: number): CdkTreeNodeDef; insertNode(nodeData: T, index: number, viewContainer?: ViewContainerRef, parentData?: T): void; @@ -103,18 +106,21 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, _nodeDefs: QueryList>; // (undocumented) _nodeOutlet: CdkTreeNodeOutlet; - renderNodeChanges(data: readonly T[], dataDiffer?: IterableDiffer, viewContainer?: ViewContainerRef, parentData?: T): void; + nodeType?: 'flat' | 'nested'; + _registerNode(node: CdkTreeNode): void; + _renderNodeChanges(data: readonly T[], dataDiffer?: IterableDiffer, viewContainer?: ViewContainerRef, parentData?: T): void; toggle(dataNode: T): void; toggleDescendants(dataNode: T): void; trackBy: TrackByFunction; // @deprecated treeControl?: TreeControl; + _unregisterNode(node: CdkTreeNode): void; readonly viewChange: BehaviorSubject<{ start: number; end: number; }>; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-tree", ["cdkTree"], { "dataSource": "dataSource"; "treeControl": "treeControl"; "levelAccessor": "levelAccessor"; "childrenAccessor": "childrenAccessor"; "trackBy": "trackBy"; "expansionKey": "expansionKey"; }, {}, ["_nodeDefs"], never, false, never>; + 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>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -161,7 +167,7 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit // (undocumented) protected _tree: CdkTree; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "cdk-tree-node", ["cdkTreeNode"], { "role": "role"; "isExpandable": "isExpandable"; "isExpanded": "isExpanded"; }, {}, never, never, false, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "cdk-tree-node", ["cdkTreeNode"], { "role": { "alias": "role"; "required": false; }; "isExpandable": { "alias": "isExpandable"; "required": false; }; "isExpanded": { "alias": "isExpanded"; "required": false; }; }, {}, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -173,7 +179,7 @@ export class CdkTreeNodeDef { template: TemplateRef; when: (index: number, nodeData: T) => boolean; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkTreeNodeDef]", never, { "when": "cdkTreeNodeDefWhen"; }, {}, never, never, false, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkTreeNodeDef]", never, { "when": { "alias": "cdkTreeNodeDefWhen"; "required": false; }; }, {}, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -220,7 +226,7 @@ export class CdkTreeNodePadding implements OnDestroy { // (undocumented) _setPadding(forceChange?: boolean): void; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkTreeNodePadding]", never, { "level": "cdkTreeNodePadding"; "indent": "cdkTreeNodePaddingIndent"; }, {}, never, never, false, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkTreeNodePadding]", never, { "level": { "alias": "cdkTreeNodePadding"; "required": false; }; "indent": { "alias": "cdkTreeNodePaddingIndent"; "required": false; }; }, {}, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, [null, null, null, { optional: true; }]>; } @@ -239,7 +245,7 @@ export class CdkTreeNodeToggle { // (undocumented) protected _treeNode: CdkTreeNode; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkTreeNodeToggle]", never, { "recursive": "cdkTreeNodeToggleRecursive"; }, {}, never, never, false, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "[cdkTreeNodeToggle]", never, { "recursive": { "alias": "cdkTreeNodeToggleRecursive"; "required": false; }; }, {}, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -267,10 +273,10 @@ export interface FlatTreeControlOptions { export function getMultipleTreeControlsError(): Error; // @public -export function getTreeControlFunctionsMissingError(): Error; +export function getTreeControlMissingError(): Error; // @public -export function getTreeControlMissingError(): Error; +export function getTreeControlNodeTypeUnspecifiedError(): Error; // @public export function getTreeMissingMatchingNodeDefError(): Error;