/**
* @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, isDataSource} from '@angular/cdk/collections';
import {
AfterContentChecked,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
Directive,
ElementRef,
Input,
IterableChangeRecord,
IterableDiffer,
IterableDiffers,
OnDestroy,
OnInit,
QueryList,
ViewChild,
ViewContainerRef,
ViewEncapsulation,
TrackByFunction
} from '@angular/core';
import {BehaviorSubject, Observable, of as observableOf, Subject, Subscription} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
import {TreeControl} from './control/tree-control';
import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node';
import {CdkTreeNodeOutlet} from './outlet';
import {
getTreeControlFunctionsMissingError,
getTreeControlMissingError,
getTreeMissingMatchingNodeDefError,
getTreeMultipleDefaultNodeDefsError,
getTreeNoValidDataSourceError
} from './tree-errors';
/**
* 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,
// The "OnPush" status for the `CdkTree` component is effectively a noop, so we are removing it.
// The view for `CdkTree` consists entirely of templates declared in other views. As they are
// declared elsewhere, they are checked when their declaration points are checked.
// tslint:disable-next-line:validate-decorators
changeDetection: ChangeDetectionStrategy.Default
})
export class CdkTree implements AfterContentChecked, CollectionViewer, OnDestroy, OnInit {
/** Subject that emits when the component has been destroyed. */
private _onDestroy = new Subject();
/** 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;
/** Level of nodes */
private _levels: Map = new Map();
/**
* 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 data 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;
/**
* Tracking function that will be used to check the differences in data changes. Used similarly
* to `ngFor` `trackBy` function. Optimize node operations by identifying a node based on its data
* relative to the function to know if a node should be added/removed/moved.
* Accepts a function that takes two parameters, `index` and `item`.
*/
@Input() trackBy: TrackByFunction;
// Outlets within the tree's template where the dataNodes will be inserted.
@ViewChild(CdkTreeNodeOutlet, {static: true}) _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(this.trackBy);
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[]) {
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;
if (isDataSource(this._dataSource)) {
dataStream = this._dataSource.connect(this);
} else if (this._dataSource instanceof Observable) {
dataStream = this._dataSource;
} else if (Array.isArray(this._dataSource)) {
dataStream = observableOf(this._dataSource);
}
if (dataStream) {
this._dataSubscription = dataStream.pipe(takeUntil(this._onDestroy))
.subscribe(data => this.renderNodeChanges(data));
} else {
throw getTreeNoValidDataSourceError();
}
}
/** Check for changes made in the data and render each change (node added/removed/moved). */
renderNodeChanges(data: T[] | ReadonlyArray, dataDiffer: IterableDiffer = this._dataDiffer,
viewContainer: ViewContainerRef = this._nodeOutlet.viewContainer,
parentData?: T) {
const changes = dataDiffer.diff(data);
if (!changes) { return; }
changes.forEachOperation((item: IterableChangeRecord,
adjustedPreviousIndex: number | null,
currentIndex: number | null) => {
if (item.previousIndex == null) {
this.insertNode(data[currentIndex!], currentIndex!, viewContainer, parentData);
} else if (currentIndex == null) {
viewContainer.remove(adjustedPreviousIndex!);
this._levels.delete(item.item);
} else {
const view = viewContainer.get(adjustedPreviousIndex!);
viewContainer.move(view!, currentIndex);
}
});
this._changeDetectorRef.detectChanges();
}
/**
* 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, parentData?: T) {
const node = this._getNodeDef(nodeData, index);
// Node context that will be provided to created embedded view
const context = new CdkTreeNodeOutletContext(nodeData);
// If the tree is flat tree, then use the `getLevel` function in flat tree control
// Otherwise, use the level of parent node.
if (this.treeControl.getLevel) {
context.level = this.treeControl.getLevel(nodeData);
} else if (typeof parentData !== 'undefined' && this._levels.has(parentData)) {
context.level = this._levels.get(parentData)! + 1;
} else {
context.level = 0;
}
this._levels.set(nodeData, context.level);
// 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;
}
}
}
/**
* 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]': 'role === "treeitem" ? level : null',
'[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();
/** Emits when the node's data has changed. */
_dataChanges = new Subject();
/** The tree node's data. */
get data(): T { return this._data; }
set data(value: T) {
if (value !== this._data) {
this._data = value;
this._setRoleFromData();
this._dataChanges.next();
}
}
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 as CdkTreeNode;
}
ngOnDestroy() {
// If this is the last tree node being destroyed,
// clear out the reference to avoid leaking memory.
if (CdkTreeNode.mostRecentTreeNode === this) {
CdkTreeNode.mostRecentTreeNode = null;
}
this._dataChanges.complete();
this._destroyed.next();
this._destroyed.complete();
}
/** Focuses the menu item. Implements for FocusableOption. */
focus(): void {
this._elementRef.nativeElement.focus();
}
protected _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();
}
const childrenNodes = this._tree.treeControl.getChildren(this._data);
if (Array.isArray(childrenNodes)) {
this._setRoleFromChildren(childrenNodes as T[]);
} else if (childrenNodes instanceof Observable) {
childrenNodes.pipe(takeUntil(this._destroyed))
.subscribe(children => this._setRoleFromChildren(children));
}
}
}
protected _setRoleFromChildren(children: T[]) {
this.role = children && children.length ? 'group' : 'treeitem';
}
}