Skip to content

Commit 02903e0

Browse files
committed
feat(cdk/tree): bug fixes for tree and key manager
1 parent cf762dc commit 02903e0

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

@@ -208,7 +214,14 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
208214
new Map<K, CdkTreeNode<T, K>>(),
209215
);
210216

211-
constructor(private _differs: IterableDiffers, private _changeDetectorRef: ChangeDetectorRef) {}
217+
/** The key manager for this tree. Handles focus and activation based on user keyboard input. */
218+
_keyManager: TreeKeyManager<CdkTreeNode<T, K>>;
219+
220+
constructor(
221+
private _differs: IterableDiffers,
222+
private _changeDetectorRef: ChangeDetectorRef,
223+
private _dir: Directionality,
224+
) {}
212225

213226
ngOnInit() {
214227
this._dataDiffer = this._differs.find([]).create(this.trackBy);
@@ -250,6 +263,26 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
250263
}
251264
}
252265

266+
ngAfterContentInit() {
267+
this._keyManager = new TreeKeyManager({
268+
items: combineLatest([this._dataNodes, this._nodes]).pipe(
269+
map(([dataNodes, nodes]) =>
270+
dataNodes.map(data => nodes.get(this._getExpansionKey(data))).filter(isNotNullish),
271+
),
272+
),
273+
trackBy: node => this._getExpansionKey(node.data),
274+
typeAheadDebounceInterval: true,
275+
horizontalOrientation: this._dir.value,
276+
});
277+
278+
this._keyManager.change
279+
.pipe(startWith(null), pairwise(), takeUntil(this._onDestroy))
280+
.subscribe(([prev, next]) => {
281+
prev?._setTabUnfocusable();
282+
next?._setTabFocusable();
283+
});
284+
}
285+
253286
ngAfterContentChecked() {
254287
const defaultNodeDefs = this._nodeDefs.filter(def => !def.when);
255288
if (defaultNodeDefs.length > 1 && (typeof ngDevMode === 'undefined' || ngDevMode)) {
@@ -262,13 +295,17 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
262295
}
263296
}
264297

265-
// TODO(tinayuangao): Work on keyboard traversal and actions, make sure it's working for RTL
266-
// and nested trees.
298+
_getTabindex() {
299+
// If the `TreeKeyManager` has no active item, then we know that we need to focus the initial
300+
// item when the tree is focused. We set the tabindex to be `0` so that we can capture
301+
// the focus event and redirect it. Otherwise, we unset it.
302+
return this._keyManager.getActiveItem() ? null : 0;
303+
}
267304

268305
/**
269306
* Switch to the provided data source by resetting the data and unsubscribing from the current
270307
* render change subscription if one exists. If the data source is null, interpret this by
271-
* clearing the node outlet. Otherwise start listening for new data.
308+
* clearIng the node outlet. Otherwise start listening for new data.
272309
*/
273310
private _switchDataSource(dataSource: DataSource<T> | Observable<T[]> | T[]) {
274311
if (this._dataSource && typeof (this._dataSource as DataSource<T>).disconnect === 'function') {
@@ -676,6 +713,37 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
676713
this._nodes.next(this._nodes.value);
677714
}
678715

716+
/** Given a CdkTreeNode, gets the node that renders that node's parent's data. */
717+
_getNodeParent(node: CdkTreeNode<T, K>) {
718+
const parent = this._parents.get(node.data);
719+
return parent && this._nodes.value.get(this._getExpansionKey(parent));
720+
}
721+
722+
/** Given a CdkTreeNode, gets the nodes that renders that node's child data. */
723+
_getNodeChildren(node: CdkTreeNode<T, K>) {
724+
const children = coerceObservable(this._getChildrenAccessor()?.(node.data) ?? []);
725+
return children.pipe(
726+
map(children =>
727+
children
728+
.map(child => this._nodes.value.get(this._getExpansionKey(child)))
729+
.filter(isNotNullish),
730+
),
731+
);
732+
}
733+
734+
/** `keydown` event handler; this just passes the event to the `TreeKeyManager`. */
735+
_sendKeydownToKeyManager(event: KeyboardEvent) {
736+
this._keyManager.onKeydown(event);
737+
}
738+
739+
/** `focus` event handler; this focuses the initial item if there isn't already one available. */
740+
_focusInitialTreeItem() {
741+
if (this._keyManager.getActiveItem()) {
742+
return;
743+
}
744+
this._keyManager.onInitialFocus();
745+
}
746+
679747
private _getAllDescendants(): Observable<T[]> {
680748
return merge(...this._dataNodes.value.map(dataNode => this._getDescendants(dataNode)));
681749
}
@@ -810,9 +878,10 @@ export class CdkTree<T, K = T> implements AfterContentChecked, CollectionViewer,
810878
'[attr.aria-level]': 'level + 1',
811879
'[attr.aria-posinset]': '_getPositionInSet()',
812880
'[attr.aria-setsize]': '_getSetSize()',
881+
'tabindex': '-1',
813882
},
814883
})
815-
export class CdkTreeNode<T, K = T> implements FocusableOption, OnDestroy, OnInit {
884+
export class CdkTreeNode<T, K = T> implements OnDestroy, OnInit, TreeKeyManagerItem {
816885
/**
817886
* The role of the tree node.
818887
*
@@ -843,6 +912,16 @@ export class CdkTreeNode<T, K = T> implements FocusableOption, OnDestroy, OnInit
843912
}
844913
}
845914

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

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

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

0 commit comments

Comments
 (0)