diff --git a/src/cdk/a11y/a11y.md b/src/cdk/a11y/a11y.md index 6724827b019d..64464a5cb7a0 100644 --- a/src/cdk/a11y/a11y.md +++ b/src/cdk/a11y/a11y.md @@ -27,7 +27,7 @@ Navigation through options can be made to wrap via the `withWrap` method this.keyManager = new FocusKeyManager(...).withWrap(); ``` -#### Types of key managers +#### Types of list key managers There are two varieties of `ListKeyManager`, `FocusKeyManager` and `ActiveDescendantKeyManager`. @@ -55,6 +55,64 @@ interface Highlightable extends ListKeyManagerOption { Each item must also have an ID bound to the listbox's or menu's `aria-activedescendant`. +### TreeKeyManager + +`TreeKeyManager` manages the active option in a tree view. This is intended to be used with +components that correspond to a `role="tree"` pattern. + +#### Basic usage + +Any component that uses a `TreeKeyManager` will generally do three things: +* Create a `@ViewChildren` query for the tree items being managed. +* Initialize the `TreeKeyManager`, passing in the options. +* Forward keyboard events from the managed component to the `TreeKeyManager` via `onKeydown`. + +Each tree item should implement the `TreeKeyManagerItem` interface: +```ts +interface TreeKeyManagerItem { + /** Whether the item is disabled. */ + isDisabled?: (() => boolean) | boolean; + + /** The user-facing label for this item. */ + getLabel?(): string; + + /** Perform the main action (i.e. selection) for this item. */ + activate(): void; + + /** Retrieves the parent for this item. This is `null` if there is no parent. */ + getParent(): TreeKeyManagerItem | null; + + /** Retrieves the children for this item. */ + getChildren(): TreeKeyManagerItem[] | Observable; + + /** Determines if the item is currently expanded. */ + isExpanded: (() => boolean) | boolean; + + /** Collapses the item, hiding its children. */ + collapse(): void; + + /** Expands the item, showing its children. */ + expand(): void; + + /** + * Focuses the item. This should provide some indication to the user that this item is focused. + */ + focus(): void; +} +``` + +#### Focus management + +The `TreeKeyManager` will handle focusing the appropriate item on keyboard interactions. However, +the component should call `onInitialFocus` when the component is focused for the first time (i.e. +when there is no active item). + +`tabindex` should also be set by the component when the active item changes. This can be listened to +via the `change` property on the `TreeKeyManager`. In particular, the tree should only have a +`tabindex` set if there is no active item, and should not have a `tabindex` set if there is an +active item. Only the HTML node corresponding to the active item should have a `tabindex` set to +`0`, with all other items set to `-1`. + ### FocusTrap diff --git a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts index cceea2b40dec..17abfa81ea76 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.spec.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -23,6 +23,7 @@ class FakeBaseTreeKeyManagerItem { _children: FakeBaseTreeKeyManagerItem[] = []; isDisabled?: boolean = false; + skipItem?: boolean = false; constructor(private _label: string) {} @@ -263,6 +264,16 @@ describe('TreeKeyManager', () => { expect(keyManager.getActiveItemIndex()).toBe(0); }); + it('should focus the first non-disabled item when Home is pressed', () => { + itemList.get(0)!.isDisabled = true; + keyManager.onClick(itemList.get(2)!); + expect(keyManager.getActiveItemIndex()).toBe(2); + + keyManager.onKeydown(fakeKeyEvents.home); + + expect(keyManager.getActiveItemIndex()).toBe(1); + }); + it('should focus the last item when End is pressed', () => { keyManager.onClick(itemList.get(0)!); expect(keyManager.getActiveItemIndex()).toBe(0); @@ -270,6 +281,16 @@ describe('TreeKeyManager', () => { keyManager.onKeydown(fakeKeyEvents.end); expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 1); }); + + it('should focus the last non-disabled item when End is pressed', () => { + itemList.get(itemList.length - 1)!.isDisabled = true; + keyManager.onClick(itemList.get(0)!); + expect(keyManager.getActiveItemIndex()).toBe(0); + + keyManager.onKeydown(fakeKeyEvents.end); + + expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 2); + }); }); describe('up/down key events', () => { @@ -946,6 +967,195 @@ describe('TreeKeyManager', () => { expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(-1); })); }); + + describe('focusItem', () => { + beforeEach(() => { + keyManager.onInitialFocus(); + }); + + it('should focus the provided index', () => { + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + + keyManager.focusItem(1); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + }); + + it('should be able to set the active item by reference', () => { + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + + keyManager.focusItem(itemList.get(2)!); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + }); + + it('should be able to set the active item without emitting an event', () => { + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + + expect(keyManager.getActiveItemIndex()).toBe(0); + + keyManager.focusItem(2, {emitChangeEvent: false}); + + expect(keyManager.getActiveItemIndex()).toBe(2); + expect(spy).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + }); + + it('should not emit an event if the item did not change', () => { + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + + keyManager.focusItem(2); + keyManager.focusItem(2); + + expect(spy).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + }); + }); + + describe('focusFirstItem', () => { + beforeEach(() => { + keyManager.onInitialFocus(); + }); + + it('should focus the first item', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + + keyManager.focusFirstItem(); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + }); + + it('should set the active item to the second item if the first one is disabled', () => { + itemList.get(0)!.isDisabled = true; + + keyManager.focusFirstItem(); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + }); + }); + + describe('focusLastItem', () => { + beforeEach(() => { + keyManager.onInitialFocus(); + }); + + it('should focus the last item', () => { + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + + keyManager.focusLastItem(); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index') + .toBe(itemList.length - 1); + }); + + it('should set the active item to the second-to-last item if the last is disabled', () => { + itemList.get(itemList.length - 1)!.isDisabled = true; + + keyManager.focusLastItem(); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index') + .toBe(itemList.length - 2); + }); + }); + + describe('focusNextItem', () => { + beforeEach(() => { + keyManager.onInitialFocus(); + }); + + it('should focus the next item', () => { + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + + keyManager.focusNextItem(); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + }); + + it('should skip disabled items', () => { + itemList.get(1)!.isDisabled = true; + + keyManager.focusNextItem(); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + }); + }); + + describe('focusPreviousItem', () => { + beforeEach(() => { + keyManager.onInitialFocus(); + }); + + it('should focus the previous item', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + + keyManager.focusPreviousItem(); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + }); + + it('should skip disabled items', () => { + itemList.get(1)!.isDisabled = true; + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + + keyManager.focusPreviousItem(); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + }); + }); + + describe('skip predicate', () => { + beforeEach(() => { + keyManager = new TreeKeyManager({ + items: itemList, + skipPredicate: item => item.skipItem ?? false, + }); + keyManager.onInitialFocus(); + }); + + it('should be able to skip items with a custom predicate', () => { + itemList.get(1)!.skipItem = true; + expect(keyManager.getActiveItemIndex()).toBe(0); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()).toBe(2); + }); + }); + + describe('focus', () => { + beforeEach(() => { + keyManager.onInitialFocus(); + + for (const item of itemList) { + spyOn(item, 'focus'); + } + }); + + it('calls .focus() on focused items', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(itemList.get(0)!.focus).not.toHaveBeenCalled(); + expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1); + expect(itemList.get(2)!.focus).not.toHaveBeenCalled(); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(itemList.get(0)!.focus).not.toHaveBeenCalled(); + expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1); + expect(itemList.get(2)!.focus).toHaveBeenCalledTimes(1); + }); + + it('calls .focus() on focused items, when pressing up key', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(itemList.get(0)!.focus).not.toHaveBeenCalled(); + expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1); + + keyManager.onKeydown(fakeKeyEvents.upArrow); + + expect(itemList.get(0)!.focus).toHaveBeenCalledTimes(1); + expect(itemList.get(1)!.focus).toHaveBeenCalledTimes(1); + }); + }); }); } }); diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index f5103d05b42b..f15ef34fda52 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -66,6 +66,9 @@ export interface TreeKeyManagerItem { focus(): void; } +/** + * Configuration for the TreeKeyManager. + */ export interface TreeKeyManagerOptions { items: Observable | QueryList | T[]; @@ -284,9 +287,49 @@ export class TreeKeyManager { this._focusFirstItem(); } - private _setActiveItem(index: number): void; - private _setActiveItem(item: T): void; - private _setActiveItem(itemOrIndex: number | T) { + /** + * Focus the provided item by index. + * @param index The index of the item to focus. + * @param options Additional focusing options. + */ + focusItem(index: number, options?: {emitChangeEvent?: boolean}): void; + /** + * Focus the provided item. + * @param item The item to focus. Equality is determined via the trackBy function. + * @param options Additional focusing options. + */ + focusItem(item: T, options?: {emitChangeEvent?: boolean}): void; + focusItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void { + this._setActiveItem(itemOrIndex, options); + } + + /** Focus the first available item. */ + focusFirstItem(): void { + this._focusFirstItem(); + } + + /** Focus the last available item. */ + focusLastItem(): void { + this._focusLastItem(); + } + + /** Focus the next available item. */ + focusNextItem(): void { + this._focusNextItem(); + } + + /** Focus the previous available item. */ + focusPreviousItem(): void { + this._focusPreviousItem(); + } + + private _setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void; + private _setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void; + private _setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void; + private _setActiveItem(itemOrIndex: number | T, options: {emitChangeEvent?: boolean} = {}) { + // Set default options + options.emitChangeEvent ??= true; + let index = typeof itemOrIndex === 'number' ? itemOrIndex @@ -307,7 +350,9 @@ export class TreeKeyManager { this._activeItem = activeItem ?? null; this._activeItemIndex = index; - this.change.next(this._activeItem); + if (options.emitChangeEvent) { + this.change.next(this._activeItem); + } this._activeItem?.focus(); if (this._activationFollowsFocus) { this._activateCurrentItem(); @@ -315,8 +360,11 @@ export class TreeKeyManager { } private _updateActiveItemIndex(newItems: T[]) { - if (this._activeItem) { - const newIndex = newItems.indexOf(this._activeItem); + const activeItem = this._activeItem; + if (activeItem) { + const newIndex = newItems.findIndex( + item => this._trackByFn(item) === this._trackByFn(activeItem), + ); if (newIndex > -1 && newIndex !== this._activeItemIndex) { this._activeItemIndex = newIndex; diff --git a/src/cdk/tree/nested-node.ts b/src/cdk/tree/nested-node.ts index 0f6d5ddd863e..a79be60b1b5e 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -81,6 +81,7 @@ export class CdkNestedTreeNode // This is a workaround for https://github.com/angular/angular/issues/23091 // In aot mode, the lifecycle hooks from parent class are not called. override ngOnInit() { + this._tree._setNodeTypeIfUnset('nested'); super.ngOnInit(); } diff --git a/src/cdk/tree/tree-errors.ts b/src/cdk/tree/tree-errors.ts index d07879aa5e69..ba70ea5bfa80 100644 --- a/src/cdk/tree/tree-errors.ts +++ b/src/cdk/tree/tree-errors.ts @@ -46,11 +46,3 @@ export function getTreeControlMissingError() { export function getMultipleTreeControlsError() { return Error(`More than one of tree control, levelAccessor, or childrenAccessor were provided.`); } - -/** - * Returns an error to be thrown when the node type is not specified. - * @docs-private - */ -export function getTreeControlNodeTypeUnspecifiedError() { - return Error(`The nodeType was not specified for the tree.`); -} diff --git a/src/cdk/tree/tree.md b/src/cdk/tree/tree.md index 6e9da076bab1..4d1061d50b91 100644 --- a/src/cdk/tree/tree.md +++ b/src/cdk/tree/tree.md @@ -2,19 +2,13 @@ The `` enables developers to build a customized tree experience for st `` 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 +There are two types of trees: flat and nested. The DOM structures are different for these 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. +are rendered as siblings in sequence. ```html @@ -25,16 +19,16 @@ the appropriate level. ``` + + 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. +In a nested tree, children nodes are placed inside their parent node in DOM. The parent node +contains a node outlet into which children are projected. ```html @@ -46,15 +40,18 @@ a node outlet into which children are projected. ``` + + 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 + +### Usage #### 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 +In order to use the tree, you must define a 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. @@ -69,9 +66,12 @@ data to be used in any bindings in the node template. ##### 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. +Flat trees use the `level` of a node to both render and determine hierarchy of the nodes for screen +readers. This may be provided either via `levelAccessor`, or will be calculated by `CdkTree` if +`childrenAccessor` is provided. + +Spacing can be added either by applying the `cdkNodePadding` directive or by applying custom styles +based on the `aria-level` attribute. ##### Nested tree node template @@ -84,24 +84,16 @@ 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 +The `cdkTreeNodeToggle` directive can be used to add expand/collapse functionality for tree nodes. +The toggle calls the expand/collapse functions in the `CdkTree` and is able to expand/collapse a tree node recursively by setting `[cdkTreeNodeToggleRecursive]` to true. -```html - - {{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`. +`cdkTreeNodeToggle` should be attached to button elements, and will trigger upon click or keyboard +activation. For icon buttons, ensure that `aria-label` is provided. ```html @@ -114,25 +106,24 @@ For best accessibility, `cdkTreeNodeToggle` should be on a button element and ha #### Padding (Flat tree only) -The cdkTreeNodePadding can be placed in a flat tree's node template to display the level +The `cdkTreeNodePadding` directive can be placed in a flat tree's node template to display the level information of a flat tree node. ```html {{node.value}} - ``` -Nested tree does not need this padding since padding can be easily added to the hierarchy structure -in DOM. +This is unnecessary for a nested tree, since the hierarchical structure of the DOM allows for +padding to be added via CSS. #### 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. - ```html {{node.value}} @@ -154,20 +145,30 @@ Because the data source provides this stream, it bears the responsibility of tog updates. This can be based on anything: tree node expansion change, websocket connections, user interaction, model updates, time-based intervals, etc. +There are two main methods of providing data to the tree: -#### Flat tree +* flattened data, combined with `levelAccessor`. This should be used if the data source already + flattens the nested data structure into a single array. +* only root data, combined with `childrenAccessor`. This should be used if the data source is + already provided as a nested data structure. -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. +#### `levelAccessor` +`levelAccessor` is a function that when provided a datum, returns the level the data sits at in the +tree structure. If `levelAccessor` is provided, the data provided by `dataSource` should contain all +renderable nodes in a single array. -#### Nested tree +The data source is responsible for handling node expand/collapse events and providing an updated +array of renderable nodes, if applicable. This can be listened to via the `(expansionChange)` event +on `cdk-tree-node` and `cdk-nested-tree-node`. + +#### `childrenAccessor` -The data source for nested tree has an option to leave the node expansion/collapsing event for each -tree node component to handle. +`childrenAccessor` is a function that when provided a datum, returns the children of that particular +datum. If `childrenAccessor` is provided, the data provided by `dataSource` should _only_ contain +the root nodes of the tree. -##### `trackBy` +#### `trackBy` To improve performance, a `trackBy` function can be provided to the tree similar to Angular’s [`ngFor` `trackBy`](https://angular.io/api/common/NgForOf#change-propagation). This informs the @@ -176,3 +177,25 @@ tree how to uniquely identify nodes to track how the data changes with each upda ```html ``` + +### Accessibility + +The `` implements the [`tree` widget](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/), +including keyboard navigation and appropriate roles and ARIA attributes. + +#### Activation actions + +For trees with nodes that have actions upon activation or click, `` will emit +`(activation)` events that can be listened to when the user activates a node via keyboard +interaction. + +```html + + +``` + +In this example, `$event` contains the node's data and is equivalent to the implicit data passed in +the `cdkNodeDef` context. diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 0513ec1d06f0..296e124245d2 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -7,8 +7,14 @@ */ import {TreeKeyManager, TreeKeyManagerItem} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; -import {coerceNumberProperty} from '@angular/cdk/coercion'; -import {CollectionViewer, DataSource, isDataSource, SelectionModel} from '@angular/cdk/collections'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; +import { + CollectionViewer, + DataSource, + isDataSource, + SelectionChange, + SelectionModel, +} from '@angular/cdk/collections'; import { AfterContentChecked, AfterContentInit, @@ -38,7 +44,6 @@ import { concat, EMPTY, isObservable, - merge, Observable, of as observableOf, Subject, @@ -54,7 +59,6 @@ import { take, takeUntil, tap, - withLatestFrom, } from 'rxjs/operators'; import {TreeControl} from './control/tree-control'; import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node'; @@ -62,7 +66,6 @@ import {CdkTreeNodeOutlet} from './outlet'; import { getMultipleTreeControlsError, getTreeControlMissingError, - getTreeControlNodeTypeUnspecifiedError, getTreeMissingMatchingNodeDefError, getTreeMultipleDefaultNodeDefsError, getTreeNoValidDataSourceError, @@ -123,15 +126,16 @@ export class CdkTree private _parents: Map = new Map(); /** - * The internal node groupings for each node; we use this, primarily for flattened trees, to - * determine where a particular node is within each group. + * The internal node groupings for each node; we use this to determine where + * a particular node is within each group. This allows us to compute the + * correct aria attribute values. * * The structure of this is that: * - the outer index is the level * - the inner index is the parent node for this particular group. If there is no parent node, we * use `null`. */ - private _groups: Map> = new Map>(); + private _groups: Map = new Map(); /** * Provides a stream containing the latest data array to render. Influenced by the tree's @@ -186,16 +190,6 @@ export class CdkTree */ @Input() expansionKey?: (dataNode: T) => K; - /** - * What type of node is being used in the tree. This must be provided if either of - * `levelAccessor` or `childrenAccessor` are provided. - * - * This controls what selection of data the tree will render. - */ - // NB: we're unable to determine this ourselves; Angular's ContentChildren - // unfortunately does not pick up the necessary information. - @Input() nodeType?: 'flat' | 'nested'; - // Outlets within the tree's template where the dataNodes will be inserted. @ViewChild(CdkTreeNodeOutlet, {static: true}) _nodeOutlet: CdkTreeNodeOutlet; @@ -222,17 +216,29 @@ export class CdkTree private _expansionModel?: SelectionModel; /** - * Maintain a synchronous cache of the currently known data nodes. In the - * case of nested nodes (i.e. if `nodeType` is 'nested'), this will - * only contain the root nodes. + * Maintain a synchronous cache of flattened data nodes. This will only be + * populated after initial render, and in certain cases, will be delayed due to + * relying on Observable `getChildren` calls. */ - private _dataNodes: BehaviorSubject = new BehaviorSubject([]); + private _flattenedNodes: BehaviorSubject = new BehaviorSubject([]); + + /** The automatically determined node type for the tree. */ + private _nodeType: BehaviorSubject<'flat' | 'nested' | null> = new BehaviorSubject< + 'flat' | 'nested' | null + >(null); /** The mapping between data and the node that is rendered. */ private _nodes: BehaviorSubject>> = new BehaviorSubject( new Map>(), ); + /** + * Synchronous cache of nodes for the `TreeKeyManager`. This is separate + * from `_flattenedNodes` so they can be independently updated at different + * times. + */ + private _keyManagerNodes: BehaviorSubject = new BehaviorSubject([]); + /** The key manager for this tree. Handles focus and activation based on user keyboard input. */ _keyManager: TreeKeyManager>; @@ -254,33 +260,7 @@ export class CdkTree } else if (provided === 0) { throw getTreeControlMissingError(); } - - // Check that the node type is also provided if treeControl is not. - if (!this.treeControl && !this.nodeType) { - throw getTreeControlNodeTypeUnspecifiedError(); - } - } - - let expansionModel; - if (!this.treeControl) { - expansionModel = new SelectionModel(true); - this._expansionModel = expansionModel; - } else { - expansionModel = this.treeControl.expansionModel; } - - // We manually detect changes on all the children nodes when expansion - // status changes; otherwise, the various attributes won't be updated. - expansionModel.changed - .pipe(withLatestFrom(this._nodes), takeUntil(this._onDestroy)) - .subscribe(([changes, nodes]) => { - for (const added of changes.added) { - nodes.get(added)?._changeDetectorRef.detectChanges(); - } - for (const removed of changes.removed) { - nodes.get(removed)?._changeDetectorRef.detectChanges(); - } - }); } ngOnDestroy() { @@ -302,12 +282,13 @@ export class CdkTree ngAfterContentInit() { this._keyManager = new TreeKeyManager({ - items: combineLatest([this._dataNodes, this._nodes]).pipe( + items: combineLatest([this._keyManagerNodes, this._nodes]).pipe( map(([dataNodes, nodes]) => dataNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish), ), ), trackBy: node => this._getExpansionKey(node.data), + skipPredicate: node => !!node.isDisabled, typeAheadDebounceInterval: true, horizontalOrientation: this._dir.value, }); @@ -337,6 +318,18 @@ export class CdkTree } } + /** + * Sets the node type for the tree, if it hasn't been set yet. + * + * This will be called by the first node that's rendered in order for the tree + * to determine what data transformations are required. + */ + _setNodeTypeIfUnset(nodeType: 'flat' | 'nested') { + if (this._nodeType.value === null) { + this._nodeType.next(nodeType); + } + } + /** * Sets the tabIndex on the host element. * @@ -392,18 +385,75 @@ export class CdkTree dataStream = observableOf(this._dataSource); } + let expansionModel; + if (!this.treeControl) { + this._expansionModel = new SelectionModel(true); + expansionModel = this._expansionModel; + } else { + expansionModel = this.treeControl.expansionModel; + } + if (dataStream) { - this._dataSubscription = dataStream + this._dataSubscription = combineLatest([ + dataStream, + this._nodeType, + // NB: the data is unused below, however we add it here to essentially + // trigger data rendering when expansion changes occur. + expansionModel.changed.pipe( + startWith(null), + tap(expansionChanges => { + this._emitExpansionChanges(expansionChanges); + }), + ), + ]) .pipe( - switchMap(data => this._convertChildren(data)), + switchMap(([data, nodeType]) => { + if (nodeType === null) { + return observableOf([{renderNodes: data}, nodeType] as const); + } + + // If we're here, then we know what our node type is, and therefore can + // perform our usual rendering pipeline, which necessitates converting the data + return this._convertData(data, nodeType).pipe( + map(convertedData => [convertedData, nodeType] as const), + ); + }), takeUntil(this._onDestroy), ) - .subscribe(data => this._renderNodeChanges(data)); + .subscribe(([data, nodeType]) => { + if (nodeType === null) { + // Skip saving cached and key manager data. + this._renderNodeChanges(data.renderNodes); + return; + } + + // If we're here, then we know what our node type is, and therefore can + // perform our usual rendering pipeline. + this._updateCachedData(data.flattenedNodes); + this._renderNodeChanges(data.renderNodes); + this._updateKeyManagerItems(data.flattenedNodes); + }); } else if (typeof ngDevMode === 'undefined' || ngDevMode) { throw getTreeNoValidDataSourceError(); } } + private _emitExpansionChanges(expansionChanges: SelectionChange | null) { + if (!expansionChanges) { + return; + } + + const nodes = this._nodes.value; + for (const added of expansionChanges.added) { + const node = nodes.get(added); + node?._emitExpansionState(true); + } + for (const removed of expansionChanges.removed) { + const node = nodes.get(removed); + node?._emitExpansionState(false); + } + } + /** Check for changes made in the data and render each change (node added/removed/moved). */ _renderNodeChanges( data: readonly T[], @@ -427,9 +477,11 @@ export class CdkTree } else if (currentIndex == null) { viewContainer.remove(adjustedPreviousIndex!); const group = this._getNodeGroup(item.item); - this._levels.delete(this._getExpansionKey(item.item)); - this._parents.delete(this._getExpansionKey(item.item)); - group.splice(group.indexOf(item.item), 1); + const key = this._getExpansionKey(item.item); + group.splice( + group.findIndex(groupItem => this._getExpansionKey(groupItem) === key), + 1, + ); } else { const view = viewContainer.get(adjustedPreviousIndex!); viewContainer.move(view!, currentIndex); @@ -466,15 +518,17 @@ export class CdkTree * within the data node view container. */ insertNode(nodeData: T, index: number, viewContainer?: ViewContainerRef, parentData?: T) { + const levelAccessor = this._getLevelAccessor(); + const node = this._getNodeDef(nodeData, index); + const key = this._getExpansionKey(nodeData); // Node context that will be provided to created embedded view const context = new CdkTreeNodeOutletContext(nodeData); - parentData ??= this._parents.get(this._getExpansionKey(nodeData)) ?? undefined; + parentData ??= this._parents.get(key) ?? undefined; // If the tree is flat tree, then use the `getLevel` function in flat tree control // Otherwise, use the level of parent node. - const levelAccessor = this._getLevelAccessor(); if (levelAccessor) { context.level = levelAccessor(nodeData); } else if ( @@ -485,17 +539,7 @@ export class CdkTree } else { context.level = 0; } - this._levels.set(this._getExpansionKey(nodeData), context.level); - const parent = parentData ?? this._findParentForNode(nodeData, index); - this._parents.set(this._getExpansionKey(nodeData), parent); - - // We're essentially replicating the tree structure within each `group`; - // we insert the node into the group at the specified index. - const currentGroup = this._groups.get(context.level) ?? new Map(); - const group = currentGroup.get(parent) ?? []; - group.splice(index, 0, nodeData); - currentGroup.set(parent, group); - this._groups.set(context.level, currentGroup); + this._levels.set(key, context.level); // Use default tree nodeOutlet, or nested node's nodeOutlet const container = viewContainer ? viewContainer : this._nodeOutlet.viewContainer; @@ -569,6 +613,7 @@ export class CdkTree this.treeControl.expandDescendants(dataNode); } else if (this._expansionModel) { const expansionModel = this._expansionModel; + expansionModel.select(this._getExpansionKey(dataNode)); this._getDescendants(dataNode) .pipe(take(1), takeUntil(this._onDestroy)) .subscribe(children => { @@ -583,6 +628,7 @@ export class CdkTree this.treeControl.collapseDescendants(dataNode); } else if (this._expansionModel) { const expansionModel = this._expansionModel; + expansionModel.deselect(this._getExpansionKey(dataNode)); this._getDescendants(dataNode) .pipe(take(1), takeUntil(this._onDestroy)) .subscribe(children => { @@ -597,7 +643,7 @@ export class CdkTree this.treeControl.expandAll(); } else if (this._expansionModel) { const expansionModel = this._expansionModel; - this._getAllDescendants() + this._getAllNodes() .pipe(takeUntil(this._onDestroy)) .subscribe(children => { expansionModel.select(...children.map(child => this._getExpansionKey(child))); @@ -611,7 +657,7 @@ export class CdkTree this.treeControl.collapseAll(); } else if (this._expansionModel) { const expansionModel = this._expansionModel; - this._getAllDescendants() + this._getAllNodes() .pipe(takeUntil(this._onDestroy)) .subscribe(children => { expansionModel.deselect(...children.map(child => this._getExpansionKey(child))); @@ -635,42 +681,48 @@ export class CdkTree */ _getDirectChildren(dataNode: T): Observable { const levelAccessor = this._getLevelAccessor(); - if (levelAccessor && this._expansionModel) { - const key = this._getExpansionKey(dataNode); - const isExpanded = this._expansionModel.changed.pipe( - switchMap(changes => { - if (changes.added.includes(key)) { - return observableOf(true); - } else if (changes.removed.includes(key)) { - return observableOf(false); - } - return EMPTY; - }), - startWith(this.isExpanded(dataNode)), - ); + const expansionModel = this._expansionModel ?? this.treeControl?.expansionModel; + if (!expansionModel) { + return observableOf([]); + } + + const key = this._getExpansionKey(dataNode); - return combineLatest([isExpanded, this._dataNodes]).pipe( - map(([expanded, dataNodes]) => { + const isExpanded = expansionModel.changed.pipe( + switchMap(changes => { + if (changes.added.includes(key)) { + return observableOf(true); + } else if (changes.removed.includes(key)) { + return observableOf(false); + } + return EMPTY; + }), + startWith(this.isExpanded(dataNode)), + ); + + if (levelAccessor) { + return combineLatest([isExpanded, this._flattenedNodes]).pipe( + map(([expanded, flattenedNodes]) => { if (!expanded) { return []; } - const startIndex = dataNodes.indexOf(dataNode); + const startIndex = flattenedNodes.findIndex(node => this._getExpansionKey(node) === key); const level = levelAccessor(dataNode) + 1; const results: T[] = []; - // Goes through flattened tree nodes in the `dataNodes` array, and get all direct descendants. - // The level of descendants of a tree node must be equal to the level of the given - // tree node + 1. + // Goes through flattened tree nodes in the `flattenedNodes` array, and get all direct + // descendants. The level of descendants of a tree node must be equal to the level of the + // given tree node + 1. // If we reach a node whose level is equal to the level of the tree node, we hit a sibling. // If we reach a node whose level is greater than the level of the tree node, we hit a // sibling of an ancestor. - for (let i = startIndex + 1; i < dataNodes.length; i++) { - const currentLevel = levelAccessor(dataNodes[i]); + for (let i = startIndex + 1; i < flattenedNodes.length; i++) { + const currentLevel = levelAccessor(flattenedNodes[i]); if (level > currentLevel) { break; } if (level === currentLevel) { - results.push(dataNodes[i]); + results.push(flattenedNodes[i]); } } return results; @@ -726,7 +778,8 @@ export class CdkTree */ _getPositionInSet(dataNode: T) { const group = this._getNodeGroup(dataNode); - return group.indexOf(dataNode) + 1; + const key = this._getExpansionKey(dataNode); + return group.findIndex(node => this._getExpansionKey(node) === key) + 1; } /** Given a CdkTreeNode, gets the node that renders that node's parent's data. */ @@ -737,7 +790,7 @@ export class CdkTree /** Given a CdkTreeNode, gets the nodes that renders that node's child data. */ _getNodeChildren(node: CdkTreeNode) { - return coerceObservable(this._getChildrenAccessor()?.(node.data) ?? []).pipe( + return this._getDirectChildren(node.data).pipe( map(children => children .map(child => this._nodes.value.get(this._getExpansionKey(child))) @@ -759,28 +812,22 @@ export class CdkTree this._keyManager.onInitialFocus(); } - /** - * Gets all nodes in the tree, through recursive expansion. - * - * NB: this will emit multiple times; the collective sum of the emissions - * will encompass the entire tree. This is done so that `expandAll` and - * `collapseAll` can incrementally expand/collapse instead of waiting for an - * all asynchronous operations to complete before expanding. - * - * Note also that this does not capture continual changes to descendants in - * the tree. - */ - private _getAllDescendants(): Observable { - return merge(...this._dataNodes.value.map(dataNode => this._getDescendants(dataNode))); + /** Gets all nodes in the tree, using the cached nodes. */ + private _getAllNodes(): Observable { + return this._flattenedNodes; } + /** Gets all nested descendants of a given node. */ private _getDescendants(dataNode: T): Observable { if (this.treeControl) { return observableOf(this.treeControl.getDescendants(dataNode)); } if (this.levelAccessor) { - const startIndex = this._dataNodes.value.indexOf(dataNode); - const results: T[] = [dataNode]; + const key = this._getExpansionKey(dataNode); + const startIndex = this._flattenedNodes.value.findIndex( + node => this._getExpansionKey(node) === key, + ); + 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 @@ -791,23 +838,20 @@ export class CdkTree const currentLevel = this.levelAccessor(dataNode); for ( let i = startIndex + 1; - i < this._dataNodes.value.length && - currentLevel < this.levelAccessor(this._dataNodes.value[i]); + i < this._flattenedNodes.value.length && + currentLevel < this.levelAccessor(this._flattenedNodes.value[i]); i++ ) { - results.push(this._dataNodes.value[i]); + results.push(this._flattenedNodes.value[i]); } return observableOf(results); } if (this.childrenAccessor) { return this._getAllChildrenRecursively(dataNode).pipe( - reduce( - (allChildren: T[], nextChildren) => { - allChildren.push(...nextChildren); - return allChildren; - }, - [dataNode], - ), + reduce((allChildren: T[], nextChildren) => { + allChildren.push(...nextChildren); + return allChildren; + }, []), ); } throw getTreeControlMissingError(); @@ -850,22 +894,28 @@ export class CdkTree } private _getNodeGroup(node: T) { - const level = this._levels.get(this._getExpansionKey(node)); - const parent = this._parents.get(this._getExpansionKey(node)); - const group = this._groups.get(level ?? 0)?.get(parent ?? null); + const key = this._getExpansionKey(node); + const parent = this._parents.get(key); + const parentKey = parent ? this._getExpansionKey(parent) : null; + const group = this._groups.get(parentKey); return group ?? [node]; } - private _findParentForNode(node: T, index: number) { + /** + * Finds the parent for the given node. If this is a root node, this + * returns null. If we're unable to determine the parent, for example, + * if we don't have cached node data, this returns undefined. + */ + private _findParentForNode(node: T, index: number, cachedNodes: readonly T[]): T | null { // In all cases, we have a mapping from node to level; all we need to do here is backtrack in // our flattened list of nodes to determine the first node that's of a level lower than the // provided node. - if (!this._dataNodes) { + if (!cachedNodes.length) { return null; } const currentLevel = this._levels.get(this._getExpansionKey(node)) ?? 0; - for (let parentIndex = index; parentIndex >= 0; parentIndex--) { - const parentNode = this._dataNodes.value[parentIndex]; + for (let parentIndex = index - 1; parentIndex >= 0; parentIndex--) { + const parentNode = cachedNodes[parentIndex]; const parentLevel = this._levels.get(this._getExpansionKey(parentNode)) ?? 0; if (parentLevel < currentLevel) { @@ -876,35 +926,149 @@ export class CdkTree } /** - * Converts children for certain tree configurations. Note also that this - * caches the known nodes for use in other parts of the tree. + * Given a set of root nodes and the current node level, flattens any nested + * nodes into a single array. + * + * If any nodes are not expanded, then their children will not be added into the array. + * NB: this will still traverse all nested children in order to build up our + * internal data models, but will not include them in the returned array. + */ + private _flattenNestedNodesWithExpansion(nodes: readonly T[], level = 0): Observable { + const childrenAccessor = this._getChildrenAccessor(); + // If we're using a level accessor, we don't need to flatten anything. + if (!childrenAccessor) { + return observableOf([...nodes]); + } + + return observableOf(...nodes).pipe( + concatMap(node => { + const parentKey = this._getExpansionKey(node); + if (!this._parents.has(parentKey)) { + this._parents.set(parentKey, null); + } + this._levels.set(parentKey, level); + + const children = coerceObservable(childrenAccessor(node)); + return concat( + observableOf([node]), + children.pipe( + take(1), + tap(childNodes => { + this._groups.set(parentKey, [...(childNodes ?? [])]); + for (const child of childNodes ?? []) { + const childKey = this._getExpansionKey(child); + this._parents.set(childKey, node); + this._levels.set(childKey, level + 1); + } + }), + switchMap(childNodes => { + if (!childNodes) { + return observableOf([]); + } + return this._flattenNestedNodesWithExpansion(childNodes, level + 1).pipe( + map(nestedNodes => (this.isExpanded(node) ? nestedNodes : [])), + ); + }), + ), + ); + }), + reduce((results, children) => { + results.push(...children); + return results; + }, [] as T[]), + ); + } + + /** + * Converts children for certain tree configurations. + * + * This also computes parent, level, and group data. */ - private _convertChildren(nodes: readonly T[]): Observable { + private _convertData( + nodes: readonly T[], + nodeType: 'flat' | 'nested', + ): Observable<{ + renderNodes: readonly T[]; + flattenedNodes: readonly T[]; + }> { // The only situations where we have to convert children types is when // they're mismatched; i.e. if the tree is using a childrenAccessor and the // nodes are flat, or if the tree is using a levelAccessor and the nodes are // nested. - if (this.childrenAccessor && this.nodeType === 'flat') { + if (this.childrenAccessor && nodeType === 'flat') { // This flattens children into a single array. - return observableOf(...nodes).pipe( - concatMap(node => concat(observableOf([node]), this._getAllChildrenRecursively(node))), - reduce((results, children) => { - results.push(...children); - return results; - }, [] as T[]), - tap(allNodes => { - this._dataNodes.next(allNodes); - }), + this._groups.set(null, [...nodes]); + return this._flattenNestedNodesWithExpansion(nodes).pipe( + map(flattenedNodes => ({ + renderNodes: flattenedNodes, + flattenedNodes, + })), ); - } else if (this.levelAccessor && this.nodeType === 'nested') { - this._dataNodes.next(nodes); + } else if (this.levelAccessor && nodeType === 'nested') { // In the nested case, we only look for root nodes. The CdkNestedNode // itself will handle rendering each individual node's children. const levelAccessor = this.levelAccessor; - return observableOf(nodes.filter(node => levelAccessor(node) === 0)); + return observableOf(nodes.filter(node => levelAccessor(node) === 0)).pipe( + map(rootNodes => ({ + renderNodes: rootNodes, + flattenedNodes: nodes, + })), + tap(({flattenedNodes}) => { + this._calculateParents(flattenedNodes); + }), + ); + } else if (nodeType === 'flat') { + // In the case of a TreeControl, we know that the node type matches up + // with the TreeControl, and so no conversions are necessary. Otherwise, + // we've already confirmed that the data model matches up with the + // desired node type here. + return observableOf({renderNodes: nodes, flattenedNodes: nodes}).pipe( + tap(({flattenedNodes}) => { + this._calculateParents(flattenedNodes); + }), + ); } else { - this._dataNodes.next(nodes); - return observableOf(nodes); + // For nested nodes, we still need to perform the node flattening in order + // to maintain our caches for various tree operations. + this._groups.set(null, [...nodes]); + return this._flattenNestedNodesWithExpansion(nodes).pipe( + map(flattenedNodes => ({ + renderNodes: nodes, + flattenedNodes, + })), + ); + } + } + + private _updateCachedData(flattenedNodes: readonly T[]) { + this._flattenedNodes.next(flattenedNodes); + } + + private _updateKeyManagerItems(flattenedNodes: readonly T[]) { + this._keyManagerNodes.next(flattenedNodes); + } + + /** Traverse the flattened node data and compute parents, levels, and group data. */ + private _calculateParents(flattenedNodes: readonly T[]): void { + const levelAccessor = this._getLevelAccessor(); + if (!levelAccessor) { + return; + } + + this._parents.clear(); + this._groups.clear(); + + for (let index = 0; index < flattenedNodes.length; index++) { + const dataNode = flattenedNodes[index]; + const key = this._getExpansionKey(dataNode); + this._levels.set(key, levelAccessor(dataNode)); + const parent = this._findParentForNode(dataNode, index, flattenedNodes); + this._parents.set(key, parent); + const parentKey = parent ? this._getExpansionKey(parent) : null; + + const group = this._groups.get(parentKey) ?? []; + group.splice(index, 0, dataNode); + this._groups.set(parentKey, group); } } } @@ -922,6 +1086,7 @@ export class CdkTree '[attr.aria-posinset]': '_getPositionInSet()', '[attr.aria-setsize]': '_getSetSize()', 'tabindex': '-1', + 'role': 'treeitem', '(click)': '_setActiveItem()', }, }) @@ -939,7 +1104,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI set role(_role: 'treeitem' | 'group') { // ignore any role setting, we handle this internally. - this._setRoleFromData(); } /** @@ -948,7 +1112,13 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI * If not using `FlatTreeControl`, or if `isExpandable` is not provided to * `NestedTreeControl`, this should be provided for correct node a11y. */ - @Input() isExpandable: boolean = false; + @Input() + get isExpandable() { + return this._isExpandable(); + } + set isExpandable(isExpandable: boolean | '' | null) { + this._inputIsExpandable = coerceBooleanProperty(isExpandable); + } @Input() get isExpanded(): boolean { @@ -968,7 +1138,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI */ @Input() isDisabled?: boolean; - /** This emits when the node has been programatically activated. */ + /** This emits when the node has been programatically activated or activated by keyboard. */ @Output() readonly activation: EventEmitter = new EventEmitter(); @@ -988,6 +1158,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Emits when the node's data has changed. */ readonly _dataChanges = new Subject(); + private _inputIsExpandable: boolean = false; private _parentNodeAriaLevel: number; /** The tree node's data. */ @@ -997,7 +1168,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI set data(value: T) { if (value !== this._data) { this._data = value; - this._setRoleFromData(); this._dataChanges.next(); } } @@ -1015,7 +1185,7 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI if (typeof this._tree.treeControl?.isExpandable === 'function') { return this._tree.treeControl.isExpandable(this._data); } - return this.isExpandable; + return this._inputIsExpandable; } /** @@ -1054,11 +1224,11 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI public _changeDetectorRef: ChangeDetectorRef, ) { CdkTreeNode.mostRecentTreeNode = this as CdkTreeNode; - this.role = 'treeitem'; } ngOnInit(): void { this._parentNodeAriaLevel = getParentNodeAriaLevel(this._elementRef.nativeElement); + this._tree._setNodeTypeIfUnset('flat'); this._tree._registerNode(this); } @@ -1097,14 +1267,18 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Collapses this data node. Implemented for TreeKeyManagerItem. */ collapse(): void { + if (!this._isExpandable()) { + return; + } this._tree.collapse(this._data); - this.expandedChange.emit(this.isExpanded); } /** Expands this data node. Implemented for TreeKeyManagerItem. */ expand(): void { + if (!this._isExpandable()) { + return; + } this._tree.expand(this._data); - this.expandedChange.emit(this.isExpanded); } _setTabFocusable() { @@ -1122,9 +1296,8 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI this._tree._keyManager.onClick(this); } - // TODO: role should eventually just be set in the component host - protected _setRoleFromData(): void { - this._elementRef.nativeElement.setAttribute('role', 'treeitem'); + _emitExpansionState(expanded: boolean) { + this.expandedChange.emit(expanded); } } diff --git a/src/components-examples/cdk/tree/BUILD.bazel b/src/components-examples/cdk/tree/BUILD.bazel index da97ed3380ec..46b61041e57a 100644 --- a/src/components-examples/cdk/tree/BUILD.bazel +++ b/src/components-examples/cdk/tree/BUILD.bazel @@ -13,6 +13,7 @@ ng_module( "//src/cdk/tree", "//src/material/button", "//src/material/icon", + "//src/material/progress-spinner", ], ) diff --git a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.css b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.css new file mode 100644 index 000000000000..00fa2d29167f --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.css @@ -0,0 +1,4 @@ +cdk-tree-node { + display: flex; + align-items: center; +} diff --git a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.html b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.html new file mode 100644 index 000000000000..3796809f4336 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.html @@ -0,0 +1,34 @@ + + + + + + + + + + + +
+ + {{node.raw.name}} +
+
+
diff --git a/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts new file mode 100644 index 000000000000..eb4f34017d2c --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts @@ -0,0 +1,303 @@ +import {CdkTreeModule} from '@angular/cdk/tree'; +import {CommonModule} from '@angular/common'; +import {Component, OnInit} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {BehaviorSubject, Observable, combineLatest, of as observableOf} from 'rxjs'; +import {delay, map, shareReplay} from 'rxjs/operators'; + +interface BackendData { + id: string; + name: string; + parent?: string; + children?: string[]; +} + +const TREE_DATA: Map = new Map( + [ + { + id: '1', + name: 'Fruit', + children: ['1-1', '1-2', '1-3'], + }, + {id: '1-1', name: 'Apple', parent: '1'}, + {id: '1-2', name: 'Banana', parent: '1'}, + {id: '1-3', name: 'Fruit Loops', parent: '1'}, + { + id: '2', + name: 'Vegetables', + children: ['2-1', '2-2'], + }, + { + id: '2-1', + name: 'Green', + parent: '2', + children: ['2-1-1', '2-1-2'], + }, + { + id: '2-2', + name: 'Orange', + parent: '2', + children: ['2-2-1', '2-2-2'], + }, + {id: '2-1-1', name: 'Broccoli', parent: '2-1'}, + {id: '2-1-2', name: 'Brussel sprouts', parent: '2-1'}, + {id: '2-2-1', name: 'Pumpkins', parent: '2-2'}, + {id: '2-2-2', name: 'Carrots', parent: '2-2'}, + ].map(datum => [datum.id, datum]), +); + +class FakeDataBackend { + private _getRandomDelayTime() { + // anywhere from 100 to 500ms. + return Math.floor(Math.random() * 400) + 100; + } + + getChildren(id: string): Observable { + // first, find the specified ID in our tree + const item = TREE_DATA.get(id); + const children = item?.children ?? []; + + return observableOf(children.map(childId => TREE_DATA.get(childId)!)).pipe( + delay(this._getRandomDelayTime()), + ); + } + + getRoots(): Observable { + return observableOf([...TREE_DATA.values()].filter(datum => !datum.parent)).pipe( + delay(this._getRandomDelayTime()), + ); + } +} + +type LoadingState = 'INIT' | 'LOADING' | 'LOADED'; + +interface RawData { + id: string; + name: string; + parentId?: string; + childrenIds?: string[]; + childrenLoading: LoadingState; +} + +class TransformedData { + constructor(public raw: RawData) {} + + areChildrenLoading() { + return this.raw.childrenLoading === 'LOADING'; + } + + isExpandable() { + return ( + (this.raw.childrenLoading === 'INIT' || this.raw.childrenLoading === 'LOADED') && + !!this.raw.childrenIds?.length + ); + } + + isLeaf() { + return !this.isExpandable() && !this.areChildrenLoading(); + } +} + +interface State { + rootIds: string[]; + rootsLoading: LoadingState; + allData: Map; + dataLoading: Map; +} + +type ObservedValueOf = T extends Observable ? U : never; + +type ObservedValuesOf[]> = { + [K in keyof T]: ObservedValueOf; +}; + +type TransformFn[], U> = ( + ...args: [...ObservedValuesOf, State] +) => U; + +class ComplexDataStore { + private readonly _backend = new FakeDataBackend(); + + private _state = new BehaviorSubject({ + rootIds: [], + rootsLoading: 'INIT', + allData: new Map(), + dataLoading: new Map(), + }); + + private readonly _rootIds = this.select(state => state.rootIds); + private readonly _allData = this.select(state => state.allData); + private readonly _loadingData = this.select(state => state.dataLoading); + private readonly _rootsLoadingState = this.select(state => state.rootsLoading); + readonly areRootsLoading = this.select( + this._rootIds, + this._loadingData, + this._rootsLoadingState, + (rootIds, loading, rootsLoading) => + rootsLoading !== 'LOADED' || rootIds.some(id => loading.get(id) !== 'LOADED'), + ); + readonly roots = this.select( + this.areRootsLoading, + this._rootIds, + this._allData, + (rootsLoading, rootIds, data) => { + if (rootsLoading) { + return []; + } + return this._getDataByIds(rootIds, data); + }, + ); + + getChildren(parentId: string) { + return this.select(this._allData, this._loadingData, (data, loading) => { + const parentData = data.get(parentId); + if (parentData?.childrenLoading !== 'LOADED') { + return []; + } + const childIds = parentData.childrenIds ?? []; + if (childIds.some(id => loading.get(id) !== 'LOADED')) { + return []; + } + return this._getDataByIds(childIds, data); + }); + } + + loadRoots() { + this._setRootsLoading(); + this._backend.getRoots().subscribe(roots => { + this._setRoots(roots); + }); + } + + loadChildren(parentId: string) { + this._setChildrenLoading(parentId); + this._backend.getChildren(parentId).subscribe(children => { + this._addLoadedData(parentId, children); + }); + } + + private _setRootsLoading() { + this._state.next({ + ...this._state.value, + rootsLoading: 'LOADING', + }); + } + + private _setRoots(roots: BackendData[]) { + const currentState = this._state.value; + + this._state.next({ + ...currentState, + rootIds: roots.map(root => root.id), + rootsLoading: 'LOADED', + ...this._addData(currentState, roots), + }); + } + + private _setChildrenLoading(parentId: string) { + const currentState = this._state.value; + const parentData = currentState.allData.get(parentId); + + this._state.next({ + ...currentState, + dataLoading: new Map([ + ...currentState.dataLoading, + ...(parentData?.childrenIds?.map(childId => [childId, 'LOADING'] as const) ?? []), + ]), + }); + } + + private _addLoadedData(parentId: string, childData: BackendData[]) { + const currentState = this._state.value; + + this._state.next({ + ...currentState, + ...this._addData(currentState, childData, parentId), + }); + } + + private _addData( + {allData, dataLoading}: State, + data: BackendData[], + parentId?: string, + ): Pick { + const parentData = parentId && allData.get(parentId); + const allChildren = data.flatMap(datum => datum.children ?? []); + return { + allData: new Map([ + ...allData, + ...data.map(datum => { + return [ + datum.id, + { + id: datum.id, + name: datum.name, + parentId, + childrenIds: datum.children, + childrenLoading: 'INIT', + }, + ] as const; + }), + ...(parentData ? ([[parentId, {...parentData, childrenLoading: 'LOADED'}]] as const) : []), + ]), + dataLoading: new Map([ + ...dataLoading, + ...data.map(datum => [datum.id, 'LOADED'] as const), + ...allChildren.map(childId => [childId, 'INIT'] as const), + ]), + }; + } + + private _getDataByIds(ids: string[], data: State['allData']) { + return ids + .map(id => data.get(id)) + .filter((item: T | undefined): item is T => !!item) + .map(datum => new TransformedData(datum)); + } + + select[], U>( + ...sourcesAndTransform: [...T, TransformFn] + ) { + const sources = sourcesAndTransform.slice(0, -1) as unknown as T; + const transformFn = sourcesAndTransform[sourcesAndTransform.length - 1] as TransformFn; + + return combineLatest([...sources, this._state]).pipe( + map(args => transformFn(...(args as [...ObservedValuesOf, State]))), + shareReplay({refCount: true, bufferSize: 1}), + ); + } +} + +/** + * @title Complex example making use of the redux pattern. + */ +@Component({ + selector: 'cdk-tree-complex-example', + templateUrl: 'cdk-tree-complex-example.html', + styleUrls: ['cdk-tree-complex-example.css'], + standalone: true, + imports: [CdkTreeModule, MatButtonModule, MatIconModule, CommonModule, MatProgressSpinnerModule], +}) +export class CdkTreeComplexExample implements OnInit { + private readonly _dataStore = new ComplexDataStore(); + + areRootsLoading = this._dataStore.areRootsLoading; + roots = this._dataStore.roots; + + getChildren = (node: TransformedData) => this._dataStore.getChildren(node.raw.id); + trackBy = (index: number, node: TransformedData) => this.expansionKey(node); + expansionKey = (node: TransformedData) => node.raw.id; + + ngOnInit() { + this._dataStore.loadRoots(); + } + + onExpand(node: TransformedData, expanded: boolean) { + if (expanded) { + // Only perform a load on expansion. + this._dataStore.loadChildren(node.raw.id); + } + } +} diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html index 6976db2de744..b16da4c8e2db 100644 --- a/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html @@ -1,4 +1,4 @@ - + + + {{node.name}} diff --git a/src/components-examples/cdk/tree/cdk-tree-nested/cdk-tree-nested-example.html b/src/components-examples/cdk/tree/cdk-tree-nested/cdk-tree-nested-example.html index ec03afc6a8fa..403cb3b7a13e 100644 --- a/src/components-examples/cdk/tree/cdk-tree-nested/cdk-tree-nested-example.html +++ b/src/components-examples/cdk/tree/cdk-tree-nested/cdk-tree-nested-example.html @@ -1,8 +1,6 @@ - @@ -11,7 +9,7 @@ - {{node.name}} - - -
- + +
+ + {{node.name}} +
+ +
+
diff --git a/src/dev-app/tree/tree-demo.html b/src/dev-app/tree/tree-demo.html index 21fb0e5d8196..53228d32d04b 100644 --- a/src/dev-app/tree/tree-demo.html +++ b/src/dev-app/tree/tree-demo.html @@ -39,4 +39,8 @@ Load more flat tree + + Complex tree (Redux pattern) + + diff --git a/src/dev-app/tree/tree-demo.ts b/src/dev-app/tree/tree-demo.ts index 5bacdc861850..80d535bfa70c 100644 --- a/src/dev-app/tree/tree-demo.ts +++ b/src/dev-app/tree/tree-demo.ts @@ -14,6 +14,7 @@ import { CdkTreeFlatLevelAccessorExample, CdkTreeNestedLevelAccessorExample, CdkTreeFlatChildrenAccessorExample, + CdkTreeComplexExample, } from '@angular/components-examples/cdk/tree'; import { TreeChecklistExample, @@ -45,6 +46,7 @@ import {MatTreeModule} from '@angular/material/tree'; CdkTreeFlatChildrenAccessorExample, CdkTreeFlatLevelAccessorExample, CdkTreeNestedLevelAccessorExample, + CdkTreeComplexExample, CommonModule, FormsModule, TreeChecklistExample, diff --git a/src/material/tree/tree.md b/src/material/tree/tree.md index 21c030f9a27c..0f9bab5e8eb7 100644 --- a/src/material/tree/tree.md +++ b/src/material/tree/tree.md @@ -1,21 +1,17 @@ -The `mat-tree` provides a Material Design styled tree that can be used to display hierarchy +The `mat-tree` provides a Material Design styled tree that can be used to display hierarchical 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 trees generally offer better performance, while nested trees provide -flexibility. +There are two types of trees: flat and nested. 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. +In a flat tree, the hierarchy is flattened; nodes are not rendered inside of each other, +but instead are rendered as siblings in sequence. ```html @@ -28,14 +24,12 @@ used to style the node such that it is indented to the appropriate level. Flat trees are generally easier to style and inspect. They are also more friendly to scrolling -variations, such as infinite or virtual scrolling. Flat trees -generally offer better performance. - - +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. + +In a nested tree, children nodes are placed inside their parent node in DOM. The parent node +contains a node outlet into which children are projected. ```html @@ -49,26 +43,66 @@ outlet to keep all the children nodes. -Nested trees are easier to work with when hierarchical relationships are visually -represented in ways that would be difficult to accomplish with flat nodes. +Nested trees are easier to work with when hierarchical relationships are visually represented in +ways that would be difficult to accomplish with flat nodes. - +### Usage -### Features +#### Writing your tree template -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. +In order to use the tree, you must define a 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. -### TreeControl +A node definition is specified via any element with `matNodeDef`. This directive exports the node +data to be used in any bindings in the node template. + +```html + + {{node.key}}: {{node.value}} + +``` -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. The `getChildren` function may return an -observable of children for a given node, or an array of children. -For flattened tree node, `getLevel` and `isExpandable` functions need to pass to the -`FlatTreeControl` to make it work recursively. +##### Flat tree node template + +Flat trees use the `level` of a node to both render and determine hierarchy of the nodes for screen +readers. This may be provided either via `levelAccessor`, or will be calculated by `MatTree` if +`childrenAccessor` is provided. + +Spacing can be added either by applying the `matNodePadding` directive or by applying custom styles +based on the `aria-level` attribute. + + +##### Nested tree node template + +When using nested tree nodes, the node template must contain a `matTreeNodeOutlet`, which marks +where the children of the node will be rendered. + +```html + + {{node.value}} + + +``` + +#### Adding expand/collapse + +The `matTreeNodeToggle` directive can be used to add expand/collapse functionality for tree nodes. +The toggle calls the expand/collapse functions in the `matTree` and is able to expand/collapse +a tree node recursively by setting `[matTreeNodeToggleRecursive]` to true. + +`matTreeNodeToggle` should be attached to button elements, and will trigger upon click or keyboard +activation. For icon buttons, ensure that `aria-label` is provided. + +```html + + + {{node.value}} + +``` ### Toggle @@ -84,16 +118,93 @@ The toggle can be placed anywhere in the tree node, and is only toggled by `clic 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. +```html + + {{node.value}} + +``` + +This is unnecessary for a nested tree, since the hierarchical structure of the DOM allows for +padding to be added via CSS. + +#### 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. + +```html + + {{node.value}} + + + [ A special node {{node.value}} ] + +``` + +### Data Source + +#### Connecting the tree to a data source + +Similar to `mat-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. + +There are two main methods of providing data to the tree: + +* flattened data, combined with `levelAccessor`. This should be used if the data source already + flattens the nested data structure into a single array. +* only root data, combined with `childrenAccessor`. This should be used if the data source is + already provided as a nested data structure. + +#### `levelAccessor` + +`levelAccessor` is a function that when provided a datum, returns the level the data sits at in the +tree structure. If `levelAccessor` is provided, the data provided by `dataSource` should contain all +renderable nodes in a single array. + +The data source is responsible for handling node expand/collapse events and providing an updated +array of renderable nodes, if applicable. This can be listened to via the `(expansionChange)` event +on `mat-tree-node` and `mat-nested-tree-node`. + +#### `childrenAccessor` + +`childrenAccessor` is a function that when provided a datum, returns the children of that particular +datum. If `childrenAccessor` is provided, the data provided by `dataSource` should _only_ contain +the root nodes of the tree. + +#### `trackBy` + +To improve performance, a `trackBy` function can be provided to the tree similar to Angular’s +[`ngFor` `trackBy`](https://angular.io/api/common/NgForOf#change-propagation). This informs the +tree how to uniquely identify nodes to track how the data changes with each update. + +```html + +``` ### 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"` +The `` implements the [`tree` widget](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/), +including keyboard navigation and appropriate roles and ARIA attributes. + +#### Activation actions + +For trees with nodes that have actions upon activation or click, `` will emit +`(activation)` events that can be listened to when the user activates a node via keyboard +interaction. + +```html + + +``` -`mat-tree` does not manage any focus/keyboard interaction on its own. Users can add desired -focus/keyboard interactions in their application. +In this example, `$event` contains the node's data and is equivalent to the implicit data passed in +the `matNodeDef` context. diff --git a/src/material/tree/tree.ts b/src/material/tree/tree.ts index 1a6c2c609877..70bf55328846 100644 --- a/src/material/tree/tree.ts +++ b/src/material/tree/tree.ts @@ -19,9 +19,6 @@ import {MatTreeNodeOutlet} from './outlet'; template: ``, host: { 'class': 'mat-tree', - 'role': 'tree', - '(keydown)': '_sendKeydownToKeyManager($event)', - '(focus)': '_focusInitialTreeItem()', }, styleUrls: ['tree.css'], encapsulation: ViewEncapsulation.None, diff --git a/tools/public_api_guard/cdk/a11y.md b/tools/public_api_guard/cdk/a11y.md index dc134ebb51b5..85a8d2daff6d 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -428,6 +428,16 @@ export function removeAriaReferencedId(el: Element, attr: `aria-${string}`, id: export class TreeKeyManager { constructor({ items, skipPredicate, trackBy, horizontalOrientation, activationFollowsFocus, typeAheadDebounceInterval, }: TreeKeyManagerOptions); readonly change: Subject; + focusFirstItem(): void; + focusItem(index: number, options?: { + emitChangeEvent?: boolean; + }): void; + focusItem(item: T, options?: { + emitChangeEvent?: boolean; + }): void; + focusLastItem(): void; + focusNextItem(): void; + focusPreviousItem(): void; getActiveItem(): T | null; getActiveItemIndex(): number | null; onClick(treeItem: T): void; @@ -449,7 +459,7 @@ export interface TreeKeyManagerItem { isExpanded: (() => boolean) | boolean; } -// @public (undocumented) +// @public export interface TreeKeyManagerOptions { activationFollowsFocus?: boolean; horizontalOrientation?: 'rtl' | 'ltr'; diff --git a/tools/public_api_guard/cdk/tree.md b/tools/public_api_guard/cdk/tree.md index b9bb20eb1b93..27d682fed7f1 100644 --- a/tools/public_api_guard/cdk/tree.md +++ b/tools/public_api_guard/cdk/tree.md @@ -115,10 +115,10 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, _nodeDefs: QueryList>; // (undocumented) _nodeOutlet: CdkTreeNodeOutlet; - nodeType?: 'flat' | 'nested'; _registerNode(node: CdkTreeNode): void; _renderNodeChanges(data: readonly T[], dataDiffer?: IterableDiffer, viewContainer?: ViewContainerRef, parentData?: T): void; _sendKeydownToKeyManager(event: KeyboardEvent): void; + _setNodeTypeIfUnset(nodeType: 'flat' | 'nested'): void; _setTabIndex(): void; toggle(dataNode: T): void; toggleDescendants(dataNode: T): void; @@ -131,7 +131,7 @@ export class CdkTree implements AfterContentChecked, AfterContentInit, end: number; }>; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-tree", ["cdkTree"], { "dataSource": { "alias": "dataSource"; "required": false; }; "treeControl": { "alias": "treeControl"; "required": false; }; "levelAccessor": { "alias": "levelAccessor"; "required": false; }; "childrenAccessor": { "alias": "childrenAccessor"; "required": false; }; "trackBy": { "alias": "trackBy"; "required": false; }; "expansionKey": { "alias": "expansionKey"; "required": false; }; "nodeType": { "alias": "nodeType"; "required": false; }; }, {}, ["_nodeDefs"], never, false, never, false>; + static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-tree", ["cdkTree"], { "dataSource": { "alias": "dataSource"; "required": false; }; "treeControl": { "alias": "treeControl"; "required": false; }; "levelAccessor": { "alias": "levelAccessor"; "required": false; }; "childrenAccessor": { "alias": "childrenAccessor"; "required": false; }; "trackBy": { "alias": "trackBy"; "required": false; }; "expansionKey": { "alias": "expansionKey"; "required": false; }; }, {}, ["_nodeDefs"], never, false, never, false>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -162,6 +162,8 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI protected readonly _destroyed: Subject; // (undocumented) protected _elementRef: ElementRef; + // (undocumented) + _emitExpansionState(expanded: boolean): void; expand(): void; readonly expandedChange: EventEmitter; focus(): void; @@ -173,7 +175,8 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI _getPositionInSet(): number; _getSetSize(): number; isDisabled?: boolean; - isExpandable: boolean; + get isExpandable(): boolean | '' | null; + set isExpandable(isExpandable: boolean | '' | null); _isExpandable(): boolean; // (undocumented) get isExpanded(): boolean; @@ -191,8 +194,6 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI // (undocumented) _setActiveItem(): void; // (undocumented) - protected _setRoleFromData(): void; - // (undocumented) _setTabFocusable(): void; // (undocumented) _setTabUnfocusable(): void; @@ -307,9 +308,6 @@ export function getMultipleTreeControlsError(): Error; // @public export function getTreeControlMissingError(): Error; -// @public -export function getTreeControlNodeTypeUnspecifiedError(): Error; - // @public export function getTreeMissingMatchingNodeDefError(): Error;