From edd45ed26158cf5c8765fb8ff5095b34138f6baf Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:22 -0500 Subject: [PATCH 01/18] feat(cdk/tree): add cache of nodes to the tree --- src/cdk/tree/tree.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 780e8697ad1b..3255d4b35d8b 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -60,6 +60,10 @@ function coerceObservable(data: T | Observable): Observable { return data; } +function isNotNullish(val: T | null | undefined): val is T { + return val != null; +} + /** * 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. From 6210d3dced3301b03f1f614af384c26f33a8bf8b Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:22 -0500 Subject: [PATCH 02/18] feat(cdk/tree): bug fixes for tree and key manager --- src/cdk/a11y/key-manager/tree-key-manager.ts | 8 ++ src/cdk/tree/toggle.ts | 2 +- src/cdk/tree/tree.ts | 124 +++++++++++++++++-- 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/cdk/a11y/key-manager/tree-key-manager.ts b/src/cdk/a11y/key-manager/tree-key-manager.ts index e998ece5d102..f5103d05b42b 100644 --- a/src/cdk/a11y/key-manager/tree-key-manager.ts +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -276,6 +276,14 @@ export class TreeKeyManager { return this._activeItem; } + /** + * Focus the initial element; this is intended to be called when the tree is focused for + * the first time. + */ + onInitialFocus(): void { + this._focusFirstItem(); + } + private _setActiveItem(index: number): void; private _setActiveItem(item: T): void; private _setActiveItem(itemOrIndex: number | T) { diff --git a/src/cdk/tree/toggle.ts b/src/cdk/tree/toggle.ts index a5f95cee5fab..3a4466f776ca 100644 --- a/src/cdk/tree/toggle.ts +++ b/src/cdk/tree/toggle.ts @@ -18,7 +18,7 @@ import {CdkTree, CdkTreeNode} from './tree'; selector: '[cdkTreeNodeToggle]', host: { '(click)': '_toggle($event)', - 'tabindex': '0', + 'tabindex': '-1', }, }) export class CdkTreeNodeToggle { diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 3255d4b35d8b..532745e638da 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -5,7 +5,8 @@ * 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 {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 { @@ -16,12 +17,14 @@ import { ContentChildren, Directive, ElementRef, + EventEmitter, Input, IterableChangeRecord, IterableDiffer, IterableDiffers, OnDestroy, OnInit, + Output, QueryList, TrackByFunction, ViewChild, @@ -75,6 +78,9 @@ function isNotNullish(val: T | null | undefined): val is T { host: { 'class': 'cdk-tree', 'role': 'tree', + '[attr.tabindex]': '_getTabindex()', + '(keydown)': '_sendKeydownToKeyManager($event)', + '(focus)': '_focusInitialTreeItem()', }, encapsulation: ViewEncapsulation.None, @@ -214,7 +220,14 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, new Map>(), ); - constructor(private _differs: IterableDiffers, private _changeDetectorRef: ChangeDetectorRef) {} + /** The key manager for this tree. Handles focus and activation based on user keyboard input. */ + _keyManager: TreeKeyManager>; + + constructor( + private _differs: IterableDiffers, + private _changeDetectorRef: ChangeDetectorRef, + private _dir: Directionality, + ) {} ngOnInit() { this._dataDiffer = this._differs.find([]).create(this.trackBy); @@ -256,6 +269,26 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, } } + ngAfterContentInit() { + this._keyManager = new TreeKeyManager({ + items: combineLatest([this._dataNodes, this._nodes]).pipe( + map(([dataNodes, nodes]) => + dataNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish), + ), + ), + trackBy: node => this._getExpansionKey(node.data), + typeAheadDebounceInterval: true, + horizontalOrientation: this._dir.value, + }); + + this._keyManager.change + .pipe(startWith(null), pairwise(), takeUntil(this._onDestroy)) + .subscribe(([prev, next]) => { + prev?._setTabUnfocusable(); + next?._setTabFocusable(); + }); + } + ngAfterContentChecked() { const defaultNodeDefs = this._nodeDefs.filter(def => !def.when); if (defaultNodeDefs.length > 1 && (typeof ngDevMode === 'undefined' || ngDevMode)) { @@ -268,13 +301,17 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, } } - // TODO(tinayuangao): Work on keyboard traversal and actions, make sure it's working for RTL - // and nested trees. + _getTabindex() { + // If the `TreeKeyManager` has no active item, then we know that we need to focus the initial + // item when the tree is focused. We set the tabindex to be `0` so that we can capture + // the focus event and redirect it. Otherwise, we unset it. + return this._keyManager.getActiveItem() ? null : 0; + } /** * 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. + * 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') { @@ -649,6 +686,37 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, return group.indexOf(dataNode) + 1; } + /** Given a CdkTreeNode, gets the node that renders that node's parent's data. */ + _getNodeParent(node: CdkTreeNode) { + const parent = this._parents.get(node.data); + return parent && this._nodes.value.get(this._getExpansionKey(parent)); + } + + /** Given a CdkTreeNode, gets the nodes that renders that node's child data. */ + _getNodeChildren(node: CdkTreeNode) { + const children = coerceObservable(this._getChildrenAccessor()?.(node.data) ?? []); + return children.pipe( + map(children => + children + .map(child => this._nodes.value.get(this._getExpansionKey(child))) + .filter(isNotNullish), + ), + ); + } + + /** `keydown` event handler; this just passes the event to the `TreeKeyManager`. */ + _sendKeydownToKeyManager(event: KeyboardEvent) { + this._keyManager.onKeydown(event); + } + + /** `focus` event handler; this focuses the initial item if there isn't already one available. */ + _focusInitialTreeItem() { + if (this._keyManager.getActiveItem()) { + return; + } + this._keyManager.onInitialFocus(); + } + /** * Gets all nodes in the tree, through recursive expansion. * @@ -811,9 +879,10 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, '[attr.aria-level]': 'level + 1', '[attr.aria-posinset]': '_getPositionInSet()', '[attr.aria-setsize]': '_getSetSize()', + 'tabindex': '-1', }, }) -export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit { +export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { /** * The role of the tree node. * @@ -844,6 +913,16 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit } } + /** + * Whether or not this node is disabled. If it's disabled, then the user won't be able to focus + * or activate this node. + */ + @Input() isDisabled?: boolean; + + /** This emits when the node has been programatically activated. */ + @Output() + readonly activation: EventEmitter = new EventEmitter(); + /** * The most recently created `CdkTreeNode`. We save it in static variable so we can retrieve it * in `CdkTree` and set the data to it. @@ -918,11 +997,42 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit this._destroyed.complete(); } - /** Focuses the menu item. Implements for FocusableOption. */ + getParent(): CdkTreeNode | null { + return this._tree._getNodeParent(this) ?? null; + } + + getChildren(): Array> | Observable>> { + return this._tree._getNodeChildren(this); + } + + /** Focuses this data node. Implemented for TreeKeyManagerItem. */ focus(): void { this._elementRef.nativeElement.focus(); } + /** Emits an activation event. Implemented for TreeKeyManagerItem. */ + activate(): void { + this.activation.next(this._data); + } + + /** Collapses this data node. Implemented for TreeKeyManagerItem. */ + collapse(): void { + this._tree.collapse(this._data); + } + + /** Expands this data node. Implemented for TreeKeyManagerItem. */ + expand(): void { + this._tree.expand(this._data); + } + + _setTabFocusable() { + this._elementRef.nativeElement.setAttribute('tabindex', '0'); + } + + _setTabUnfocusable() { + this._elementRef.nativeElement.setAttribute('tabindex', '-1'); + } + // TODO: role should eventually just be set in the component host protected _setRoleFromData(): void { this.role = 'treeitem'; From 09e707d5b4659f2fe426a4d707d0894fde0c6d1b Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:22 -0500 Subject: [PATCH 03/18] feat(cdk/tree): flatten data that uses childrenAccessor --- src/cdk/tree/tree.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 532745e638da..84e3393f6c42 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -43,7 +43,17 @@ import { Subject, Subscription, } from 'rxjs'; -import {concatMap, map, reduce, startWith, switchMap, take, takeUntil, tap} from 'rxjs/operators'; +import { + concatMap, + map, + pairwise, + reduce, + startWith, + switchMap, + take, + takeUntil, + tap, +} from 'rxjs/operators'; import {TreeControl} from './control/tree-control'; import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node'; import {CdkTreeNodeOutlet} from './outlet'; @@ -865,6 +875,22 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, } return null; } + + private _flattenChildren(nodes: readonly T[]): Observable { + // If we're using TreeControl or levelAccessor, we don't need to manually + // flatten things here. + if (!this.childrenAccessor) { + return observableOf(nodes); + } else { + return observableOf(...nodes).pipe( + concatMap(node => concat(observableOf([node]), this._getAllChildrenRecursively(node))), + reduce((results, nodes) => { + results.push(...nodes); + return results; + }, [] as T[]), + ); + } + } } /** From d4543968248a73e6e391eab317c582d9bef5a6d4 Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Fri, 17 Feb 2023 16:47:23 -0500 Subject: [PATCH 04/18] feat(cdk/tree): add examples, fix bugs --- src/cdk/tree/tree.ts | 10 ++++++++-- .../cdk/tree/cdk-tree-flat/cdk-tree-flat-example.html | 4 +++- .../tree/cdk-tree-nested/cdk-tree-nested-example.html | 10 ++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 84e3393f6c42..d1b6c0d73702 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -933,9 +933,9 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI } set isExpanded(isExpanded: boolean) { if (isExpanded) { - this._tree.expand(this.data); + this.expand(); } else { - this._tree.collapse(this.data); + this.collapse(); } } @@ -949,6 +949,10 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI @Output() readonly activation: EventEmitter = new EventEmitter(); + /** This emits when the node's expansion status has been changed. */ + @Output() + readonly expandedChange: EventEmitter = new EventEmitter(); + /** * The most recently created `CdkTreeNode`. We save it in static variable so we can retrieve it * in `CdkTree` and set the data to it. @@ -1044,11 +1048,13 @@ export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerI /** Collapses this data node. Implemented for TreeKeyManagerItem. */ collapse(): void { this._tree.collapse(this._data); + this.expandedChange.emit(this.isExpanded); } /** Expands this data node. Implemented for TreeKeyManagerItem. */ expand(): void { this._tree.expand(this._data); + this.expandedChange.emit(this.isExpanded); } _setTabFocusable() { diff --git a/src/components-examples/cdk/tree/cdk-tree-flat/cdk-tree-flat-example.html b/src/components-examples/cdk/tree/cdk-tree-flat/cdk-tree-flat-example.html index aadb02f9da85..0f956d818f2a 100644 --- a/src/components-examples/cdk/tree/cdk-tree-flat/cdk-tree-flat-example.html +++ b/src/components-examples/cdk/tree/cdk-tree-flat/cdk-tree-flat-example.html @@ -2,6 +2,7 @@ @@ -10,10 +11,11 @@ {{node.name}} - +