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/control/nested-tree-control.ts b/src/cdk/tree/control/nested-tree-control.ts index d8e8009b3115..a94eeedde55a 100644 --- a/src/cdk/tree/control/nested-tree-control.ts +++ b/src/cdk/tree/control/nested-tree-control.ts @@ -11,6 +11,8 @@ import {BaseTreeControl} from './base-tree-control'; /** Optional set of configuration that can be provided to the NestedTreeControl. */ export interface NestedTreeControlOptions { + /** Function to determine if the provided node is expandable. */ + isExpandable?: (dataNode: T) => boolean; trackBy?: (dataNode: T) => K; } @@ -31,6 +33,10 @@ export class NestedTreeControl extends BaseTreeControl { if (this.options) { this.trackBy = this.options.trackBy; } + + if (this.options?.isExpandable) { + this.isExpandable = this.options.isExpandable; + } } /** diff --git a/src/cdk/tree/nested-node.ts b/src/cdk/tree/nested-node.ts index 91a328744e42..0f6d5ddd863e 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -7,6 +7,7 @@ */ import { AfterContentInit, + ChangeDetectorRef, ContentChildren, Directive, ElementRef, @@ -60,9 +61,10 @@ export class CdkNestedTreeNode constructor( elementRef: ElementRef, tree: CdkTree, + changeDetectorRef: ChangeDetectorRef, protected _differs: IterableDiffers, ) { - super(elementRef, tree); + super(elementRef, tree, changeDetectorRef); } ngAfterContentInit() { 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-redesign.spec.ts b/src/cdk/tree/tree-redesign.spec.ts index 109d9b32a353..53f96977fc6b 100644 --- a/src/cdk/tree/tree-redesign.spec.ts +++ b/src/cdk/tree/tree-redesign.spec.ts @@ -117,46 +117,6 @@ describe('CdkTree redesign', () => { expect(nodes[0].classList).toContain('customNodeClass'); }); - it('with the right accessibility roles', () => { - expect(treeElement.getAttribute('role')).toBe('tree'); - - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('role') === 'treeitem'; - }), - ).toBe(true); - }); - - it('with the right aria-levels', () => { - // add a child to the first node - let data = dataSource.data; - dataSource.addChild(data[0], true); - - const ariaLevels = getNodes(treeElement).map(n => n.getAttribute('aria-level')); - expect(ariaLevels).toEqual(['2', '3', '2', '2']); - }); - - it('with the right aria-expanded attrs', () => { - // add a child to the first node - let data = dataSource.data; - dataSource.addChild(data[2]); - fixture.detectChanges(); - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'false'; - }), - ).toBe(true); - - component.expandAll(); - fixture.detectChanges(); - - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'true'; - }), - ).toBe(true); - }); - it('with the right data', () => { expect(dataSource.data.length).toBe(3); @@ -624,16 +584,6 @@ describe('CdkTree redesign', () => { expect(nodes[0].classList).toContain('customNodeClass'); }); - it('with the right accessibility roles', () => { - expect(treeElement.getAttribute('role')).toBe('tree'); - - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('role') === 'treeitem'; - }), - ).toBe(true); - }); - it('with the right data', () => { expect(dataSource.data.length).toBe(3); @@ -797,11 +747,9 @@ describe('CdkTree redesign', () => { }); it('with the right aria-expanded attrs', () => { - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'false'; - }), - ).toBe(true); + expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, null, null]); component.toggleRecursively = false; let data = dataSource.data; @@ -812,8 +760,11 @@ describe('CdkTree redesign', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - const ariaExpanded = getNodes(treeElement).map(n => n.getAttribute('aria-expanded')); - expect(ariaExpanded).toEqual(['false', 'true', 'false', 'false']); + // NB: only four elements are present here; children are not present + // in DOM unless the parent node is expanded. + expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'true', 'false', null]); }); it('should expand/collapse the node multiple times', () => { @@ -1157,6 +1108,135 @@ describe('CdkTree redesign', () => { expect(depthElements.length).toBe(5); }); }); + + describe('accessibility', () => { + let fixture: ComponentFixture; + let component: StaticNestedCdkTreeApp; + let nodes: HTMLElement[]; + + beforeEach(() => { + configureCdkTreeTestingModule([StaticNestedCdkTreeApp]); + fixture = TestBed.createComponent(StaticNestedCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + nodes = getNodes(treeElement); + }); + + describe('focus management', () => { + it('the tree is tabbable when no element is active', () => { + expect(treeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('the tree is not tabbable when an element is active', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(treeElement.getAttribute('tabindex')).toBe(null); + }); + + it('sets tabindex on the latest activated item, with all others "-1"', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']); + + // activate the first child by clicking on it + nodes[0].click(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['0', '-1', '-1', '-1', '-1', '-1']); + }); + + it('maintains tabindex when component is blurred', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(document.activeElement).toBe(nodes[1]); + // blur the currently active element (which we just checked is the above node) + nodes[1].blur(); + + expect(treeElement.getAttribute('tabindex')).toBe(null); + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']); + }); + + it('ignores clicks on disabled items', () => { + dataSource.data[0].isDisabled = true; + fixture.detectChanges(); + + // attempt to click on the first child + nodes[0].click(); + + expect(treeElement.getAttribute('tabindex')).toBe('0'); + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '-1', '-1', '-1', '-1', '-1']); + }); + + describe('when no item is currently active', () => { + it('redirects focus to the first item when the tree is focused', () => { + treeElement.focus(); + + expect(document.activeElement).toBe(nodes[0]); + }); + + it('redirects focus to the first non-disabled item when the tree is focused', () => { + dataSource.data[0].isDisabled = true; + fixture.detectChanges(); + + treeElement.focus(); + + expect(document.activeElement).toBe(nodes[1]); + }); + }); + }); + + describe('tree role & attributes', () => { + it('sets the tree role on the tree element', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + }); + + it('sets the treeitem role on all nodes', () => { + expect(getNodeAttributes(nodes, 'role')).toEqual([ + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + ]); + }); + + it('sets aria attributes for tree nodes', () => { + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'false', 'false', null, null, null]); + expect(getNodeAttributes(nodes, 'aria-level')) + .withContext('aria-level attributes') + .toEqual(['1', '1', '2', '3', '3', '1']); + expect(getNodeAttributes(nodes, 'aria-posinset')) + .withContext('aria-posinset attributes') + .toEqual(['1', '2', '1', '1', '2', '3']); + expect(getNodeAttributes(nodes, 'aria-setsize')) + .withContext('aria-setsize attributes') + .toEqual(['3', '3', '1', '2', '2', '3']); + }); + + it('changes aria-expanded status when expanded or collapsed', () => { + tree.expand(dataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'true', 'false', null, null, null]); + + tree.collapse(dataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'false', 'false', null, null, null]); + }); + }); + }); }); export class TestData { @@ -1165,6 +1245,7 @@ export class TestData { pizzaBase: string; level: number; children: TestData[]; + isDisabled?: boolean; readonly observableChildren: BehaviorSubject; constructor(pizzaTopping: string, pizzaCheese: string, pizzaBase: string, level: number = 1) { @@ -1339,6 +1420,10 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { } } +function getNodeAttributes(nodes: HTMLElement[], attribute: string) { + return nodes.map(node => node.getAttribute(attribute)); +} + @Component({ template: ` - - {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} - + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + `, @@ -1486,7 +1575,9 @@ class CdkTreeAppWithToggle { + [isExpandable]="isExpandable(node) | async" + cdkTreeNodeToggle + [cdkTreeNodeToggleRecursive]="toggleRecursively"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
@@ -1499,6 +1590,8 @@ class NestedCdkTreeAppWithToggle { toggleRecursively: boolean = true; getChildren = (node: TestData) => node.observableChildren; + isExpandable = (node: TestData) => + node.observableChildren.pipe(map(children => children.length > 0)); dataSource: FakeDataSource | null = new FakeDataSource(); diff --git a/src/cdk/tree/tree.spec.ts b/src/cdk/tree/tree.spec.ts index 3ba2ca114050..23b3bd9d38db 100644 --- a/src/cdk/tree/tree.spec.ts +++ b/src/cdk/tree/tree.spec.ts @@ -116,46 +116,6 @@ describe('CdkTree', () => { expect(nodes[0].classList).toContain('customNodeClass'); }); - it('with the right accessibility roles', () => { - expect(treeElement.getAttribute('role')).toBe('tree'); - - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('role') === 'treeitem'; - }), - ).toBe(true); - }); - - it('with the right aria-levels', () => { - // add a child to the first node - let data = dataSource.data; - dataSource.addChild(data[0], true); - - const ariaLevels = getNodes(treeElement).map(n => n.getAttribute('aria-level')); - expect(ariaLevels).toEqual(['2', '3', '2', '2']); - }); - - it('with the right aria-expanded attrs', () => { - // add a child to the first node - let data = dataSource.data; - dataSource.addChild(data[2]); - fixture.detectChanges(); - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'false'; - }), - ).toBe(true); - - component.treeControl.expandAll(); - fixture.detectChanges(); - - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'true'; - }), - ).toBe(true); - }); - it('with the right data', () => { expect(dataSource.data.length).toBe(3); @@ -796,11 +756,9 @@ describe('CdkTree', () => { }); it('with the right aria-expanded attrs', () => { - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'false'; - }), - ).toBe(true); + expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, null, null]); component.toggleRecursively = false; let data = dataSource.data; @@ -811,8 +769,11 @@ describe('CdkTree', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - const ariaExpanded = getNodes(treeElement).map(n => n.getAttribute('aria-expanded')); - expect(ariaExpanded).toEqual(['false', 'true', 'false', 'false']); + // NB: only four elements are present here; children are not present + // in DOM unless the parent node is expanded. + expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'true', 'false', null]); }); it('should expand/collapse the node multiple times', () => { @@ -1156,6 +1117,135 @@ describe('CdkTree', () => { expect(depthElements.length).toBe(5); }); }); + + describe('accessibility', () => { + let fixture: ComponentFixture; + let component: StaticNestedCdkTreeApp; + let nodes: HTMLElement[]; + + beforeEach(() => { + configureCdkTreeTestingModule([StaticNestedCdkTreeApp]); + fixture = TestBed.createComponent(StaticNestedCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + nodes = getNodes(treeElement); + }); + + describe('focus management', () => { + it('the tree is tabbable when no element is active', () => { + expect(treeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('the tree is not tabbable when an element is active', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(treeElement.getAttribute('tabindex')).toBe(null); + }); + + it('sets tabindex on the latest activated item, with all others "-1"', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']); + + // activate the first child by clicking on it + nodes[0].click(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['0', '-1', '-1', '-1', '-1', '-1']); + }); + + it('maintains tabindex when component is blurred', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(document.activeElement).toBe(nodes[1]); + // blur the currently active element (which we just checked is the above node) + nodes[1].blur(); + + expect(treeElement.getAttribute('tabindex')).toBe(null); + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']); + }); + + it('ignores clicks on disabled items', () => { + dataSource.data[0].isDisabled = true; + fixture.detectChanges(); + + // attempt to click on the first child + nodes[0].click(); + + expect(treeElement.getAttribute('tabindex')).toBe('0'); + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '-1', '-1', '-1', '-1', '-1']); + }); + + describe('when no item is currently active', () => { + it('redirects focus to the first item when the tree is focused', () => { + treeElement.focus(); + + expect(document.activeElement).toBe(nodes[0]); + }); + + it('redirects focus to the first non-disabled item when the tree is focused', () => { + dataSource.data[0].isDisabled = true; + fixture.detectChanges(); + + treeElement.focus(); + + expect(document.activeElement).toBe(nodes[1]); + }); + }); + }); + + describe('tree role & attributes', () => { + it('sets the tree role on the tree element', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + }); + + it('sets the treeitem role on all nodes', () => { + expect(getNodeAttributes(nodes, 'role')).toEqual([ + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + ]); + }); + + it('sets aria attributes for tree nodes', () => { + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'false', 'false', null, null, null]); + expect(getNodeAttributes(nodes, 'aria-level')) + .withContext('aria-level attributes') + .toEqual(['1', '1', '2', '3', '3', '1']); + expect(getNodeAttributes(nodes, 'aria-posinset')) + .withContext('aria-posinset attributes') + .toEqual(['1', '2', '1', '1', '2', '3']); + expect(getNodeAttributes(nodes, 'aria-setsize')) + .withContext('aria-setsize attributes') + .toEqual(['3', '3', '1', '2', '2', '3']); + }); + + it('changes aria-expanded status when expanded or collapsed', () => { + tree.expand(dataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'true', 'false', null, null, null]); + + tree.collapse(dataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'false', 'false', null, null, null]); + }); + }); + }); }); export class TestData { @@ -1164,6 +1254,7 @@ export class TestData { pizzaBase: string; level: number; children: TestData[]; + isDisabled?: boolean; readonly observableChildren: BehaviorSubject; constructor(pizzaTopping: string, pizzaCheese: string, pizzaBase: string, level: number = 1) { @@ -1329,6 +1420,10 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { } } +function getNodeAttributes(nodes: HTMLElement[], attribute: string) { + return nodes.map(node => node.getAttribute(attribute)); +} + @Component({ template: ` @@ -1390,7 +1485,10 @@ class NestedCdkTreeApp { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1400,7 +1498,9 @@ class NestedCdkTreeApp { class StaticNestedCdkTreeApp { getChildren = (node: TestData) => node.children; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); + treeControl: TreeControl = new NestedTreeControl(this.getChildren, { + isExpandable: node => node.children.length > 0, + }); dataSource: FakeDataSource; @@ -1470,7 +1570,8 @@ class CdkTreeAppWithToggle { template: ` + cdkTreeNodeToggle + [cdkTreeNodeToggleRecursive]="toggleRecursively"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
@@ -1484,7 +1585,9 @@ class NestedCdkTreeAppWithToggle { getChildren = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); + treeControl: TreeControl = new NestedTreeControl(this.getChildren, { + isExpandable: node => node.children.length > 0, + }); dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); @ViewChild(CdkTree) tree: CdkTree; diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 780e8697ad1b..0513ec1d06f0 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -5,23 +5,27 @@ * 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 { AfterContentChecked, + AfterContentInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, Directive, ElementRef, + EventEmitter, Input, IterableChangeRecord, IterableDiffer, IterableDiffers, OnDestroy, OnInit, + Output, QueryList, TrackByFunction, ViewChild, @@ -40,7 +44,18 @@ 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, + withLatestFrom, +} from 'rxjs/operators'; import {TreeControl} from './control/tree-control'; import {CdkTreeNodeDef, CdkTreeNodeOutletContext} from './node'; import {CdkTreeNodeOutlet} from './outlet'; @@ -60,6 +75,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. @@ -71,6 +90,8 @@ function coerceObservable(data: T | Observable): Observable { host: { 'class': 'cdk-tree', 'role': 'tree', + '(keydown)': '_sendKeydownToKeyManager($event)', + '(focus)': '_focusInitialTreeItem()', }, encapsulation: ViewEncapsulation.None, @@ -80,7 +101,9 @@ function coerceObservable(data: T | Observable): Observable { // tslint:disable-next-line:validate-decorators changeDetection: ChangeDetectionStrategy.Default, }) -export class CdkTree implements AfterContentChecked, CollectionViewer, OnDestroy, OnInit { +export class CdkTree + implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit +{ /** Subject that emits when the component has been destroyed. */ private readonly _onDestroy = new Subject(); @@ -210,7 +233,15 @@ 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, + private _elementRef: ElementRef, + ) {} ngOnInit() { this._dataDiffer = this._differs.find([]).create(this.trackBy); @@ -230,9 +261,26 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, } } + let expansionModel; if (!this.treeControl) { - this._expansionModel = new SelectionModel(true); + 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() { @@ -252,6 +300,31 @@ 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(); + }); + + this._keyManager.change.pipe(startWith(null), takeUntil(this._onDestroy)).subscribe(() => { + // refresh the tabindex when the active item changes. + this._setTabIndex(); + }); + } + ngAfterContentChecked() { const defaultNodeDefs = this._nodeDefs.filter(def => !def.when); if (defaultNodeDefs.length > 1 && (typeof ngDevMode === 'undefined' || ngDevMode)) { @@ -264,8 +337,22 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, } } - // TODO(tinayuangao): Work on keyboard traversal and actions, make sure it's working for RTL - // and nested trees. + /** + * Sets the tabIndex on the host element. + * + * NB: we don't set this as a host binding since children being activated + * (e.g. on user click) doesn't trigger this component's change detection. + */ + _setTabIndex() { + // 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. + if (!this._keyManager.getActiveItem()) { + this._elementRef.nativeElement.setAttribute('tabindex', '0'); + } else { + this._elementRef.nativeElement.removeAttribute('tabindex'); + } + } /** * Switch to the provided data source by resetting the data and unsubscribing from the current @@ -402,14 +489,11 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, const parent = parentData ?? this._findParentForNode(nodeData, index); this._parents.set(this._getExpansionKey(nodeData), parent); - // Determine where to insert this new node into the group, then insert it. - // We do this by looking at the previous node in our flattened node list. If it's in the same - // group, we place the current node after. Otherwise, we place it at the start of the group. + // 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) ?? []; - const previousNode = this._dataNodes.value?.[index - 1]; - const groupInsertionIndex = (previousNode && group.indexOf(previousNode) + 1) ?? 0; - group.splice(groupInsertionIndex, 0, nodeData); + group.splice(index, 0, nodeData); currentGroup.set(parent, group); this._groups.set(context.level, currentGroup); @@ -645,6 +729,36 @@ 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(this._getExpansionKey(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) { + return coerceObservable(this._getChildrenAccessor()?.(node.data) ?? []).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. * @@ -735,6 +849,32 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, return this.expansionKey?.(dataNode) ?? (dataNode as unknown as K); } + 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); + return group ?? [node]; + } + + private _findParentForNode(node: T, index: number) { + // 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) { + return null; + } + const currentLevel = this._levels.get(this._getExpansionKey(node)) ?? 0; + for (let parentIndex = index; parentIndex >= 0; parentIndex--) { + const parentNode = this._dataNodes.value[parentIndex]; + const parentLevel = this._levels.get(this._getExpansionKey(parentNode)) ?? 0; + + if (parentLevel < currentLevel) { + return parentNode; + } + } + return null; + } + /** * Converts children for certain tree configurations. Note also that this * caches the known nodes for use in other parts of the tree. @@ -767,32 +907,6 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, return observableOf(nodes); } } - - 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); - return group ?? [node]; - } - - private _findParentForNode(node: T, index: number) { - // 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) { - return null; - } - const currentLevel = this._levels.get(this._getExpansionKey(node)) ?? 0; - for (let parentIndex = index; parentIndex >= 0; parentIndex--) { - const parentNode = this._dataNodes.value[parentIndex]; - const parentLevel = this._levels.get(this._getExpansionKey(parentNode)) ?? 0; - - if (parentLevel < currentLevel) { - return parentNode; - } - } - return null; - } } /** @@ -803,13 +917,15 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, exportAs: 'cdkTreeNode', host: { 'class': 'cdk-tree-node', - '[attr.aria-expanded]': 'isExpanded', + '[attr.aria-expanded]': '_getAriaExpanded()', '[attr.aria-level]': 'level + 1', '[attr.aria-posinset]': '_getPositionInSet()', '[attr.aria-setsize]': '_getSetSize()', + 'tabindex': '-1', + '(click)': '_setActiveItem()', }, }) -export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit { +export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { /** * The role of the tree node. * @@ -822,10 +938,16 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit } set role(_role: 'treeitem' | 'group') { - // TODO: move to host after View Engine deprecation - this._elementRef.nativeElement.setAttribute('role', _role); + // ignore any role setting, we handle this internally. + this._setRoleFromData(); } + /** + * Whether or not this node is expandable. + * + * 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() @@ -834,12 +956,26 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit } set isExpanded(isExpanded: boolean) { if (isExpanded) { - this._tree.expand(this.data); + this.expand(); } else { - this._tree.collapse(this.data); + this.collapse(); } } + /** + * 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(); + + /** 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. @@ -874,6 +1010,26 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit return this._tree._getLevel(this._data) ?? this._parentNodeAriaLevel; } + /** Determines if the tree node is expandable. */ + _isExpandable(): boolean { + if (typeof this._tree.treeControl?.isExpandable === 'function') { + return this._tree.treeControl.isExpandable(this._data); + } + return this.isExpandable; + } + + /** + * Determines the value for `aria-expanded`. + * + * For non-expandable nodes, this is `null`. + */ + _getAriaExpanded(): string | null { + if (!this._isExpandable()) { + return null; + } + return String(this.isExpanded); + } + /** * Determines the size of this node's parent's child set. * @@ -892,7 +1048,11 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit return this._tree._getPositionInSet(this._data); } - constructor(protected _elementRef: ElementRef, protected _tree: CdkTree) { + constructor( + protected _elementRef: ElementRef, + protected _tree: CdkTree, + public _changeDetectorRef: ChangeDetectorRef, + ) { CdkTreeNode.mostRecentTreeNode = this as CdkTreeNode; this.role = 'treeitem'; } @@ -914,14 +1074,57 @@ 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(): CdkTreeNode[] | 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 { + if (this.isDisabled) { + return; + } + this.activation.next(this._data); + } + + /** 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() { + this._elementRef.nativeElement.setAttribute('tabindex', '0'); + } + + _setTabUnfocusable() { + this._elementRef.nativeElement.setAttribute('tabindex', '-1'); + } + + _setActiveItem() { + if (this.isDisabled) { + return; + } + this._tree._keyManager.onClick(this); + } + // TODO: role should eventually just be set in the component host protected _setRoleFromData(): void { - this.role = 'treeitem'; + this._elementRef.nativeElement.setAttribute('role', 'treeitem'); } } diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts index 48ed2152d27a..fbd197ca15b6 100644 --- a/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts @@ -1,6 +1,8 @@ import {ArrayDataSource} from '@angular/cdk/collections'; -import {CdkTree} from '@angular/cdk/tree'; +import {CdkTree, CdkTreeModule} from '@angular/cdk/tree'; import {Component, ViewChild} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; import {timer} from 'rxjs'; import {mapTo} from 'rxjs/operators'; import {NestedFoodNode, NESTED_DATA} from '../tree-data'; @@ -23,6 +25,8 @@ function flattenNodes(nodes: NestedFoodNode[]): NestedFoodNode[] { selector: 'cdk-tree-flat-children-accessor-example', templateUrl: 'cdk-tree-flat-children-accessor-example.html', styleUrls: ['cdk-tree-flat-children-accessor-example.css'], + standalone: true, + imports: [CdkTreeModule, MatButtonModule, MatIconModule], }) export class CdkTreeFlatChildrenAccessorExample { @ViewChild(CdkTree) diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts index 3484953f7abb..863f5dc7197c 100644 --- a/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts +++ b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts @@ -1,6 +1,8 @@ import {ArrayDataSource} from '@angular/cdk/collections'; -import {CdkTree} from '@angular/cdk/tree'; +import {CdkTree, CdkTreeModule} from '@angular/cdk/tree'; import {Component, ViewChild} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; import {FlatFoodNode, FLAT_DATA} from '../tree-data'; /** @@ -10,6 +12,8 @@ import {FlatFoodNode, FLAT_DATA} from '../tree-data'; selector: 'cdk-tree-flat-level-accessor-example', templateUrl: 'cdk-tree-flat-level-accessor-example.html', styleUrls: ['cdk-tree-flat-level-accessor-example.css'], + standalone: true, + imports: [CdkTreeModule, MatButtonModule, MatIconModule], }) export class CdkTreeFlatLevelAccessorExample { @ViewChild(CdkTree) 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}} - +