Skip to content

Commit 3e212ea

Browse files
committed
feat(cdk/tree): bug fixes for tree and key manager
1 parent c12547f commit 3e212ea

File tree

3 files changed

+126
-8
lines changed

3 files changed

+126
-8
lines changed

src/cdk/a11y/key-manager/tree-key-manager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,14 @@ export class TreeKeyManager<T extends TreeKeyManagerItem> {
276276
return this._activeItem;
277277
}
278278

279+
/**
280+
* Focus the initial element; this is intended to be called when the tree is focused for
281+
* the first time.
282+
*/
283+
onInitialFocus(): void {
284+
this._focusFirstItem();
285+
}
286+
279287
private _setActiveItem(index: number): void;
280288
private _setActiveItem(item: T): void;
281289
private _setActiveItem(itemOrIndex: number | T) {

src/cdk/tree/toggle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {CdkTree, CdkTreeNode} from './tree';
1818
selector: '[cdkTreeNodeToggle]',
1919
host: {
2020
'(click)': '_toggle($event)',
21-
'tabindex': '0',
21+
'tabindex': '-1',
2222
},
2323
})
2424
export class CdkTreeNodeToggle<T, K = T> {

src/cdk/tree/tree.ts

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import {FocusableOption} from '@angular/cdk/a11y';
8+
import {TreeKeyManager, TreeKeyManagerItem} from '@angular/cdk/a11y';
9+
import {Directionality} from '@angular/cdk/bidi';
910
import {coerceNumberProperty} from '@angular/cdk/coercion';
1011
import {CollectionViewer, DataSource, isDataSource, SelectionModel} from '@angular/cdk/collections';
1112
import {
@@ -16,12 +17,14 @@ import {
1617
ContentChildren,
1718
Directive,
1819
ElementRef,
20+
EventEmitter,
1921
Input,
2022
IterableChangeRecord,
2123
IterableDiffer,
2224
IterableDiffers,
2325
OnDestroy,
2426
OnInit,
27+
Output,
2528
QueryList,
2629
TrackByFunction,
2730
ViewChild,
@@ -75,6 +78,9 @@ function isNotNullish<T>(val: T | null | undefined): val is T {
7578
host: {
7679
'class': 'cdk-tree',
7780
'role': 'tree',
81+
'[attr.tabindex]': '_getTabindex()',
82+
'(keydown)': '_sendKeydownToKeyManager($event)',
83+
'(focus)': '_focusInitialTreeItem()',
7884
},
7985
encapsulation: ViewEncapsulation.None,
8086

@@ -214,7 +220,14 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
214220
new Map<K, CdkTreeNode<T, K>>(),
215221
);
216222

217-
constructor(private _differs: IterableDiffers, private _changeDetectorRef: ChangeDetectorRef) {}
223+
/** The key manager for this tree. Handles focus and activation based on user keyboard input. */
224+
_keyManager: TreeKeyManager<CdkTreeNode<T, K>>;
225+
226+
constructor(
227+
private _differs: IterableDiffers,
228+
private _changeDetectorRef: ChangeDetectorRef,
229+
private _dir: Directionality,
230+
) {}
218231

219232
ngOnInit() {
220233
this._dataDiffer = this._differs.find([]).create(this.trackBy);
@@ -256,6 +269,26 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
256269
}
257270
}
258271

272+
ngAfterContentInit() {
273+
this._keyManager = new TreeKeyManager({
274+
items: combineLatest([this._dataNodes, this._nodes]).pipe(
275+
map(([dataNodes, nodes]) =>
276+
dataNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish),
277+
),
278+
),
279+
trackBy: node => this._getExpansionKey(node.data),
280+
typeAheadDebounceInterval: true,
281+
horizontalOrientation: this._dir.value,
282+
});
283+
284+
this._keyManager.change
285+
.pipe(startWith(null), pairwise(), takeUntil(this._onDestroy))
286+
.subscribe(([prev, next]) => {
287+
prev?._setTabUnfocusable();
288+
next?._setTabFocusable();
289+
});
290+
}
291+
259292
ngAfterContentChecked() {
260293
const defaultNodeDefs = this._nodeDefs.filter(def => !def.when);
261294
if (defaultNodeDefs.length > 1 && (typeof ngDevMode === 'undefined' || ngDevMode)) {
@@ -268,13 +301,17 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
268301
}
269302
}
270303

271-
// TODO(tinayuangao): Work on keyboard traversal and actions, make sure it's working for RTL
272-
// and nested trees.
304+
_getTabindex() {
305+
// If the `TreeKeyManager` has no active item, then we know that we need to focus the initial
306+
// item when the tree is focused. We set the tabindex to be `0` so that we can capture
307+
// the focus event and redirect it. Otherwise, we unset it.
308+
return this._keyManager.getActiveItem() ? null : 0;
309+
}
273310

274311
/**
275312
* Switch to the provided data source by resetting the data and unsubscribing from the current
276313
* render change subscription if one exists. If the data source is null, interpret this by
277-
* clearing the node outlet. Otherwise start listening for new data.
314+
* clearIng the node outlet. Otherwise start listening for new data.
278315
*/
279316
private _switchDataSource(dataSource: DataSource<T> | Observable<T[]> | T[]) {
280317
if (this._dataSource && typeof (this._dataSource as DataSource<T>).disconnect === 'function') {
@@ -649,6 +686,37 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
649686
return group.indexOf(dataNode) + 1;
650687
}
651688

689+
/** Given a CdkTreeNode, gets the node that renders that node's parent's data. */
690+
_getNodeParent(node: CdkTreeNode<T, K>) {
691+
const parent = this._parents.get(node.data);
692+
return parent && this._nodes.value.get(this._getExpansionKey(parent));
693+
}
694+
695+
/** Given a CdkTreeNode, gets the nodes that renders that node's child data. */
696+
_getNodeChildren(node: CdkTreeNode<T, K>) {
697+
const children = coerceObservable(this._getChildrenAccessor()?.(node.data) ?? []);
698+
return children.pipe(
699+
map(children =>
700+
children
701+
.map(child => this._nodes.value.get(this._getExpansionKey(child)))
702+
.filter(isNotNullish),
703+
),
704+
);
705+
}
706+
707+
/** `keydown` event handler; this just passes the event to the `TreeKeyManager`. */
708+
_sendKeydownToKeyManager(event: KeyboardEvent) {
709+
this._keyManager.onKeydown(event);
710+
}
711+
712+
/** `focus` event handler; this focuses the initial item if there isn't already one available. */
713+
_focusInitialTreeItem() {
714+
if (this._keyManager.getActiveItem()) {
715+
return;
716+
}
717+
this._keyManager.onInitialFocus();
718+
}
719+
652720
/**
653721
* Gets all nodes in the tree, through recursive expansion.
654722
*
@@ -811,9 +879,10 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
811879
'[attr.aria-level]': 'level + 1',
812880
'[attr.aria-posinset]': '_getPositionInSet()',
813881
'[attr.aria-setsize]': '_getSetSize()',
882+
'tabindex': '-1',
814883
},
815884
})
816-
export class CdkTreeNode<T, K = T> implements FocusableOption, OnDestroy, OnInit {
885+
export class CdkTreeNode<T, K = T> implements OnDestroy, OnInit, TreeKeyManagerItem {
817886
/**
818887
* The role of the tree node.
819888
*
@@ -844,6 +913,16 @@ export class CdkTreeNode<T, K = T> implements FocusableOption, OnDestroy, OnInit
844913
}
845914
}
846915

916+
/**
917+
* Whether or not this node is disabled. If it's disabled, then the user won't be able to focus
918+
* or activate this node.
919+
*/
920+
@Input() isDisabled?: boolean;
921+
922+
/** This emits when the node has been programatically activated. */
923+
@Output()
924+
readonly activation: EventEmitter<T> = new EventEmitter<T>();
925+
847926
/**
848927
* The most recently created `CdkTreeNode`. We save it in static variable so we can retrieve it
849928
* in `CdkTree` and set the data to it.
@@ -918,11 +997,42 @@ export class CdkTreeNode<T, K = T> implements FocusableOption, OnDestroy, OnInit
918997
this._destroyed.complete();
919998
}
920999

921-
/** Focuses the menu item. Implements for FocusableOption. */
1000+
getParent(): CdkTreeNode<T, K> | null {
1001+
return this._tree._getNodeParent(this) ?? null;
1002+
}
1003+
1004+
getChildren(): Array<CdkTreeNode<T, K>> | Observable<Array<CdkTreeNode<T, K>>> {
1005+
return this._tree._getNodeChildren(this);
1006+
}
1007+
1008+
/** Focuses this data node. Implemented for TreeKeyManagerItem. */
9221009
focus(): void {
9231010
this._elementRef.nativeElement.focus();
9241011
}
9251012

1013+
/** Emits an activation event. Implemented for TreeKeyManagerItem. */
1014+
activate(): void {
1015+
this.activation.next(this._data);
1016+
}
1017+
1018+
/** Collapses this data node. Implemented for TreeKeyManagerItem. */
1019+
collapse(): void {
1020+
this._tree.collapse(this._data);
1021+
}
1022+
1023+
/** Expands this data node. Implemented for TreeKeyManagerItem. */
1024+
expand(): void {
1025+
this._tree.expand(this._data);
1026+
}
1027+
1028+
_setTabFocusable() {
1029+
this._elementRef.nativeElement.setAttribute('tabindex', '0');
1030+
}
1031+
1032+
_setTabUnfocusable() {
1033+
this._elementRef.nativeElement.setAttribute('tabindex', '-1');
1034+
}
1035+
9261036
// TODO: role should eventually just be set in the component host
9271037
protected _setRoleFromData(): void {
9281038
this.role = 'treeitem';

0 commit comments

Comments
 (0)