diff --git a/src/cdk/tree/control/flat-tree-control.spec.ts b/src/cdk/tree/control/flat-tree-control.spec.ts index e17fc8b389c1..66736a124e8d 100644 --- a/src/cdk/tree/control/flat-tree-control.spec.ts +++ b/src/cdk/tree/control/flat-tree-control.spec.ts @@ -39,7 +39,7 @@ describe('CdkFlatTreeControl', () => { expect(treeControl.expansionModel.selected.length) .toBe(2, 'Expect two dataNodes in expansionModel'); - treeControl.collapse(seconNode); + treeControl.collapse(secondNode); expect(treeControl.isExpanded(secondNode)) .toBeFalsy('Expect second node to be collapsed'); diff --git a/src/cdk/tree/control/nested-tree-control.ts b/src/cdk/tree/control/nested-tree-control.ts index c684b1c39be3..bc8dba3b0f47 100644 --- a/src/cdk/tree/control/nested-tree-control.ts +++ b/src/cdk/tree/control/nested-tree-control.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ import {Observable} from 'rxjs/Observable'; -import {first} from 'rxjs/operators'; +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. */ @@ -40,7 +40,7 @@ export class NestedTreeControl extends BaseTreeControl { /** A helper function to get descendants recursively. */ protected _getDescendants(descendants: T[], dataNode: T): void { descendants.push(dataNode); - first.call(this.getChildren(dataNode)).subscribe(children => { + 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/nested-node.ts b/src/cdk/tree/nested-node.ts index 674dc557f7df..72b0241fa796 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -13,7 +13,7 @@ import { OnDestroy, QueryList, } from '@angular/core'; -import {takeUntil} from 'rxjs/operators'; +import {takeUntil} from 'rxjs/operators/takeUntil'; import {CdkTree} from './tree'; import {CdkTreeNodeOutlet} from './outlet'; import {CdkTreeNode} from './node'; @@ -58,14 +58,14 @@ export class CdkNestedTreeNode extends CdkTreeNode implements AfterContent } ngAfterContentInit() { - takeUntil.call(this._tree.treeControl.getChildren(this.data), this._destroyed) + this._tree.treeControl.getChildren(this.data).pipe(takeUntil(this._destroyed)) .subscribe(result => { // 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(); }); - takeUntil.call(this.nodeOutlet.changes, this._destroyed) + this.nodeOutlet.changes.pipe(takeUntil(this._destroyed)) .subscribe((_) => this._addChildrenNodes()); } diff --git a/src/cdk/tree/node.ts b/src/cdk/tree/node.ts index d693f29eca18..e2311b48189e 100644 --- a/src/cdk/tree/node.ts +++ b/src/cdk/tree/node.ts @@ -13,7 +13,7 @@ import { OnDestroy, TemplateRef } from '@angular/core'; -import {takeUntil} from 'rxjs/operators'; +import {takeUntil} from 'rxjs/operators/takeUntil'; import {Subject} from 'rxjs/Subject'; import {CdkTree} from './tree'; import {getTreeControlFunctionsMissingError} from './tree-errors'; @@ -69,7 +69,6 @@ export class CdkTreeNodeDef { host: { '[attr.role]': 'role', 'class': 'cdk-tree-node', - 'tabindex': '0', }, }) export class CdkTreeNode implements FocusableOption, OnDestroy { @@ -77,7 +76,7 @@ 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; + static mostRecentTreeNode: CdkTreeNode | null = null; /** Subject that emits when the component has been destroyed. */ protected _destroyed = new Subject(); @@ -118,7 +117,7 @@ export class CdkTreeNode implements FocusableOption, OnDestroy { if (!this._tree.treeControl.getChildren) { throw getTreeControlFunctionsMissingError(); } - takeUntil.call(this._tree.treeControl.getChildren(this._data), this._destroyed) + this._tree.treeControl.getChildren(this._data).pipe(takeUntil(this._destroyed)) .subscribe(children => { this.role = children ? 'group' : 'treeitem'; }); diff --git a/src/cdk/tree/padding.ts b/src/cdk/tree/padding.ts index 1e823e91f60f..a23124ed2cc6 100644 --- a/src/cdk/tree/padding.ts +++ b/src/cdk/tree/padding.ts @@ -9,7 +9,7 @@ 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'; +import {takeUntil} from 'rxjs/operators/takeUntil'; import {Subject} from 'rxjs/Subject'; import {CdkTreeNode} from './node'; import {CdkTree} from './tree'; @@ -49,7 +49,7 @@ export class CdkTreeNodePadding implements OnDestroy { @Optional() private _dir: Directionality) { this._setPadding(); if (this._dir) { - takeUntil.call(this._dir.change, this._destroyed).subscribe(() => this._setPadding()); + this._dir.change.pipe(takeUntil(this._destroyed)).subscribe(() => this._setPadding()); } } diff --git a/src/cdk/tree/tree.spec.ts b/src/cdk/tree/tree.spec.ts index 21c724758bac..769a555e04ea 100644 --- a/src/cdk/tree/tree.spec.ts +++ b/src/cdk/tree/tree.spec.ts @@ -5,7 +5,7 @@ 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'; +import {map} from 'rxjs/operators/map'; import {TreeControl} from './control/tree-control'; import {FlatTreeControl} from './control/flat-tree-control'; @@ -54,7 +54,7 @@ describe('CdkTree', () => { it('with rendered dataNodes', () => { const nodes = getNodes(treeElement); - expect(nodes).not.toBe(undefined); + expect(nodes).toBeDefined('Expect nodes to be defined'); expect(nodes[0].classList).toContain('customNodeClass'); }); diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 62be164d480c..e71638fd41fd 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -23,7 +23,7 @@ import { ViewEncapsulation, } from '@angular/core'; import {BehaviorSubject} from 'rxjs/BehaviorSubject'; -import {takeUntil} from 'rxjs/operators'; +import {takeUntil} from 'rxjs/operators/takeUntil'; import {Subject} from 'rxjs/Subject'; import {CdkTreeNodeDef, CdkTreeNode, CdkTreeNodeOutletContext} from './node'; import {CdkTreeNodeOutlet} from './outlet'; @@ -155,7 +155,7 @@ export class CdkTree implements CollectionViewer, OnInit, OnDestroy { /** Set up a subscription for the data provided by the data source. */ private _observeRenderChanges() { - takeUntil.call(this.dataSource.connect(this), this._destroyed) + this.dataSource.connect(this).pipe(takeUntil(this._destroyed)) .subscribe(data => { this._data = data; this._renderNodeChanges(data); @@ -191,7 +191,7 @@ export class CdkTree implements CollectionViewer, OnInit, OnDestroy { if (this._nodeDefs.length == 1) { return this._nodeDefs.first; } const nodeDef = - this._nodeDefs.find(def => def.when && def.when(data, i)) || this._defaultNodeDef; + this._nodeDefs.find(def => def.when && def.when(i, data)) || this._defaultNodeDef; if (!nodeDef) { throw getTreeMissingMatchingNodeDefError(); } return nodeDef; @@ -214,7 +214,9 @@ export class CdkTree implements CollectionViewer, OnInit, OnDestroy { // 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. - CdkTreeNode.mostRecentTreeNode.data = nodeData; + if (CdkTreeNode.mostRecentTreeNode) { + CdkTreeNode.mostRecentTreeNode.data = nodeData; + } this._changeDetectorRef.detectChanges(); } diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts index 262bbe220679..1476fe6c2019 100644 --- a/src/demo-app/demo-material-module.ts +++ b/src/demo-app/demo-material-module.ts @@ -37,6 +37,7 @@ import { MatTabsModule, MatToolbarModule, MatTooltipModule, + MatTreeModule, MatStepperModule, } from '@angular/material'; import {MatNativeDateModule, MatRippleModule} from '@angular/material'; @@ -48,6 +49,7 @@ import {PlatformModule} from '@angular/cdk/platform'; import {ObserversModule} from '@angular/cdk/observers'; import {PortalModule} from '@angular/cdk/portal'; import {CdkTableModule} from '@angular/cdk/table'; +import {CdkTreeModule} from '@angular/cdk/tree'; /** * NgModule that includes all Material modules that are required to serve the demo-app. @@ -85,8 +87,10 @@ import {CdkTableModule} from '@angular/cdk/table'; 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 c922840c0989..ac8a41218b9e 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -54,6 +54,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/button': 'dist/packages/material/button/index.js', @@ -86,6 +87,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. diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index a2ed3f9459ba..2b6b52c12230 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -27,6 +27,7 @@ @import '../../tabs/tabs-theme'; @import '../../toolbar/toolbar-theme'; @import '../../tooltip/tooltip-theme'; +@import '../../tree/tree-theme'; @import '../../snack-bar/snack-bar-theme'; @import '../../form-field/form-field-theme'; @@ -62,5 +63,6 @@ @include mat-tabs-theme($theme); @include mat-toolbar-theme($theme); @include mat-tooltip-theme($theme); + @include mat-tree-theme($theme); @include mat-snack-bar-theme($theme); } diff --git a/src/lib/core/typography/_all-typography.scss b/src/lib/core/typography/_all-typography.scss index a0766383a781..c7d268c7d55a 100644 --- a/src/lib/core/typography/_all-typography.scss +++ b/src/lib/core/typography/_all-typography.scss @@ -26,6 +26,7 @@ @import '../../tabs/tabs-theme'; @import '../../toolbar/toolbar-theme'; @import '../../tooltip/tooltip-theme'; +@import '../../tree/tree-theme'; @import '../../snack-bar/snack-bar-theme'; @import '../option/option-theme'; @import '../option/optgroup-theme'; @@ -66,6 +67,7 @@ @include mat-tabs-typography($config); @include mat-toolbar-typography($config); @include mat-tooltip-typography($config); + @include mat-tree-typography($config); @include mat-list-typography($config); @include mat-option-typography($config); @include mat-optgroup-typography($config); diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts index c29802fbed02..d08a7506550a 100644 --- a/src/lib/public-api.ts +++ b/src/lib/public-api.ts @@ -38,3 +38,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 000000000000..57679003ec63 --- /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/index.ts b/src/lib/tree/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /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 000000000000..c1259ad645dc --- /dev/null +++ b/src/lib/tree/node.ts @@ -0,0 +1,73 @@ +/** + * @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 { + ContentChildren, + Directive, + Input, + QueryList +} from '@angular/core'; +import { + CdkNestedTreeNode, + CdkTreeNodeDef, + CdkTreeNode, +} from '@angular/cdk/tree'; +import {MatTreeNodeOutlet} from './outlet'; + +/** + * Wrapper for the CdkTree node with Material design styles. + */ +// TODO(tinayuangao): use mixinTabIndex +@Directive({ + selector: 'mat-tree-node', + exportAs: 'matTreeNode', + host: { + '[attr.role]': 'role', + 'class': 'mat-tree-node', + 'tabindex': '0', + }, + providers: [{provide: CdkTreeNode, useExisting: MatTreeNode}] +}) +export class MatTreeNode extends CdkTreeNode { + @Input() role: 'treeitem' | 'group' = 'treeitem'; +} + +/** + * 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.role]': 'role', + 'class': 'mat-nested-tree-node', + }, + providers: [ + {provide: CdkNestedTreeNode, useExisting: MatNestedTreeNode}, + {provide: CdkTreeNode, useExisting: MatNestedTreeNode} + ] +}) +export class MatNestedTreeNode extends CdkNestedTreeNode { + @Input('matNestedTreeNode') node: T; + + @ContentChildren(MatTreeNodeOutlet) nodeOutlet: QueryList; +} diff --git a/src/lib/tree/outlet.ts b/src/lib/tree/outlet.ts new file mode 100644 index 000000000000..6d01a6bf65e6 --- /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 000000000000..5738c82b4ee3 --- /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 000000000000..785d438f07c1 --- /dev/null +++ b/src/lib/tree/public-api.ts @@ -0,0 +1,14 @@ +/** + * @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 './trigger'; diff --git a/src/lib/tree/tree-data-source.ts b/src/lib/tree/tree-data-source.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/lib/tree/tree-module.ts b/src/lib/tree/tree-module.ts new file mode 100644 index 000000000000..bed63e47e49f --- /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 {MatTreeNodeTrigger} from './trigger'; +import {MatTreeNodeOutlet} from './outlet'; +import {MatTreeNodePadding} from './padding'; + +const MAT_TREE_DIRECTIVES = [ + MatNestedTreeNode, + MatTreeNodeDef, + MatTreeNodePadding, + MatTreeNodeTrigger, + 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 000000000000..8ce45d8cf54a --- /dev/null +++ b/src/lib/tree/tree.md @@ -0,0 +1,91 @@ +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 trigger). 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. + +### Trigger + +A `matTreeNodeTrigger` can be added in the tree node template to expand/collapse the tree node. The +trigger triggers the expand/collapse functions in `TreeControl` and is able to expand/collapse a +tree node recursively by setting `[matTreeNodeTriggerRecursive]` to `true`. + +The trigger can be placed anywhere in the tree node, and is only triggered 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 000000000000..fc3d1231bd37 --- /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 000000000000..d080fd509092 --- /dev/null +++ b/src/lib/tree/tree.spec.ts @@ -0,0 +1,201 @@ +import {async, 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'; + +import {TreeControl, FlatTreeControl} from '@angular/cdk/tree'; +import {MatTreeModule} from './index'; +import {MatTree} from './tree'; + + +describe('MatTree', () => { + let fixture: ComponentFixture; + + let component: SimpleMatTreeApp; + let dataSource: FakeDataSource; + let tree: MatTree; + let treeElement: HTMLElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MatTreeModule], + declarations: [ + SimpleMatTreeApp, + // TODO(tinayuangao): Add more test cases with the mat-tree + // DynamicDataSourceMatTreeApp, + // NodeContextMatTreeApp, + // WhenNodeMatTreeApp + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleMatTreeApp); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + describe('should initialize', () => { + 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; + expectFlatTreeToMatchContent(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}` + ], + [1, 1, 1]); + + dataSource.addData(2); + fixture.detectChanges(); + + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatchContent(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}`, + `${data[3].pizzaTopping} - ${data[3].pizzaCheese} + ${data[3].pizzaBase}` + ], + [1, 1, 1, 2]); + }); + }); +}); + +export class TestData { + pizzaTopping: string; + pizzaCheese: string; + pizzaBase: string; + level: number; + children: TestData[]; + + constructor(pizzaTopping: string, pizzaCheese: string, pizzaBase: string, level: number = 1) { + this.pizzaTopping = pizzaTopping; + this.pizzaCheese = pizzaCheese; + this.pizzaBase = pizzaBase; + this.level = level; + this.children = []; + } +} + +class FakeDataSource extends DataSource { + isConnected = false; + + _dataChange = new BehaviorSubject([]); + set data(data: TestData[]) { this._dataChange.next(data); } + get data() { return this._dataChange.getValue(); } + + constructor() { + super(); + for (let i = 0; i < 3; i++) { + this.addData(); + } + } + + connect(collectionViewer: CollectionViewer): Observable { + this.isConnected = true; + const streams = [this._dataChange, collectionViewer.viewChange]; + return map.call(combineLatest(streams), ([data]) => data); + } + + disconnect() { + this.isConnected = false; + } + + addData(level: number = 1) { + const nextIndex = this.data.length + 1; + + let copiedData = this.data.slice(); + copiedData.push( + new TestData(`topping_${nextIndex}`, `cheese_${nextIndex}`, `base_${nextIndex}`, level)); + + this.data = copiedData; + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + ` +}) +class SimpleMatTreeApp { + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + dataSource: FakeDataSource | null = new FakeDataSource(); + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + @ViewChild(MatTree) tree: MatTree; +} + +function getNodes(treeElement: Element): Element[] { + return [].slice.call(treeElement.querySelectorAll('.mat-tree-node'))!; +} + +// TODO(tinayuangao): Add expectedNestedTreeToMatchContent +function expectFlatTreeToMatchContent(treeElement: Element, expectedTreeContent: any[], + expectedLevels: number[]) { + const paddingIndent = 28; + const missedExpectations: string[] = []; + function checkNodeContent(node: Element, expectedTextContent: string) { + const actualTextContent = node.textContent!.trim(); + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`); + } + } + + getNodes(treeElement).forEach((node, index) => { + const expected = expectedTreeContent ? + expectedTreeContent[index] : + null; + checkNodeContent(node, expected); + const actualLevel = (node as HTMLElement).style.paddingLeft; + const expectedLevel = `${expectedLevels[index] * paddingIndent}px`; + if (actualLevel != expectedLevel) { + missedExpectations.push( + `Expected node level to be ${expectedLevel} but was ${actualLevel}`); + } + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} diff --git a/src/lib/tree/tree.ts b/src/lib/tree/tree.ts new file mode 100644 index 000000000000..c5f7baf4cf7f --- /dev/null +++ b/src/lib/tree/tree.ts @@ -0,0 +1,37 @@ +/** + * @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', + '(focus)': 'focus()', + '(keydown)': 'handleKeydown($event)' + }, + 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/trigger.ts b/src/lib/tree/trigger.ts new file mode 100644 index 000000000000..f05a5bc2df78 --- /dev/null +++ b/src/lib/tree/trigger.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 {CdkTreeNodeTrigger} from '@angular/cdk/tree'; + +/** + * Wrapper for the CdkTree's trigger with Material design styles. + */ +@Directive({ + selector: '[matTreeNodeTrigger]', + host: { + '(click)': '_trigger($event)', + }, + providers: [{provide: CdkTreeNodeTrigger, useExisting: MatTreeNodeTrigger}] +}) +export class MatTreeNodeTrigger extends CdkTreeNodeTrigger { + @Input('matTreeNodeTriggerRecursive') recursive: boolean = true; +} diff --git a/src/cdk/tree/tsconfig-es5.json b/src/lib/tree/tsconfig-build.json similarity index 75% rename from src/cdk/tree/tsconfig-es5.json rename to src/lib/tree/tsconfig-build.json index 9f33cc937cdb..83dacb09a5cb 100644 --- a/src/cdk/tree/tsconfig-es5.json +++ b/src/lib/tree/tsconfig-build.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig-es5", + "extends": "../tsconfig-build", "files": [ "public-api.ts", "../typings.d.ts" @@ -8,7 +8,7 @@ "annotateForClosureCompiler": true, "strictMetadataEmit": true, "flatModuleOutFile": "index.js", - "flatModuleId": "@angular/cdk/tree", + "flatModuleId": "@angular/material/tree", "skipTemplateCodegen": true } }