diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e526353343e..2c31af3d6c0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -33,6 +33,7 @@ /src/lib/toolbar/** @devversion /src/lib/tooltip/** @andrewseguin /src/lib/badge/** @amcdnl +/src/lib/tree/** @tinayuangao # Angular Material core /src/lib/core/* @jelbourn @@ -71,6 +72,7 @@ /src/cdk/stepper/** @mmalerba /src/cdk/table/** @andrewseguin /src/cdk/testing/** @devversion +/src/cdk/tree/** @tinayuangao # Moment adapter package /src/material-moment-adapter/** @mmalerba @@ -129,6 +131,7 @@ /src/demo-app/tabs/** @andrewseguin /src/demo-app/toolbar/** @devversion /src/demo-app/tooltip/** @andrewseguin +/src/demo-app/tree/** @tinayuangao /src/demo-app/typography/** @crisbeto /src/demo-app/badge/** @amcdnl diff --git a/src/cdk/collections/public-api.ts b/src/cdk/collections/public-api.ts index f18860e3756..c7d81888b03 100644 --- a/src/cdk/collections/public-api.ts +++ b/src/cdk/collections/public-api.ts @@ -14,3 +14,4 @@ export { UniqueSelectionDispatcherListener, UNIQUE_SELECTION_DISPATCHER_PROVIDER, } from './unique-selection-dispatcher'; +export * from './tree-adapter'; diff --git a/src/cdk/collections/tree-adapter.ts b/src/cdk/collections/tree-adapter.ts new file mode 100644 index 00000000000..abbab2fceaa --- /dev/null +++ b/src/cdk/collections/tree-adapter.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {SelectionModel} from './selection'; + + +/** + * Interface for a class that can flatten hierarchical structured data and re-expand the flattened + * data back into its original structure. Should be used in conjunction with the cdk-tree. + */ +export interface TreeDataNodeFlattener { + /** Transforms a set of hierarchical structured data into a flattened data array. */ + flattenNodes(structuredData: any[]): T[]; + + /** + * Expands a flattened array of data into its hierarchical form using the provided expansion + * model. + */ + expandFlattenedNodes(nodes: T[], expansionModel: SelectionModel): T[]; + + /** + * Put node descendants of node in array. + * If `onlyExpandable` is true, then only process expandable descendants. + */ + nodeDescendents(node: T, nodes: T[], onlyExpandable: boolean); +} diff --git a/src/cdk/tree/control/base-tree-control.ts b/src/cdk/tree/control/base-tree-control.ts new file mode 100644 index 00000000000..c1c6e0d9612 --- /dev/null +++ b/src/cdk/tree/control/base-tree-control.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {SelectionModel} from '@angular/cdk/collections'; +import {Observable} from 'rxjs/Observable'; +import {TreeControl} from './tree-control'; + +/** Base tree control. It has basic toggle/expand/collapse operations on a single data node. */ +export abstract class BaseTreeControl implements TreeControl { + + /** Gets a list of descendent data nodes of a subtree rooted at given data node recursively. */ + abstract getDescendants(dataNode: T): T[]; + + /** Expands all data nodes in the tree. */ + abstract expandAll(): void; + + /** Saved data node for `expandAll` action. */ + dataNodes: T[]; + + /** A selection model with multi-selection to track expansion status. */ + expansionModel: SelectionModel = new SelectionModel(true); + + /** Get depth of a given data node, return the level number. This is for flat tree node. */ + getLevel: (dataNode: T) => number; + + /** + * Whether the data node is expandable. Returns true if expandable. + * This is for flat tree node. + */ + isExpandable: (dataNode: T) => boolean; + + /** Gets a stream that emits whenever the given data node's children change. */ + getChildren: (dataNode: T) => Observable; + + /** Toggles one single data node's expanded/collapsed state. */ + toggle(dataNode: T): void { + this.expansionModel.toggle(dataNode); + } + + /** Expands one single data node. */ + expand(dataNode: T): void { + this.expansionModel.select(dataNode); + } + + /** Collapses one single data node. */ + collapse(dataNode: T): void { + this.expansionModel.deselect(dataNode); + } + + /** Whether a given data node is expanded or not. Returns true if the data node is expanded. */ + isExpanded(dataNode: T): boolean { + return this.expansionModel.isSelected(dataNode); + } + + /** Toggles a subtree rooted at `node` recursively. */ + toggleDescendants(dataNode: T): void { + this.expansionModel.isSelected(dataNode) + ? this.collapseDescendants(dataNode) + : this.expandDescendants(dataNode); + } + + /** Collapse all dataNodes in the tree. */ + collapseAll(): void { + this.expansionModel.clear(); + } + + /** Expands a subtree rooted at given data node recursively. */ + expandDescendants(dataNode: T): void { + let toBeProcessed = [dataNode]; + toBeProcessed.push(...this.getDescendants(dataNode)); + this.expansionModel.select(...toBeProcessed); + } + + /** Collapses a subtree rooted at given data node recursively. */ + collapseDescendants(dataNode: T): void { + let toBeProcessed = [dataNode]; + toBeProcessed.push(...this.getDescendants(dataNode)); + this.expansionModel.deselect(...toBeProcessed); + } +} diff --git a/src/cdk/tree/control/flat-tree-control.spec.ts b/src/cdk/tree/control/flat-tree-control.spec.ts new file mode 100644 index 00000000000..66736a124e8 --- /dev/null +++ b/src/cdk/tree/control/flat-tree-control.spec.ts @@ -0,0 +1,188 @@ +import {FlatTreeControl} from './flat-tree-control'; + +describe('CdkFlatTreeControl', () => { + let treeControl: FlatTreeControl; + let getLevel = (node: TestData) => node.level; + let isExpandable = (node: TestData) => node.children && node.children.length > 0; + + beforeEach(() => { + treeControl = new FlatTreeControl(getLevel, isExpandable); + }); + + describe('base tree control actions', () => { + it('should be able to expand and collapse dataNodes', () => { + const nodes = generateData(10, 4); + const secondNode = nodes[1]; + const sixthNode = nodes[5]; + treeControl.dataNodes = nodes; + + treeControl.expand(secondNode); + + + expect(treeControl.isExpanded(secondNode)) + .toBeTruthy('Expect second node to be expanded'); + expect(treeControl.expansionModel.selected) + .toContain(secondNode, 'Expect second node in expansionModel'); + expect(treeControl.expansionModel.selected.length) + .toBe(1, 'Expect only second node in expansionModel'); + + treeControl.toggle(sixthNode); + + expect(treeControl.isExpanded(secondNode)) + .toBeTruthy('Expect second node to stay expanded'); + expect(treeControl.isExpanded(sixthNode)) + .toBeTruthy('Expect sixth node to be expanded'); + expect(treeControl.expansionModel.selected) + .toContain(sixthNode, 'Expect sixth node in expansionModel'); + expect(treeControl.expansionModel.selected) + .toContain(secondNode, 'Expect second node in expansionModel'); + expect(treeControl.expansionModel.selected.length) + .toBe(2, 'Expect two dataNodes in expansionModel'); + + treeControl.collapse(secondNode); + + expect(treeControl.isExpanded(secondNode)) + .toBeFalsy('Expect second node to be collapsed'); + expect(treeControl.expansionModel.selected.length) + .toBe(1, 'Expect one node in expansionModel'); + expect(treeControl.isExpanded(sixthNode)).toBeTruthy('Expect sixth node to stay expanded'); + expect(treeControl.expansionModel.selected) + .toContain(sixthNode, 'Expect sixth node in expansionModel'); + }); + + it('should return correct expandable values', () => { + const nodes = generateData(10, 4); + treeControl.dataNodes = nodes; + + for (let i = 0; i < 10; i++) { + expect(treeControl.isExpandable(nodes[i])) + .toBeTruthy(`Expect node[${i}] to be expandable`); + + for (let j = 0; j < 4; j++) { + expect(treeControl.isExpandable(nodes[i].children[j])) + .toBeFalsy(`Expect node[${i}]'s child[${j}] to be not expandable`); + } + } + }); + + it('should return correct levels', () => { + const numNodes = 10; + const numChildren = 4; + const numGrandChildren = 2; + const nodes = generateData(numNodes, numChildren, numGrandChildren); + treeControl.dataNodes = nodes; + + for (let i = 0; i < numNodes; i++) { + expect(treeControl.getLevel(nodes[i])) + .toBe(1, `Expec node[${i}]'s level to be 1`); + + for (let j = 0; j < numChildren; j++) { + expect(treeControl.getLevel(nodes[i].children[j])) + .toBe(2, `Expect node[${i}]'s child[${j}] to be not expandable`); + + for (let k = 0; k < numGrandChildren; k++) { + expect(treeControl.getLevel(nodes[i].children[j].children[k])) + .toBe(3, `Expect node[${i}]'s child[${j}] to be not expandable`); + } + } + } + }); + + it('should toggle descendants correctly', () => { + const numNodes = 10; + const numChildren = 4; + const numGrandChildren = 2; + const nodes = generateData(numNodes, numChildren, numGrandChildren); + + let data = []; + flatten(nodes, data); + treeControl.dataNodes = data; + + treeControl.expandDescendants(nodes[1]); + + const expandedNodesNum = 1 + numChildren + numChildren * numGrandChildren; + expect(treeControl.expansionModel.selected.length) + .toBe(expandedNodesNum, `Expect expanded ${expandedNodesNum} nodes`); + + expect(treeControl.isExpanded(nodes[1])).toBeTruthy('Expect second node to be expanded'); + for (let i = 0; i < numChildren; i++) { + + expect(treeControl.isExpanded(nodes[1].children[i])) + .toBeTruthy(`Expect second node's children to be expanded`); + for (let j = 0; j < numGrandChildren; j++) { + expect(treeControl.isExpanded(nodes[1].children[i].children[j])) + .toBeTruthy(`Expect second node grand children to be not expanded`); + } + } + + }); + + it('should be able to expand/collapse all the dataNodes', () => { + const numNodes = 10; + const numChildren = 4; + const numGrandChildren = 2; + const nodes = generateData(numNodes, numChildren, numGrandChildren); + let data = []; + flatten(nodes, data); + treeControl.dataNodes = data; + + treeControl.expandDescendants(nodes[1]); + + treeControl.collapseAll(); + + expect(treeControl.expansionModel.selected.length).toBe(0, `Expect no expanded nodes`); + + treeControl.expandAll(); + + const totalNumber = numNodes + numNodes * numChildren + + numNodes * numChildren * numGrandChildren; + expect(treeControl.expansionModel.selected.length) + .toBe(totalNumber, `Expect ${totalNumber} expanded nodes`); + }); + }); +}); + +export class TestData { + a: string; + b: string; + c: string; + level: number; + children: TestData[]; + + constructor(a: string, b: string, c: string, level: number = 1, children: TestData[] = []) { + this.a = a; + this.b = b; + this.c = c; + this.level = level; + this.children = children; + } +} + +function generateData(dataLength: number, childLength: number, grandChildLength: number = 0) + : TestData[] { + let data = []; + let nextIndex = 0; + for (let i = 0; i < dataLength; i++) { + let children = []; + for (let j = 0; j < childLength; j++) { + let grandChildren = []; + for (let k = 0; k < grandChildLength; k++) { + grandChildren.push(new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 3)); + } + children.push( + new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 2, grandChildren)); + } + data.push(new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 1, children)); + } + return data; +} + +function flatten(nodes: TestData[], data: TestData[]) { + for (let node of nodes) { + data.push(node); + + if (node.children && node.children.length > 0) { + flatten(node.children, data); + } + } +} diff --git a/src/cdk/tree/control/flat-tree-control.ts b/src/cdk/tree/control/flat-tree-control.ts new file mode 100644 index 00000000000..d930116f5f2 --- /dev/null +++ b/src/cdk/tree/control/flat-tree-control.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {BaseTreeControl} from './base-tree-control'; + +/** Flat tree control. Able to expand/collapse a subtree recursively for flattened tree. */ +export class FlatTreeControl extends BaseTreeControl { + + /** Construct with flat tree data node functions getLevel and isExpandable. */ + constructor(public getLevel: (dataNode: T) => number, + public isExpandable: (dataNode: T) => boolean) { + super(); + } + + /** + * Gets a list of the data node's subtree of descendent data nodes. + * + * To make this working, the `dataNodes` of the TreeControl must be flattened tree nodes + * with correct levels. + */ + getDescendants(dataNode: T): T[] { + const startIndex = this.dataNodes.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 + // tree node. + // 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 < this.dataNodes.length && this.getLevel(dataNode) < this.getLevel(this.dataNodes[i]); + i++) { + results.push(this.dataNodes[i]); + } + return results; + } + + /** + * Expands all data nodes in the tree. + * + * To make this working, the `dataNodes` variable of the TreeControl must be set to all flattened + * data nodes of the tree. + */ + expandAll(): void { + this.expansionModel.select(...this.dataNodes); + } +} diff --git a/src/cdk/tree/control/nested-tree-control.spec.ts b/src/cdk/tree/control/nested-tree-control.spec.ts new file mode 100644 index 00000000000..ef60e5ccfa7 --- /dev/null +++ b/src/cdk/tree/control/nested-tree-control.spec.ts @@ -0,0 +1,130 @@ +import {of as observableOf} from 'rxjs/observable/of'; +import {NestedTreeControl} from './nested-tree-control'; + + +describe('CdkNestedTreeControl', () => { + let treeControl: NestedTreeControl; + let getChildren = (node: TestData) => observableOf(node.children); + + beforeEach(() => { + treeControl = new NestedTreeControl(getChildren); + }); + + describe('base tree control actions', () => { + it('should be able to expand and collapse dataNodes', () => { + const nodes = generateData(10, 4); + const node = nodes[1]; + const sixthNode = nodes[5]; + treeControl.dataNodes = nodes; + + treeControl.expand(node); + + + expect(treeControl.isExpanded(node)).toBeTruthy('Expect second node to be expanded'); + expect(treeControl.expansionModel.selected) + .toContain(node, 'Expect second node in expansionModel'); + expect(treeControl.expansionModel.selected.length) + .toBe(1, 'Expect only second node in expansionModel'); + + treeControl.toggle(sixthNode); + + expect(treeControl.isExpanded(node)).toBeTruthy('Expect second node to stay expanded'); + expect(treeControl.expansionModel.selected) + .toContain(sixthNode, 'Expect sixth node in expansionModel'); + expect(treeControl.expansionModel.selected) + .toContain(node, 'Expect second node in expansionModel'); + expect(treeControl.expansionModel.selected.length) + .toBe(2, 'Expect two dataNodes in expansionModel'); + + treeControl.collapse(node); + + expect(treeControl.isExpanded(node)).toBeFalsy('Expect second node to be collapsed'); + expect(treeControl.expansionModel.selected.length) + .toBe(1, 'Expect one node in expansionModel'); + expect(treeControl.isExpanded(sixthNode)).toBeTruthy('Expect sixth node to stay expanded'); + expect(treeControl.expansionModel.selected) + .toContain(sixthNode, 'Expect sixth node in expansionModel'); + }); + + it('should toggle descendants correctly', () => { + const numNodes = 10; + const numChildren = 4; + const numGrandChildren = 2; + const nodes = generateData(numNodes, numChildren, numGrandChildren); + treeControl.dataNodes = nodes; + + treeControl.expandDescendants(nodes[1]); + + const expandedNodesNum = 1 + numChildren + numChildren * numGrandChildren; + expect(treeControl.expansionModel.selected.length) + .toBe(expandedNodesNum, `Expect expanded ${expandedNodesNum} nodes`); + + expect(treeControl.isExpanded(nodes[1])).toBeTruthy('Expect second node to be expanded'); + for (let i = 0; i < numChildren; i++) { + + expect(treeControl.isExpanded(nodes[1].children[i])) + .toBeTruthy(`Expect second node's children to be expanded`); + for (let j = 0; j < numGrandChildren; j++) { + expect(treeControl.isExpanded(nodes[1].children[i].children[j])) + .toBeTruthy(`Expect second node grand children to be expanded`); + } + } + }); + + it('should be able to expand/collapse all the dataNodes', () => { + const numNodes = 10; + const numChildren = 4; + const numGrandChildren = 2; + const nodes = generateData(numNodes, numChildren, numGrandChildren); + treeControl.dataNodes = nodes; + + treeControl.expandDescendants(nodes[1]); + + treeControl.collapseAll(); + + expect(treeControl.expansionModel.selected.length).toBe(0, `Expect no expanded nodes`); + + treeControl.expandAll(); + + const totalNumber = numNodes + numNodes * numChildren + + numNodes * numChildren * numGrandChildren; + expect(treeControl.expansionModel.selected.length) + .toBe(totalNumber, `Expect ${totalNumber} expanded nodes`); + }); + }); +}); + +export class TestData { + a: string; + b: string; + c: string; + level: number; + children: TestData[]; + + constructor(a: string, b: string, c: string, level: number = 1, children: TestData[] = []) { + this.a = a; + this.b = b; + this.c = c; + this.level = level; + this.children = children; + } +} + +function generateData(dataLength: number, childLength: number, grandChildLength: number = 0) + : TestData[] { + let data = []; + let nextIndex = 0; + for (let i = 0; i < dataLength; i++) { + let children = []; + for (let j = 0; j < childLength; j++) { + let grandChildren = []; + for (let k = 0; k < grandChildLength; k++) { + grandChildren.push(new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 3)); + } + children.push( + new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 2, grandChildren)); + } + data.push(new TestData(`a_${nextIndex}`, `b_${nextIndex}`, `c_${nextIndex++}`, 1, children)); + } + return data; +} diff --git a/src/cdk/tree/control/nested-tree-control.ts b/src/cdk/tree/control/nested-tree-control.ts new file mode 100644 index 00000000000..bc8dba3b0f4 --- /dev/null +++ b/src/cdk/tree/control/nested-tree-control.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Observable} from 'rxjs/Observable'; +import {take} from 'rxjs/operators/take'; +import {BaseTreeControl} from './base-tree-control'; + +/** Nested tree control. Able to expand/collapse a subtree recursively for NestedNode type. */ +export class NestedTreeControl extends BaseTreeControl { + + /** Construct with nested tree function getChildren. */ + constructor(public getChildren: (dataNode: T) => Observable) { + super(); + } + + /** + * Expands all dataNodes in the tree. + * + * To make this working, the `dataNodes` variable of the TreeControl must be set to all root level + * data nodes of the tree. + */ + expandAll(): void { + this.expansionModel.clear(); + let toBeExpanded = []; + this.dataNodes.forEach(dataNode => toBeExpanded.push(...this.getDescendants(dataNode))); + this.expansionModel.select(...toBeExpanded); + } + + /** Gets a list of descendant dataNodes of a subtree rooted at given data node recursively. */ + getDescendants(dataNode: T): T[] { + const descendants = []; + this._getDescendants(descendants, dataNode); + return descendants; + } + + /** A helper function to get descendants recursively. */ + protected _getDescendants(descendants: T[], dataNode: T): void { + descendants.push(dataNode); + this.getChildren(dataNode).pipe(take(1)).subscribe(children => { + if (children && children.length > 0) { + children.forEach((child: T) => this._getDescendants(descendants, child)); + } + }); + } +} diff --git a/src/cdk/tree/control/tree-control.ts b/src/cdk/tree/control/tree-control.ts new file mode 100644 index 00000000000..a04e66eb956 --- /dev/null +++ b/src/cdk/tree/control/tree-control.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {SelectionModel} from '@angular/cdk/collections'; +import {Observable} from 'rxjs/Observable'; + +/** + * Tree control interface. User can implement TreeControl to expand/collapse dataNodes in the tree. + * The CDKTree will use this TreeControl to expand/collapse a node. + * User can also use it outside the `` to control the expansion status of the tree. + */ +export interface TreeControl { + /** The saved tree nodes data for `expandAll` action. */ + dataNodes: T[]; + + /** The expansion model */ + expansionModel: SelectionModel; + + /** Whether the data node is expanded or collapsed. Return true if it's expanded. */ + isExpanded(dataNode: T): boolean; + + /** Get all descendants of a data node */ + getDescendants(dataNode: T): any[]; + + /** Expand or collapse data node */ + toggle(dataNode: T): void; + + /** Expand one data node */ + expand(dataNode: T): void; + + /** Collapse one data node */ + collapse(dataNode: T): void; + + /** Expand all the dataNodes in the tree */ + expandAll(): void; + + /** Collapse all the dataNodes in the tree */ + collapseAll(): void; + + /** Toggle a data node by expand/collapse it and all its descendants */ + toggleDescendants(dataNode: T): void; + + /** Expand a data node and all its descendants */ + expandDescendants(dataNode: T): void; + + /** Collapse a data node and all its descendants */ + collapseDescendants(dataNode: T): void; + + /** Get depth of a given data node, return the level number. This is for flat tree node. */ + readonly getLevel: (dataNode: T) => number; + + /** + * Whether the data node is expandable. Returns true if expandable. + * This is for flat tree node. + */ + readonly isExpandable: (dataNode: T) => boolean; + + /** Gets a stream that emits whenever the given data node's children change. */ + readonly getChildren: (dataNode: T) => Observable; +} diff --git a/src/cdk/tree/index.ts b/src/cdk/tree/index.ts new file mode 100644 index 00000000000..f14e9b6d670 --- /dev/null +++ b/src/cdk/tree/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + + +export * from './public-api'; diff --git a/src/cdk/tree/nested-node.ts b/src/cdk/tree/nested-node.ts new file mode 100644 index 00000000000..7768b95719d --- /dev/null +++ b/src/cdk/tree/nested-node.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { + AfterContentInit, + ContentChildren, + Directive, + ElementRef, + OnDestroy, + QueryList, +} from '@angular/core'; +import {takeUntil} from 'rxjs/operators/takeUntil'; + +import {CdkTree, CdkTreeNode} from './tree'; +import {CdkTreeNodeOutlet} from './outlet'; + +/** + * Nested node is a child of ``. It works with nested tree. + * By using `cdk-nested-tree-node` component in tree node template, children of the parent node will + * be added in the `cdkTreeNodeOutlet` in tree node template. + * For example: + * ```html + * + * {{node.name}} + * + * + * ``` + * The children of node will be automatically added to `cdkTreeNodeOutlet`, the result dom will be + * like this: + * ```html + * + * {{node.name}} + * {{child1.name}} + * {{child2.name}} + * + * ``` + */ +@Directive({ + selector: 'cdk-nested-tree-node', + exportAs: 'cdkNestedTreeNode', + host: { + '[attr.aria-expanded]': 'isExpanded', + '[attr.role]': 'role', + 'class': 'cdk-tree-node cdk-nested-tree-node', + }, + providers: [{provide: CdkTreeNode, useExisting: CdkNestedTreeNode}] +}) +export class CdkNestedTreeNode extends CdkTreeNode implements AfterContentInit, OnDestroy { + /** The children data dataNodes of current node. They will be placed in `CdkTreeNodeOutlet`. */ + protected _children: T[]; + + /** The children node placeholder. */ + @ContentChildren(CdkTreeNodeOutlet) nodeOutlet: QueryList; + + constructor(protected _elementRef: ElementRef, + protected _tree: CdkTree) { + super(_elementRef, _tree); + } + + ngAfterContentInit() { + this._tree.treeControl.getChildren(this.data).pipe(takeUntil(this._destroyed)) + .subscribe(result => { + if (result && result.length) { + // In case when nodeOutlet is not in the DOM when children changes, save it in the node + // and add to nodeOutlet when it's available. + this._children = result as T[]; + this._addChildrenNodes(); + } + }); + this.nodeOutlet.changes.pipe(takeUntil(this._destroyed)) + .subscribe((_) => this._addChildrenNodes()); + } + + ngOnDestroy() { + this._clear(); + this._destroyed.next(); + this._destroyed.complete(); + } + + /** Add children dataNodes to the NodeOutlet */ + protected _addChildrenNodes(): void { + this._clear(); + if (this.nodeOutlet.length && this._children && this._children.length) { + this._children.forEach((child, index) => { + this._tree.insertNode(child, index, this.nodeOutlet.first.viewContainer); + }); + } + } + + /** Clear the children dataNodes. */ + protected _clear(): void { + if (this.nodeOutlet && this.nodeOutlet.first) { + this.nodeOutlet.first.viewContainer.clear(); + } + } +} diff --git a/src/cdk/tree/node.ts b/src/cdk/tree/node.ts new file mode 100644 index 00000000000..df34de29f27 --- /dev/null +++ b/src/cdk/tree/node.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, TemplateRef} from '@angular/core'; + + +/** Context provided to the tree node component. */ +export class CdkTreeNodeOutletContext { + /** Data for the node. */ + $implicit: T; + + /** Index location of the node. */ + index?: number; + + /** Length of the number of total dataNodes. */ + count?: number; + + constructor(data: T) { + this.$implicit = data; + } +} + +/** + * Data node definition for the CdkTree. + * Captures the node's template and a when predicate that describes when this node should be used. + */ +@Directive({ + selector: '[cdkTreeNodeDef]', + inputs: [ + 'when: cdkTreeNodeDefWhen' + ], +}) +export class CdkTreeNodeDef { + /** + * Function that should return true if this node template should be used for the provided node + * data and index. If left undefined, this node will be considered the default node template to + * use when no other when functions return true for the data. + * For every node, there must be at least one when function that passes or an undefined to + * default. + */ + when: (index: number, nodeData: T) => boolean; + + /** @docs-private */ + constructor(public template: TemplateRef) {} +} diff --git a/src/cdk/tree/outlet.ts b/src/cdk/tree/outlet.ts new file mode 100644 index 00000000000..2895003d893 --- /dev/null +++ b/src/cdk/tree/outlet.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { + Directive, + ViewContainerRef, +} from '@angular/core'; + +/** + * Outlet for nested CdkNode. Put `[cdkTreeNodeOutlet]` on a tag to place children dataNodes + * inside the outlet. + */ +@Directive({ + selector: '[cdkTreeNodeOutlet]' +}) +export class CdkTreeNodeOutlet { + constructor(public viewContainer: ViewContainerRef) {} +} diff --git a/src/cdk/tree/padding.ts b/src/cdk/tree/padding.ts new file mode 100644 index 00000000000..e19d2db2bed --- /dev/null +++ b/src/cdk/tree/padding.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directionality} from '@angular/cdk/bidi'; +import {coerceNumberProperty} from '@angular/cdk/coercion'; +import {Directive, ElementRef, Input, OnDestroy, Optional, Renderer2} from '@angular/core'; +import {takeUntil} from 'rxjs/operators/takeUntil'; +import {Subject} from 'rxjs/Subject'; +import {CdkTree, CdkTreeNode} from './tree'; + +/** + * Indent for the children tree dataNodes. + * This directive will add left-padding to the node to show hierarchy. + */ +@Directive({ + selector: '[cdkTreeNodePadding]', +}) +export class CdkTreeNodePadding implements OnDestroy { + /** Subject that emits when the component has been destroyed. */ + private _destroyed = new Subject(); + + /** The level of depth of the tree node. The padding will be `level * indent` pixels. */ + @Input('cdkTreeNodePadding') + set level(value: number) { + this._level = coerceNumberProperty(value); + this._setPadding(); + } + get level(): number { return this._level; } + _level: number; + + /** The indent for each level. Default number 40px from material design menu sub-menu spec. */ + // TODO(tinayuangao): Make indent working with a string with unit, e.g. 10em + @Input('cdkTreeNodePaddingIndent') + set indent(value: number) { + this._indent = coerceNumberProperty(value); + this._setPadding(); + } + get indent(): number { return this._indent; } + _indent: number = 40; + + constructor(private _treeNode: CdkTreeNode, + private _tree: CdkTree, + private _renderer: Renderer2, + private _element: ElementRef, + @Optional() private _dir: Directionality) { + this._setPadding(); + if (this._dir) { + this._dir.change.pipe(takeUntil(this._destroyed)).subscribe(() => this._setPadding()); + } + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + /** 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.treeControl.getLevel) + ? this._tree.treeControl.getLevel(this._treeNode.data) + : null; + const level = this._level || nodeLevel; + return level ? `${level * this._indent}px` : null; + } + + _setPadding() { + const padding = this._paddingIndent(); + const paddingProp = this._dir && this._dir.value === 'rtl' ? 'padding-right' : 'padding-left'; + + this._renderer.setStyle(this._element.nativeElement, paddingProp, padding); + } +} diff --git a/src/cdk/tree/public-api.ts b/src/cdk/tree/public-api.ts new file mode 100644 index 00000000000..0b803e560f1 --- /dev/null +++ b/src/cdk/tree/public-api.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './control/base-tree-control'; +export * from './control/flat-tree-control'; +export * from './control/nested-tree-control'; +export * from './control/tree-control'; +export * from './nested-node'; +export * from './node'; +export * from './padding'; +export * from './outlet'; +export * from './tree'; +export * from './tree-errors'; +export * from './tree-module'; +export * from './toggle'; diff --git a/src/cdk/tree/toggle.ts b/src/cdk/tree/toggle.ts new file mode 100644 index 00000000000..a6aa04084d0 --- /dev/null +++ b/src/cdk/tree/toggle.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import { + Directive, + Input, +} from '@angular/core'; +import {CdkTree, CdkTreeNode} from './tree'; + +/** + * Node toggle to expand/collapse the node. + */ +@Directive({ + selector: '[cdkTreeNodeToggle]', + host: { + '(click)': '_toggle($event)', + } +}) +export class CdkTreeNodeToggle { + /** Whether expand/collapse the node recursively. */ + @Input('cdkTreeNodeToggleRecursive') + get recursive(): boolean { return this._recursive; } + set recursive(value: boolean) { this._recursive = coerceBooleanProperty(value); } + protected _recursive = true; + + constructor(protected _tree: CdkTree, + protected _treeNode: CdkTreeNode) {} + + _toggle(event: Event): void { + this.recursive + ? this._tree.treeControl.toggleDescendants(this._treeNode.data) + : this._tree.treeControl.toggle(this._treeNode.data); + + event.stopPropagation(); + } +} diff --git a/src/cdk/tree/tree-errors.ts b/src/cdk/tree/tree-errors.ts new file mode 100644 index 00000000000..aad9df0911a --- /dev/null +++ b/src/cdk/tree/tree-errors.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Returns an error to be thrown when there is no usable data. + * @docs-private + */ +export function getTreeNoValidDataSourceError() { + return Error(`A valid data source must be provided.`); +} + +/** + * Returns an error to be thrown when there are multiple nodes that are missing a when function. + * @docs-private + */ +export function getTreeMultipleDefaultNodeDefsError() { + return Error(`There can only be one default row without a when predicate function.`); +} + +/** + * Returns an error to be thrown when there are no matching node defs for a particular set of data. + * @docs-private + */ +export function getTreeMissingMatchingNodeDefError() { + return Error(`Could not find a matching node definition for the provided node data.`); +} + +/** + * Returns an error to be thrown when there are tree control. + * @docs-private + */ +export function getTreeControlMissingError() { + return Error(`Could not find a tree control for the tree.`); +} + +/** + * Returns an error to be thrown when tree control did not implement functions for flat/nested node. + * @docs-private + */ +export function getTreeControlFunctionsMissingError() { + return Error(`Could not find functions for nested/flat tree in tree control.`); +} diff --git a/src/cdk/tree/tree-module.ts b/src/cdk/tree/tree-module.ts new file mode 100644 index 00000000000..3c63a7f8df6 --- /dev/null +++ b/src/cdk/tree/tree-module.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {FocusMonitor} from '@angular/cdk/a11y'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {CdkTreeNodeOutlet} from './outlet'; +import {CdkTreeNodePadding} from './padding'; +import {CdkTreeNodeToggle} from './toggle'; +import {CdkTree, CdkTreeNode} from './tree'; +import {CdkTreeNodeDef} from './node'; +import {CdkNestedTreeNode} from './nested-node'; + +const EXPORTED_DECLARATIONS = [ + CdkNestedTreeNode, + CdkTreeNodeDef, + CdkTreeNodePadding, + CdkTreeNodeToggle, + CdkTree, + CdkTreeNode, + CdkTreeNodeOutlet, +]; + +@NgModule({ + imports: [CommonModule], + exports: EXPORTED_DECLARATIONS, + declarations: EXPORTED_DECLARATIONS, + providers: [FocusMonitor, CdkTreeNodeDef] +}) +export class CdkTreeModule {} diff --git a/src/cdk/tree/tree.md b/src/cdk/tree/tree.md new file mode 100644 index 00000000000..62835fac659 --- /dev/null +++ b/src/cdk/tree/tree.md @@ -0,0 +1,163 @@ +The `` enables developers to build a customized tree experience for structured data. The +`` 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 +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. + +``` + + parent node + -- child node1 + -- child node2 + + +``` + +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. + +``` + + + parent node + -- child node1 + -- child node2 + + +``` + +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 + +#### 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 +template defines the look of the tree node, expansion/collapsing control and the structure for +nested children nodes. + +A node definition is specified via any element with `cdkNodeDef`. This directive exports the node +data to be used in any bindings in the node template. + +``` + + {{node.key}}: {{node.value}} + +``` + +##### 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. + + +##### Nested tree node template + +When using nested tree nodes, the node template must contain a `cdkTreeNodeOutlet`, which marks +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 +a tree node recursively by setting `[cdkTreeNodeToggleRecursive]` to true. + +``` + + {{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`. + +``` + + + {{node.value}} + +``` + +#### Padding (Flat tree only) + +The cdkTreeNodePadding can be placed in a flat tree's node template to display the level +information of a flat tree node. + +``` + + {{node.value}} + + +``` + +Nested tree does not need this padding since padding can be easily added to the hierarchy structure +in DOM. + + +#### 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. + + +``` + + {{node.value}} + + + [ A special node {{node.value}} ] + +``` + +### Data Source + +#### Connecting the tree to a data source + +Similar to `cdk-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. + + +#### Flat tree + +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. + + +#### Nested tree + +The data source for nested tree has an option to leave the node expansion/collapsing event for each +tree node component to handle. diff --git a/src/cdk/tree/tree.spec.ts b/src/cdk/tree/tree.spec.ts new file mode 100644 index 00000000000..6919c15bce1 --- /dev/null +++ b/src/cdk/tree/tree.spec.ts @@ -0,0 +1,996 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component, ViewChild} from '@angular/core'; + +import {CollectionViewer, DataSource} from '@angular/cdk/collections'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; +import {Observable} from 'rxjs/Observable'; +import {combineLatest} from 'rxjs/observable/combineLatest'; +import {map} from 'rxjs/operators/map'; + +import {TreeControl} from './control/tree-control'; +import {FlatTreeControl} from './control/flat-tree-control'; +import {NestedTreeControl} from './control/nested-tree-control'; +import {CdkTreeModule} from './index'; +import {CdkTree} from './tree'; + + +describe('CdkTree', () => { + /** Represents an indent for expectNestedTreeToMatch */ + const _ = {}; + let dataSource: FakeDataSource; + let treeElement: HTMLElement; + let tree: CdkTree; + + function configureCdkTreeTestingModule(declarations) { + TestBed.configureTestingModule({ + imports: [CdkTreeModule], + declarations: declarations, + }).compileComponents(); + } + + describe('flat tree', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + let component: SimpleCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([SimpleCdkTreeApp]); + fixture = TestBed.createComponent(SimpleCdkTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('with a connected data source', () => { + expect(tree.dataSource).toBe(dataSource); + expect(dataSource.isConnected).toBe(true); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).toBeDefined('Expect nodes to be defined'); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + + it('with the right accessibility roles', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + + getNodes(treeElement).forEach(node => { + expect(node.getAttribute('role')).toBe('treeitem'); + }); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectFlatTreeToMatch(treeElement, 28, + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`]); + + dataSource.addData(2); + fixture.detectChanges(); + + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch(treeElement, 28, + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + [_, `${data[3].pizzaTopping} - ${data[3].pizzaCheese} + ${data[3].pizzaBase}`]); + }); + }); + + describe('with toggle', () => { + let fixture: ComponentFixture; + let component: CdkTreeAppWithToggle; + + beforeEach(() => { + configureCdkTreeTestingModule([CdkTreeAppWithToggle]); + fixture = TestBed.createComponent(CdkTreeAppWithToggle); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('should expand/collapse the node', () => { + expect(dataSource.data.length).toBe(3); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect no expanded node`); + + component.toggleRecursively = false; + let data = dataSource.data; + dataSource.addChild(data[2]); + fixture.detectChanges(); + + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch(treeElement, 40, + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + [_, `${data[3].pizzaTopping} - ${data[3].pizzaCheese} + ${data[3].pizzaBase}`]); + + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(1, `Expect node expanded`); + expect(component.treeControl.expansionModel.selected[0]).toBe(data[2]); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect node collapsed`); + }); + + it('should expand/collapse the node recursively', () => { + expect(dataSource.data.length).toBe(3); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect no expanded node`); + + let data = dataSource.data; + dataSource.addChild(data[2]); + fixture.detectChanges(); + + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch(treeElement, 40, + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + [_, `${data[3].pizzaTopping} - ${data[3].pizzaCheese} + ${data[3].pizzaBase}`]); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(2, `Expect nodes expanded`); + expect(component.treeControl.expansionModel.selected[0]) + .toBe(data[2], `Expect parent node expanded`); + expect(component.treeControl.expansionModel.selected[1]) + .toBe(data[3], `Expected child node expanded`); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect node collapsed`); + }); + }); + + describe('with when node template', () => { + let fixture: ComponentFixture; + let component: WhenNodeCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([WhenNodeCdkTreeApp]); + fixture = TestBed.createComponent(WhenNodeCdkTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectFlatTreeToMatch(treeElement, 28, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + + dataSource.addChild(data[1]); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch(treeElement, 28, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `topping_4 - cheese_4 + base_4`], + [`[topping_3] - [cheese_3] + [base_3]`]); + }); + }); + + describe('with array data source', () => { + let fixture: ComponentFixture; + let component: ArrayDataSourceCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([ArrayDataSourceCdkTreeApp]); + fixture = TestBed.createComponent(ArrayDataSourceCdkTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectFlatTreeToMatch(treeElement, 28, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + + dataSource.addChild(data[1]); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch(treeElement, 28, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `[topping_4] - [cheese_4] + [base_4]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + }); + }); + + describe('with observable data source', () => { + let fixture: ComponentFixture; + let component: ObservableDataSourceCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([ObservableDataSourceCdkTreeApp]); + fixture = TestBed.createComponent(ObservableDataSourceCdkTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectFlatTreeToMatch(treeElement, 28, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + + dataSource.addChild(data[1]); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch(treeElement, 28, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `[topping_4] - [cheese_4] + [base_4]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + }); + }); + }); + + describe('nested tree', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + let component: NestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([NestedCdkTreeApp]); + fixture = TestBed.createComponent(NestedCdkTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('with a connected data source', () => { + expect(tree.dataSource).toBe(dataSource); + expect(dataSource.isConnected).toBe(true); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).toBeDefined('Expect nodes to be defined'); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + + it('with the right accessibility roles', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + + getNodes(treeElement).forEach(node => { + expect(node.getAttribute('role')).toBe('treeitem'); + }); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectNestedTreeToMatch(treeElement, + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`]); + + dataSource.addChild(data[1], false); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(3); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`]); + }); + + it('with nested child data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + fixture.detectChanges(); + + expect(data.length).toBe(3); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [`topping_3 - cheese_3 + base_3`]); + + dataSource.addChild(child, false); + fixture.detectChanges(); + + expect(data.length).toBe(3); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [_, _, `topping_6 - cheese_6 + base_6`], + [`topping_3 - cheese_3 + base_3`]); + }); + }); + + describe('with when node', () => { + let fixture: ComponentFixture; + let component: WhenNodeNestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([WhenNodeNestedCdkTreeApp]); + fixture = TestBed.createComponent(WhenNodeNestedCdkTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`>> topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + + dataSource.addChild(data[1], false); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(3); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`>> topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`]); + }); + }); + + describe('with toggle', () => { + let fixture: ComponentFixture; + let component: NestedCdkTreeAppWithToggle; + + beforeEach(() => { + configureCdkTreeTestingModule([NestedCdkTreeAppWithToggle]); + fixture = TestBed.createComponent(NestedCdkTreeAppWithToggle); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('should expand/collapse the node', () => { + component.toggleRecursively = false; + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + + fixture.detectChanges(); + + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + + fixture.detectChanges(); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(1, `Expect node expanded`); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`]); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect node collapsed`); + }); + + it('should expand/collapse the node recursively', () => { + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + fixture.detectChanges(); + + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(3, `Expect node expanded`); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [`topping_3 - cheese_3 + base_3`]); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect node collapsed`); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + }); + }); + + describe('with array data source', () => { + let fixture: ComponentFixture; + let component: ArrayDataSourceNestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([ArrayDataSourceNestedCdkTreeApp]); + fixture = TestBed.createComponent(ArrayDataSourceNestedCdkTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectNestedTreeToMatch(treeElement, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + + dataSource.addChild(data[1], false); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + expectNestedTreeToMatch(treeElement, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `[topping_4] - [cheese_4] + [base_4]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + }); + }); + + describe('with observable data source', () => { + let fixture: ComponentFixture; + let component: ObservableDataSourceNestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([ObservableDataSourceNestedCdkTreeApp]); + fixture = TestBed.createComponent(ObservableDataSourceNestedCdkTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectNestedTreeToMatch(treeElement, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + + dataSource.addChild(data[1], false); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + expectNestedTreeToMatch(treeElement, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `[topping_4] - [cheese_4] + [base_4]`], + [`[topping_3] - [cheese_3] + [base_3]`]); + }); + }); + }); +}); + +export class TestData { + pizzaTopping: string; + pizzaCheese: string; + pizzaBase: string; + level: number; + children: TestData[]; + observableChildren: BehaviorSubject; + + constructor(pizzaTopping: string, pizzaCheese: string, pizzaBase: string, level: number = 1) { + this.pizzaTopping = pizzaTopping; + this.pizzaCheese = pizzaCheese; + this.pizzaBase = pizzaBase; + this.level = level; + this.children = []; + this.observableChildren = new BehaviorSubject(this.children); + } +} + +class FakeDataSource extends DataSource { + dataIndex = 0; + isConnected = false; + + _dataChange = new BehaviorSubject([]); + set data(data: TestData[]) { this._dataChange.next(data); } + get data() { return this._dataChange.getValue(); } + + constructor(public treeControl: TreeControl) { + super(); + for (let i = 0; i < 3; i++) { + this.addData(); + } + } + + connect(collectionViewer: CollectionViewer): Observable { + this.isConnected = true; + const streams = [this._dataChange, collectionViewer.viewChange]; + return combineLatest<[TestData[]]>(streams) + .pipe(map(([data]) => { + this.treeControl.dataNodes = data; + return data; + })); + } + + disconnect() { + this.isConnected = false; + } + + addChild(parent: TestData, isFlat: boolean = true) { + const nextIndex = ++this.dataIndex; + const child = new TestData(`topping_${nextIndex}`, `cheese_${nextIndex}`, `base_${nextIndex}`, + parent.level + 1); + parent.children.push(child); + if (isFlat) { + let copiedData = this.data.slice(); + copiedData.splice(this.data.indexOf(parent) + 1, 0, child); + this.data = copiedData; + } else { + parent.observableChildren.next(parent.children); + } + return child; + } + + addData(level: number = 1) { + const nextIndex = ++this.dataIndex; + + let copiedData = this.data.slice(); + copiedData.push( + new TestData(`topping_${nextIndex}`, `cheese_${nextIndex}`, `base_${nextIndex}`, level)); + + this.data = copiedData; + } +} + +function getNodes(treeElement: Element): Element[] { + return [].slice.call(treeElement.querySelectorAll('.cdk-tree-node'))!; +} + +function expectFlatTreeToMatch(treeElement: Element, expectedPaddingIndent: number = 28, + ...expectedTree: any[]) { + const missedExpectations: string[] = []; + + function checkNode(node: Element, expectedNode: any[]) { + const actualTextContent = node.textContent!.trim(); + const expectedTextContent = expectedNode[expectedNode.length - 1]; + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`); + } + } + + function checkLevel(node: Element, expectedNode: any[]) { + const actualLevel = (node as HTMLElement).style.paddingLeft; + const expectedLevel = `${(expectedNode.length) * expectedPaddingIndent}px`; + if (actualLevel != expectedLevel) { + missedExpectations.push( + `Expected node level to be ${expectedLevel} but was ${actualLevel}`); + } + } + + getNodes(treeElement).forEach((node, index) => { + const expected = expectedTree ? + expectedTree[index] : + null; + + checkLevel(node, expected); + checkNode(node, expected); + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} + +function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { + const missedExpectations: string[] = []; + function checkNodeContent(node: Element, expectedNode: any[]) { + const expectedTextContent = expectedNode[expectedNode.length - 1]; + const actualTextContent = node.childNodes.item(0).textContent!.trim(); + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`); + } + } + + function checkNodeDescendants(node: Element, expectedNode: any[], currentIndex: number) { + let expectedDescendant = 0; + + for (let i = currentIndex + 1; i < expectedTree.length; ++i) { + if (expectedTree[i].length > expectedNode.length) { + ++expectedDescendant; + } else if (expectedTree[i].length === expectedNode.length) { + break; + } + } + + const actualDescendant = getNodes(node).length; + if (actualDescendant !== expectedDescendant) { + missedExpectations.push( + `Expected node descendant num to be ${expectedDescendant} but was ${actualDescendant}`); + } + } + + getNodes(treeElement).forEach((node, index) => { + + const expected = expectedTree ? + expectedTree[index] : + null; + + checkNodeDescendants(node, expected, index); + checkNodeContent(node, expected); + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + ` +}) +class SimpleCdkTreeApp { + 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: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + ` +}) +class NestedCdkTreeApp { + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + >> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + ` +}) +class WhenNodeNestedCdkTreeApp { + isSecondNode = (_: number, node: TestData) => node.pizzaBase.indexOf('2') > 0; + + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + ` +}) +class CdkTreeAppWithToggle { + toggleRecursively: boolean = true; + + 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: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} +
+ +
+
+
+ ` +}) +class NestedCdkTreeAppWithToggle { + toggleRecursively: boolean = true; + + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + ` +}) +class WhenNodeCdkTreeApp { + isOddNode = (_: number, node: TestData) => node.level % 2 === 1; + 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: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + ` +}) +class ArrayDataSourceCdkTreeApp { + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataArray() { + return this.dataSource.data; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + ` +}) +class ObservableDataSourceCdkTreeApp { + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataObservable() { + return this.dataSource._dataChange; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + + ` +}) +class ArrayDataSourceNestedCdkTreeApp { + + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataArray() { + return this.dataSource.data; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + + ` +}) +class ObservableDataSourceNestedCdkTreeApp { + + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataObservable() { + return this.dataSource._dataChange; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts new file mode 100644 index 00000000000..3abf6b953ea --- /dev/null +++ b/src/cdk/tree/tree.ts @@ -0,0 +1,339 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {FocusableOption} from '@angular/cdk/a11y'; +import {CollectionViewer, DataSource} from '@angular/cdk/collections'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + Directive, + ElementRef, + Input, + IterableDiffers, + IterableDiffer, + IterableChangeRecord, + OnDestroy, + OnInit, + QueryList, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; +import {takeUntil} from 'rxjs/operators/takeUntil'; +import {Observable} from 'rxjs/Observable'; +import {of} from 'rxjs/observable/of'; +import {Subject} from 'rxjs/Subject'; +import {Subscription} from 'rxjs/Subscription'; +import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node'; +import {CdkTreeNodeOutlet} from './outlet'; +import {TreeControl} from './control/tree-control'; +import { + getTreeControlMissingError, + getTreeMissingMatchingNodeDefError, + getTreeMultipleDefaultNodeDefsError, + getTreeControlFunctionsMissingError, + getTreeNoValidDataSourceError +} from './tree-errors'; + +/** + * Tree node for CdkTree. It contains the data in the tree node. + */ +@Directive({ + selector: 'cdk-tree-node', + exportAs: 'cdkTreeNode', + host: { + '[attr.aria-expanded]': 'isExpanded', + '[attr.aria-level]': 'level', + '[attr.role]': 'role', + 'class': 'cdk-tree-node', + }, +}) +export class CdkTreeNode implements FocusableOption, OnDestroy { + /** + * The most recently created `CdkTreeNode`. We save it in static variable so we can retrieve it + * in `CdkTree` and set the data to it. + */ + static mostRecentTreeNode: CdkTreeNode | null = null; + + /** Subject that emits when the component has been destroyed. */ + protected _destroyed = new Subject(); + + /** The tree node's data. */ + get data(): T { return this._data; } + set data(value: T) { + this._data = value; + this._setRoleFromData(); + } + protected _data: T; + + get isExpanded(): boolean { + return this._tree.treeControl.isExpanded(this._data); + } + + get level(): number { + return this._tree.treeControl.getLevel ? this._tree.treeControl.getLevel(this._data) : 0; + } + + /** + * The role of the node should be 'group' if it's an internal node, + * and 'treeitem' if it's a leaf node. + */ + @Input() role: 'treeitem' | 'group' = 'treeitem'; + + constructor(protected _elementRef: ElementRef, + protected _tree: CdkTree) { + CdkTreeNode.mostRecentTreeNode = this; + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + /** Focuses the menu item. Implements for FocusableOption. */ + focus(): void { + this._elementRef.nativeElement.focus(); + } + + private _setRoleFromData(): void { + if (this._tree.treeControl.isExpandable) { + this.role = this._tree.treeControl.isExpandable(this._data) ? 'group' : 'treeitem'; + } else { + if (!this._tree.treeControl.getChildren) { + throw getTreeControlFunctionsMissingError(); + } + this._tree.treeControl.getChildren(this._data).pipe(takeUntil(this._destroyed)) + .subscribe(children => { + this.role = children && children.length ? 'group' : 'treeitem'; + }); + } + } +} + + +/** + * 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. + */ +@Component({ + moduleId: module.id, + selector: 'cdk-tree', + exportAs: 'cdkTree', + template: ``, + host: { + 'class': 'cdk-tree', + 'role': 'tree', + }, + encapsulation: ViewEncapsulation.None, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CdkTree implements CollectionViewer, OnInit, OnDestroy { + /** Subject that emits when the component has been destroyed. */ + private _onDestroy = new Subject(); + + /** Latest data provided by the data source through the connect interface. */ + private _data: Array = new Array(); + + /** Differ used to find the changes in the data provided by the data source. */ + private _dataDiffer: IterableDiffer; + + /** Stores the node definition that does not have a when predicate. */ + private _defaultNodeDef: CdkTreeNodeDef | null; + + /** Data subscription */ + private _dataSubscription: Subscription | null; + + /** + * Provides a stream containing the latest data array to render. Influenced by the tree's + * stream of view window (what dataNodes are currently on screen). + * Data source can be an observable of data array, or a dara array to render. + */ + @Input() + get dataSource(): DataSource | Observable | T[] { return this._dataSource; } + set dataSource(dataSource: DataSource | Observable | T[]) { + if (this._dataSource !== dataSource) { + this._switchDataSource(dataSource); + } + } + private _dataSource: DataSource | Observable | T[]; + + /** The tree controller */ + @Input() treeControl: TreeControl; + + // Outlets within the tree's template where the dataNodes will be inserted. + @ViewChild(CdkTreeNodeOutlet) _nodeOutlet: CdkTreeNodeOutlet; + + /** The tree node template for the tree */ + @ContentChildren(CdkTreeNodeDef) _nodeDefs: QueryList>; + + // TODO(tinayuangao): Setup a listener for scrolling, emit the calculated view to viewChange. + // Remove the MAX_VALUE in viewChange + /** + * Stream containing the latest information on what rows are being displayed on screen. + * Can be used by the data source to as a heuristic of what data should be provided. + */ + viewChange = + new BehaviorSubject<{start: number, end: number}>({start: 0, end: Number.MAX_VALUE}); + + constructor(private _differs: IterableDiffers, + private _changeDetectorRef: ChangeDetectorRef) {} + + ngOnInit() { + this._dataDiffer = this._differs.find([]).create(); + if (!this.treeControl) { + throw getTreeControlMissingError(); + } + } + + ngOnDestroy() { + this._nodeOutlet.viewContainer.clear(); + + 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; + } + } + + ngAfterContentChecked() { + const defaultNodeDefs = this._nodeDefs.filter(def => !def.when); + if (defaultNodeDefs.length > 1) { + throw getTreeMultipleDefaultNodeDefsError(); + } + this._defaultNodeDef = defaultNodeDefs[0]; + + if (this.dataSource && this._nodeDefs && !this._dataSubscription) { + this._observeRenderChanges(); + } + } + + + // TODO(tinayuangao): Work on keyboard traversal and actions, make sure it's working for RTL + // and nested trees. + + /** + * 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[]) { + this._data = new Array(); + + 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._observeRenderChanges(); + } + } + + /** Set up a subscription for the data provided by the data source. */ + private _observeRenderChanges() { + let dataStream: Observable | undefined; + + // Cannot use `instanceof DataSource` since the data source could be a literal with + // `connect` function and may not extends DataSource. + if (typeof (this._dataSource as DataSource).connect === 'function') { + dataStream = (this._dataSource as DataSource).connect(this); + } else if (this._dataSource instanceof Observable) { + dataStream = this._dataSource; + } else if (Array.isArray(this._dataSource)) { + dataStream = of(this._dataSource); + } + + if (dataStream) { + this._dataSubscription = dataStream.pipe(takeUntil(this._onDestroy)) + .subscribe(data => { + this._data = data; + this._renderNodeChanges(data); + }); + } else { + throw getTreeNoValidDataSourceError(); + } + } + + /** Check for changes made in the data and render each change (node added/removed/moved). */ + private _renderNodeChanges(dataNodes: T[]) { + const changes = this._dataDiffer.diff(dataNodes); + if (!changes) { return; } + + const viewContainer = this._nodeOutlet.viewContainer; + changes.forEachOperation( + (item: IterableChangeRecord, adjustedPreviousIndex: number, currentIndex: number) => { + if (item.previousIndex == null) { + this.insertNode(dataNodes[currentIndex], currentIndex); + } else if (currentIndex == null) { + viewContainer.remove(adjustedPreviousIndex); + } else { + const view = viewContainer.get(adjustedPreviousIndex); + viewContainer.move(view!, currentIndex); + } + }); + } + + /** + * Finds the matching node definition that should be used for this node data. If there is only + * one node definition, it is returned. Otherwise, find the node definition that has a when + * predicate that returns true with the data. If none return true, return the default node + * definition. + */ + _getNodeDef(data: T, i: number): CdkTreeNodeDef { + if (this._nodeDefs.length == 1) { return this._nodeDefs.first; } + + const nodeDef = + this._nodeDefs.find(def => def.when && def.when(i, data)) || this._defaultNodeDef; + if (!nodeDef) { throw getTreeMissingMatchingNodeDefError(); } + + return nodeDef; + } + + /** + * Create the embedded view for the data node template and place it in the correct index location + * within the data node view container. + */ + insertNode(nodeData: T, index: number, viewContainer?: ViewContainerRef) { + const node = this._getNodeDef(nodeData, index); + + // Node context that will be provided to created embedded view + const context: CdkTreeNodeOutletContext = new CdkTreeNodeOutletContext(nodeData); + + // Use default tree nodeOutlet, or nested node's nodeOutlet + const container = viewContainer ? viewContainer : this._nodeOutlet.viewContainer; + container.createEmbeddedView(node.template, context, index); + + // Set the data to just created `CdkTreeNode`. + // The `CdkTreeNode` created from `createEmbeddedView` will be saved in static variable + // `mostRecentTreeNode`. We get it from static variable and pass the node data to it. + if (CdkTreeNode.mostRecentTreeNode) { + CdkTreeNode.mostRecentTreeNode.data = nodeData; + } + + this._changeDetectorRef.detectChanges(); + } +} diff --git a/src/cdk/tree/tsconfig-build.json b/src/cdk/tree/tsconfig-build.json new file mode 100644 index 00000000000..928747959b4 --- /dev/null +++ b/src/cdk/tree/tsconfig-build.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public-api.ts", + "../typings.d.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/cdk/tree", + "skipTemplateCodegen": true + } +} diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index 3eb21c9ec0c..2624072af22 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -88,6 +88,7 @@ export class DemoApp { {name: 'Tabs', route: '/tabs'}, {name: 'Toolbar', route: '/toolbar'}, {name: 'Tooltip', route: '/tooltip'}, + {name: 'Tree', route: '/tree'}, {name: 'Typography', route: '/typography'} ]; diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index 4b61a3c8589..449ef2778b4 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -62,6 +62,7 @@ import {DemoApp, Home} from './demo-app'; import {DEMO_APP_ROUTES} from './routes'; import {TableDemoModule} from '../table/table-demo-module'; import {BadgeDemo} from '../badge/badge-demo'; +import {TreeDemoModule} from '../tree/tree-demo-module'; @NgModule({ imports: [ @@ -72,6 +73,7 @@ import {BadgeDemo} from '../badge/badge-demo'; DemoMaterialModule, LayoutModule, TableDemoModule, + TreeDemoModule, ], declarations: [ AutocompleteDemo, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index b7c80e5ae2a..281ae53ce8b 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -47,6 +47,7 @@ import {TABS_DEMO_ROUTES} from '../tabs/routes'; import {TabsDemo} from '../tabs/tabs-demo'; import {ToolbarDemo} from '../toolbar/toolbar-demo'; import {TooltipDemo} from '../tooltip/tooltip-demo'; +import {TreeDemo} from '../tree/tree-demo'; import {TypographyDemo} from '../typography/typography-demo'; import {DemoApp, Home} from './demo-app'; import {TableDemoPage} from '../table/table-demo-page'; @@ -94,6 +95,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'tabs', component: TabsDemo, children: TABS_DEMO_ROUTES}, {path: 'toolbar', component: ToolbarDemo}, {path: 'tooltip', component: TooltipDemo}, + {path: 'tree', component: TreeDemo}, {path: 'typography', component: TypographyDemo}, {path: 'expansion', component: ExpansionDemo}, {path: 'stepper', component: StepperDemo}, diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts index cec18c8cb6a..0700ca429af 100644 --- a/src/demo-app/demo-material-module.ts +++ b/src/demo-app/demo-material-module.ts @@ -14,6 +14,7 @@ import {OverlayModule} from '@angular/cdk/overlay'; import {PlatformModule} from '@angular/cdk/platform'; import {PortalModule} from '@angular/cdk/portal'; import {CdkTableModule} from '@angular/cdk/table'; +import {CdkTreeModule} from '@angular/cdk/tree'; import {NgModule} from '@angular/core'; import { MatAutocompleteModule, @@ -51,6 +52,7 @@ import { MatTabsModule, MatToolbarModule, MatTooltipModule, + MatTreeModule } from '@angular/material'; /** @@ -92,8 +94,10 @@ import { MatTabsModule, MatToolbarModule, MatTooltipModule, + MatTreeModule, MatNativeDateModule, CdkTableModule, + CdkTreeModule, A11yModule, BidiModule, CdkAccordionModule, diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts index 51dfa2a6c8e..aed53636aac 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -57,6 +57,7 @@ System.config({ '@angular/cdk/scrolling': 'dist/packages/cdk/scrolling/index.js', '@angular/cdk/stepper': 'dist/packages/cdk/stepper/index.js', '@angular/cdk/table': 'dist/packages/cdk/table/index.js', + '@angular/cdk/tree': 'dist/packages/cdk/tree/index.js', '@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js', '@angular/material/bottom-sheet': 'dist/packages/material/bottom-sheet/index.js', @@ -92,6 +93,7 @@ System.config({ '@angular/material/toolbar': 'dist/packages/material/toolbar/index.js', '@angular/material/tooltip': 'dist/packages/material/tooltip/index.js', '@angular/material/badge': 'dist/packages/material/badge/index.js', + '@angular/material/tree': 'dist/packages/material/tree/index.js', }, packages: { // Thirdparty barrels. diff --git a/src/demo-app/tree/checklist-tree-demo/checklist-database.ts b/src/demo-app/tree/checklist-tree-demo/checklist-database.ts new file mode 100644 index 00000000000..4f44a2ca50d --- /dev/null +++ b/src/demo-app/tree/checklist-tree-demo/checklist-database.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +/** + * Node for to-do item + */ +export class TodoItemNode { + children: TodoItemNode[]; + item: string; +} + +/** Flat to-do item node with expandable and level information */ +export class TodoItemFlatNode { + item: string; + level: number; + expandable: boolean; +} + +/** + * The Json object for to-do list data. + */ +const TREE_DATA = { + 'Reminders': [ + 'Cook dinner', + 'Read the Material Design spec', + 'Upgrade Application to Angular' + ], + 'Groceries': { + 'Organic eggs': null, + 'Protein Powder': null, + 'Almond Meal flour': null, + 'Fruits': { + 'Apple': null, + 'Orange': null, + 'Berries': ['Blueberry', 'Raspberry'] + } + } +}; + +/** + * Checklist database, it can build a tree structured Json object. + * Each node in Json object represents a to-do item or a category. + * If a node is a category, it has children items and new items can be added under the category. + */ +@Injectable() +export class ChecklistDatabase { + dataChange: BehaviorSubject = new BehaviorSubject([]); + + get data(): TodoItemNode[] { return this.dataChange.value; } + + constructor() { + this.initialize(); + } + + initialize() { + // Build the tree nodes from Json object. The result is a list of `TodoItemNode` with nested + // file node as children. + const data = this.buildFileTree(TREE_DATA, 0); + + // Notify the change. + this.dataChange.next(data); + } + + /** + * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object. + * The return value is the list of `TodoItemNode`. + */ + buildFileTree(value: any, level: number) { + let data: any[] = []; + for (let k in value) { + let v = value[k]; + let node = new TodoItemNode(); + node.item = `${k}`; + if (v === null || v === undefined) { + // no action + } else if (typeof v === 'object') { + node.children = this.buildFileTree(v, level + 1); + } else { + node.item = v; + } + data.push(node); + } + return data; + } + + /** Add an item to to-do list */ + insertItem(parent: TodoItemNode, name: string) { + const child = {item: name}; + if (parent.children) { + parent.children.push(child); + this.dataChange.next(this.data); + } + } +} diff --git a/src/demo-app/tree/checklist-tree-demo/checklist-tree-demo.html b/src/demo-app/tree/checklist-tree-demo/checklist-tree-demo.html new file mode 100644 index 00000000000..5a227297608 --- /dev/null +++ b/src/demo-app/tree/checklist-tree-demo/checklist-tree-demo.html @@ -0,0 +1,28 @@ + + + + {{node.item}} + + + + {{node.item}} + + + + + +Selected parent: {{selectedParent ? selectedParent.item : 'No selected'}} +
+ + + + diff --git a/src/demo-app/tree/checklist-tree-demo/checklist-tree-demo.scss b/src/demo-app/tree/checklist-tree-demo/checklist-tree-demo.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/demo-app/tree/checklist-tree-demo/checklist-tree-demo.ts b/src/demo-app/tree/checklist-tree-demo/checklist-tree-demo.ts new file mode 100644 index 00000000000..7484ed9c953 --- /dev/null +++ b/src/demo-app/tree/checklist-tree-demo/checklist-tree-demo.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component} from '@angular/core'; +import {SelectionModel} from '@angular/cdk/collections'; +import {FlatTreeControl} from '@angular/cdk/tree'; +import {MatTreeFlattener, MatTreeFlatDataSource} from '@angular/material/tree'; +import {of as ofObservable} from 'rxjs/observable/of'; +import {TodoItemNode, TodoItemFlatNode, ChecklistDatabase} from './checklist-database'; + +/** + * A nested checklist example using tree component. + * Each item/category has a checkbox, and user can check/uncheck a to-do item. + */ +@Component({ + moduleId: module.id, + selector: 'checklist-tree-demo', + templateUrl: 'checklist-tree-demo.html', + styleUrls: ['checklist-tree-demo.css'], + providers: [ChecklistDatabase] +}) +export class ChecklistTreeDemo { + /** Map from flat node to nested node. This helps us finding the nested node to be modified */ + flatNodeMap: Map = new Map(); + + /** Map from nested node to flattened node. This helps us to keep the same object for selection */ + nestedNodeMap: Map = new Map(); + + /** A selected parent node to be inserted */ + selectedParent: TodoItemFlatNode | null = null; + + /** The new item's name */ + newItemName: string = ''; + + treeControl: FlatTreeControl; + + treeFlattener: MatTreeFlattener; + + dataSource: MatTreeFlatDataSource; + + /** The selection for checklist */ + checklistSelection = new SelectionModel(true /* multiple */); + + constructor(private database: ChecklistDatabase) { + this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, + this.isExpandable, this.getChildren); + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe(data => { + this.dataSource.data = data; + }); + } + + getLevel = (node: TodoItemFlatNode) => { return node.level; }; + + isExpandable = (node: TodoItemFlatNode) => { return node.expandable; }; + + getChildren = (node: TodoItemNode) => { return ofObservable(node.children); }; + + hasChild = (_: number, _nodeData: TodoItemFlatNode) => { return _nodeData.expandable; }; + + /** + * Transformer to convert nested node to flat node. Record the nodes in maps for later use. + */ + transformer = (node: TodoItemNode, level: number) => { + let flatNode = this.nestedNodeMap.has(node) + ? this.nestedNodeMap.get(node)! + : new TodoItemFlatNode(); + flatNode.item = node.item; + flatNode.level = level; + flatNode.expandable = !!node.children; + this.flatNodeMap.set(flatNode, node); + this.nestedNodeMap.set(node, flatNode); + return flatNode; + } + + /** Whether all the descendants of the node are selected */ + descendantsAllSelected(node: TodoItemFlatNode): boolean { + const descendants = this.treeControl.getDescendants(node); + return descendants.every(child => this.checklistSelection.isSelected(child)); + } + + /** Whether part of the descendants are selected */ + descendantsPartiallySelected(node: TodoItemFlatNode): boolean { + const descendants = this.treeControl.getDescendants(node); + const result = descendants.some(child => this.checklistSelection.isSelected(child)); + return result && !this.descendantsAllSelected(node); + } + + /** Toggle the to-do item selection. Select/deselect all the descendants node */ + todoItemSelectionToggle(node: TodoItemFlatNode): void { + this.checklistSelection.toggle(node); + const descendants = this.treeControl.getDescendants(node); + this.checklistSelection.isSelected(node) + ? this.checklistSelection.select(...descendants) + : this.checklistSelection.deselect(...descendants); + } + + /** Insert a new item with name `newItemName` to be under category `selectedParent`. */ + addNode() { + if (this.selectedParent) { + const parentNode = this.flatNodeMap.get(this.selectedParent); + this.database.insertItem(parentNode!, this.newItemName); + } + } + + /** Select the category so we can insert the new item. */ + setParent(node: TodoItemFlatNode) { + this.selectedParent = node; + } +} diff --git a/src/demo-app/tree/dynamic-tree-demo/dynamic-database.ts b/src/demo-app/tree/dynamic-tree-demo/dynamic-database.ts new file mode 100644 index 00000000000..7c1a25ca750 --- /dev/null +++ b/src/demo-app/tree/dynamic-tree-demo/dynamic-database.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Injectable} from '@angular/core'; +import {FlatTreeControl} from '@angular/cdk/tree'; +import {CollectionViewer, SelectionChange} from '@angular/cdk/collections'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; +import {merge} from 'rxjs/observable/merge'; +import {map} from 'rxjs/operators/map'; + +/** Flat node with expandable and level information */ +export class DynamicFlatNode { + constructor(public item: string, public level: number = 1, public expandable: boolean = false) {} +} + + +/** + * Database for dynamic data. When expanding a node in the tree, the data source will need to fetch + * the descendants data from the database. + */ +export class DynamicDatabase { + dataMap = new Map([ + ['Fruits', ['Apple', 'Orange', 'Banana']], + ['Vegetables', ['Tomato', 'Potato', 'Onion']], + ['Apple', ['Fuji', 'Macintosh']], + ['Onion', ['Yellow', 'White', 'Purple']] + ]); + + rootLevelNodes = ['Fruits', 'Vegetables']; + + /** Initial data from database */ + initialData(): DynamicFlatNode[] { + return this.rootLevelNodes.map(name => new DynamicFlatNode(name, 1, true)); + } + + + getChildren(node: string): string[] | undefined { + return this.dataMap.get(node); + } + + isExpandable(node: string): boolean { + return this.dataMap.has(node); + } +} +/** + * File database, it can build a tree structured Json object from string. + * Each node in Json object represents a file or a directory. For a file, it has filename and type. + * For a directory, it has filename and children (a list of files or directories). + * The input will be a json object string, and the output is a list of `FileNode` with nested + * structure. + */ +@Injectable() +export class DynamicDataSource { + + dataChange: BehaviorSubject = new BehaviorSubject([]); + + get data(): DynamicFlatNode[] { return this.dataChange.value; } + set data(value: DynamicFlatNode[]) { + this.treeControl.dataNodes = value; + this.dataChange.next(value); + } + + constructor(private treeControl: FlatTreeControl, + private database: DynamicDatabase) {} + + connect(collectionViewer: CollectionViewer) { + const changes = [ + collectionViewer.viewChange, + this.treeControl.expansionModel.onChange!, + ]; + return merge(...changes).pipe(map((change) => { + if ((change as SelectionChange).added || + (change as SelectionChange).removed) { + this.handleTreeControl(change as SelectionChange); + } + return this.data; + })); + } + + /** Handle expand/collapse behaviors */ + handleTreeControl(change: SelectionChange) { + if (change.added) { + change.added.forEach((node) => this.toggleNode(node, true)); + } + if (change.removed) { + change.removed.forEach((node) => this.toggleNode(node, false)); + } + } + + /** + * Toggle the node, remove from display list + */ + toggleNode(node: DynamicFlatNode, expand: boolean) { + const children = this.database.getChildren(node.item); + const index = this.data.indexOf(node); + if (!children || index < 0) { // If no children, or cannot find the node, no op + return; + } + + if (expand) { + const nodes = children.map(name => + new DynamicFlatNode(name, node.level + 1, this.database.isExpandable(name))); + this.data.splice(index + 1, 0, ...nodes); + } else { + this.data.splice(index + 1, children.length); + } + + // notify the change + this.dataChange.next(this.data); + } +} diff --git a/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.html b/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.html new file mode 100644 index 00000000000..639e6bd5f8a --- /dev/null +++ b/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.html @@ -0,0 +1,14 @@ + + + {{node.item}} + + + + {{node.item}} + + diff --git a/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.scss b/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.scss new file mode 100644 index 00000000000..230e196353d --- /dev/null +++ b/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.scss @@ -0,0 +1,15 @@ +.demo-tree-container { + .tree-demo-invisible { + display: none; + } + + ul, li { + -webkit-margin-before: 0; + -webkit-margin-after: 0; + list-style-type: none; + } + + .mat-card { + margin: 16px; + } +} diff --git a/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.ts b/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.ts new file mode 100644 index 00000000000..f3ceaa01839 --- /dev/null +++ b/src/demo-app/tree/dynamic-tree-demo/dynamic-tree-demo.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component} from '@angular/core'; +import {FlatTreeControl} from '@angular/cdk/tree'; +import {DynamicDataSource, DynamicFlatNode, DynamicDatabase} from './dynamic-database'; + +@Component({ + moduleId: module.id, + selector: 'dynamic-tree-demo', + templateUrl: 'dynamic-tree-demo.html', + styleUrls: ['dynamic-tree-demo.css'], + providers: [DynamicDatabase] +}) +export class DynamicTreeDemo { + + constructor(database: DynamicDatabase) { + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + this.dataSource = new DynamicDataSource(this.treeControl, database); + + this.dataSource.data = database.initialData(); + } + + treeControl: FlatTreeControl; + + dataSource: DynamicDataSource; + + getLevel = (node: DynamicFlatNode) => { return node.level; }; + + isExpandable = (node: DynamicFlatNode) => { return node.expandable; }; + + hasChild = (_: number, _nodeData: DynamicFlatNode) => { return _nodeData.expandable; }; +} diff --git a/src/demo-app/tree/file-database.ts b/src/demo-app/tree/file-database.ts new file mode 100644 index 00000000000..2c39a2164f8 --- /dev/null +++ b/src/demo-app/tree/file-database.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +/** + * File node data with nested structure. + * Each node has a filename, and a type or a list of children. + */ +export class FileNode { + children: FileNode[]; + filename: string; + type: any; +} + +/** Flat node with expandable and level information */ +export class FileFlatNode { + filename: string; + type: any; + level: number; + expandable: boolean; +} + +/** + * The file structure tree data in string. The data could be parsed into a Json object + */ +const TREE_DATA = `{"Tina": + { + "Documents": { + "angular": { + "src": { + "core": "ts", + "compiler": "ts" + } + }, + "material2": { + "src": { + "button": "ts", + "checkbox": "ts", + "input": "ts" + } + } + }, + "Downloads": { + "Tutorial": "html", + "November": "pdf", + "October": "pdf" + }, + "Pictures": { + "Sun": "png", + "Woods": "jpg", + "Photo Booth Library": { + "Contents": "dir", + "Pictures": "dir" + } + }, + "Applications": { + "Chrome": "app", + "Calendar": "app", + "Webstorm": "app" + } +}}`; + +/** + * File database, it can build a tree structured Json object from string. + * Each node in Json object represents a file or a directory. For a file, it has filename and type. + * For a directory, it has filename and children (a list of files or directories). + * The input will be a json object string, and the output is a list of `FileNode` with nested + * structure. + */ +@Injectable() +export class FileDatabase { + dataChange: BehaviorSubject = new BehaviorSubject([]); + + get data(): FileNode[] { return this.dataChange.value; } + + constructor() { + this.initialize(); + } + + initialize() { + // Parse the string to json object. + const dataObject = JSON.parse(TREE_DATA); + + // Build the tree nodes from Json object. The result is a list of `FileNode` with nested + // file node as children. + const data = this.buildFileTree(dataObject, 0); + + // Notify the change. + this.dataChange.next(data); + } + + /** + * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object. + * The return value is the list of `FileNode`. + */ + buildFileTree(value: any, level: number) { + let data: any[] = []; + for (let k in value) { + let v = value[k]; + let node = new FileNode(); + node.filename = `${k}`; + if (v === null || v === undefined) { + // no action + } else if (typeof v === 'object') { + node.children = this.buildFileTree(v, level + 1); + } else { + node.type = v; + } + data.push(node); + } + return data; + } +} diff --git a/src/demo-app/tree/loadmore-tree-demo/loadmore-database.ts b/src/demo-app/tree/loadmore-tree-demo/loadmore-database.ts new file mode 100644 index 00000000000..39802fe9668 --- /dev/null +++ b/src/demo-app/tree/loadmore-tree-demo/loadmore-database.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +const LOAD_MORE = 'LOAD_MORE'; + +/** Nested node */ +export class LoadmoreNode { + childrenChange: BehaviorSubject = new BehaviorSubject([]); + + get children(): LoadmoreNode[] { + return this.childrenChange.value; + } + + constructor(public item: string, + public hasChildren: boolean = false, + public loadMoreParentItem: string | null = null) {} +} + +/** Flat node with expandable and level information */ +export class LoadmoreFlatNode { + constructor(public item: string, + public level: number = 1, + public expandable: boolean = false, + public loadMoreParentItem: string | null = null) {} +} + +/** + * A database that only load part of the data initially. After user clicks on the `Load more` + * button, more data will be loaded. + */ +@Injectable() +export class LoadmoreDatabase { + batchNumber = 5; + dataChange: BehaviorSubject = new BehaviorSubject([]); + nodeMap: Map = new Map(); + + /** The data */ + rootLevelNodes = ['Vegetables', 'Fruits']; + dataMap = new Map([ + ['Fruits', ['Apple', 'Orange', 'Banana']], + ['Vegetables', ['Tomato', 'Potato', 'Onion']], + ['Apple', ['Fuji', 'Macintosh']], + ['Onion', ['Yellow', 'White', 'Purple', 'Green', 'Shallot', 'Sweet', 'Red', 'Leek']], + ]); + + initialize() { + const data = this.rootLevelNodes.map(name => this._generateNode(name)); + this.dataChange.next(data); + } + + /** Expand a node whose children are not loaded */ + loadMore(item: string, onlyFirstTime: boolean = false) { + if (!this.nodeMap.has(item) || !this.dataMap.has(item)) { + return; + } + const parent = this.nodeMap.get(item)!; + const children = this.dataMap.get(item)!; + if (onlyFirstTime && parent.children!.length > 0) { + return; + } + const newChildrenNumber = parent.children!.length + this.batchNumber; + let nodes = children.slice(0, newChildrenNumber) + .map(name => this._generateNode(name)); + if (newChildrenNumber < children.length) { + // Need a new load more node + nodes.push(new LoadmoreNode(LOAD_MORE, false, item)); + } + + parent.childrenChange.next(nodes); + this.dataChange.next(this.dataChange.value); + } + + private _generateNode(item: string): LoadmoreNode { + if (this.nodeMap.has(item)) { + return this.nodeMap.get(item)!; + } + const result = new LoadmoreNode(item, this.dataMap.has(item)); + this.nodeMap.set(item, result); + return result; + } +} diff --git a/src/demo-app/tree/loadmore-tree-demo/loadmore-tree-demo.html b/src/demo-app/tree/loadmore-tree-demo/loadmore-tree-demo.html new file mode 100644 index 00000000000..943a17e221d --- /dev/null +++ b/src/demo-app/tree/loadmore-tree-demo/loadmore-tree-demo.html @@ -0,0 +1,25 @@ + + + + {{node.item}} + + + + + + {{node.item}} + + + + + + diff --git a/src/demo-app/tree/loadmore-tree-demo/loadmore-tree-demo.scss b/src/demo-app/tree/loadmore-tree-demo/loadmore-tree-demo.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/demo-app/tree/loadmore-tree-demo/loadmore-tree-demo.ts b/src/demo-app/tree/loadmore-tree-demo/loadmore-tree-demo.ts new file mode 100644 index 00000000000..35cf81da36b --- /dev/null +++ b/src/demo-app/tree/loadmore-tree-demo/loadmore-tree-demo.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component} from '@angular/core'; +import {FlatTreeControl} from '@angular/cdk/tree'; +import {MatTreeFlatDataSource, MatTreeFlattener} from '@angular/material/tree'; +import {LoadmoreNode, LoadmoreFlatNode, LoadmoreDatabase} from './loadmore-database'; + +const LOAD_MORE = 'LOAD_MORE'; + +/** + * When a node has a large number of children, only load part of the children, and display a + * `Load more...` button to manually request for more data in the tree. + */ +@Component({ + moduleId: module.id, + selector: 'loadmore-tree-demo', + templateUrl: 'loadmore-tree-demo.html', + styleUrls: ['loadmore-tree-demo.css'], + providers: [LoadmoreDatabase] +}) +export class LoadmoreTreeDemo { + + nodeMap: Map = new Map(); + + treeControl: FlatTreeControl; + + treeFlattener: MatTreeFlattener; + + // Flat tree data source + dataSource: MatTreeFlatDataSource; + + constructor(private database: LoadmoreDatabase) { + this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, + this.isExpandable, this.getChildren); + + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe(data => { + this.dataSource.data = data; + }); + + database.initialize(); + } + + getChildren = (node: LoadmoreNode) => { return node.childrenChange; }; + + transformer = (node: LoadmoreNode, level: number) => { + if (this.nodeMap.has(node.item)) { + return this.nodeMap.get(node.item)!; + } + let newNode = new LoadmoreFlatNode(node.item, level, node.hasChildren, node.loadMoreParentItem); + this.nodeMap.set(node.item, newNode); + return newNode; + } + + getLevel = (node: LoadmoreFlatNode) => { return node.level; }; + + isExpandable = (node: LoadmoreFlatNode) => { return node.expandable; }; + + hasChild = (_: number, _nodeData: LoadmoreFlatNode) => { return _nodeData.expandable; }; + + isLoadMore = (_: number, _nodeData: LoadmoreFlatNode) => { return _nodeData.item === LOAD_MORE; }; + + /** Load more nodes from data source */ + loadMore(item: string) { + this.database.loadMore(item); + } + + loadChildren(node: LoadmoreFlatNode) { + this.database.loadMore(node.item, true); + } +} diff --git a/src/demo-app/tree/tree-demo-module.ts b/src/demo-app/tree/tree-demo-module.ts new file mode 100644 index 00000000000..b6001c54c84 --- /dev/null +++ b/src/demo-app/tree/tree-demo-module.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {CdkTreeModule} from '@angular/cdk/tree'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import { + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTreeModule +} from '@angular/material'; +import {TreeDemo} from './tree-demo'; +import {ChecklistTreeDemo} from './checklist-tree-demo/checklist-tree-demo'; +import {DynamicTreeDemo} from './dynamic-tree-demo/dynamic-tree-demo'; +import {LoadmoreTreeDemo} from './loadmore-tree-demo/loadmore-tree-demo'; + +@NgModule({ + imports: [ + CdkTreeModule, + CommonModule, + FormsModule, + MatButtonModule, + MatCardModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTreeModule, + ], + declarations: [ + ChecklistTreeDemo, + TreeDemo, + DynamicTreeDemo, + LoadmoreTreeDemo + ], +}) +export class TreeDemoModule {} diff --git a/src/demo-app/tree/tree-demo.html b/src/demo-app/tree/tree-demo.html new file mode 100644 index 00000000000..0135e524354 --- /dev/null +++ b/src/demo-app/tree/tree-demo.html @@ -0,0 +1,69 @@ +
+ + Flattened tree + + + + {{node.filename}} : {{node.type}} + + + + + {{node.filename}} : {{node.type}} + + + + + + + + + Nested tree + + + +
  • +
    {{node.filename}}: {{node.type}}
    +
  • +
    + + +
  • +
    + + {{node.filename}} +
    +
      + +
    +
  • +
    +
    +
    + + + Todo list Flattened tree + + + + + Dynamic flat tree + + + + + Loadmore flat tree + + + +
    diff --git a/src/demo-app/tree/tree-demo.scss b/src/demo-app/tree/tree-demo.scss new file mode 100644 index 00000000000..48a147d1206 --- /dev/null +++ b/src/demo-app/tree/tree-demo.scss @@ -0,0 +1,25 @@ +.demo-tree-container { + .tree-demo-invisible { + display: none; + } + + ul, li { + -webkit-margin-before: 0; + -webkit-margin-after: 0; + list-style-type: none; + } + + .mat-card { + margin: 16px; + } +} + +.checklist-leaf-node { + padding-left: 40px; + padding-right: inherit; + + [dir='rtl'] & { + padding-right: 60px; + padding-left: inherit; + } +} diff --git a/src/demo-app/tree/tree-demo.ts b/src/demo-app/tree/tree-demo.ts new file mode 100644 index 00000000000..720e92862c1 --- /dev/null +++ b/src/demo-app/tree/tree-demo.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component} from '@angular/core'; +import {FlatTreeControl, NestedTreeControl} from '@angular/cdk/tree'; +import { + MatTreeFlattener, + MatTreeFlatDataSource, + MatTreeNestedDataSource +} from '@angular/material/tree'; +import {of as ofObservable} from 'rxjs/observable/of'; +import {Observable} from 'rxjs/Observable'; + +import {FileNode, FileFlatNode, FileDatabase} from './file-database'; + +@Component({ + moduleId: module.id, + selector: 'tree-demo', + templateUrl: 'tree-demo.html', + styleUrls: ['tree-demo.css'], + providers: [FileDatabase] +}) +export class TreeDemo { + // Flat tree control + treeControl: FlatTreeControl; + + // Nested tree control + nestedTreeControl: NestedTreeControl; + + treeFlattener: MatTreeFlattener; + + // Flat tree data source + dataSource: MatTreeFlatDataSource; + + // Nested tree data source + nestedDataSource: MatTreeNestedDataSource; + + constructor(database: FileDatabase) { + this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, + this.isExpandable, this.getChildren); + // For flat tree + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + + // For nested tree + this.nestedTreeControl = new NestedTreeControl(this.getChildren); + this.nestedDataSource = new MatTreeNestedDataSource(); + + database.dataChange.subscribe(data => { + this.dataSource.data = data; + this.nestedDataSource.data = data; + }); + } + + transformer = (node: FileNode, level: number) => { + let flatNode = new FileFlatNode(); + flatNode.filename = node.filename; + flatNode.type = node.type; + flatNode.level = level; + flatNode.expandable = !!node.children; + return flatNode; + } + + getLevel = (node: FileFlatNode) => { return node.level; }; + + isExpandable = (node: FileFlatNode) => { return node.expandable; }; + + getChildren = (node: FileNode): Observable => { return ofObservable(node.children); }; + + hasChild = (_: number, _nodeData: FileFlatNode) => { return _nodeData.expandable; }; + + hasNestedChild = (_: number, nodeData: FileNode) => {return !(nodeData.type); }; +} diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts index 33001993d55..0a2d1c25296 100644 --- a/src/lib/public-api.ts +++ b/src/lib/public-api.ts @@ -41,3 +41,4 @@ export * from '@angular/material/table'; export * from '@angular/material/tabs'; export * from '@angular/material/toolbar'; export * from '@angular/material/tooltip'; +export * from '@angular/material/tree'; diff --git a/src/lib/tree/_tree-theme.scss b/src/lib/tree/_tree-theme.scss new file mode 100644 index 00000000000..57679003ec6 --- /dev/null +++ b/src/lib/tree/_tree-theme.scss @@ -0,0 +1,27 @@ +@import '../core/theming/palette'; +@import '../core/theming/theming'; +@import '../core/typography/typography-utils'; + +@mixin mat-tree-theme($theme) { + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + + .mat-tree { + background: mat-color($background, 'card'); + } + + .mat-tree-node { + color: mat-color($foreground, text); + } +} + +@mixin mat-tree-typography($config) { + .mat-tree { + font-family: mat-font-family($config); + } + + .mat-tree-node { + font-weight: mat-font-weight($config, body-1); + font-size: mat-font-size($config, body-1); + } +} diff --git a/src/lib/tree/data-source/flat-data-source.ts b/src/lib/tree/data-source/flat-data-source.ts new file mode 100644 index 00000000000..2a5ee82b7e6 --- /dev/null +++ b/src/lib/tree/data-source/flat-data-source.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CollectionViewer, DataSource} from '@angular/cdk/collections'; +import {FlatTreeControl, TreeControl} from '@angular/cdk/tree'; +import {Observable} from 'rxjs/Observable'; +import {merge} from 'rxjs/observable/merge'; +import {map} from 'rxjs/operators/map'; +import {take} from 'rxjs/operators/take'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +/** + * Tree flattener to convert a normal type of node to node with children & level information. + * Transform nested nodes of type `T` to flattened nodes of type `F`. + * + * For example, the input data of type `T` is nested, and contains its children data: + * SomeNode: { + * key: 'Fruits', + * children: [ + * NodeOne: { + * key: 'Apple', + * }, + * NodeTwo: { + * key: 'Pear', + * } + * ] + * } + * After flattener flatten the tree, the structure will become + * SomeNode: { + * key: 'Fruits', + * expandable: true, + * level: 1 + * }, + * NodeOne: { + * key: 'Apple', + * expandable: false, + * level: 2 + * }, + * NodeTwo: { + * key: 'Pear', + * expandable: false, + * level: 2 + * } + * and the output flattened type is `F` with additional information. + */ +export class MatTreeFlattener { + + constructor(public transformFunction: (node: T, level: number) => F, + public getLevel: (node: F) => number, + public isExpandable: (node: F) => boolean, + public getChildren: (node: T) => Observable) {} + + _flattenNode(node: T, level: number, + resultNodes: F[], parentMap: boolean[]): F[] { + const flatNode = this.transformFunction(node, level); + resultNodes.push(flatNode); + + if (this.isExpandable(flatNode)) { + this.getChildren(node).pipe(take(1)).subscribe(children => { + children.forEach((child, index) => { + let childParentMap: boolean[] = parentMap.slice(); + childParentMap.push(index != children.length - 1); + this._flattenNode(child, level + 1, resultNodes, childParentMap); + }); + }); + } + return resultNodes; + } + + /** + * Flatten a list of node type T to flattened version of node F. + * Please note that type T may be nested, and the length of `structuredData` may be different + * from that of returned list `F[]`. + */ + flattenNodes(structuredData: T[]): F[] { + let resultNodes: F[] = []; + structuredData.forEach(node => this._flattenNode(node, 0, resultNodes, [])); + return resultNodes; + } + + /** + * Expand flattened node with current expansion status. + * The returned list may have different length. + */ + expandFlattenedNodes(nodes: F[], treeControl: TreeControl): F[] { + let results: F[] = []; + let currentExpand: boolean[] = []; + currentExpand[0] = true; + + nodes.forEach((node) => { + let expand = true; + for (let i = 0; i <= this.getLevel(node); i++) { + expand = expand && currentExpand[i]; + } + if (expand) { + results.push(node); + } + if (this.isExpandable(node)) { + currentExpand[this.getLevel(node) + 1] = treeControl.isExpanded(node); + } + }); + return results; + } +} + + +/** + * Data source for flat tree. + * The data source need to handle expansion/collapsion of the tree node and change the data feed + * to `MatTree`. + * The nested tree nodes of type `T` are flattened through `MatTreeFlattener`, and converted + * to type `F` for `MatTree` to consume. + */ +export class MatTreeFlatDataSource extends DataSource { + _flattenedData = new BehaviorSubject([]); + + _expandedData = new BehaviorSubject([]); + + _data: BehaviorSubject; + get data() { return this._data.value; } + set data(value: T[]) { + this._data.next(value); + this._flattenedData.next(this.treeFlattener.flattenNodes(this.data)); + this.treeControl.dataNodes = this._flattenedData.value; + } + + constructor(private treeControl: FlatTreeControl, + private treeFlattener: MatTreeFlattener, + initialData: T[] = []) { + super(); + this._data = new BehaviorSubject(initialData); + } + + connect(collectionViewer: CollectionViewer): Observable { + const changes = [ + collectionViewer.viewChange, + this.treeControl.expansionModel.onChange!, + this._flattenedData + ]; + return merge(...changes).pipe(map(() => { + this._expandedData.next( + this.treeFlattener.expandFlattenedNodes(this._flattenedData.value, this.treeControl)); + return this._expandedData.value; + })); + } + + disconnect() { + // no op + } +} + diff --git a/src/lib/tree/data-source/nested-data-source.ts b/src/lib/tree/data-source/nested-data-source.ts new file mode 100644 index 00000000000..bd695a65850 --- /dev/null +++ b/src/lib/tree/data-source/nested-data-source.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CollectionViewer, DataSource} from '@angular/cdk/collections'; +import {Observable} from 'rxjs/Observable'; +import {merge} from 'rxjs/observable/merge'; +import {map} from 'rxjs/operators/map'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +/** + * Data source for nested tree. + * + * The data source for nested tree doesn't have to consider node flattener, or the way to expand + * or collapse. The expansion/collapsion will be handled by TreeControl and each non-leaf node. + */ +export class MatTreeNestedDataSource extends DataSource { + _data = new BehaviorSubject([]); + + /** + * Data for the nested treee + */ + get data() { return this._data.value; } + set data(value: T[]) { this._data.next(value); } + + connect(collectionViewer: CollectionViewer): Observable { + return merge(...[collectionViewer.viewChange, this._data]) + .pipe(map(() => { + return this.data; + })); + } + + disconnect() { + // no op + } +} + diff --git a/src/lib/tree/index.ts b/src/lib/tree/index.ts new file mode 100644 index 00000000000..676ca90f1ff --- /dev/null +++ b/src/lib/tree/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/lib/tree/node.ts b/src/lib/tree/node.ts new file mode 100644 index 00000000000..03448e7a93e --- /dev/null +++ b/src/lib/tree/node.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Attribute, + ContentChildren, + Directive, + ElementRef, + Input, + QueryList +} from '@angular/core'; +import { + CdkNestedTreeNode, + CdkTree, + CdkTreeNodeDef, + CdkTreeNode, +} from '@angular/cdk/tree'; +import {MatTreeNodeOutlet} from './outlet'; +import {mixinTabIndex, mixinDisabled, CanDisable, HasTabIndex} from '@angular/material/core'; + + +export const _MatTreeNodeMixinBase = mixinTabIndex(mixinDisabled(CdkTreeNode)); +export const _MatNestedTreeNodeMixinBase = mixinTabIndex(mixinDisabled(CdkNestedTreeNode)); + +/** + * Wrapper for the CdkTree node with Material design styles. + */ +@Directive({ + selector: 'mat-tree-node', + exportAs: 'matTreeNode', + inputs: ['disabled', 'tabIndex'], + host: { + '[attr.aria-expanded]': 'isExpanded', + '[attr.aria-level]': 'level', + '[attr.role]': 'role', + 'class': 'mat-tree-node' + }, + providers: [{provide: CdkTreeNode, useExisting: MatTreeNode}] +}) +export class MatTreeNode extends _MatTreeNodeMixinBase implements HasTabIndex, CanDisable { + @Input() role: 'treeitem' | 'group' = 'treeitem'; + + constructor(protected _elementRef: ElementRef, + protected _tree: CdkTree, + @Attribute('tabindex') tabIndex: string) { + super(_elementRef, _tree); + + this.tabIndex = Number(tabIndex) || 0; + } +} + +/** + * Wrapper for the CdkTree node definition with Material design styles. + */ +@Directive({ + selector: '[matTreeNodeDef]', + inputs: [ + 'when: matTreeNodeDefWhen' + ], + providers: [{provide: CdkTreeNodeDef, useExisting: MatTreeNodeDef}] +}) +export class MatTreeNodeDef extends CdkTreeNodeDef { + @Input('matTreeNode') data: T; +} + +/** + * Wrapper for the CdkTree nested node with Material design styles. + */ +@Directive({ + selector: 'mat-nested-tree-node', + exportAs: 'matNestedTreeNode', + host: { + '[attr.aria-expanded]': 'isExpanded', + '[attr.role]': 'role', + 'class': 'mat-nested-tree-node', + }, + inputs: ['disabled', 'tabIndex'], + providers: [ + {provide: CdkNestedTreeNode, useExisting: MatNestedTreeNode}, + {provide: CdkTreeNode, useExisting: MatNestedTreeNode} + ] +}) +export class MatNestedTreeNode extends _MatNestedTreeNodeMixinBase + implements HasTabIndex, CanDisable { + + @Input('matNestedTreeNode') node: T; + + @ContentChildren(MatTreeNodeOutlet) nodeOutlet: QueryList; + + constructor(protected _elementRef: ElementRef, + protected _tree: CdkTree, + @Attribute('tabindex') tabIndex: string) { + super(_elementRef, _tree); + + this.tabIndex = Number(tabIndex) || 0; + } +} diff --git a/src/lib/tree/outlet.ts b/src/lib/tree/outlet.ts new file mode 100644 index 00000000000..6d01a6bf65e --- /dev/null +++ b/src/lib/tree/outlet.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {CdkTreeNodeOutlet} from '@angular/cdk/tree'; +import { + Directive, + ViewContainerRef, +} from '@angular/core'; + +/** + * Outlet for nested CdkNode. Put `[matTreeNodeOutlet]` on a tag to place children dataNodes + * inside the outlet. + */ +@Directive({ + selector: '[matTreeNodeOutlet]' +}) +export class MatTreeNodeOutlet implements CdkTreeNodeOutlet { + constructor(public viewContainer: ViewContainerRef) {} +} diff --git a/src/lib/tree/padding.ts b/src/lib/tree/padding.ts new file mode 100644 index 00000000000..5738c82b4ee --- /dev/null +++ b/src/lib/tree/padding.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {CdkTreeNodePadding} from '@angular/cdk/tree'; +import {Directive, Input} from '@angular/core'; + + +/** + * Wrapper for the CdkTree padding with Material design styles. + */ +@Directive({ + selector: '[matTreeNodePadding]', + providers: [{provide: CdkTreeNodePadding, useExisting: MatTreeNodePadding}] +}) +export class MatTreeNodePadding extends CdkTreeNodePadding { + + /** The level of depth of the tree node. The padding will be `level * indent` pixels. */ + @Input('matTreeNodePadding') level: number; + + /** The indent for each level. Default number 40px from material design menu sub-menu spec. */ + @Input('matTreeNodePaddingIndent') indent: number; +} diff --git a/src/lib/tree/public-api.ts b/src/lib/tree/public-api.ts new file mode 100644 index 00000000000..85fbba35eb1 --- /dev/null +++ b/src/lib/tree/public-api.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + + +export * from './node'; +export * from './padding'; +export * from './tree'; +export * from './tree-module'; +export * from './toggle'; +export * from './data-source/flat-data-source'; +export * from './data-source/nested-data-source'; diff --git a/src/lib/tree/toggle.ts b/src/lib/tree/toggle.ts new file mode 100644 index 00000000000..4983bc447e6 --- /dev/null +++ b/src/lib/tree/toggle.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, Input} from '@angular/core'; +import {CdkTreeNodeToggle} from '@angular/cdk/tree'; + +/** + * Wrapper for the CdkTree's toggle with Material design styles. + */ +@Directive({ + selector: '[matTreeNodeToggle]', + host: { + '(click)': '_toggle($event)', + }, + providers: [{provide: CdkTreeNodeToggle, useExisting: MatTreeNodeToggle}] +}) +export class MatTreeNodeToggle extends CdkTreeNodeToggle { + @Input('matTreeNodeToggleRecursive') recursive: boolean = true; +} diff --git a/src/lib/tree/tree-module.ts b/src/lib/tree/tree-module.ts new file mode 100644 index 00000000000..53d23c0fa38 --- /dev/null +++ b/src/lib/tree/tree-module.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; + +import {CdkTreeModule} from '@angular/cdk/tree'; +import {CommonModule} from '@angular/common'; +import {MatCommonModule} from '@angular/material/core'; +import {MatNestedTreeNode, MatTreeNodeDef, MatTreeNode} from './node'; +import {MatTree} from './tree'; +import {MatTreeNodeToggle} from './toggle'; +import {MatTreeNodeOutlet} from './outlet'; +import {MatTreeNodePadding} from './padding'; + +const MAT_TREE_DIRECTIVES = [ + MatNestedTreeNode, + MatTreeNodeDef, + MatTreeNodePadding, + MatTreeNodeToggle, + MatTree, + MatTreeNode, + MatTreeNodeOutlet +]; + +@NgModule({ + imports: [CdkTreeModule, CommonModule, MatCommonModule], + exports: MAT_TREE_DIRECTIVES, + declarations: MAT_TREE_DIRECTIVES, +}) +export class MatTreeModule {} diff --git a/src/lib/tree/tree.md b/src/lib/tree/tree.md new file mode 100644 index 00000000000..e5e6b3e28a6 --- /dev/null +++ b/src/lib/tree/tree.md @@ -0,0 +1,95 @@ +The `mat-tree` provides a Material Design styled tree that can be used to display hierarchy +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 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. + + +``` + + parent node + -- child node1 + -- child node2 + +``` + + + +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 has an +outlet to keep all the children nodes. + +``` + + + parent node + -- child node1 + -- child node2 + + +``` + + + +Nested trees are easier to work with when hierarchical relationships are visually +represented in ways that would be difficult to accomplish with flat nodes. + + + +### Features + +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. + +### TreeControl + +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. For flattened tree node, `getLevel` and +`isExpandable` functions need to pass to the `FlatTreeControl` to make it work recursively. + +### Toggle + +A `matTreeNodeToggle` 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 a +tree node recursively by setting `[matTreeNodeToggleRecursive]` to `true`. + +The toggle can be placed anywhere in the tree node, and is only toggled by `click` action. + + +### Padding (Flat tree only) + +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. + + +### 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"` + +`mat-tree` does not manage any focus/keyboard interaction on its own. Users can add desired +focus/keyboard interactions in their application. diff --git a/src/lib/tree/tree.scss b/src/lib/tree/tree.scss new file mode 100644 index 00000000000..fc3d1231bd3 --- /dev/null +++ b/src/lib/tree/tree.scss @@ -0,0 +1,20 @@ +$mat-node-height: 48px; +$mat-node-horizontal-padding: 24px; + +.mat-tree { + display: block; +} + +.mat-tree-node { + display: flex; + align-items: center; + min-height: $mat-node-height; + padding: 0 $mat-node-horizontal-padding; + flex: 1; + overflow: hidden; + word-wrap: break-word; +} + +.mat-nested-tree-ndoe { + border-bottom-width: 0; +} diff --git a/src/lib/tree/tree.spec.ts b/src/lib/tree/tree.spec.ts new file mode 100644 index 00000000000..5cb8a8a6653 --- /dev/null +++ b/src/lib/tree/tree.spec.ts @@ -0,0 +1,800 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {TreeControl, FlatTreeControl, NestedTreeControl} from '@angular/cdk/tree'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component, ViewChild} from '@angular/core'; +import { + MatTree, + MatTreeModule, + MatTreeFlattener, + MatTreeFlatDataSource, + MatTreeNestedDataSource +} from './index'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; +import {Observable} from 'rxjs/Observable'; + + +describe('MatTree', () => { + /** Represents an indent for expectNestedTreeToMatch */ + const _ = {}; + + let treeElement: HTMLElement; + let tree: MatTree; + let underlyingDataSource: FakeDataSource; + + function configureMatTreeTestingModule(declarations) { + TestBed.configureTestingModule({ + imports: [MatTreeModule], + declarations: declarations, + }).compileComponents(); + } + + describe('flat tree', () => { + let dataSource: MatTreeFlatDataSource; + + describe('should initialize', () => { + let fixture: ComponentFixture; + let component: SimpleMatTreeApp; + + + beforeEach(() => { + configureMatTreeTestingModule([SimpleMatTreeApp]); + fixture = TestBed.createComponent(SimpleMatTreeApp); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + tree = component.tree; + dataSource = component.dataSource; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).toBeDefined('Expect nodes to be defined'); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + + it('with the right accessibility roles', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + + getNodes(treeElement).forEach(node => { + expect(node.getAttribute('role')).toBe('treeitem'); + }); + }); + + it('with the right data', () => { + expect(underlyingDataSource.data.length).toBe(3); + + let data = underlyingDataSource.data; + expectFlatTreeToMatch(treeElement, 28, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + + underlyingDataSource.addChild(data[2]); + fixture.detectChanges(); + + expectFlatTreeToMatch(treeElement, 28, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [`_, topping_4 - cheese_4 + base_4`]); + }); + }); + + describe('with toggle', () => { + let fixture: ComponentFixture; + let component: MatTreeAppWithToggle; + + beforeEach(() => { + configureMatTreeTestingModule([MatTreeAppWithToggle]); + fixture = TestBed.createComponent(MatTreeAppWithToggle); + + component = fixture.componentInstance; + tree = component.tree; + underlyingDataSource = component.underlyingDataSource; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('should expand/collapse the node', () => { + expect(underlyingDataSource.data.length).toBe(3); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect no expanded node`); + + component.toggleRecursively = false; + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[2]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expectFlatTreeToMatch(treeElement, 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(1, `Expect node expanded one level`); + expectFlatTreeToMatch(treeElement, 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [_, `topping_4 - cheese_4 + base_4`]); + + (getNodes(treeElement)[3] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(2, `Expect node expanded`); + expectFlatTreeToMatch(treeElement, 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`]); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expectFlatTreeToMatch(treeElement, 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + }); + + it('should expand/collapse the node recursively', () => { + expect(underlyingDataSource.data.length).toBe(3); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect no expanded node`); + + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[2]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expectFlatTreeToMatch(treeElement, 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(3, `Expect nodes expanded`); + expectFlatTreeToMatch(treeElement, 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`]); + + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect node collapsed`); + + expectFlatTreeToMatch(treeElement, 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + }); + }); + + describe('with when node template', () => { + let fixture: ComponentFixture; + let component: WhenNodeMatTreeApp; + + beforeEach(() => { + configureMatTreeTestingModule([WhenNodeMatTreeApp]); + fixture = TestBed.createComponent(WhenNodeMatTreeApp); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expectFlatTreeToMatch(treeElement, 28, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [`>>> topping_4 - cheese_4 + base_4`]); + }); + }); + }); + + describe('nested tree', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + let component: NestedMatTreeApp; + + beforeEach(() => { + configureMatTreeTestingModule([NestedMatTreeApp]); + fixture = TestBed.createComponent(NestedMatTreeApp); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).toBeDefined('Expect nodes to be defined'); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + + it('with the right accessibility roles', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + + getNodes(treeElement).forEach(node => { + expect(node.getAttribute('role')).toBe('treeitem'); + }); + }); + + it('with the right data', () => { + expect(underlyingDataSource.data.length).toBe(3); + + let data = underlyingDataSource.data; + expectNestedTreeToMatch(treeElement, + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`]); + + underlyingDataSource.addChild(data[1]); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('mat-tree'); + data = underlyingDataSource.data; + expect(data.length).toBe(3); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`]); + }); + + it('with nested child data', () => { + expect(underlyingDataSource.data.length).toBe(3); + + let data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expect(data.length).toBe(3); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [`topping_3 - cheese_3 + base_3`]); + + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expect(data.length).toBe(3); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [_, _, `topping_6 - cheese_6 + base_6`], + [`topping_3 - cheese_3 + base_3`]); + }); + }); + + describe('with when node', () => { + let fixture: ComponentFixture; + let component: WhenNodeNestedMatTreeApp; + + beforeEach(() => { + configureMatTreeTestingModule([WhenNodeNestedMatTreeApp]); + fixture = TestBed.createComponent(WhenNodeNestedMatTreeApp); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [`>>> topping_4 - cheese_4 + base_4`]); + }); + }); + + describe('with toggle', () => { + let fixture: ComponentFixture; + let component: NestedMatTreeAppWithToggle; + + beforeEach(() => { + configureMatTreeTestingModule([NestedMatTreeAppWithToggle]); + fixture = TestBed.createComponent(NestedMatTreeAppWithToggle); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('should expand/collapse the node', () => { + component.toggleRecursively = false; + let data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1]); + underlyingDataSource.addChild(child); + + fixture.detectChanges(); + + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + + fixture.detectChanges(); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(1, `Expect node expanded`); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`]); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect node collapsed`); + }); + + it('should expand/collapse the node recursively', () => { + let data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(3, `Expect node expanded`); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [`topping_3 - cheese_3 + base_3`]); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .toBe(0, `Expect node collapsed`); + expectNestedTreeToMatch(treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`]); + }); + }); + }); +}); + +export class TestData { + pizzaTopping: string; + pizzaCheese: string; + pizzaBase: string; + level: number; + children: TestData[]; + observableChildren: BehaviorSubject; + isSpecial: boolean; + + constructor(pizzaTopping: string, pizzaCheese: string, pizzaBase: string, + children: TestData[] = [], isSpecial: boolean = false) { + this.pizzaTopping = pizzaTopping; + this.pizzaCheese = pizzaCheese; + this.pizzaBase = pizzaBase; + this.isSpecial = isSpecial; + this.children = children; + this.observableChildren = new BehaviorSubject(this.children); + } +} + +class FakeDataSource { + dataIndex = 0; + _dataChange = new BehaviorSubject([]); + set data(data: TestData[]) { this._dataChange.next(data); } + get data() { return this._dataChange.getValue(); } + + connect(): Observable { + return this._dataChange; + } + + disconnect() {} + + constructor() { + for (let i = 0; i < 3; i++) { + this.addData(); + } + } + + addChild(parent: TestData, isSpecial: boolean = false) { + const nextIndex = ++this.dataIndex; + const child = new TestData(`topping_${nextIndex}`, `cheese_${nextIndex}`, `base_${nextIndex}`); + + const index = this.data.indexOf(parent); + if (index > -1) { + parent = new TestData( + parent.pizzaTopping, parent.pizzaCheese, parent.pizzaBase, parent.children, isSpecial); + } + parent.children.push(child); + parent.observableChildren.next(parent.children); + + let copiedData = this.data.slice(); + if (index > -1) { + copiedData.splice(index, 1, parent); + } + this.data = copiedData; + return child; + } + + addData(isSpecial: boolean = false) { + const nextIndex = ++this.dataIndex; + let copiedData = this.data.slice(); + copiedData.push(new TestData( + `topping_${nextIndex}`, `cheese_${nextIndex}`, `base_${nextIndex}`, [], isSpecial)); + + this.data = copiedData; + } +} + +function getNodes(treeElement: Element): Element[] { + return [].slice.call(treeElement.querySelectorAll('.mat-tree-node, .mat-nested-tree-node'))!; +} + +function expectFlatTreeToMatch(treeElement: Element, expectedPaddingIndent: number = 28, + ...expectedTree: any[]) { + const missedExpectations: string[] = []; + + function checkNode(node: Element, expectedNode: any[]) { + const actualTextContent = node.textContent!.trim(); + const expectedTextContent = expectedNode[expectedNode.length - 1]; + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`); + } + } + + function checkLevel(node: Element, expectedNode: any[]) { + + const actualLevel = (node as HTMLElement).style.paddingLeft; + if (expectedNode.length === 1) { + if (actualLevel !== ``) { + missedExpectations.push( + `Expected node level to be 0 but was ${actualLevel}`); + } + } else { + const expectedLevel = `${(expectedNode.length - 1) * expectedPaddingIndent}px`; + if (actualLevel != expectedLevel) { + missedExpectations.push( + `Expected node level to be ${expectedLevel} but was ${actualLevel}`); + } + } + } + + getNodes(treeElement).forEach((node, index) => { + const expected = expectedTree ? + expectedTree[index] : + null; + + checkLevel(node, expected); + checkNode(node, expected); + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} + +function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { + const missedExpectations: string[] = []; + function checkNodeContent(node: Element, expectedNode: any[]) { + const expectedTextContent = expectedNode[expectedNode.length - 1]; + const actualTextContent = node.childNodes.item(0).textContent!.trim(); + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`); + } + } + + function checkNodeDescendants(node: Element, expectedNode: any[], currentIndex: number) { + let expectedDescendant = 0; + + for (let i = currentIndex + 1; i < expectedTree.length; ++i) { + if (expectedTree[i].length > expectedNode.length) { + ++expectedDescendant; + } else if (expectedTree[i].length === expectedNode.length) { + break; + } + } + + const actualDescendant = getNodes(node).length; + if (actualDescendant !== expectedDescendant) { + missedExpectations.push( + `Expected node descendant num to be ${expectedDescendant} but was ${actualDescendant}`); + } + } + + getNodes(treeElement).forEach((node, index) => { + + const expected = expectedTree ? + expectedTree[index] : + null; + + checkNodeDescendants(node, expected, index); + checkNodeContent(node, expected); + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + ` +}) +class SimpleMatTreeApp { + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + getChildren = (node: TestData) => node.observableChildren; + transformer = (node: TestData, level: number) => { + node.level = level; + return node; + } + + treeFlattener = new MatTreeFlattener( + this.transformer, this.getLevel, this.isExpandable, this.getChildren); + + treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + ` +}) +class NestedMatTreeApp { + getChildren = (node: TestData) => node.observableChildren; + + treeControl = new NestedTreeControl(this.getChildren); + + dataSource = new MatTreeNestedDataSource(); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + >>> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} +
    + +
    +
    +
    + ` +}) +class WhenNodeNestedMatTreeApp { + isSpecial = (_: number, node: TestData) => node.isSpecial; + + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource = new MatTreeNestedDataSource(); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + ` +}) +class MatTreeAppWithToggle { + toggleRecursively: boolean = true; + + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + getChildren = (node: TestData) => node.observableChildren; + transformer = (node: TestData, level: number) => { + node.level = level; + return node; + } + + treeFlattener = new MatTreeFlattener( + this.transformer, this.getLevel, this.isExpandable, this.getChildren); + + treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} +
    + +
    +
    +
    + ` +}) +class NestedMatTreeAppWithToggle { + toggleRecursively: boolean = true; + + getChildren = (node: TestData) => node.observableChildren; + + treeControl = new NestedTreeControl(this.getChildren); + dataSource = new MatTreeNestedDataSource(); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + >>> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + ` +}) +class WhenNodeMatTreeApp { + isSpecial = (_: number, node: TestData) => node.isSpecial; + + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + getChildren = (node: TestData) => node.observableChildren; + transformer = (node: TestData, level: number) => { + node.level = level; + return node; + } + + treeFlattener = new MatTreeFlattener( + this.transformer, this.getLevel, this.isExpandable, this.getChildren); + + treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} diff --git a/src/lib/tree/tree.ts b/src/lib/tree/tree.ts new file mode 100644 index 00000000000..34b81695c36 --- /dev/null +++ b/src/lib/tree/tree.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ChangeDetectionStrategy, Component, ViewChild, ViewEncapsulation} from '@angular/core'; +import {CdkTree} from '@angular/cdk/tree'; +import {MatTreeNodeOutlet} from './outlet'; + +/** + * Wrapper for the CdkTable with Material design styles. + */ +@Component({ + moduleId: module.id, + selector: 'mat-tree', + exportAs: 'matTree', + template: ``, + host: { + 'class': 'mat-tree', + 'role': 'tree', + }, + styleUrls: ['tree.css'], + encapsulation: ViewEncapsulation.None, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{provide: CdkTree, useExisting: MatTree}] +}) +export class MatTree extends CdkTree { + // Outlets within the tree's template where the dataNodes will be inserted. + @ViewChild(MatTreeNodeOutlet) _nodeOutlet: MatTreeNodeOutlet; +} + diff --git a/src/lib/tree/tsconfig-build.json b/src/lib/tree/tsconfig-build.json new file mode 100644 index 00000000000..6938790ee3a --- /dev/null +++ b/src/lib/tree/tsconfig-build.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public-api.ts", + "../typings.d.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/material/tree", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} diff --git a/src/material-examples/material-module.ts b/src/material-examples/material-module.ts index abdd7c97891..099754511db 100644 --- a/src/material-examples/material-module.ts +++ b/src/material-examples/material-module.ts @@ -1,6 +1,7 @@ import {NgModule} from '@angular/core'; import {CdkTableModule} from '@angular/cdk/table'; +import {CdkTreeModule} from '@angular/cdk/tree'; import { MatAutocompleteModule, MatButtonModule, MatButtonToggleModule, MatPaginatorModule, MatCardModule, MatCheckboxModule, MatChipsModule, MatDatepickerModule, @@ -8,12 +9,13 @@ import { MatListModule, MatMenuModule, MatProgressBarModule, MatProgressSpinnerModule, MatRadioModule, MatSelectModule, MatSidenavModule, MatSliderModule, MatSortModule, MatSlideToggleModule, MatSnackBarModule, MatTableModule, MatTabsModule, MatToolbarModule, - MatTooltipModule, MatFormFieldModule, MatExpansionModule, MatStepperModule + MatTooltipModule, MatFormFieldModule, MatExpansionModule, MatStepperModule, MatTreeModule } from '@angular/material'; @NgModule({ exports: [ CdkTableModule, + CdkTreeModule, MatAutocompleteModule, MatButtonModule, MatButtonToggleModule, @@ -45,6 +47,7 @@ import { MatTabsModule, MatToolbarModule, MatTooltipModule, + MatTreeModule ] }) export class ExampleMaterialModule {} diff --git a/src/material-examples/tree-flat-overview/tree-flat-overview-example.css b/src/material-examples/tree-flat-overview/tree-flat-overview-example.css new file mode 100644 index 00000000000..138500bdf17 --- /dev/null +++ b/src/material-examples/tree-flat-overview/tree-flat-overview-example.css @@ -0,0 +1,6 @@ +.example-tree ul, +.example-tree li { + -webkit-margin-before: 0; + -webkit-margin-after: 0; + list-style-type: none; +} diff --git a/src/material-examples/tree-flat-overview/tree-flat-overview-example.html b/src/material-examples/tree-flat-overview/tree-flat-overview-example.html new file mode 100644 index 00000000000..0a24659edaa --- /dev/null +++ b/src/material-examples/tree-flat-overview/tree-flat-overview-example.html @@ -0,0 +1,15 @@ + + + {{node.filename}} : {{node.type}} + + + + + {{node.filename}} : {{node.type}} + + diff --git a/src/material-examples/tree-flat-overview/tree-flat-overview-example.ts b/src/material-examples/tree-flat-overview/tree-flat-overview-example.ts new file mode 100644 index 00000000000..86f86a8b274 --- /dev/null +++ b/src/material-examples/tree-flat-overview/tree-flat-overview-example.ts @@ -0,0 +1,162 @@ +import {Component, Injectable} from '@angular/core'; +import {FlatTreeControl} from '@angular/cdk/tree'; +import {MatTreeFlattener, MatTreeFlatDataSource} from '@angular/material/tree'; +import {of} from 'rxjs/observable/of'; +import {Observable} from 'rxjs/Observable'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +/** + * File node data with nested structure. + * Each node has a filename, and a type or a list of children. + */ +export class FileNode { + children: FileNode[]; + filename: string; + type: any; +} + +/** Flat node with expandable and level information */ +export class FileFlatNode { + filename: string; + type: any; + level: number; + expandable: boolean; +} + +/** + * The file structure tree data in string. The data could be parsed into a Json object + */ +const TREE_DATA = `{"Tina": + { + "Documents": { + "angular": { + "src": { + "core": "ts", + "compiler": "ts" + } + }, + "material2": { + "src": { + "button": "ts", + "checkbox": "ts", + "input": "ts" + } + } + }, + "Downloads": { + "Tutorial": "html", + "November": "pdf", + "October": "pdf" + }, + "Pictures": { + "Sun": "png", + "Woods": "jpg", + "Photo Booth Library": { + "Contents": "dir", + "Pictures": "dir" + } + }, + "Applications": { + "Chrome": "app", + "Calendar": "app", + "Webstorm": "app" + } +}}`; + +/** + * File database, it can build a tree structured Json object from string. + * Each node in Json object represents a file or a directory. For a file, it has filename and type. + * For a directory, it has filename and children (a list of files or directories). + * The input will be a json object string, and the output is a list of `FileNode` with nested + * structure. + */ +@Injectable() +export class FileDatabase { + dataChange: BehaviorSubject = new BehaviorSubject([]); + + get data(): FileNode[] { return this.dataChange.value; } + + constructor() { + this.initialize(); + } + + initialize() { + // Parse the string to json object. + const dataObject = JSON.parse(TREE_DATA); + + // Build the tree nodes from Json object. The result is a list of `FileNode` with nested + // file node as children. + const data = this.buildFileTree(dataObject, 0); + + // Notify the change. + this.dataChange.next(data); + } + + /** + * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object. + * The return value is the list of `FileNode`. + */ + buildFileTree(value: any, level: number) { + let data: any[] = []; + for (let k in value) { + let v = value[k]; + let node = new FileNode(); + node.filename = `${k}`; + if (v === null || v === undefined) { + // no action + } else if (typeof v === 'object') { + node.children = this.buildFileTree(v, level + 1); + } else { + node.type = v; + } + data.push(node); + } + return data; + } +} + +/** + * @title Tree with flat nodes + */ +@Component({ + selector: 'tree-flat-overview-example', + templateUrl: 'tree-flat-overview-example.html', + styleUrls: ['tree-flat-overview-example.css'], + providers: [FileDatabase] +}) +export class TreeFlatOverviewExample { + + treeControl: FlatTreeControl; + + treeFlattener: MatTreeFlattener; + + dataSource: MatTreeFlatDataSource; + + constructor(database: FileDatabase) { + this.treeFlattener = new MatTreeFlattener(this.transformer, this._getLevel, + this._isExpandable, this._getChildren); + this.treeControl = new FlatTreeControl(this._getLevel, this._isExpandable); + this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + + database.dataChange.subscribe(data => { + this.dataSource.data = data; + }); + } + + transformer = (node: FileNode, level: number) => { + let flatNode = new FileFlatNode(); + flatNode.filename = node.filename; + flatNode.type = node.type; + flatNode.level = level; + flatNode.expandable = !!node.children; + return flatNode; + } + + private _getLevel = (node: FileFlatNode) => { return node.level; }; + + private _isExpandable = (node: FileFlatNode) => { return node.expandable; }; + + private _getChildren = (node: FileNode): Observable => { return of(node.children); }; + + hasChild = (_: number, _nodeData: FileFlatNode) => { return _nodeData.expandable; }; +} diff --git a/src/material-examples/tree-nested-overview/tree-nested-overview-example.css b/src/material-examples/tree-nested-overview/tree-nested-overview-example.css new file mode 100644 index 00000000000..1bcbfb831ce --- /dev/null +++ b/src/material-examples/tree-nested-overview/tree-nested-overview-example.css @@ -0,0 +1,10 @@ +.example-tree-invisible { + display: none; +} + +.example-tree ul, +.example-tree li { + -webkit-margin-before: 0; + -webkit-margin-after: 0; + list-style-type: none; +} diff --git a/src/material-examples/tree-nested-overview/tree-nested-overview-example.html b/src/material-examples/tree-nested-overview/tree-nested-overview-example.html new file mode 100644 index 00000000000..efe21202aba --- /dev/null +++ b/src/material-examples/tree-nested-overview/tree-nested-overview-example.html @@ -0,0 +1,24 @@ + + +
  • +
    {{node.filename}}: {{node.type}}
    +
  • +
    + + +
  • +
    + + {{node.filename}} +
    +
      + +
    +
  • +
    +
    diff --git a/src/material-examples/tree-nested-overview/tree-nested-overview-example.ts b/src/material-examples/tree-nested-overview/tree-nested-overview-example.ts new file mode 100644 index 00000000000..6a80f1d4c72 --- /dev/null +++ b/src/material-examples/tree-nested-overview/tree-nested-overview-example.ts @@ -0,0 +1,132 @@ +import {Component, Injectable} from '@angular/core'; +import {NestedTreeControl} from '@angular/cdk/tree'; +import {MatTreeNestedDataSource} from '@angular/material/tree'; +import {of} from 'rxjs/observable/of'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; + +/** + * Json node data with nested structure. Each node has a filename and a value or a list of children + */ +export class FileNode { + children: FileNode[]; + filename: string; + type: any; +} + +/** + * The Json tree data in string. The data could be parsed into Json object + */ +const TREE_DATA = `{"Tina": + { + "Documents": { + "angular": { + "src": { + "core": "ts", + "compiler": "ts" + } + }, + "material2": { + "src": { + "button": "ts", + "checkbox": "ts", + "input": "ts" + } + } + }, + "Downloads": { + "Tutorial": "html", + "November": "pdf", + "October": "pdf" + }, + "Pictures": { + "Sun": "png", + "Woods": "jpg", + "Photo Booth Library": { + "Contents": "dir", + "Pictures": "dir" + } + }, + "Applications": { + "Chrome": "app", + "Calendar": "app", + "Webstorm": "app" + } +}}`; + +/** + * File database, it can build a tree structured Json object from string. + * Each node in Json object represents a file or a directory. For a file, it has filename and type. + * For a directory, it has filename and children (a list of files or directories). + * The input will be a json object string, and the output is a list of `FileNode` with nested + * structure. + */ +@Injectable() +export class FileDatabase { + dataChange: BehaviorSubject = new BehaviorSubject([]); + + get data(): FileNode[] { return this.dataChange.value; } + + constructor() { + this.initialize(); + } + + initialize() { + // Parse the string to json object. + const dataObject = JSON.parse(TREE_DATA); + + // Build the tree nodes from Json object. The result is a list of `FileNode` with nested + // file node as children. + const data = this.buildFileTree(dataObject, 0); + + // Notify the change. + this.dataChange.next(data); + } + + /** + * Build the file structure tree. The `value` is the Json object, or a sub-tree of a Json object. + * The return value is the list of `FileNode`. + */ + buildFileTree(value: any, level: number) { + let data: any[] = []; + for (let k in value) { + let v = value[k]; + let node = new FileNode(); + node.filename = `${k}`; + if (v === null || v === undefined) { + // no action + } else if (typeof v === 'object') { + node.children = this.buildFileTree(v, level + 1); + } else { + node.type = v; + } + data.push(node); + } + return data; + } +} + +/** + * @title Tree with nested nodes + */ +@Component({ + selector: 'tree-nested-overview-example', + templateUrl: 'tree-nested-overview-example.html', + styleUrls: ['tree-nested-overview-example.css'], + providers: [FileDatabase] +}) +export class TreeNestedOverviewExample { + nestedTreeControl: NestedTreeControl; + + nestedDataSource: MatTreeNestedDataSource; + + constructor(database: FileDatabase) { + this.nestedTreeControl = new NestedTreeControl(this._getChildren); + this.nestedDataSource = new MatTreeNestedDataSource(); + + database.dataChange.subscribe(data => this.nestedDataSource.data = data); + } + + private _getChildren = (node: FileNode) => { return of(node.children); }; + + hasNestedChild = (_: number, nodeData: FileNode) => {return !(nodeData.type); }; +} diff --git a/src/material-experimental/tsconfig-build.json b/src/material-experimental/tsconfig-build.json index 264f6924840..f2d8d63ca59 100644 --- a/src/material-experimental/tsconfig-build.json +++ b/src/material-experimental/tsconfig-build.json @@ -13,6 +13,10 @@ "moduleResolution": "node", "outDir": "../../dist/packages/material-experimental", "rootDir": ".", + "rootDirs": [ + ".", + "../../dist/packages/material-experimental" + ], "sourceMap": true, "inlineSources": true, "target": "es2015", diff --git a/test/karma-test-shim.js b/test/karma-test-shim.js index ae949d35a9f..7cb51136e50 100644 --- a/test/karma-test-shim.js +++ b/test/karma-test-shim.js @@ -69,6 +69,7 @@ System.config({ '@angular/cdk/stepper': 'dist/packages/cdk/stepper/index.js', '@angular/cdk/table': 'dist/packages/cdk/table/index.js', '@angular/cdk/testing': 'dist/packages/cdk/testing/index.js', + '@angular/cdk/tree': 'dist/packages/cdk/tree/index.js', '@angular/material/autocomplete': 'dist/packages/material/autocomplete/index.js', '@angular/material/badge': 'dist/packages/material/badge/index.js', @@ -104,6 +105,7 @@ System.config({ '@angular/material/tabs': 'dist/packages/material/tabs/index.js', '@angular/material/toolbar': 'dist/packages/material/toolbar/index.js', '@angular/material/tooltip': 'dist/packages/material/tooltip/index.js', + '@angular/material/tree': 'dist/packages/material/tree/index.js', }, packages: { // Thirdparty barrels.