From 732665d78e0fcde21f8fde5405235f6773475c8a Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Sun, 8 Oct 2023 15:10:48 +0000 Subject: [PATCH] fix(material/tree): add data model functions, improve a11y and update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add functionality to the Tree data model, improve accessibility and update documentation. Add levelAcessor and childrenAccessor functions. Given a data node, childrenAccessor determines the children of that node. Given a data node, levelAccessor determines level of the node. One of levelAccessor or childrenAccessor must be specified, not both. CdkTreeNode#levelAcessor and CdkTreeNode#childrenAccessor replace CdkTreeNode#treeControl. See deprecations for updating apps using treeControl. Implement keyboard navigation in Tree component. Keyboard commands match [WAI ARIA Tree View Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). Make changes directly to Cdk tree API, which also apply to Mat tree APIs. See “Breaking Change” and “Deprecated” sections below for specifics on adopting changes. Recommend adopting levelAccessor or childrenAccessor to access all accessibility improvements. Accessibility improvements * Implement keyboard navigation for CdkTree and MatTree * Improve keyboard usability for CdkTreeNodeToggle. * Improve ARIA semantics for CdkTree, CdkTreeNode, Tree and TreeNode components * Fix miscellaneous accessibility issues in tree and cdk*tree examples * Add accessibility instructions to documentation Documentation updates * Add API and usage examples for TreeKeyManager * Update @angular/cdk/tree and @angular/material/tree to be more consistent * Update examples to use levelAccessor and childrenAccessor * Add example for (activation) on MatTreeNode and CdkTreeNode API CHANGE: control expanded state of tree nodes using isExpandable and isExpanded * Add isExpandable function to NestedTreeControlOptions to determine if argument tree node can be expanded or collapsed. * Add isExpanded Input to CdkTreeNode to specify the expanded state. Has no effect if node is not expandable. Note when upgrading: isExpandable works differently on Trees using treeControl than trees using childrenAccessor or levelAccessor. Nodes on trees that have a treeControl are expandable by default. Nodes on trees using childrenAccessor or levelAccessor are *not* expandable by default. Provide isExpandable to override default behavior. For trees using treeControl, recommend providing isExpandable if not already provided. API CHANGE: use CdkTree to manage expansion state * Add CdkTree#isExpanded method. * Add CdkTree#toggle, CdkTree#expand and CdkTree#collapse methods. * Add CdkTree#toggleDescendants, CdkTree#expandDescendants, and CdkTree#collapseDescendants methods to CdkTree * Add CdkTree#expandAll and CdkTree#collapseAll methods * Add expandedChange Output to CdkTreeNode BEHAVIOR CHANGE: MatTree and CdkTree components respond to keyboard navigation. * CdkTree and MatTree respond to arrow keys, page up, page down, etc.; Keyboard commands match [WAI ARIA Tree View Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). * Can no longer set the tabindex on MatTreeNode. MatTreeNode ignores the passed value of tabindex. MatTreeNode always has a tabindex attribute of “-1”. * Add TreeKeyManager class * CdkTree consumes TreeKeyManager DEPRECATED: Tree controller deprecated. Use one of levelAccessor or childrenAccessor instead. To be removed in a future version. * BaseTreeControl, TreeControl, FlatTreeControl, and NestedTreeControl deprecated * CdkTree#treeControl deprecated. Provide one of CdkTree#levelAccessor or CdkTree#childrenAccessor instead. * MatTreeFlattener deprecated. Use MatTree#childrenAccessor and MatTreeNode#isExpandable instead. * MatTreeFlatDataSource deprecated. Use one of levelAccessor or childrenAccessor instead of TreeControl. DEPRECATED: Setting tabindex of tree nodes deprecated. * MatTreeNode#tabIndex deprecated. MatTreeNode ignores Input tabIndex and manages its own focus behavior. * MatTreeNode#defaultTabIndex deprecated. MatTreeNode ignores Input defaultTabIndex and manages its own focus behavior. * MatNestedTreeNode#tabIndex deprecated. MatTreeNode ignores Input defaultTabIndex and manages its own focus behavior. DEPRECATED: disabled renamed to isDisabled. * CdkTreeNode#disabled deprecated and alias to CdkTreeNode#isDisabled --- src/cdk/a11y/a11y.md | 60 +- .../a11y/key-manager/tree-key-manager.spec.ts | 1165 +++++++++++ src/cdk/a11y/key-manager/tree-key-manager.ts | 525 +++++ src/cdk/a11y/public-api.ts | 1 + src/cdk/tree/BUILD.bazel | 3 + src/cdk/tree/control/base-tree-control.ts | 7 +- src/cdk/tree/control/flat-tree-control.ts | 8 +- src/cdk/tree/control/nested-tree-control.ts | 14 +- src/cdk/tree/control/tree-control.ts | 3 + src/cdk/tree/nested-node.ts | 18 +- src/cdk/tree/padding.ts | 5 +- src/cdk/tree/toggle.ts | 30 +- src/cdk/tree/tree-errors.ts | 11 +- src/cdk/tree/tree-with-tree-control.spec.ts | 1825 +++++++++++++++++ src/cdk/tree/tree.md | 118 +- src/cdk/tree/tree.spec.ts | 549 ++--- src/cdk/tree/tree.ts | 988 ++++++++- src/components-examples/cdk/tree/BUILD.bazel | 1 + .../cdk-tree-complex-example.css | 4 + .../cdk-tree-complex-example.html | 34 + .../cdk-tree-complex-example.ts | 303 +++ ...dk-tree-flat-children-accessor-example.css | 4 + ...k-tree-flat-children-accessor-example.html | 25 + ...cdk-tree-flat-children-accessor-example.ts | 61 + .../cdk-tree-flat-level-accessor-example.css | 4 + .../cdk-tree-flat-level-accessor-example.html | 27 + .../cdk-tree-flat-level-accessor-example.ts | 47 + .../cdk-tree-flat/cdk-tree-flat-example.html | 5 +- ...-tree-nested-children-accessor-example.css | 18 + ...tree-nested-children-accessor-example.html | 25 + ...k-tree-nested-children-accessor-example.ts | 53 + ...cdk-tree-nested-level-accessor-example.css | 18 + ...dk-tree-nested-level-accessor-example.html | 25 + .../cdk-tree-nested-level-accessor-example.ts | 46 + .../cdk-tree-nested-example.html | 9 +- src/components-examples/cdk/tree/index.ts | 5 + src/components-examples/cdk/tree/tree-data.ts | 94 + .../tree-dynamic/tree-dynamic-example.html | 2 +- .../tree-flat-overview-example.html | 2 +- .../tree-harness/tree-harness-example.html | 2 +- .../tree-loadmore/tree-loadmore-example.css | 20 + .../tree-loadmore/tree-loadmore-example.html | 18 +- .../tree-loadmore/tree-loadmore-example.ts | 155 +- .../tree-nested-overview-example.html | 36 +- src/dev-app/tree/tree-demo.html | 20 + src/dev-app/tree/tree-demo.ts | 15 +- .../tree/data-source/flat-data-source.ts | 8 + src/material/tree/node.ts | 83 +- .../tree/testing/tree-harness.spec.ts | 4 +- .../tree/tree-using-tree-control.spec.ts | 1207 +++++++++++ src/material/tree/tree.md | 196 +- src/material/tree/tree.spec.ts | 438 ++-- src/material/tree/tree.ts | 1 - tools/public_api_guard/cdk/a11y.md | 57 + tools/public_api_guard/cdk/tree.md | 86 +- tools/public_api_guard/material/tree.md | 22 +- 56 files changed, 7762 insertions(+), 748 deletions(-) create mode 100644 src/cdk/a11y/key-manager/tree-key-manager.spec.ts create mode 100644 src/cdk/a11y/key-manager/tree-key-manager.ts create mode 100644 src/cdk/tree/tree-with-tree-control.spec.ts create mode 100644 src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.css create mode 100644 src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.html create mode 100644 src/components-examples/cdk/tree/cdk-tree-complex/cdk-tree-complex-example.ts create mode 100644 src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.css create mode 100644 src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html create mode 100644 src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts create mode 100644 src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.css create mode 100644 src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.html create mode 100644 src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts create mode 100644 src/components-examples/cdk/tree/cdk-tree-nested-children-accessor/cdk-tree-nested-children-accessor-example.css create mode 100644 src/components-examples/cdk/tree/cdk-tree-nested-children-accessor/cdk-tree-nested-children-accessor-example.html create mode 100644 src/components-examples/cdk/tree/cdk-tree-nested-children-accessor/cdk-tree-nested-children-accessor-example.ts create mode 100644 src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.css create mode 100644 src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.html create mode 100644 src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.ts create mode 100644 src/components-examples/cdk/tree/tree-data.ts create mode 100644 src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.css create mode 100644 src/material/tree/tree-using-tree-control.spec.ts 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 new file mode 100644 index 000000000000..1f9abc891ac9 --- /dev/null +++ b/src/cdk/a11y/key-manager/tree-key-manager.spec.ts @@ -0,0 +1,1165 @@ +import { + DOWN_ARROW, + EIGHT, + END, + ENTER, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + TAB, + UP_ARROW, +} from '@angular/cdk/keycodes'; +import {createKeyboardEvent} from '../../testing/private'; +import {QueryList} from '@angular/core'; +import {take} from 'rxjs/operators'; +import {TreeKeyManager, TreeKeyManagerItem} from './tree-key-manager'; +import {Observable, of as observableOf, Subscription} from 'rxjs'; +import {fakeAsync, tick} from '@angular/core/testing'; + +class FakeBaseTreeKeyManagerItem { + _isExpanded = false; + _parent: FakeBaseTreeKeyManagerItem | null = null; + _children: FakeBaseTreeKeyManagerItem[] = []; + + isDisabled?: boolean = false; + skipItem?: boolean = false; + + constructor(private _label: string) {} + + getLabel(): string { + return this._label; + } + activate(): void {} + getParent(): this | null { + return this._parent as this | null; + } + isExpanded(): boolean { + return this._isExpanded; + } + collapse(): void { + this._isExpanded = false; + } + expand(): void { + this._isExpanded = true; + } + focus(): void {} +} + +class FakeArrayTreeKeyManagerItem extends FakeBaseTreeKeyManagerItem implements TreeKeyManagerItem { + getChildren(): FakeArrayTreeKeyManagerItem[] { + return this._children as FakeArrayTreeKeyManagerItem[]; + } +} + +class FakeObservableTreeKeyManagerItem + extends FakeBaseTreeKeyManagerItem + implements TreeKeyManagerItem +{ + getChildren(): Observable { + return observableOf(this._children as FakeObservableTreeKeyManagerItem[]); + } +} + +interface ItemConstructorTestContext { + description: string; + constructor: new ( + label: string, + ) => FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; +} + +interface ExpandCollapseKeyEventTestContext { + direction: 'ltr' | 'rtl'; + expandKeyEvent: () => KeyboardEvent; + collapseKeyEvent: () => KeyboardEvent; +} + +describe('TreeKeyManager', () => { + let fakeKeyEvents: { + downArrow: KeyboardEvent; + upArrow: KeyboardEvent; + leftArrow: KeyboardEvent; + rightArrow: KeyboardEvent; + tab: KeyboardEvent; + home: KeyboardEvent; + end: KeyboardEvent; + enter: KeyboardEvent; + space: KeyboardEvent; + star: KeyboardEvent; + unsupported: KeyboardEvent; + }; + + beforeEach(() => { + fakeKeyEvents = { + downArrow: createKeyboardEvent('keydown', DOWN_ARROW), + upArrow: createKeyboardEvent('keydown', UP_ARROW), + leftArrow: createKeyboardEvent('keydown', LEFT_ARROW), + rightArrow: createKeyboardEvent('keydown', RIGHT_ARROW), + tab: createKeyboardEvent('keydown', TAB), + home: createKeyboardEvent('keydown', HOME), + end: createKeyboardEvent('keydown', END), + enter: createKeyboardEvent('keydown', ENTER), + space: createKeyboardEvent('keydown', SPACE), + star: createKeyboardEvent('keydown', EIGHT, '*'), + unsupported: createKeyboardEvent('keydown', 192), // corresponds to the tilde character (~) + }; + }); + + const itemParameters: ItemConstructorTestContext[] = [ + {description: 'Observable children', constructor: FakeObservableTreeKeyManagerItem}, + {description: 'array children', constructor: FakeArrayTreeKeyManagerItem}, + ]; + + for (const itemParam of itemParameters) { + describe(itemParam.description, () => { + let itemList: QueryList; + let keyManager: TreeKeyManager< + FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem + >; + + let parentItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 0 + let childItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 1 + let childItemWithNoChildren: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 3 + let lastItem: FakeArrayTreeKeyManagerItem | FakeObservableTreeKeyManagerItem; // index 5 + + beforeEach(() => { + itemList = new QueryList(); + const parent1 = new itemParam.constructor('one'); + const parent1Child1 = new itemParam.constructor('two'); + const parent1Child1Child1 = new itemParam.constructor('three'); + const parent1Child2 = new itemParam.constructor('four'); + const parent2 = new itemParam.constructor('five'); + const parent2Child1 = new itemParam.constructor('six'); + + parent1._children = [parent1Child1, parent1Child2]; + parent1Child1._parent = parent1; + parent1Child1._children = [parent1Child1Child1]; + parent1Child1Child1._parent = parent1Child1; + parent1Child2._parent = parent1; + parent2._children = [parent2Child1]; + parent2Child1._parent = parent2; + + parentItem = parent1; + childItem = parent1Child1; + childItemWithNoChildren = parent1Child2; + lastItem = parent2Child1; + + itemList.reset([ + parent1, + parent1Child1, + parent1Child1Child1, + parent1Child2, + parent2, + parent2Child1, + ]); + keyManager = new TreeKeyManager< + FakeObservableTreeKeyManagerItem | FakeArrayTreeKeyManagerItem + >({ + items: itemList, + }); + }); + + it('should start off the activeItem as null', () => { + expect(keyManager.getActiveItem()).withContext('active item').toBeNull(); + }); + + it('should maintain the active item if the amount of items changes', () => { + keyManager.setActiveItem(itemList.get(0)!); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + expect(keyManager.getActiveItem()?.getLabel()) + .withContext('active item label') + .toBe('one'); + itemList.reset([new FakeObservableTreeKeyManagerItem('parent0'), ...itemList.toArray()]); + itemList.notifyOnChanges(); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + expect(keyManager.getActiveItem()?.getLabel()) + .withContext('active item label') + .toBe('one'); + }); + + describe('Key events', () => { + it('should emit tabOut when tab key is pressed', () => { + const spy = jasmine.createSpy('tabOut spy'); + keyManager.tabOut.pipe(take(1)).subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.tab); + + expect(spy).toHaveBeenCalled(); + }); + + it('should emit tabOut when the tab key is pressed with a modifier', () => { + const spy = jasmine.createSpy('tabOut spy'); + keyManager.tabOut.pipe(take(1)).subscribe(spy); + + Object.defineProperty(fakeKeyEvents.tab, 'shiftKey', {get: () => true}); + keyManager.onKeydown(fakeKeyEvents.tab); + + expect(spy).toHaveBeenCalled(); + }); + + it('should emit an event whenever the active item changes', () => { + keyManager.setActiveItem(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(spy).toHaveBeenCalledTimes(1); + + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(spy).toHaveBeenCalledTimes(2); + + subscription.unsubscribe(); + }); + + it('should emit if the active item changed, but not the active index', () => { + keyManager.setActiveItem(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + + itemList.reset([new itemParam.constructor('zero'), ...itemList.toArray()]); + itemList.notifyOnChanges(); + keyManager.setActiveItem(itemList.get(0)!); + + expect(spy).toHaveBeenCalledTimes(1); + subscription.unsubscribe(); + }); + + it('should activate the first item when pressing down on a clean key manager', () => { + expect(keyManager.getActiveItemIndex()) + .withContext('default focused item index') + .toBe(-1); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('focused item index, after down arrow') + .toBe(0); + }); + + it('should not prevent the default keyboard action when pressing tab', () => { + expect(fakeKeyEvents.tab.defaultPrevented).toBe(false); + + keyManager.onKeydown(fakeKeyEvents.tab); + + expect(fakeKeyEvents.tab.defaultPrevented).toBe(false); + }); + + it('should not do anything for unsupported key presses', () => { + keyManager.setActiveItem(itemList.get(1)!); + + expect(keyManager.getActiveItemIndex()).toBe(1); + expect(fakeKeyEvents.unsupported.defaultPrevented).toBe(false); + + keyManager.onKeydown(fakeKeyEvents.unsupported); + + expect(keyManager.getActiveItemIndex()).toBe(1); + expect(fakeKeyEvents.unsupported.defaultPrevented).toBe(false); + }); + + it('should focus the first item when Home is pressed', () => { + keyManager.setActiveItem(itemList.get(1)!); + expect(keyManager.getActiveItemIndex()).toBe(1); + + keyManager.onKeydown(fakeKeyEvents.home); + + expect(keyManager.getActiveItemIndex()).toBe(0); + }); + + it('should focus the first non-disabled item when Home is pressed', () => { + itemList.get(0)!.isDisabled = true; + keyManager.setActiveItem(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.setActiveItem(itemList.get(0)!); + expect(keyManager.getActiveItemIndex()).toBe(0); + + 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.setActiveItem(itemList.get(0)!); + expect(keyManager.getActiveItemIndex()).toBe(0); + + keyManager.onKeydown(fakeKeyEvents.end); + + expect(keyManager.getActiveItemIndex()).toBe(itemList.length - 2); + }); + }); + + describe('up/down key events', () => { + it('should set subsequent items as active when the down key is pressed', () => { + keyManager.setActiveItem(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one down key event.') + .toBe(1); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(1)); + expect(spy).not.toHaveBeenCalledWith(itemList.get(2)); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after two down key events.') + .toBe(2); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(2)); + subscription.unsubscribe(); + }); + + it('should set first item active when the down key is pressed if no active item', () => { + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after down key if active item was null') + .toBe(0); + }); + + it('should set previous item as active when the up key is pressed', () => { + keyManager.setActiveItem(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.downArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one down key event.') + .toBe(1); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(1)); + + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one down and one up key event.') + .toBe(0); + expect(spy).toHaveBeenCalledWith(itemList.get(0)); + + subscription.unsubscribe(); + }); + + it('should do nothing when the up key is pressed if no active item', () => { + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.upArrow); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, if up event occurs and no active item.') + .toBe(-1); + expect(spy).not.toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + it('should skip disabled items', () => { + itemList.get(1)!.isDisabled = true; + keyManager.setActiveItem(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + // down event should skip past disabled item from 0 to 2 + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, skipping past disabled item on down event.') + .toBe(2); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).not.toHaveBeenCalledWith(itemList.get(1)); + expect(spy).toHaveBeenCalledWith(itemList.get(2)); + + // up event should skip past disabled item from 2 to 0 + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, skipping past disabled item on up event.') + .toBe(0); + expect(spy).toHaveBeenCalledWith(itemList.get(0)); + expect(spy).not.toHaveBeenCalledWith(itemList.get(1)); + expect(spy).toHaveBeenCalledWith(itemList.get(2)); + subscription.unsubscribe(); + }); + + it('should work normally when disabled property does not exist', () => { + itemList.get(0)!.isDisabled = undefined; + itemList.get(1)!.isDisabled = undefined; + itemList.get(2)!.isDisabled = undefined; + keyManager.setActiveItem(itemList.get(0)!); + + const spy = jasmine.createSpy('change spy'); + const subscription = keyManager.change.subscribe(spy); + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one down event when disabled is not set.') + .toBe(1); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(1)); + expect(spy).not.toHaveBeenCalledWith(itemList.get(2)); + + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after two down events when disabled is not set.') + .toBe(2); + expect(spy).not.toHaveBeenCalledWith(itemList.get(0)); + expect(spy).toHaveBeenCalledWith(itemList.get(1)); + expect(spy).toHaveBeenCalledWith(itemList.get(2)); + subscription.unsubscribe(); + }); + + it('should not move active item past either end of the list', () => { + keyManager.setActiveItem(itemList.get(itemList.length - 1)!); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, selecting the last item') + .toBe(itemList.length - 1); + + // This down event would move active item past the end of the list + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, last item still selected after a down event') + .toBe(itemList.length - 1); + + keyManager.setActiveItem(itemList.get(0)!); + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, selecting the first item') + .toBe(0); + + // This up event would move active item past the beginning of the list + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, first item still selected after a up event') + .toBe(0); + }); + + it('should not move active item to end when the last item is disabled', () => { + itemList.get(itemList.length - 1)!.isDisabled = true; + + keyManager.setActiveItem(itemList.get(itemList.length - 2)!); + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, last non-disabled item selected') + .toBe(itemList.length - 2); + + // This down key event would set active item to the last item, which is disabled + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(keyManager.getActiveItemIndex()) + .withContext( + 'active item index, last non-disabled item still selected, after down event', + ) + .toBe(itemList.length - 2); + }); + + it('should prevent the default keyboard action of handled events', () => { + expect(fakeKeyEvents.downArrow.defaultPrevented).toBe(false); + keyManager.onKeydown(fakeKeyEvents.downArrow); + expect(fakeKeyEvents.downArrow.defaultPrevented).toBe(true); + + expect(fakeKeyEvents.upArrow.defaultPrevented).toBe(false); + keyManager.onKeydown(fakeKeyEvents.upArrow); + expect(fakeKeyEvents.upArrow.defaultPrevented).toBe(true); + }); + }); + + describe('expand/collapse key events', () => { + const parameters: ExpandCollapseKeyEventTestContext[] = [ + { + direction: 'ltr', + expandKeyEvent: () => fakeKeyEvents.rightArrow, + collapseKeyEvent: () => fakeKeyEvents.leftArrow, + }, + { + direction: 'rtl', + expandKeyEvent: () => fakeKeyEvents.leftArrow, + collapseKeyEvent: () => fakeKeyEvents.rightArrow, + }, + ]; + + for (const param of parameters) { + describe(`in ${param.direction} mode`, () => { + beforeEach(() => { + keyManager = new TreeKeyManager({ + items: itemList, + horizontalOrientation: param.direction, + }); + for (const item of itemList) { + item._isExpanded = false; + } + }); + + it('with nothing active, expand key does not expand any items', () => { + expect(itemList.toArray().map(item => item.isExpanded())) + .withContext('item expansion state, for all items') + .toEqual(itemList.toArray().map(_ => false)); + + keyManager.onKeydown(param.expandKeyEvent()); + + expect(itemList.toArray().map(item => item.isExpanded())) + .withContext('item expansion state, for all items, after expand event') + .toEqual(itemList.toArray().map(_ => false)); + }); + + it('with nothing active, collapse key does not collapse any items', () => { + for (const item of itemList) { + item._isExpanded = true; + } + expect(itemList.toArray().map(item => item.isExpanded())) + .withContext('item expansion state, for all items') + .toEqual(itemList.toArray().map(_ => true)); + + keyManager.onKeydown(param.collapseKeyEvent()); + + expect(itemList.toArray().map(item => item.isExpanded())) + .withContext('item expansion state, for all items') + .toEqual(itemList.toArray().map(_ => true)); + }); + + it('with nothing active, expand key does not change the active item index', () => { + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, initial') + .toEqual(-1); + + keyManager.onKeydown(param.expandKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after expand event') + .toEqual(-1); + }); + + it('with nothing active, collapse key does not change the active item index', () => { + for (const item of itemList) { + item._isExpanded = true; + } + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, initial') + .toEqual(-1); + + keyManager.onKeydown(param.collapseKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after collapse event') + .toEqual(-1); + }); + + describe('if the current item is expanded', () => { + let spy: jasmine.Spy; + let subscription: Subscription; + + beforeEach(() => { + keyManager.setActiveItem(parentItem); + parentItem._isExpanded = true; + + spy = jasmine.createSpy('change spy'); + subscription = keyManager.change.subscribe(spy); + }); + + afterEach(() => { + subscription.unsubscribe(); + }); + + it('when the expand key is pressed, moves to the first child', () => { + keyManager.onKeydown(param.expandKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one expand key event.') + .toBe(1); + expect(spy).not.toHaveBeenCalledWith(parentItem); + expect(spy).toHaveBeenCalledWith(childItem); + }); + + it( + 'when the expand key is pressed, and the first child is disabled, ' + + 'moves to the first non-disabled child', + () => { + childItem.isDisabled = true; + + keyManager.onKeydown(param.expandKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one expand key event.') + .toBe(3); + expect(spy).not.toHaveBeenCalledWith(parentItem); + expect(spy).not.toHaveBeenCalledWith(childItem); + expect(spy).toHaveBeenCalledWith(childItemWithNoChildren); + }, + ); + + it( + 'when the expand key is pressed, and all children are disabled, ' + + 'does not change the active item', + () => { + childItem.isDisabled = true; + childItemWithNoChildren.isDisabled = true; + + keyManager.onKeydown(param.expandKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one expand key event.') + .toBe(0); + expect(spy).not.toHaveBeenCalled(); + }, + ); + + it('when the collapse key is pressed, collapses the item', () => { + expect(parentItem.isExpanded()) + .withContext('active item initial expansion state') + .toBe(true); + + keyManager.onKeydown(param.collapseKeyEvent()); + + expect(parentItem.isExpanded()) + .withContext('active item expansion state, after collapse key') + .toBe(false); + }); + + it('when the collapse key is pressed, does not change the active item', () => { + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, initial') + .toBe(0); + + keyManager.onKeydown(param.collapseKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one collapse key event.') + .toBe(0); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('if the current item is expanded, and there are no children', () => { + let spy: jasmine.Spy; + let subscription: Subscription; + + beforeEach(() => { + keyManager.setActiveItem(childItemWithNoChildren); + childItemWithNoChildren._isExpanded = true; + + spy = jasmine.createSpy('change spy'); + subscription = keyManager.change.subscribe(spy); + }); + + afterEach(() => { + subscription.unsubscribe(); + }); + + it('when the expand key is pressed, does not change the active item', () => { + keyManager.onKeydown(param.expandKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one expand key event.') + .toBe(3); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('if the current item is collapsed, and has a parent item', () => { + let spy: jasmine.Spy; + let subscription: Subscription; + + beforeEach(() => { + keyManager.setActiveItem(childItem); + childItem._isExpanded = false; + + spy = jasmine.createSpy('change spy'); + subscription = keyManager.change.subscribe(spy); + }); + + afterEach(() => { + subscription.unsubscribe(); + }); + + it('when the expand key is pressed, expands the current item', () => { + expect(childItem.isExpanded()) + .withContext('active item initial expansion state') + .toBe(false); + + keyManager.onKeydown(param.expandKeyEvent()); + + expect(childItem.isExpanded()) + .withContext('active item expansion state, after expand key') + .toBe(true); + }); + + it('when the expand key is pressed, does not change active item', () => { + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, initial') + .toBe(1); + + keyManager.onKeydown(param.expandKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one collapse key event.') + .toBe(1); + expect(spy).not.toHaveBeenCalled(); + }); + + it('when the collapse key is pressed, moves the active item to the parent', () => { + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, initial') + .toBe(1); + + keyManager.onKeydown(param.collapseKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one collapse key event.') + .toBe(0); + }); + + it('when the collapse key is pressed, and the parent is disabled, does nothing', () => { + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, initial') + .toBe(1); + + parentItem.isDisabled = true; + keyManager.onKeydown(param.collapseKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one collapse key event.') + .toBe(1); + }); + }); + + describe('if the current item is collapsed, and has no parent items', () => { + let spy: jasmine.Spy; + let subscription: Subscription; + + beforeEach(() => { + keyManager.setActiveItem(parentItem); + parentItem._isExpanded = false; + + spy = jasmine.createSpy('change spy'); + subscription = keyManager.change.subscribe(spy); + }); + + afterEach(() => { + subscription.unsubscribe(); + }); + + it('when the collapse key is pressed, does nothing', () => { + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, initial') + .toBe(0); + + keyManager.onKeydown(param.collapseKeyEvent()); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after one collapse key event.') + .toBe(0); + expect(spy).not.toHaveBeenCalledWith(parentItem); + }); + }); + }); + } + }); + + describe('typeahead mode', () => { + const debounceInterval = 300; + + beforeEach(() => { + keyManager = new TreeKeyManager({ + items: itemList, + typeAheadDebounceInterval: debounceInterval, + }); + }); + + it('should throw if the items do not implement the getLabel method', () => { + const invalidQueryList = new QueryList(); + invalidQueryList.reset([{disabled: false}]); + + expect( + () => + new TreeKeyManager({ + items: invalidQueryList, + typeAheadDebounceInterval: true, + }), + ).toThrowError(/must implement/); + }); + + it('should debounce the input key presses', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + tick(1); + keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n" + tick(1); + keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e" + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, before debounce interval') + .not.toBe(0); + + tick(debounceInterval - 1); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after partial debounce interval') + .not.toBe(0); + + tick(1); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after full debounce interval') + .toBe(0); + })); + + it('uses a default debounce interval', fakeAsync(() => { + const defaultInterval = 200; + keyManager = new TreeKeyManager({ + items: itemList, + typeAheadDebounceInterval: true, + }); + + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + tick(1); + keyManager.onKeydown(createKeyboardEvent('keydown', 78, 'n')); // types "n" + tick(1); + keyManager.onKeydown(createKeyboardEvent('keydown', 69, 'e')); // types "e" + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, before debounce interval') + .not.toBe(0); + + tick(defaultInterval - 1); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after partial debounce interval') + .not.toBe(0); + + tick(1); + + expect(keyManager.getActiveItemIndex()) + .withContext('active item index, after full debounce interval') + .toBe(0); + })); + + it('should focus the first item that starts with a letter', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + })); + + it('should focus the first item that starts with sequence of letters', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h" + + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + })); + + it('should cancel any pending timers if a navigation key is pressed', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 84, 't')); // types "t" + keyManager.onKeydown(createKeyboardEvent('keydown', 72, 'h')); // types "h" + keyManager.onKeydown(fakeKeyEvents.downArrow); + + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should handle non-English input', fakeAsync(() => { + itemList.reset([ + new itemParam.constructor('едно'), + new itemParam.constructor('две'), + new itemParam.constructor('три'), + ]); + itemList.notifyOnChanges(); + + const keyboardEvent = createKeyboardEvent('keydown', 68, 'д'); + + keyManager.onKeydown(keyboardEvent); // types "д" + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + })); + + it('should handle non-letter characters', fakeAsync(() => { + itemList.reset([ + new itemParam.constructor('[]'), + new itemParam.constructor('321'), + new itemParam.constructor('`!?'), + ]); + itemList.notifyOnChanges(); + + keyManager.onKeydown(createKeyboardEvent('keydown', 192, '`')); // types "`" + tick(debounceInterval); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(2); + + keyManager.onKeydown(createKeyboardEvent('keydown', 51, '3')); // types "3" + tick(debounceInterval); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(1); + + keyManager.onKeydown(createKeyboardEvent('keydown', 219, '[')); // types "[" + tick(debounceInterval); + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should not focus disabled items', fakeAsync(() => { + expect(keyManager.getActiveItemIndex()).withContext('initial active item index').toBe(-1); + + parentItem.isDisabled = true; + + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); // types "o" + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('initial active item index').toBe(-1); + })); + + it('should start looking for matches after the active item', fakeAsync(() => { + const frodo = new itemParam.constructor('Frodo'); + itemList.reset([ + new itemParam.constructor('Bilbo'), + frodo, + new itemParam.constructor('Pippin'), + new itemParam.constructor('Boromir'), + new itemParam.constructor('Aragorn'), + ]); + itemList.notifyOnChanges(); + + keyManager.setActiveItem(frodo); + keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(3); + })); + + it('should wrap back around if there were no matches after the active item', fakeAsync(() => { + const boromir = new itemParam.constructor('Boromir'); + itemList.reset([ + new itemParam.constructor('Bilbo'), + new itemParam.constructor('Frodo'), + new itemParam.constructor('Pippin'), + boromir, + new itemParam.constructor('Aragorn'), + ]); + itemList.notifyOnChanges(); + + keyManager.setActiveItem(boromir); + keyManager.onKeydown(createKeyboardEvent('keydown', 66, 'b')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should wrap back around if the last item is active', fakeAsync(() => { + keyManager.setActiveItem(lastItem); + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should be able to select the first item', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 79, 'o')); + tick(debounceInterval); + + expect(keyManager.getActiveItemIndex()).withContext('active item index').toBe(0); + })); + + it('should not do anything if there is no match', fakeAsync(() => { + keyManager.onKeydown(createKeyboardEvent('keydown', 87, 'w')); + tick(debounceInterval); + + 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 new file mode 100644 index 000000000000..0de04aabba4b --- /dev/null +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -0,0 +1,525 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 { + DOWN_ARROW, + END, + ENTER, + HOME, + LEFT_ARROW, + RIGHT_ARROW, + SPACE, + TAB, + UP_ARROW, + A, + Z, + ZERO, + NINE, +} from '@angular/cdk/keycodes'; +import {QueryList} from '@angular/core'; +import {of as observableOf, isObservable, Observable, Subject, Subscription} from 'rxjs'; +import {debounceTime, filter, map, take, tap} from 'rxjs/operators'; + +const DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS = 200; + +function coerceObservable(data: T | Observable): Observable { + if (!isObservable(data)) { + return observableOf(data); + } + return data; +} + +/** Represents an item within a tree that can be passed to a TreeKeyManager. */ +export 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; +} + +/** + * Configuration for the TreeKeyManager. + */ +export interface TreeKeyManagerOptions { + items: Observable | QueryList | T[]; + + /** + * Sets the predicate function that determines which items should be skipped by the tree key + * manager. By default, disabled items are skipped. + * + * If the item is to be skipped, this function should return false. + */ + skipPredicate?: (item: T) => boolean; + + /** + * If true, then the key manager will call `activate` in addition to calling `focus` when a + * particular item is focused. By default, this is false. + */ + activationFollowsFocus?: boolean; + + /** + * The direction in which the tree items are laid out horizontally. This influences which key + * will be interpreted as expand or collapse. Defaults to 'ltr'. + */ + horizontalOrientation?: 'rtl' | 'ltr'; + + /** + * If provided, determines how the key manager determines if two items are equivalent. + * + * It should provide a unique key for each unique tree item. If two tree items are equivalent, + * then this function should return the same value. + */ + trackBy?: (treeItem: T) => unknown; + + /** + * If a value is provided, enables typeahead mode, which allows users to set the active item + * by typing the visible label of the item. + * + * If a number is provided, this will be the time to wait after the last keystroke before + * setting the active item. If `true` is provided, the default interval of 200ms will be used. + */ + typeAheadDebounceInterval?: true | number; +} + +/** + * This class manages keyboard events for trees. If you pass it a QueryList or other list of tree + * items, it will set the active item, focus, handle expansion and typeahead correctly when + * keyboard events occur. + */ +export class TreeKeyManager { + private _activeItemIndex = -1; + private _activeItem: T | null = null; + private _activationFollowsFocus = false; + private _horizontal: 'ltr' | 'rtl' = 'ltr'; + private readonly _letterKeyStream = new Subject(); + private _typeaheadSubscription = Subscription.EMPTY; + + /** + * Predicate function that can be used to check whether an item should be skipped + * by the key manager. By default, disabled items are skipped. + */ + private _skipPredicateFn = (item: T) => this._isItemDisabled(item); + + /** Function to determine equivalent items. */ + private _trackByFn: (item: T) => unknown = (item: T) => item; + + /** Buffer for the letters that the user has pressed when the typeahead option is turned on. */ + private _pressedLetters: string[] = []; + + private _items: T[] = []; + + constructor({ + items, + skipPredicate, + trackBy, + horizontalOrientation, + activationFollowsFocus, + typeAheadDebounceInterval, + }: TreeKeyManagerOptions) { + // We allow for the items to be an array or Observable because, in some cases, the consumer may + // not have access to a QueryList of the items they want to manage (e.g. when the + // items aren't being collected via `ViewChildren` or `ContentChildren`). + if (items instanceof QueryList) { + this._items = items.toArray(); + items.changes.subscribe((newItems: QueryList) => { + this._items = newItems.toArray(); + this._updateActiveItemIndex(this._items); + }); + } else if (isObservable(items)) { + items.subscribe(newItems => { + this._items = newItems; + this._updateActiveItemIndex(newItems); + }); + } else { + this._items = items; + } + + if (typeof skipPredicate !== 'undefined') { + this._skipPredicateFn = skipPredicate; + } + if (typeof trackBy !== 'undefined') { + this._trackByFn = trackBy; + } + if (typeof horizontalOrientation !== 'undefined') { + this._horizontal = horizontalOrientation; + } + if (typeof activationFollowsFocus !== 'undefined') { + this._activationFollowsFocus = activationFollowsFocus; + } + if (typeof typeAheadDebounceInterval !== 'undefined') { + this._setTypeAhead( + typeof typeAheadDebounceInterval === 'number' + ? typeAheadDebounceInterval + : DEFAULT_TYPEAHEAD_DEBOUNCE_INTERVAL_MS, + ); + } + } + + /** + * Stream that emits any time the TAB key is pressed, so components can react + * when focus is shifted off of the list. + */ + readonly tabOut = new Subject(); + + /** Stream that emits any time the focused item changes. */ + readonly change = new Subject(); + + /** + * Handles a keyboard event on the tree. + * @param event Keyboard event that represents the user interaction with the tree. + */ + onKeydown(event: KeyboardEvent) { + const keyCode = event.keyCode; + + switch (keyCode) { + case TAB: + this.tabOut.next(); + // NB: return here, in order to allow Tab to actually tab out of the tree + return; + + case DOWN_ARROW: + this._focusNextItem(); + break; + + case UP_ARROW: + this._focusPreviousItem(); + break; + + case RIGHT_ARROW: + this._horizontal === 'rtl' ? this._collapseCurrentItem() : this._expandCurrentItem(); + break; + + case LEFT_ARROW: + this._horizontal === 'rtl' ? this._expandCurrentItem() : this._collapseCurrentItem(); + break; + + case HOME: + this._focusFirstItem(); + break; + + case END: + this._focusLastItem(); + break; + + case ENTER: + case SPACE: + this._activateCurrentItem(); + break; + + default: + // The keyCode for `*` is the same as the keyCode for `8`, so we check the event key + // instead. + if (event.key === '*') { + this._expandAllItemsAtCurrentItemLevel(); + break; + } + + // Attempt to use the `event.key` which also maps it to the user's keyboard language, + // otherwise fall back to resolving alphanumeric characters via the keyCode. + if (event.key && event.key.length === 1) { + this._letterKeyStream.next(event.key.toLocaleUpperCase()); + } else if ((keyCode >= A && keyCode <= Z) || (keyCode >= ZERO && keyCode <= NINE)) { + this._letterKeyStream.next(String.fromCharCode(keyCode)); + } + + // NB: return here, in order to avoid preventing the default action of non-navigational + // keys or resetting the buffer of pressed letters. + return; + } + + // Reset the typeahead since the user has used a navigational key. + this._pressedLetters = []; + event.preventDefault(); + } + + /** Index of the currently active item. */ + getActiveItemIndex(): number | null { + return this._activeItemIndex; + } + + /** The currently active item. */ + getActiveItem(): T | null { + 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(); + } + + /** + * 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(); + } + + setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void; + setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void; + setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void; + setActiveItem(itemOrIndex: number | T, options: {emitChangeEvent?: boolean} = {}) { + // Set default options + options.emitChangeEvent ??= true; + + let index = + typeof itemOrIndex === 'number' + ? itemOrIndex + : this._items.findIndex(item => this._trackByFn(item) === this._trackByFn(itemOrIndex)); + if (index < 0 || index >= this._items.length) { + return; + } + const activeItem = this._items[index]; + + // If we're just setting the same item, don't re-call activate or focus + if ( + this._activeItem !== null && + this._trackByFn(activeItem) === this._trackByFn(this._activeItem) + ) { + return; + } + + this._activeItem = activeItem ?? null; + this._activeItemIndex = index; + + if (options.emitChangeEvent) { + this.change.next(this._activeItem); + } + this._activeItem?.focus(); + if (this._activationFollowsFocus) { + this._activateCurrentItem(); + } + } + + private _updateActiveItemIndex(newItems: T[]) { + 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; + } + } + } + + private _setTypeAhead(debounceInterval: number) { + this._typeaheadSubscription.unsubscribe(); + + if ( + (typeof ngDevMode === 'undefined' || ngDevMode) && + this._items.length && + this._items.some(item => typeof item.getLabel !== 'function') + ) { + throw new Error( + 'TreeKeyManager items in typeahead mode must implement the `getLabel` method.', + ); + } + + // Debounce the presses of non-navigational keys, collect the ones that correspond to letters + // and convert those letters back into a string. Afterwards find the first item that starts + // with that string and select it. + this._typeaheadSubscription = this._letterKeyStream + .pipe( + tap(letter => this._pressedLetters.push(letter)), + debounceTime(debounceInterval), + filter(() => this._pressedLetters.length > 0), + map(() => this._pressedLetters.join('').toLocaleUpperCase()), + ) + .subscribe(inputString => { + // Start at 1 because we want to start searching at the item immediately + // following the current active item. + for (let i = 1; i < this._items.length + 1; i++) { + const index = (this._activeItemIndex + i) % this._items.length; + const item = this._items[index]; + + if ( + !this._skipPredicateFn(item) && + item.getLabel?.().toLocaleUpperCase().trim().indexOf(inputString) === 0 + ) { + this.setActiveItem(index); + break; + } + } + + this._pressedLetters = []; + }); + } + + //// Navigational methods + + private _focusFirstItem() { + this.setActiveItem(this._findNextAvailableItemIndex(-1)); + } + + private _focusLastItem() { + this.setActiveItem(this._findPreviousAvailableItemIndex(this._items.length)); + } + + private _focusPreviousItem() { + this.setActiveItem(this._findPreviousAvailableItemIndex(this._activeItemIndex)); + } + + private _focusNextItem() { + this.setActiveItem(this._findNextAvailableItemIndex(this._activeItemIndex)); + } + + private _findNextAvailableItemIndex(startingIndex: number) { + for (let i = startingIndex + 1; i < this._items.length; i++) { + if (!this._skipPredicateFn(this._items[i])) { + return i; + } + } + return startingIndex; + } + + private _findPreviousAvailableItemIndex(startingIndex: number) { + for (let i = startingIndex - 1; i >= 0; i--) { + if (!this._skipPredicateFn(this._items[i])) { + return i; + } + } + return startingIndex; + } + + /** + * If the item is already expanded, we collapse the item. Otherwise, we will focus the parent. + */ + private _collapseCurrentItem() { + if (!this._activeItem) { + return; + } + + if (this._isCurrentItemExpanded()) { + this._activeItem.collapse(); + } else { + const parent = this._activeItem.getParent(); + if (!parent || this._skipPredicateFn(parent as T)) { + return; + } + this.setActiveItem(parent as T); + } + } + + /** + * If the item is already collapsed, we expand the item. Otherwise, we will focus the first child. + */ + private _expandCurrentItem() { + if (!this._activeItem) { + return; + } + + if (!this._isCurrentItemExpanded()) { + this._activeItem.expand(); + } else { + coerceObservable(this._activeItem.getChildren()) + .pipe(take(1)) + .subscribe(children => { + const firstChild = children.find(child => !this._skipPredicateFn(child as T)); + if (!firstChild) { + return; + } + this.setActiveItem(firstChild as T); + }); + } + } + + private _isCurrentItemExpanded() { + if (!this._activeItem) { + return false; + } + return typeof this._activeItem.isExpanded === 'boolean' + ? this._activeItem.isExpanded + : this._activeItem.isExpanded(); + } + + private _isItemDisabled(item: TreeKeyManagerItem) { + return typeof item.isDisabled === 'boolean' ? item.isDisabled : item.isDisabled?.(); + } + + /** For all items that are the same level as the current item, we expand those items. */ + private _expandAllItemsAtCurrentItemLevel() { + if (!this._activeItem) { + return; + } + + const parent = this._activeItem.getParent(); + let itemsToExpand; + if (!parent) { + itemsToExpand = observableOf(this._items.filter(item => item.getParent() === null)); + } else { + itemsToExpand = coerceObservable(parent.getChildren()); + } + + itemsToExpand.pipe(take(1)).subscribe(items => { + for (const item of items) { + item.expand(); + } + }); + } + + private _activateCurrentItem() { + this._activeItem?.activate(); + } +} diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index af4e24404387..ea64d62578b7 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -10,6 +10,7 @@ export * from './aria-describer/aria-reference'; export * from './key-manager/activedescendant-key-manager'; export * from './key-manager/focus-key-manager'; export * from './key-manager/list-key-manager'; +export * from './key-manager/tree-key-manager'; export * from './focus-trap/configurable-focus-trap'; export * from './focus-trap/configurable-focus-trap-config'; export * from './focus-trap/configurable-focus-trap-factory'; diff --git a/src/cdk/tree/BUILD.bazel b/src/cdk/tree/BUILD.bazel index d0a1ee75a49b..4933a9726ab4 100644 --- a/src/cdk/tree/BUILD.bazel +++ b/src/cdk/tree/BUILD.bazel @@ -20,6 +20,7 @@ ng_module( "//src/cdk/bidi", "//src/cdk/coercion", "//src/cdk/collections", + "//src/cdk/keycodes", "@npm//@angular/core", "@npm//rxjs", ], @@ -35,6 +36,8 @@ ng_test_library( ":tree", "//src/cdk/bidi", "//src/cdk/collections", + "//src/cdk/keycodes", + "//src/cdk/testing/testbed", "@npm//rxjs", ], ) diff --git a/src/cdk/tree/control/base-tree-control.ts b/src/cdk/tree/control/base-tree-control.ts index 4fad8b20e947..dbbab65d519d 100644 --- a/src/cdk/tree/control/base-tree-control.ts +++ b/src/cdk/tree/control/base-tree-control.ts @@ -9,7 +9,12 @@ import {SelectionModel} from '@angular/cdk/collections'; import {Observable} from 'rxjs'; import {TreeControl} from './tree-control'; -/** Base tree control. It has basic toggle/expand/collapse operations on a single data node. */ +/** + * Base tree control. It has basic toggle/expand/collapse operations on a single data node. + * + * @deprecated Use one of levelAccessor or childrenAccessor. To be removed in a future version. + * @breaking-change 19.0.0 + */ export abstract class BaseTreeControl implements TreeControl { /** Gets a list of descendent data nodes of a subtree rooted at given data node recursively. */ abstract getDescendants(dataNode: T): T[]; diff --git a/src/cdk/tree/control/flat-tree-control.ts b/src/cdk/tree/control/flat-tree-control.ts index 3c128295f0d4..72b691098109 100644 --- a/src/cdk/tree/control/flat-tree-control.ts +++ b/src/cdk/tree/control/flat-tree-control.ts @@ -13,7 +13,13 @@ export interface FlatTreeControlOptions { trackBy?: (dataNode: T) => K; } -/** Flat tree control. Able to expand/collapse a subtree recursively for flattened tree. */ +/** + * Flat tree control. Able to expand/collapse a subtree recursively for flattened tree. + * + * @deprecated Use one of levelAccessor or childrenAccessor instead. To be removed in a future + * version. + * @breaking-change 19.0.0 + */ export class FlatTreeControl extends BaseTreeControl { /** Construct with flat tree data node functions getLevel and isExpandable. */ constructor( diff --git a/src/cdk/tree/control/nested-tree-control.ts b/src/cdk/tree/control/nested-tree-control.ts index 6a30fabcfbdd..5ee5318f4899 100644 --- a/src/cdk/tree/control/nested-tree-control.ts +++ b/src/cdk/tree/control/nested-tree-control.ts @@ -11,10 +11,18 @@ 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; } -/** Nested tree control. Able to expand/collapse a subtree recursively for NestedNode type. */ +/** + * Nested tree control. Able to expand/collapse a subtree recursively for NestedNode type. + * + * @deprecated Use one of levelAccessor or childrenAccessor instead. To be removed in a future + * version. + * @breaking-change 19.0.0 + */ export class NestedTreeControl extends BaseTreeControl { /** Construct with nested tree function getChildren. */ constructor( @@ -26,6 +34,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/control/tree-control.ts b/src/cdk/tree/control/tree-control.ts index f32e0f4c5852..69235d10fc27 100644 --- a/src/cdk/tree/control/tree-control.ts +++ b/src/cdk/tree/control/tree-control.ts @@ -12,6 +12,9 @@ import {Observable} from 'rxjs'; * Tree control interface. User can implement TreeControl to expand/collapse dataNodes in the tree. * The CDKTree will use this TreeControl to expand/collapse a node. * User can also use it outside the `` to control the expansion status of the tree. + * + * @deprecated Use one of levelAccessor or childrenAccessor instead. To be removed in a future version. + * @breaking-change 19.0.0 */ export interface TreeControl { /** The saved tree nodes data for `expandAll` action. */ diff --git a/src/cdk/tree/nested-node.ts b/src/cdk/tree/nested-node.ts index 44d62083a620..bed3fe979a48 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -16,12 +16,10 @@ import { OnInit, QueryList, } from '@angular/core'; -import {isObservable} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {CDK_TREE_NODE_OUTLET_NODE, CdkTreeNodeOutlet} from './outlet'; import {CdkTree, CdkTreeNode} from './tree'; -import {getTreeControlFunctionsMissingError} from './tree-errors'; /** * Nested node is a child of ``. It works with nested tree. @@ -69,17 +67,10 @@ export class CdkNestedTreeNode ngAfterContentInit() { this._dataDiffer = this._differs.find([]).create(this._tree.trackBy); - if (!this._tree.treeControl.getChildren && (typeof ngDevMode === 'undefined' || ngDevMode)) { - throw getTreeControlFunctionsMissingError(); - } - const childrenNodes = this._tree.treeControl.getChildren(this.data); - if (Array.isArray(childrenNodes)) { - this.updateChildrenNodes(childrenNodes as T[]); - } else if (isObservable(childrenNodes)) { - childrenNodes - .pipe(takeUntil(this._destroyed)) - .subscribe(result => this.updateChildrenNodes(result)); - } + this._tree + ._getDirectChildren(this.data) + .pipe(takeUntil(this._destroyed)) + .subscribe(result => this.updateChildrenNodes(result)); this.nodeOutlet.changes .pipe(takeUntil(this._destroyed)) .subscribe(() => this.updateChildrenNodes()); @@ -88,6 +79,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/padding.ts b/src/cdk/tree/padding.ts index bf41d0ba9c5f..54c5c51a0774 100644 --- a/src/cdk/tree/padding.ts +++ b/src/cdk/tree/padding.ts @@ -80,10 +80,7 @@ export class CdkTreeNodePadding implements OnDestroy { /** The padding indent value for the tree node. Returns a string with px numbers if not null. */ _paddingIndent(): string | null { - const nodeLevel = - this._treeNode.data && this._tree.treeControl.getLevel - ? this._tree.treeControl.getLevel(this._treeNode.data) - : null; + const nodeLevel = (this._treeNode.data && this._tree._getLevel(this._treeNode.data)) ?? null; const level = this._level == null ? nodeLevel : this._level; return typeof level === 'number' ? `${level * this._indent}${this.indentUnits}` : null; } diff --git a/src/cdk/tree/toggle.ts b/src/cdk/tree/toggle.ts index 0fd9326b20ff..442d6ade7832 100644 --- a/src/cdk/tree/toggle.ts +++ b/src/cdk/tree/toggle.ts @@ -8,16 +8,22 @@ import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {Directive, Input} from '@angular/core'; +import {ENTER, SPACE} from '@angular/cdk/keycodes'; import {CdkTree, CdkTreeNode} from './tree'; /** - * Node toggle to expand/collapse the node. + * Node toggle to expand and collapse the node. + * + * CdkTreeNodeToggle is intended only to be used on native button elements, elements with button role, + * or elements with treeitem role. */ @Directive({ selector: '[cdkTreeNodeToggle]', host: { '(click)': '_toggle($event)', + '(keydown)': '_toggleOnEnterOrSpace($event)', + 'tabindex': '-1', }, }) export class CdkTreeNodeToggle { @@ -31,13 +37,29 @@ export class CdkTreeNodeToggle { } protected _recursive = false; - constructor(protected _tree: CdkTree, protected _treeNode: CdkTreeNode) {} + constructor( + protected _tree: CdkTree, + protected _treeNode: CdkTreeNode, + ) {} + // Toggle the expanded or collapsed state of this node. + // + // Focus this node with expanding or collapsing it. This ensures that the active node will always + // be visible when expanding and collapsing. _toggle(event: Event): void { this.recursive - ? this._tree.treeControl.toggleDescendants(this._treeNode.data) - : this._tree.treeControl.toggle(this._treeNode.data); + ? this._tree.toggleDescendants(this._treeNode.data) + : this._tree.toggle(this._treeNode.data); + + this._tree._keyManager.focusItem(this._treeNode); event.stopPropagation(); } + + _toggleOnEnterOrSpace(event: KeyboardEvent) { + if (event.keyCode === ENTER || event.keyCode === SPACE) { + this._toggle(event); + event.preventDefault(); + } + } } diff --git a/src/cdk/tree/tree-errors.ts b/src/cdk/tree/tree-errors.ts index aad9df0911a7..ba70ea5bfa80 100644 --- a/src/cdk/tree/tree-errors.ts +++ b/src/cdk/tree/tree-errors.ts @@ -31,17 +31,18 @@ export function getTreeMissingMatchingNodeDefError() { } /** - * Returns an error to be thrown when there are tree control. + * Returns an error to be thrown when there is no tree control. * @docs-private */ export function getTreeControlMissingError() { - return Error(`Could not find a tree control for the tree.`); + return Error(`Could not find a tree control, levelAccessor, or childrenAccessor for the tree.`); } /** - * Returns an error to be thrown when tree control did not implement functions for flat/nested node. + * Returns an error to be thrown when there are multiple ways of specifying children or level + * provided to the tree. * @docs-private */ -export function getTreeControlFunctionsMissingError() { - return Error(`Could not find functions for nested/flat tree in tree control.`); +export function getMultipleTreeControlsError() { + return Error(`More than one of tree control, levelAccessor, or childrenAccessor were provided.`); } diff --git a/src/cdk/tree/tree-with-tree-control.spec.ts b/src/cdk/tree/tree-with-tree-control.spec.ts new file mode 100644 index 000000000000..2566da86d598 --- /dev/null +++ b/src/cdk/tree/tree-with-tree-control.spec.ts @@ -0,0 +1,1825 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 {ComponentFixture, TestBed} from '@angular/core/testing'; +import { + Component, + ErrorHandler, + ViewChild, + TrackByFunction, + Type, + EventEmitter, + ViewChildren, + QueryList, +} from '@angular/core'; + +import {CollectionViewer, DataSource} from '@angular/cdk/collections'; +import {Directionality, Direction} from '@angular/cdk/bidi'; +import {createKeyboardEvent} from '@angular/cdk/testing/testbed/fake-events'; +import {combineLatest, BehaviorSubject, Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; + +import {TreeControl} from './control/tree-control'; +import {FlatTreeControl} from './control/flat-tree-control'; +import {NestedTreeControl} from './control/nested-tree-control'; +import {CdkTreeModule, CdkTreeNodePadding} from './index'; +import {CdkTree, CdkTreeNode} from './tree'; +import {LEFT_ARROW, RIGHT_ARROW} from '../keycodes'; + +describe('CdkTree', () => { + /** Represents an indent for expectNestedTreeToMatch */ + const _ = {}; + let dataSource: FakeDataSource; + let treeElement: HTMLElement; + let tree: CdkTree; + let dir: {value: Direction; readonly change: EventEmitter}; + + function configureCdkTreeTestingModule(declarations: Type[]) { + TestBed.configureTestingModule({ + imports: [CdkTreeModule], + providers: [ + { + provide: Directionality, + useFactory: () => (dir = {value: 'ltr', change: new EventEmitter()}), + }, + // Custom error handler that re-throws the error. Errors happening within + // change detection phase will be reported through the handler and thrown + // in Ivy. Since we do not want to pollute the "console.error", but rather + // just rely on the actual error interrupting the test, we re-throw here. + { + provide: ErrorHandler, + useValue: { + handleError: (err: any) => { + throw err; + }, + }, + }, + ], + declarations: declarations, + }).compileComponents(); + } + + it('should clear out the `mostRecentTreeNode` on destroy', () => { + configureCdkTreeTestingModule([SimpleCdkTreeApp]); + const fixture = TestBed.createComponent(SimpleCdkTreeApp); + fixture.detectChanges(); + + // Cast the assertions to a boolean to avoid Jasmine going into an + // infinite loop when stringifying the object, if the test starts failing. + expect(!!CdkTreeNode.mostRecentTreeNode).toBe(true); + + fixture.destroy(); + + expect(!!CdkTreeNode.mostRecentTreeNode).toBe(false); + }); + + it('should complete the viewChange stream on destroy', () => { + configureCdkTreeTestingModule([SimpleCdkTreeApp]); + const fixture = TestBed.createComponent(SimpleCdkTreeApp); + fixture.detectChanges(); + const spy = jasmine.createSpy('completeSpy'); + const subscription = fixture.componentInstance.tree.viewChange.subscribe({complete: spy}); + + fixture.destroy(); + expect(spy).toHaveBeenCalled(); + subscription.unsubscribe(); + }); + + describe('flat tree', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + let component: SimpleCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([SimpleCdkTreeApp]); + fixture = TestBed.createComponent(SimpleCdkTreeApp); + + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with a connected data source', () => { + expect(tree.dataSource).toBe(dataSource); + expect(dataSource.isConnected).toBe(true); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).withContext('Expect nodes to be defined').toBeDefined(); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectFlatTreeToMatch( + treeElement, + 28, + 'px', + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + ); + + dataSource.addData(2); + fixture.detectChanges(); + + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch( + treeElement, + 28, + 'px', + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + [_, `${data[3].pizzaTopping} - ${data[3].pizzaCheese} + ${data[3].pizzaBase}`], + ); + }); + + it('should be able to use units different from px for the indentation', () => { + component.indent = '15rem'; + fixture.detectChanges(); + + const data = dataSource.data; + + expectFlatTreeToMatch( + treeElement, + 15, + 'rem', + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + ); + }); + + it('should default to px if no unit is set for string value indentation', () => { + component.indent = '17'; + fixture.detectChanges(); + + const data = dataSource.data; + + expectFlatTreeToMatch( + treeElement, + 17, + 'px', + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + ); + }); + + it('should be able to set zero as the indent level', () => { + component.paddingNodes.forEach(node => (node.level = 0)); + fixture.detectChanges(); + + const data = dataSource.data; + + expectFlatTreeToMatch( + treeElement, + 0, + 'px', + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + ); + }); + + it('should reset the opposite direction padding if the direction changes', () => { + const node = getNodes(treeElement)[0]; + + component.indent = 10; + fixture.detectChanges(); + + expect(node.style.paddingLeft).toBe('10px'); + expect(node.style.paddingRight).toBeFalsy(); + + dir.value = 'rtl'; + dir.change.emit('rtl'); + fixture.detectChanges(); + + expect(node.style.paddingRight).toBe('10px'); + expect(node.style.paddingLeft).toBeFalsy(); + }); + }); + + describe('with toggle', () => { + let fixture: ComponentFixture; + let component: CdkTreeAppWithToggle; + + beforeEach(() => { + configureCdkTreeTestingModule([CdkTreeAppWithToggle]); + fixture = TestBed.createComponent(CdkTreeAppWithToggle); + + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('should expand/collapse the node', () => { + expect(dataSource.data.length).toBe(3); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect no expanded node`) + .toBe(0); + + component.toggleRecursively = false; + let data = dataSource.data; + dataSource.addChild(data[2]); + fixture.detectChanges(); + + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch( + treeElement, + 40, + 'px', + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + [_, `${data[3].pizzaTopping} - ${data[3].pizzaCheese} + ${data[3].pizzaBase}`], + ); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node expanded`) + .toBe(1); + expect(component.treeControl.expansionModel.selected[0]).toBe(data[2]); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node collapsed`) + .toBe(0); + }); + + it('should expand/collapse the node recursively', () => { + expect(dataSource.data.length).toBe(3); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect no expanded node`) + .toBe(0); + + let data = dataSource.data; + dataSource.addChild(data[2]); + fixture.detectChanges(); + + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch( + treeElement, + 40, + 'px', + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + [_, `${data[3].pizzaTopping} - ${data[3].pizzaCheese} + ${data[3].pizzaBase}`], + ); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect nodes expanded`) + .toBe(2); + expect(component.treeControl.expansionModel.selected[0]) + .withContext(`Expect parent node expanded`) + .toBe(data[2]); + expect(component.treeControl.expansionModel.selected[1]) + .withContext(`Expected child node expanded`) + .toBe(data[3]); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node collapsed`) + .toBe(0); + }); + }); + + describe('with when node template', () => { + let fixture: ComponentFixture; + let component: WhenNodeCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([WhenNodeCdkTreeApp]); + fixture = TestBed.createComponent(WhenNodeCdkTreeApp); + + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectFlatTreeToMatch( + treeElement, + 28, + 'px', + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + + dataSource.addChild(data[1]); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch( + treeElement, + 28, + 'px', + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `topping_4 - cheese_4 + base_4`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + }); + }); + + describe('with array data source', () => { + let fixture: ComponentFixture; + let component: ArrayDataSourceCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([ArrayDataSourceCdkTreeApp]); + fixture = TestBed.createComponent(ArrayDataSourceCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectFlatTreeToMatch( + treeElement, + 28, + 'px', + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + + dataSource.addChild(data[1]); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch( + treeElement, + 28, + 'px', + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `[topping_4] - [cheese_4] + [base_4]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + }); + }); + + describe('with observable data source', () => { + let fixture: ComponentFixture; + let component: ObservableDataSourceCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([ObservableDataSourceCdkTreeApp]); + fixture = TestBed.createComponent(ObservableDataSourceCdkTreeApp); + + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectFlatTreeToMatch( + treeElement, + 28, + 'px', + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + + dataSource.addChild(data[1]); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(4); + expectFlatTreeToMatch( + treeElement, + 28, + 'px', + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `[topping_4] - [cheese_4] + [base_4]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + }); + }); + + describe('with trackBy', () => { + let fixture: ComponentFixture; + let component: CdkTreeAppWithTrackBy; + + function createTrackByTestComponent(trackByStrategy: 'reference' | 'property' | 'index') { + configureCdkTreeTestingModule([CdkTreeAppWithTrackBy]); + fixture = TestBed.createComponent(CdkTreeAppWithTrackBy); + component = fixture.componentInstance; + component.trackByStrategy = trackByStrategy; + fixture.detectChanges(); + + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + // Each node receives an attribute 'initialIndex' the element's original place + getNodes(treeElement).forEach((node: Element, index: number) => { + node.setAttribute('initialIndex', index.toString()); + }); + + // Prove that the attributes match their indices + const initialNodes = getNodes(treeElement); + expect(initialNodes[0].getAttribute('initialIndex')).toBe('0'); + expect(initialNodes[1].getAttribute('initialIndex')).toBe('1'); + expect(initialNodes[2].getAttribute('initialIndex')).toBe('2'); + } + + function mutateData() { + // Swap first and second data in data array + const copiedData = component.dataSource.data.slice(); + const temp = copiedData[0]; + copiedData[0] = copiedData[1]; + copiedData[1] = temp; + + // Remove the third element + copiedData.splice(2, 1); + + // Add new data + component.dataSource.data = copiedData; + component.dataSource.addData(); + } + + it('should add/remove/move nodes with reference-based trackBy', () => { + createTrackByTestComponent('reference'); + mutateData(); + + // Expect that the first and second nodes were swapped and that the last node is new + const changedNodes = getNodes(treeElement); + expect(changedNodes.length).toBe(3); + expect(changedNodes[0].getAttribute('initialIndex')).toBe('1'); + expect(changedNodes[1].getAttribute('initialIndex')).toBe('0'); + expect(changedNodes[2].getAttribute('initialIndex')).toBe(null); + }); + + it('should add/remove/move nodes with property-based trackBy', () => { + createTrackByTestComponent('property'); + mutateData(); + + // Change each item reference to show that the trackby is checking the item properties. + // Otherwise this would cause them all to be removed/added. + component.dataSource.data = component.dataSource.data.map( + item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase), + ); + + // Expect that the first and second nodes were swapped and that the last node is new + const changedNodes = getNodes(treeElement); + expect(changedNodes.length).toBe(3); + expect(changedNodes[0].getAttribute('initialIndex')).toBe('1'); + expect(changedNodes[1].getAttribute('initialIndex')).toBe('0'); + expect(changedNodes[2].getAttribute('initialIndex')).toBe(null); + }); + + it('should add/remove/move nodes with index-based trackBy', () => { + createTrackByTestComponent('index'); + mutateData(); + + // Change each item reference to show that the trackby is checking the index. + // Otherwise this would cause them all to be removed/added. + component.dataSource.data = component.dataSource.data.map( + item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase), + ); + + // Expect first two to be the same since they were swapped but indicies are consistent. + // The third element was removed and caught by the tree so it was removed before another + // item was added, so it is without an initial index. + const changedNodes = getNodes(treeElement); + expect(changedNodes.length).toBe(3); + expect(changedNodes[0].getAttribute('initialIndex')).toBe('0'); + expect(changedNodes[1].getAttribute('initialIndex')).toBe('1'); + expect(changedNodes[2].getAttribute('initialIndex')).toBe(null); + }); + }); + + it('should pick up indirect descendant node definitions', () => { + configureCdkTreeTestingModule([SimpleCdkTreeAppWithIndirectNodes]); + const fixture = TestBed.createComponent(SimpleCdkTreeAppWithIndirectNodes); + fixture.detectChanges(); + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + expect(getNodes(treeElement).length).toBe(3); + }); + }); + + describe('nested tree', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + let component: NestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([NestedCdkTreeApp]); + fixture = TestBed.createComponent(NestedCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with a connected data source', () => { + expect(tree.dataSource).toBe(dataSource); + expect(dataSource.isConnected).toBe(true); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).withContext('Expect nodes to be defined').toBeDefined(); + 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); + + let data = dataSource.data; + expectNestedTreeToMatch( + treeElement, + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + ); + + dataSource.addChild(data[1], false); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + + it('with nested child data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + fixture.detectChanges(); + + expect(data.length).toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [`topping_3 - cheese_3 + base_3`], + ); + + dataSource.addChild(child, false); + fixture.detectChanges(); + + expect(data.length).toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [_, _, `topping_6 - cheese_6 + base_6`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + + it('with correct aria-level on nodes', () => { + expect( + getNodes(treeElement).every(node => { + return node.getAttribute('aria-level') === '1'; + }), + ).toBe(true); + + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + fixture.detectChanges(); + + const nodes = getNodes(treeElement); + const levels = nodes.map(n => n.getAttribute('aria-level')); + expect(levels).toEqual(['1', '1', '2', '3', '1']); + }); + }); + + describe('with static children', () => { + let fixture: ComponentFixture; + let component: StaticNestedCdkTreeApp; + + 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'); + }); + + it('with the right data', () => { + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [_, _, `topping_6 - cheese_6 + base_6`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + }); + + describe('with when node', () => { + let fixture: ComponentFixture; + let component: WhenNodeNestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([WhenNodeNestedCdkTreeApp]); + fixture = TestBed.createComponent(WhenNodeNestedCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`>> topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + + dataSource.addChild(data[1], false); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + data = dataSource.data; + expect(data.length).toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`>> topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + }); + + describe('with toggle', () => { + let fixture: ComponentFixture; + let component: NestedCdkTreeAppWithToggle; + + beforeEach(() => { + configureCdkTreeTestingModule([NestedCdkTreeAppWithToggle]); + fixture = TestBed.createComponent(NestedCdkTreeAppWithToggle); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with the right aria-expanded attrs', () => { + expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual(['false', 'false', 'false']); + + component.toggleRecursively = false; + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + fixture.detectChanges(); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + // 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(['false', 'true', 'false', 'false']); + }); + + it('should expand/collapse the node multiple times using keyboard', () => { + component.toggleRecursively = false; + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + + fixture.detectChanges(); + + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + + fixture.detectChanges(); + + let node = getNodes(treeElement)[1] as HTMLElement; + + node.focus(); + node.dispatchEvent(createKeyboardEvent('keydown', RIGHT_ARROW)); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node expanded`) + .toBe(1); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`], + ); + + node = getNodes(treeElement)[1] as HTMLElement; + node.focus(); + node.dispatchEvent(createKeyboardEvent('keydown', LEFT_ARROW)); + fixture.detectChanges(); + + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node collapsed`) + .toBe(0); + + node = getNodes(treeElement)[1] as HTMLElement; + node.focus(); + node.dispatchEvent(createKeyboardEvent('keydown', RIGHT_ARROW)); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node expanded`) + .toBe(1); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + + it('should expand/collapse the node recursively', () => { + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + fixture.detectChanges(); + + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node expanded`) + .toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [`topping_3 - cheese_3 + base_3`], + ); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node collapsed`) + .toBe(0); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + }); + + describe('with array data source', () => { + let fixture: ComponentFixture; + let component: ArrayDataSourceNestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([ArrayDataSourceNestedCdkTreeApp]); + fixture = TestBed.createComponent(ArrayDataSourceNestedCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectNestedTreeToMatch( + treeElement, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + + dataSource.addChild(data[1], false); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + expectNestedTreeToMatch( + treeElement, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `[topping_4] - [cheese_4] + [base_4]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + }); + }); + + describe('with observable data source', () => { + let fixture: ComponentFixture; + let component: ObservableDataSourceNestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([ObservableDataSourceNestedCdkTreeApp]); + fixture = TestBed.createComponent(ObservableDataSourceNestedCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('with the right data', () => { + expect(dataSource.data.length).toBe(3); + + let data = dataSource.data; + expectNestedTreeToMatch( + treeElement, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + + dataSource.addChild(data[1], false); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + expectNestedTreeToMatch( + treeElement, + [`[topping_1] - [cheese_1] + [base_1]`], + [`[topping_2] - [cheese_2] + [base_2]`], + [_, `[topping_4] - [cheese_4] + [base_4]`], + [`[topping_3] - [cheese_3] + [base_3]`], + ); + }); + }); + + describe('with trackBy', () => { + let fixture: ComponentFixture; + let component: NestedCdkTreeAppWithTrackBy; + + function createTrackByTestComponent(trackByStrategy: 'reference' | 'property' | 'index') { + configureCdkTreeTestingModule([NestedCdkTreeAppWithTrackBy]); + fixture = TestBed.createComponent(NestedCdkTreeAppWithTrackBy); + component = fixture.componentInstance; + component.trackByStrategy = trackByStrategy; + dataSource = component.dataSource as FakeDataSource; + fixture.detectChanges(); + + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + + // Each node receives an attribute 'initialIndex' the element's original place + getNodes(treeElement).forEach((node: Element, index: number) => { + node.setAttribute('initialIndex', index.toString()); + }); + + // Prove that the attributes match their indicies + const initialNodes = getNodes(treeElement); + expect(initialNodes.length).toBe(3); + initialNodes.forEach((node, index) => { + expect(node.getAttribute('initialIndex')).toBe(`${index}`); + }); + + const parent = dataSource.data[0]; + dataSource.addChild(parent, false); + dataSource.addChild(parent, false); + dataSource.addChild(parent, false); + getNodes(initialNodes[0]).forEach((node: Element, index: number) => { + node.setAttribute('initialIndex', `c${index}`); + }); + expect( + getNodes(initialNodes[0]).every((node, index) => { + return node.getAttribute('initialIndex') === `c${index}`; + }), + ).toBe(true); + } + + function mutateChildren(parent: TestData) { + // Swap first and second data in data array + const copiedData = parent.children.slice(); + const temp = copiedData[0]; + copiedData[0] = copiedData[1]; + copiedData[1] = temp; + + // Remove the third element + copiedData.splice(2, 1); + + // Add new data + parent.children = copiedData; + parent.observableChildren.next(copiedData); + component.dataSource.addChild(parent, false); + } + + it('should add/remove/move children nodes with reference-based trackBy', () => { + createTrackByTestComponent('reference'); + mutateChildren(dataSource.data[0]); + + const changedNodes = getNodes(treeElement); + expect(changedNodes.length).toBe(6); + expect(changedNodes[0].getAttribute('initialIndex')).toBe('0'); + + // Expect that the first and second child nodes were swapped and that the last node is new + expect(changedNodes[1].getAttribute('initialIndex')).toBe('c1'); + expect(changedNodes[2].getAttribute('initialIndex')).toBe('c0'); + expect(changedNodes[3].getAttribute('initialIndex')).toBe(null); + + expect(changedNodes[4].getAttribute('initialIndex')).toBe('1'); + expect(changedNodes[5].getAttribute('initialIndex')).toBe('2'); + }); + + it('should add/remove/move children nodes with property-based trackBy', () => { + createTrackByTestComponent('property'); + mutateChildren(dataSource.data[0]); + + // Change each item reference to show that the trackby is checking the item properties. + // Otherwise this would cause them all to be removed/added. + dataSource.data[0].observableChildren.next( + dataSource.data[0].children.map( + item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase), + ), + ); + + // Expect that the first and second nodes were swapped and that the last node is new + const changedNodes = getNodes(treeElement); + expect(changedNodes.length).toBe(6); + expect(changedNodes[0].getAttribute('initialIndex')).toBe('0'); + + // Expect that the first and second child nodes were swapped and that the last node is new + expect(changedNodes[1].getAttribute('initialIndex')).toBe('c1'); + expect(changedNodes[2].getAttribute('initialIndex')).toBe('c0'); + expect(changedNodes[3].getAttribute('initialIndex')).toBe(null); + + expect(changedNodes[4].getAttribute('initialIndex')).toBe('1'); + expect(changedNodes[5].getAttribute('initialIndex')).toBe('2'); + }); + + it('should add/remove/move children nodes with index-based trackBy', () => { + createTrackByTestComponent('index'); + mutateChildren(dataSource.data[0]); + + // Change each item reference to show that the trackby is checking the index. + // Otherwise this would cause them all to be removed/added. + dataSource.data[0].observableChildren.next( + dataSource.data[0].children.map( + item => new TestData(item.pizzaTopping, item.pizzaCheese, item.pizzaBase), + ), + ); + + const changedNodes = getNodes(treeElement); + expect(changedNodes.length).toBe(6); + expect(changedNodes[0].getAttribute('initialIndex')).toBe('0'); + + // Expect first two children to be the same since they were swapped + // but indicies are consistent. + // The third element was removed and caught by the tree so it was removed before another + // item was added, so it is without an initial index. + expect(changedNodes[1].getAttribute('initialIndex')).toBe('c0'); + expect(changedNodes[2].getAttribute('initialIndex')).toBe('c1'); + expect(changedNodes[3].getAttribute('initialIndex')).toBe(null); + + expect(changedNodes[4].getAttribute('initialIndex')).toBe('1'); + expect(changedNodes[5].getAttribute('initialIndex')).toBe('2'); + }); + }); + }); + + describe('with depth', () => { + let fixture: ComponentFixture; + let component: DepthNestedCdkTreeApp; + + beforeEach(() => { + configureCdkTreeTestingModule([DepthNestedCdkTreeApp]); + fixture = TestBed.createComponent(DepthNestedCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + }); + + it('should have correct depth for nested tree', () => { + let data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + + fixture.detectChanges(); + + const depthElements = Array.from(treeElement.querySelectorAll('.tree-test-level')!); + const expectedLevels = ['0', '0', '1', '2', '0']; + const actualLevels = depthElements.map(element => element.textContent!.trim()); + expect(actualLevels).toEqual(expectedLevels); + 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 { + pizzaTopping: string; + pizzaCheese: string; + pizzaBase: string; + level: number; + children: TestData[]; + isDisabled?: boolean; + readonly observableChildren: BehaviorSubject; + + constructor(pizzaTopping: string, pizzaCheese: string, pizzaBase: string, level: number = 1) { + this.pizzaTopping = pizzaTopping; + this.pizzaCheese = pizzaCheese; + this.pizzaBase = pizzaBase; + this.level = level; + this.children = []; + this.observableChildren = new BehaviorSubject(this.children); + } +} + +class FakeDataSource extends DataSource { + dataIndex = 0; + isConnected = false; + + _dataChange = new BehaviorSubject([]); + get data() { + return this._dataChange.getValue(); + } + set data(data: TestData[]) { + this._dataChange.next(data); + } + + constructor(public treeControl: TreeControl) { + super(); + for (let i = 0; i < 3; i++) { + this.addData(); + } + } + + connect(collectionViewer: CollectionViewer): Observable { + this.isConnected = true; + + return combineLatest([this._dataChange, collectionViewer.viewChange]).pipe( + map(([data]) => { + this.treeControl.dataNodes = data; + return data; + }), + ); + } + + disconnect() { + this.isConnected = false; + } + + addChild(parent: TestData, isFlat: boolean = true) { + const nextIndex = ++this.dataIndex; + const child = new TestData( + `topping_${nextIndex}`, + `cheese_${nextIndex}`, + `base_${nextIndex}`, + parent.level + 1, + ); + parent.children.push(child); + if (isFlat) { + let copiedData = this.data.slice(); + copiedData.splice(this.data.indexOf(parent) + 1, 0, child); + this.data = copiedData; + } else { + parent.observableChildren.next(parent.children); + } + return child; + } + + addData(level: number = 1) { + const nextIndex = ++this.dataIndex; + + let copiedData = this.data.slice(); + copiedData.push( + new TestData(`topping_${nextIndex}`, `cheese_${nextIndex}`, `base_${nextIndex}`, level), + ); + + this.data = copiedData; + } +} + +function getNodes(treeElement: Element): HTMLElement[] { + return Array.from(treeElement.querySelectorAll('.cdk-tree-node')); +} + +function expectFlatTreeToMatch( + treeElement: Element, + expectedPaddingIndent = 28, + expectedPaddingUnits = 'px', + ...expectedTree: any[] +) { + const missedExpectations: string[] = []; + + function checkNode(node: Element, expectedNode: any[]) { + const actualTextContent = node.textContent!.trim(); + const expectedTextContent = expectedNode[expectedNode.length - 1]; + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`, + ); + } + } + + function checkLevel(node: Element, expectedNode: any[]) { + const rawLevel = (node as HTMLElement).style.paddingLeft; + + // Some browsers return 0, while others return 0px. + const actualLevel = rawLevel === '0' ? '0px' : rawLevel; + const expectedLevel = `${expectedNode.length * expectedPaddingIndent}${expectedPaddingUnits}`; + if (actualLevel != expectedLevel) { + missedExpectations.push(`Expected node level to be ${expectedLevel} but was ${actualLevel}`); + } + } + + getNodes(treeElement).forEach((node, index) => { + const expected = expectedTree ? expectedTree[index] : null; + + checkLevel(node, expected); + checkNode(node, expected); + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} + +function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { + const missedExpectations: string[] = []; + function checkNodeContent(node: Element, expectedNode: any[]) { + const expectedTextContent = expectedNode[expectedNode.length - 1]; + const actualTextContent = node.childNodes.item(0).textContent!.trim(); + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`, + ); + } + } + + function checkNodeDescendants(node: Element, expectedNode: any[], currentIndex: number) { + let expectedDescendant = 0; + + for (let i = currentIndex + 1; i < expectedTree.length; ++i) { + if (expectedTree[i].length > expectedNode.length) { + ++expectedDescendant; + } else if (expectedTree[i].length === expectedNode.length) { + break; + } + } + + const actualDescendant = getNodes(node).length; + if (actualDescendant !== expectedDescendant) { + missedExpectations.push( + `Expected node descendant num to be ${expectedDescendant} but was ${actualDescendant}`, + ); + } + } + + getNodes(treeElement).forEach((node, index) => { + const expected = expectedTree ? expectedTree[index] : null; + + checkNodeDescendants(node, expected, index); + checkNodeContent(node, expected); + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} + +function getNodeAttributes(nodes: HTMLElement[], attribute: string) { + return nodes.map(node => node.getAttribute(attribute)); +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + `, +}) +class SimpleCdkTreeApp { + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + indent: number | string = 28; + + @ViewChild(CdkTree) tree: CdkTree; + @ViewChildren(CdkTreeNodePadding) paddingNodes: QueryList>; +} + +@Component({ + template: ` + + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + `, +}) +class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + `, +}) +class NestedCdkTreeApp { + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + `, +}) +class StaticNestedCdkTreeApp { + getChildren = (node: TestData) => node.children; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren, { + isExpandable: node => node.children.length > 0, + }); + + dataSource: FakeDataSource; + + @ViewChild(CdkTree) tree: CdkTree; + + constructor() { + const dataSource = new FakeDataSource(this.treeControl); + const data = dataSource.data; + const child = dataSource.addChild(data[1], false); + dataSource.addChild(child, false); + dataSource.addChild(child, false); + + this.dataSource = dataSource; + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + >> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + `, +}) +class WhenNodeNestedCdkTreeApp { + isSecondNode = (_: number, node: TestData) => node.pizzaBase.indexOf('2') > 0; + + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + `, +}) +class CdkTreeAppWithToggle { + toggleRecursively: boolean = true; + + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} +
+ +
+
+
+ `, +}) +class NestedCdkTreeAppWithToggle { + toggleRecursively: boolean = true; + + getChildren = (node: TestData) => node.observableChildren; + + isExpandable?: (node: TestData) => boolean; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + `, +}) +class WhenNodeCdkTreeApp { + isOddNode = (_: number, node: TestData) => node.level % 2 === 1; + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + `, +}) +class ArrayDataSourceCdkTreeApp { + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataArray() { + return this.dataSource.data; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + `, +}) +class ObservableDataSourceCdkTreeApp { + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataObservable() { + return this.dataSource._dataChange; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + + `, +}) +class ArrayDataSourceNestedCdkTreeApp { + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataArray() { + return this.dataSource.data; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + + `, +}) +class ObservableDataSourceNestedCdkTreeApp { + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataObservable() { + return this.dataSource._dataChange; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{level}} + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + + `, +}) +class DepthNestedCdkTreeApp { + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataArray() { + return this.dataSource.data; + } + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + `, +}) +class CdkTreeAppWithTrackBy { + trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; + + trackByFn: TrackByFunction = (index, item) => { + switch (this.trackByStrategy) { + case 'reference': + return item; + case 'property': + return item.pizzaBase; + case 'index': + return index; + } + }; + + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + + treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] + + + + `, +}) +class NestedCdkTreeAppWithTrackBy { + trackByStrategy: 'reference' | 'property' | 'index' = 'reference'; + + trackByFn: TrackByFunction = (index, item) => { + switch (this.trackByStrategy) { + case 'reference': + return item; + case 'property': + return item.pizzaBase; + case 'index': + return index; + } + }; + + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + + get dataArray() { + return this.dataSource.data; + } + + @ViewChild(CdkTree) tree: CdkTree; +} diff --git a/src/cdk/tree/tree.md b/src/cdk/tree/tree.md index 6e9da076bab1..187513b805e6 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,34 @@ 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. + +In order to use the new accessibility features, migrating to `levelAccessor` and `childrenAccessor` +is required. Trees using `treeControl` do not implement the correct accessibility features for +backwards compatibility. + +#### isExpandable + +In order for the tree to correctly determine whether or not a node is expandable, the `isExpandable` +property must be set on all `cdk-tree-node` or `cdk-tree-nested-node` that are expandable. + +#### 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.spec.ts b/src/cdk/tree/tree.spec.ts index fa6cff0a4075..2670e331bf84 100644 --- a/src/cdk/tree/tree.spec.ts +++ b/src/cdk/tree/tree.spec.ts @@ -5,7 +5,7 @@ * 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 {ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import { Component, ErrorHandler, @@ -16,21 +16,20 @@ import { ViewChildren, QueryList, } from '@angular/core'; - import {CollectionViewer, DataSource} from '@angular/cdk/collections'; import {Directionality, Direction} from '@angular/cdk/bidi'; import {combineLatest, BehaviorSubject, Observable} from 'rxjs'; import {map} from 'rxjs/operators'; - -import {BaseTreeControl} from './control/base-tree-control'; -import {TreeControl} from './control/tree-control'; -import {FlatTreeControl} from './control/flat-tree-control'; -import {NestedTreeControl} from './control/nested-tree-control'; import {CdkTreeModule, CdkTreeNodePadding} from './index'; import {CdkTree, CdkTreeNode} from './tree'; -import {getTreeControlFunctionsMissingError} from './tree-errors'; +import {createKeyboardEvent} from '@angular/cdk/testing/testbed/fake-events'; +import {ENTER} from '@angular/cdk/keycodes'; -describe('CdkTree', () => { +/** + * This is a cloned version of `tree.spec.ts` that contains all the same tests, + * but modifies them to use the newer API. + */ +describe('CdkTree redesign', () => { /** Represents an indent for expectNestedTreeToMatch */ const _ = {}; let dataSource: FakeDataSource; @@ -118,46 +117,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); @@ -272,7 +231,7 @@ describe('CdkTree', () => { it('should expand/collapse the node', () => { expect(dataSource.data.length).toBe(3); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect no expanded node`) .toBe(0); @@ -296,23 +255,47 @@ describe('CdkTree', () => { (getNodes(treeElement)[2] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect node expanded`) - .toBe(1); - expect(component.treeControl.expansionModel.selected[0]).toBe(data[2]); + const expandedNodes = getExpandedNodes( + component.dataSource?.getRecursiveData(), + component.tree, + ); + expect(expandedNodes.length).withContext(`Expect node expanded`).toBe(1); + expect(expandedNodes[0]).toBe(data[2]); (getNodes(treeElement)[2] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect node collapsed`) .toBe(0); }); + it('should focus a node when collapsing it', () => { + // Create a tree with two nodes. A parent node and its child. + dataSource.clear(); + const parent = dataSource.addData(); + dataSource.addChild(parent); + + component.tree.expandAll(); + fixture.detectChanges(); + + // focus the child node + getNodes(treeElement)[1].click(); + fixture.detectChanges(); + + // collapse the parent node + getNodes(treeElement)[0].click(); + fixture.detectChanges(); + + expect(getNodes(treeElement).map(x => x.getAttribute('tabindex'))) + .withContext(`Expecting parent node to be focused since it was collapsed.`) + .toEqual(['0', '-1']); + }); + it('should expand/collapse the node recursively', () => { expect(dataSource.data.length).toBe(3); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect no expanded node`) .toBe(0); @@ -332,23 +315,23 @@ describe('CdkTree', () => { [_, `${data[3].pizzaTopping} - ${data[3].pizzaCheese} + ${data[3].pizzaBase}`], ); - (getNodes(treeElement)[2] as HTMLElement).click(); + (getNodes(treeElement)[2] as HTMLElement)!.dispatchEvent( + createKeyboardEvent('keydown', ENTER), + ); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect nodes expanded`) - .toBe(2); - expect(component.treeControl.expansionModel.selected[0]) - .withContext(`Expect parent node expanded`) - .toBe(data[2]); - expect(component.treeControl.expansionModel.selected[1]) - .withContext(`Expected child node expanded`) - .toBe(data[3]); + const expandedNodes = getExpandedNodes( + component.dataSource?.getRecursiveData(), + component.tree, + ); + expect(expandedNodes.length).withContext(`Expect nodes expanded`).toBe(2); + expect(expandedNodes[0]).withContext(`Expect parent node expanded`).toBe(data[2]); + expect(expandedNodes[1]).withContext(`Expected child node expanded`).toBe(data[3]); (getNodes(treeElement)[2] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect node collapsed`) .toBe(0); }); @@ -625,16 +608,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 data', () => { expect(dataSource.data.length).toBe(3); @@ -798,11 +771,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; @@ -813,8 +784,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', () => { @@ -837,7 +811,7 @@ describe('CdkTree', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect node expanded`) .toBe(1); expectNestedTreeToMatch( @@ -857,14 +831,14 @@ describe('CdkTree', () => { [`topping_2 - cheese_2 + base_2`], [`topping_3 - cheese_3 + base_3`], ); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect node collapsed`) .toBe(0); (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect node expanded`) .toBe(1); expectNestedTreeToMatch( @@ -892,7 +866,7 @@ describe('CdkTree', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect node expanded`) .toBe(3); expectNestedTreeToMatch( @@ -907,7 +881,7 @@ describe('CdkTree', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) .withContext(`Expect node collapsed`) .toBe(0); expectNestedTreeToMatch( @@ -1127,32 +1101,6 @@ describe('CdkTree', () => { expect(changedNodes[5].getAttribute('initialIndex')).toBe('2'); }); }); - - it('should throw an error when missing function in nested tree', fakeAsync(() => { - configureCdkTreeTestingModule([NestedCdkErrorTreeApp]); - expect(() => { - try { - TestBed.createComponent(NestedCdkErrorTreeApp).detectChanges(); - flush(); - } catch { - flush(); - } finally { - flush(); - } - }).toThrowError(getTreeControlFunctionsMissingError().message); - })); - - it('should throw an error when missing function in flat tree', fakeAsync(() => { - configureCdkTreeTestingModule([FlatCdkErrorTreeApp]); - expect(() => { - try { - TestBed.createComponent(FlatCdkErrorTreeApp).detectChanges(); - flush(); - } catch { - flush(); - } - }).toThrowError(getTreeControlFunctionsMissingError().message); - })); }); describe('with depth', () => { @@ -1184,6 +1132,147 @@ 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 a node is programatically focused', () => { + // activate the second child by programatically focusing it + nodes[1].focus(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']); + + // activate the first child by programatically focusing it + nodes[0].focus(); + + 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 { @@ -1192,6 +1281,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) { @@ -1216,7 +1306,7 @@ class FakeDataSource extends DataSource { this._dataChange.next(data); } - constructor(public treeControl: TreeControl) { + constructor() { super(); for (let i = 0; i < 3; i++) { this.addData(); @@ -1228,7 +1318,6 @@ class FakeDataSource extends DataSource { return combineLatest([this._dataChange, collectionViewer.viewChange]).pipe( map(([data]) => { - this.treeControl.dataNodes = data; return data; }), ); @@ -1257,15 +1346,32 @@ class FakeDataSource extends DataSource { return child; } - addData(level: number = 1) { + addData(level: number = 1): TestData { const nextIndex = ++this.dataIndex; let copiedData = this.data.slice(); - copiedData.push( - new TestData(`topping_${nextIndex}`, `cheese_${nextIndex}`, `base_${nextIndex}`, level), + const newData = new TestData( + `topping_${nextIndex}`, + `cheese_${nextIndex}`, + `base_${nextIndex}`, + level, ); + copiedData.push(newData); this.data = copiedData; + + return newData; + } + + getRecursiveData(nodes: TestData[] = this._dataChange.getValue()): TestData[] { + return [ + ...new Set(nodes.flatMap(parent => [parent, ...this.getRecursiveData(parent.children)])), + ]; + } + + clear() { + this.data = []; + this.dataIndex = 0; } } @@ -1273,6 +1379,10 @@ function getNodes(treeElement: Element): HTMLElement[] { return Array.from(treeElement.querySelectorAll('.cdk-tree-node')); } +function getExpandedNodes(nodes: TestData[] | undefined, tree: CdkTree): TestData[] { + return nodes?.filter(node => tree.isExpanded(node)) ?? []; +} + function expectFlatTreeToMatch( treeElement: Element, expectedPaddingIndent = 28, @@ -1357,12 +1467,18 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { } } +function getNodeAttributes(nodes: HTMLElement[], attribute: string) { + return nodes.map(node => node.getAttribute(attribute)); +} + @Component({ template: ` - + + cdkTreeNodeToggle + [isExpandable]="isExpandable(node)"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1372,21 +1488,26 @@ class SimpleCdkTreeApp { getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; - treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource | null = new FakeDataSource(); indent: number | string = 28; @ViewChild(CdkTree) tree: CdkTree; @ViewChildren(CdkTreeNodePadding) paddingNodes: QueryList>; + + expandAll() { + this.tree.expandAll(); + } } @Component({ template: ` - + + cdkTreeNodeToggle + [isExpandable]="isExpandable(node)"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1397,7 +1518,8 @@ class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1408,19 +1530,22 @@ class SimpleCdkTreeAppWithIndirectNodes extends SimpleCdkTreeApp {} class NestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); - - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource | null = new FakeDataSource(); @ViewChild(CdkTree) tree: CdkTree; } @Component({ template: ` - - - {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} - + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + `, @@ -1428,14 +1553,12 @@ class NestedCdkTreeApp { class StaticNestedCdkTreeApp { getChildren = (node: TestData) => node.children; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); - dataSource: FakeDataSource; @ViewChild(CdkTree) tree: CdkTree; constructor() { - const dataSource = new FakeDataSource(this.treeControl); + const dataSource = new FakeDataSource(); const data = dataSource.data; const child = dataSource.addChild(data[1], false); dataSource.addChild(child, false); @@ -1447,7 +1570,8 @@ class StaticNestedCdkTreeApp { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1464,19 +1588,19 @@ class WhenNodeNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); - - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource | null = new FakeDataSource(); @ViewChild(CdkTree) tree: CdkTree; } @Component({ template: ` - + + cdkTreeNodeToggle [cdkTreeNodeToggleRecursive]="toggleRecursively" + [isExpandable]="isExpandable(node)"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1488,19 +1612,21 @@ class CdkTreeAppWithToggle { getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; - treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource | null = new FakeDataSource(); @ViewChild(CdkTree) tree: CdkTree; } @Component({ template: ` - + + [isExpandable]="isExpandable(node) | async" + cdkTreeNodeToggle + [cdkTreeNodeToggleRecursive]="toggleRecursively"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} -
+
@@ -1511,24 +1637,28 @@ class NestedCdkTreeAppWithToggle { toggleRecursively: boolean = true; getChildren = (node: TestData) => node.observableChildren; + isExpandable = (node: TestData) => + node.observableChildren.pipe(map(children => children.length > 0)); - treeControl: TreeControl = new NestedTreeControl(this.getChildren); - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource | null = new FakeDataSource(); @ViewChild(CdkTree) tree: CdkTree; } @Component({ template: ` - + + cdkTreeNodeToggle + [isExpandable]="isExpandable(node)"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + cdkTreeNodeToggle + [isExpandable]="isExpandable(node)"> [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1539,19 +1669,19 @@ class WhenNodeCdkTreeApp { getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; - treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource | null = new FakeDataSource(); @ViewChild(CdkTree) tree: CdkTree; } @Component({ template: ` - + + cdkTreeNodeToggle + [isExpandable]="isExpandable(node)"> [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1561,9 +1691,7 @@ class ArrayDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; - treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - - dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource = new FakeDataSource(); get dataArray() { return this.dataSource.data; @@ -1574,10 +1702,12 @@ class ArrayDataSourceCdkTreeApp { @Component({ template: ` - + + cdkTreeNodeToggle + [isExpandable]="isExpandable(node)"> [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1587,9 +1717,7 @@ class ObservableDataSourceCdkTreeApp { getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; - treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - - dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource = new FakeDataSource(); get dataObservable() { return this.dataSource._dataChange; @@ -1600,7 +1728,8 @@ class ObservableDataSourceCdkTreeApp { @Component({ template: ` - + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1611,9 +1740,7 @@ class ObservableDataSourceCdkTreeApp { class ArrayDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); - - dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource = new FakeDataSource(); get dataArray() { return this.dataSource.data; @@ -1624,7 +1751,8 @@ class ArrayDataSourceNestedCdkTreeApp { @Component({ template: ` - + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1635,9 +1763,7 @@ class ArrayDataSourceNestedCdkTreeApp { class ObservableDataSourceNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); - - dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource = new FakeDataSource(); get dataObservable() { return this.dataSource._dataChange; @@ -1648,60 +1774,8 @@ class ObservableDataSourceNestedCdkTreeApp { @Component({ template: ` - - - {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} - - - - `, -}) -class NestedCdkErrorTreeApp { - getLevel = (node: TestData) => node.level; - - isExpandable = (node: TestData) => node.children.length > 0; - - treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); - - @ViewChild(CdkTree) tree: CdkTree; -} - -class FakeTreeControl extends BaseTreeControl { - getDescendants(_: TestData): TestData[] { - return this.dataNodes; - } - - expandAll(): void { - // No op - } -} -@Component({ - template: ` - - - {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} - - - - `, -}) -class FlatCdkErrorTreeApp { - getLevel = (node: TestData) => node.level; - - isExpandable = (node: TestData) => node.children.length > 0; - - treeControl: TreeControl = new FakeTreeControl(); - - dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); - - @ViewChild(CdkTree) tree: CdkTree; -} - -@Component({ - template: ` - + {{level}} [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1713,9 +1787,7 @@ class FlatCdkErrorTreeApp { class DepthNestedCdkTreeApp { getChildren = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); - - dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource = new FakeDataSource(); get dataArray() { return this.dataSource.data; @@ -1726,8 +1798,9 @@ class DepthNestedCdkTreeApp { @Component({ template: ` - - + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1750,15 +1823,15 @@ class CdkTreeAppWithTrackBy { getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; - treeControl: TreeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource = new FakeDataSource(); @ViewChild(CdkTree) tree: CdkTree; } @Component({ template: ` - + [{{node.pizzaTopping}}] - [{{node.pizzaCheese}}] + [{{node.pizzaBase}}] @@ -1782,9 +1855,7 @@ class NestedCdkTreeAppWithTrackBy { getChildren = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); - - dataSource: FakeDataSource = new FakeDataSource(this.treeControl); + dataSource: FakeDataSource = new FakeDataSource(); get dataArray() { return this.dataSource.data; diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 96ad12200f4e..a25fe8ef5497 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -5,48 +5,83 @@ * 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 {CollectionViewer, DataSource, isDataSource} from '@angular/cdk/collections'; +import {TreeKeyManager, TreeKeyManagerItem} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion'; +import { + CollectionViewer, + DataSource, + isDataSource, + SelectionChange, + 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, ViewContainerRef, ViewEncapsulation, + inject, } from '@angular/core'; import { BehaviorSubject, + combineLatest, + concat, + EMPTY, isObservable, Observable, of as observableOf, Subject, Subscription, } from 'rxjs'; -import {takeUntil} 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'; import { - getTreeControlFunctionsMissingError, + getMultipleTreeControlsError, getTreeControlMissingError, getTreeMissingMatchingNodeDefError, getTreeMultipleDefaultNodeDefsError, getTreeNoValidDataSourceError, } from './tree-errors'; -import {coerceNumberProperty} from '@angular/cdk/coercion'; + +function coerceObservable(data: T | Observable): Observable { + if (!isObservable(data)) { + return observableOf(data); + } + 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 @@ -59,6 +94,8 @@ import {coerceNumberProperty} from '@angular/cdk/coercion'; host: { 'class': 'cdk-tree', 'role': 'tree', + '(keydown)': '_sendKeydownToKeyManager($event)', + '(focus)': '_focusInitialTreeItem()', }, encapsulation: ViewEncapsulation.None, @@ -68,7 +105,9 @@ import {coerceNumberProperty} from '@angular/cdk/coercion'; // 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(); @@ -82,7 +121,22 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, private _dataSubscription: Subscription | null; /** Level of nodes */ - private _levels: Map = new Map(); + private _levels: Map = new Map(); + + /** The immediate parents for a node. This is `null` if there is no parent. */ + private _parents: Map = new Map(); + + /** + * 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(); /** * Provides a stream containing the latest data array to render. Influenced by the tree's @@ -100,8 +154,30 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, } private _dataSource: DataSource | Observable | T[]; - /** The tree controller */ - @Input() treeControl: TreeControl; + /** + * The tree controller + * + * @deprecated Use one of `levelAccessor` or `childrenAccessor` instead. To be removed in a + * future version. + * @breaking-change 19.0.0 + */ + @Input() treeControl?: TreeControl; + + /** + * Given a data node, determines what tree level the node is at. + * + * One of levelAccessor or childrenAccessor must be specified, not both. + * This is enforced at run-time. + */ + @Input() levelAccessor?: (dataNode: T) => number; + + /** + * Given a data node, determines what the children of that node are. + * + * One of levelAccessor or childrenAccessor must be specified, not both. + * This is enforced at run-time. + */ + @Input() childrenAccessor?: (dataNode: T) => T[] | Observable; /** * Tracking function that will be used to check the differences in data changes. Used similarly @@ -111,6 +187,11 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, */ @Input() trackBy: TrackByFunction; + /** + * Given a data node, determines the key by which we determine whether or not this node is expanded. + */ + @Input() expansionKey?: (dataNode: T) => K; + // Outlets within the tree's template where the dataNodes will be inserted. @ViewChild(CdkTreeNodeOutlet, {static: true}) _nodeOutlet: CdkTreeNodeOutlet; @@ -133,12 +214,54 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, end: Number.MAX_VALUE, }); - constructor(private _differs: IterableDiffers, private _changeDetectorRef: ChangeDetectorRef) {} + /** Keep track of which nodes are expanded. */ + private _expansionModel?: SelectionModel; + + /** + * 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 _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>; + + constructor( + private _differs: IterableDiffers, + private _changeDetectorRef: ChangeDetectorRef, + private _dir: Directionality, + private _elementRef: ElementRef, + ) {} ngOnInit() { this._dataDiffer = this._differs.find([]).create(this.trackBy); - if (!this.treeControl && (typeof ngDevMode === 'undefined' || ngDevMode)) { - throw getTreeControlMissingError(); + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const provided = [this.treeControl, this.levelAccessor, this.childrenAccessor].filter( + value => !!value, + ).length; + if (provided > 1) { + throw getMultipleTreeControlsError(); + } else if (provided === 0) { + throw getTreeControlMissingError(); + } } } @@ -159,6 +282,32 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, } } + ngAfterContentInit() { + this._keyManager = new TreeKeyManager({ + 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, + }); + + 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)) { @@ -171,8 +320,34 @@ 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 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. + * + * 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 @@ -212,15 +387,75 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, 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 - .pipe(takeUntil(this._onDestroy)) - .subscribe(data => this.renderNodeChanges(data)); + 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, 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, 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[], @@ -243,7 +478,12 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, this.insertNode(data[currentIndex!], currentIndex!, viewContainer, parentData); } else if (currentIndex == null) { viewContainer.remove(adjustedPreviousIndex!); - this._levels.delete(item.item); + const group = this._getNodeGroup(item.item); + 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); @@ -280,21 +520,28 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, * 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(key) ?? undefined; // If the tree is flat tree, then use the `getLevel` function in flat tree control // Otherwise, use the level of parent node. - if (this.treeControl.getLevel) { - context.level = this.treeControl.getLevel(nodeData); - } else if (typeof parentData !== 'undefined' && this._levels.has(parentData)) { - context.level = this._levels.get(parentData)! + 1; + if (levelAccessor) { + context.level = levelAccessor(nodeData); + } else if ( + typeof parentData !== 'undefined' && + this._levels.has(this._getExpansionKey(parentData)) + ) { + context.level = this._levels.get(this._getExpansionKey(parentData))! + 1; } else { context.level = 0; } - this._levels.set(nodeData, context.level); + this._levels.set(key, context.level); // Use default tree nodeOutlet, or nested node's nodeOutlet const container = viewContainer ? viewContainer : this._nodeOutlet.viewContainer; @@ -307,6 +554,516 @@ export class CdkTree implements AfterContentChecked, CollectionViewer, CdkTreeNode.mostRecentTreeNode.data = nodeData; } } + + /** Whether the data node is expanded or collapsed. Returns true if it's expanded. */ + isExpanded(dataNode: T): boolean { + return ( + this.treeControl?.isExpanded(dataNode) ?? + this._expansionModel?.isSelected(this._getExpansionKey(dataNode)) ?? + false + ); + } + + /** If the data node is currently expanded, collapse it. Otherwise, expand it. */ + toggle(dataNode: T): void { + if (this.treeControl) { + this.treeControl.toggle(dataNode); + } else if (this._expansionModel) { + this._expansionModel.toggle(this._getExpansionKey(dataNode)); + } + } + + /** Expand the data node. If it is already expanded, does nothing. */ + expand(dataNode: T): void { + if (this.treeControl) { + this.treeControl.expand(dataNode); + } else if (this._expansionModel) { + this._expansionModel.select(this._getExpansionKey(dataNode)); + } + } + + /** Collapse the data node. If it is already collapsed, does nothing. */ + collapse(dataNode: T): void { + if (this.treeControl) { + this.treeControl.collapse(dataNode); + } else if (this._expansionModel) { + this._expansionModel.deselect(this._getExpansionKey(dataNode)); + } + } + + /** + * If the data node is currently expanded, collapse it and all its descendants. + * Otherwise, expand it and all its descendants. + */ + toggleDescendants(dataNode: T): void { + if (this.treeControl) { + this.treeControl.toggleDescendants(dataNode); + } else if (this._expansionModel) { + if (this.isExpanded(dataNode)) { + this.collapseDescendants(dataNode); + } else { + this.expandDescendants(dataNode); + } + } + } + + /** + * Expand the data node and all its descendants. If they are already expanded, does nothing. + */ + expandDescendants(dataNode: T): void { + if (this.treeControl) { + 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 => { + expansionModel.select(...children.map(child => this._getExpansionKey(child))); + }); + } + } + + /** Collapse the data node and all its descendants. If it is already collapsed, does nothing. */ + collapseDescendants(dataNode: T): void { + if (this.treeControl) { + 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 => { + expansionModel.deselect(...children.map(child => this._getExpansionKey(child))); + }); + } + } + + /** Expands all data nodes in the tree. */ + expandAll(): void { + if (this.treeControl) { + this.treeControl.expandAll(); + } else if (this._expansionModel) { + const expansionModel = this._expansionModel; + expansionModel.select( + ...this._flattenedNodes.value.map(child => this._getExpansionKey(child)), + ); + } + } + + /** Collapse all data nodes in the tree. */ + collapseAll(): void { + if (this.treeControl) { + this.treeControl.collapseAll(); + } else if (this._expansionModel) { + const expansionModel = this._expansionModel; + expansionModel.deselect( + ...this._flattenedNodes.value.map(child => this._getExpansionKey(child)), + ); + } + } + + /** Level accessor, used for compatibility between the old Tree and new Tree */ + _getLevelAccessor() { + return this.treeControl?.getLevel ?? this.levelAccessor; + } + + /** Children accessor, used for compatibility between the old Tree and new Tree */ + _getChildrenAccessor() { + return this.treeControl?.getChildren ?? this.childrenAccessor; + } + + /** + * Gets the direct children of a node; used for compatibility between the old tree and the + * new tree. + */ + _getDirectChildren(dataNode: T): Observable { + const levelAccessor = this._getLevelAccessor(); + const expansionModel = this._expansionModel ?? this.treeControl?.expansionModel; + if (!expansionModel) { + return observableOf([]); + } + + const key = this._getExpansionKey(dataNode); + + 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 = flattenedNodes.findIndex(node => this._getExpansionKey(node) === key); + const level = levelAccessor(dataNode) + 1; + const results: T[] = []; + + // 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 < flattenedNodes.length; i++) { + const currentLevel = levelAccessor(flattenedNodes[i]); + if (level > currentLevel) { + break; + } + if (level === currentLevel) { + results.push(flattenedNodes[i]); + } + } + return results; + }), + ); + } + const childrenAccessor = this._getChildrenAccessor(); + if (childrenAccessor) { + return coerceObservable(childrenAccessor(dataNode) ?? []); + } + throw getTreeControlMissingError(); + } + + /** + * Adds the specified node component to the tree's internal registry. + * + * This primarily facilitates keyboard navigation. + */ + _registerNode(node: CdkTreeNode) { + this._nodes.value.set(this._getExpansionKey(node.data), node); + this._nodes.next(this._nodes.value); + } + + /** Removes the specified node component from the tree's internal registry. */ + _unregisterNode(node: CdkTreeNode) { + this._nodes.value.delete(this._getExpansionKey(node.data)); + this._nodes.next(this._nodes.value); + } + + /** + * For the given node, determine the level where this node appears in the tree. + * + * This is intended to be used for `aria-level` but is 0-indexed. + */ + _getLevel(node: T) { + return this._levels.get(this._getExpansionKey(node)); + } + + /** + * For the given node, determine the size of the parent's child set. + * + * This is intended to be used for `aria-setsize`. + */ + _getSetSize(dataNode: T) { + const group = this._getNodeGroup(dataNode); + return group.length; + } + + /** + * For the given node, determine the index (starting from 1) of the node in its parent's child set. + * + * This is intended to be used for `aria-posinset`. + */ + _getPositionInSet(dataNode: T) { + const group = this._getNodeGroup(dataNode); + 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. */ + _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 this._getDirectChildren(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 nested descendants of a given node. */ + private _getDescendants(dataNode: T): Observable { + if (this.treeControl) { + return observableOf(this.treeControl.getDescendants(dataNode)); + } + if (this.levelAccessor) { + 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 + // tree node. + // 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. + const currentLevel = this.levelAccessor(dataNode); + for ( + let i = startIndex + 1; + i < this._flattenedNodes.value.length && + currentLevel < this.levelAccessor(this._flattenedNodes.value[i]); + 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; + }, []), + ); + } + throw getTreeControlMissingError(); + } + + /** + * Gets all children and sub-children of the provided node. + * + * This will emit multiple times, in the order that the children will appear + * in the tree, and can be combined with a `reduce` operator. + */ + private _getAllChildrenRecursively(dataNode: T): Observable { + if (!this.childrenAccessor) { + return observableOf([]); + } + + return coerceObservable(this.childrenAccessor(dataNode)).pipe( + take(1), + switchMap(children => { + // Here, we cache the parents of a particular child so that we can compute the levels. + for (const child of children) { + this._parents.set(this._getExpansionKey(child), dataNode); + } + return observableOf(...children).pipe( + concatMap(child => concat(observableOf([child]), this._getAllChildrenRecursively(child))), + ); + }), + ); + } + + private _getExpansionKey(dataNode: T): K { + // In the case that a key accessor function was not provided by the + // tree user, we'll default to using the node object itself as the key. + // + // This cast is safe since: + // - if an expansionKey is provided, TS will infer the type of K to be + // the return type. + // - if it's not, then K will be defaulted to T. + return this.expansionKey?.(dataNode) ?? (dataNode as unknown as K); + } + + private _getNodeGroup(node: T) { + 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]; + } + + /** + * 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 (!cachedNodes.length) { + return null; + } + const currentLevel = this._levels.get(this._getExpansionKey(node)) ?? 0; + for (let parentIndex = index - 1; parentIndex >= 0; parentIndex--) { + const parentNode = cachedNodes[parentIndex]; + const parentLevel = this._levels.get(this._getExpansionKey(parentNode)) ?? 0; + + if (parentLevel < currentLevel) { + return parentNode; + } + } + return null; + } + + /** + * 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 _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 && nodeType === 'flat') { + // This flattens children into a single array. + this._groups.set(null, [...nodes]); + return this._flattenNestedNodesWithExpansion(nodes).pipe( + map(flattenedNodes => ({ + renderNodes: flattenedNodes, + flattenedNodes, + })), + ); + } 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)).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 { + // 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); + } + } } /** @@ -317,25 +1074,74 @@ 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', + 'role': 'treeitem', + '(click)': '_setActiveItem()', + '(focus)': '_setActiveItem()', }, }) -export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit { +export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { + _changeDetectorRef = inject(ChangeDetectorRef); + /** * The role of the tree node. - * @deprecated The correct role is 'treeitem', 'group' should not be used. This input will be - * removed in a future version. - * @breaking-change 12.0.0 Remove this input + * + * @deprecated This will be ignored; the tree will automatically determine the appropriate role + * for tree node. This input will be removed in a future version. + * @breaking-change 19.0.0 */ @Input() get role(): 'treeitem' | 'group' { return 'treeitem'; } 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. + } + + /** + * 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() + get isExpandable() { + return this._isExpandable(); + } + set isExpandable(isExpandable: boolean | '' | null) { + this._inputIsExpandable = coerceBooleanProperty(isExpandable); } + @Input() + get isExpanded(): boolean { + return this._tree.isExpanded(this._data); + } + set isExpanded(isExpanded: boolean) { + if (isExpanded) { + this.expand(); + } else { + 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 or activated by keyboard. */ + @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. @@ -348,6 +1154,7 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit /** Emits when the node's data has changed. */ readonly _dataChanges = new Subject(); + private _inputIsExpandable: boolean = false; private _parentNodeAriaLevel: number; /** The tree node's data. */ @@ -357,33 +1164,73 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit set data(value: T) { if (value !== this._data) { this._data = value; - this._setRoleFromData(); this._dataChanges.next(); } } protected _data: T; - get isExpanded(): boolean { - return this._tree.treeControl.isExpanded(this._data); - } - get level(): number { - // If the treeControl has a getLevel method, use it to get the level. Otherwise read the + // If the tree has a levelAccessor, use it to get the level. Otherwise read the // aria-level off the parent node and use it as the level for this node (note aria-level is // 1-indexed, while this property is 0-indexed, so we don't need to increment). - return this._tree.treeControl.getLevel - ? this._tree.treeControl.getLevel(this._data) - : this._parentNodeAriaLevel; + return this._tree._getLevel(this._data) ?? this._parentNodeAriaLevel; + } + + /** Determines if the tree node is expandable. */ + _isExpandable(): boolean { + if (this._tree.treeControl) { + if (typeof this._tree.treeControl?.isExpandable === 'function') { + return this._tree.treeControl.isExpandable(this._data); + } + + // For compatibility with trees created using TreeControl before we added + // CdkTreeNode#isExpandable. + return true; + } + return this._inputIsExpandable; + } + + /** + * 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); } - constructor(protected _elementRef: ElementRef, protected _tree: CdkTree) { + /** + * Determines the size of this node's parent's child set. + * + * This is intended to be used for `aria-setsize`. + */ + _getSetSize(): number { + return this._tree._getSetSize(this._data); + } + + /** + * Determines the index (starting from 1) of this node in its parent's child set. + * + * This is intended to be used for `aria-posinset`. + */ + _getPositionInSet(): number { + return this._tree._getPositionInSet(this._data); + } + + constructor( + protected _elementRef: ElementRef, + protected _tree: CdkTree, + ) { CdkTreeNode.mostRecentTreeNode = this as CdkTreeNode; - this.role = 'treeitem'; } ngOnInit(): void { this._parentNodeAriaLevel = getParentNodeAriaLevel(this._elementRef.nativeElement); - this._elementRef.nativeElement.setAttribute('aria-level', `${this.level + 1}`); + this._tree._setNodeTypeIfUnset('flat'); + this._tree._registerNode(this); } ngOnDestroy() { @@ -398,21 +1245,58 @@ 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(); } - // TODO: role should eventually just be set in the component host - protected _setRoleFromData(): void { - if ( - !this._tree.treeControl.isExpandable && - !this._tree.treeControl.getChildren && - (typeof ngDevMode === 'undefined' || ngDevMode) - ) { - throw getTreeControlFunctionsMissingError(); + /** Emits an activation event. Implemented for TreeKeyManagerItem. */ + activate(): void { + if (this.isDisabled) { + return; } - this.role = 'treeitem'; + this.activation.next(this._data); + } + + /** Collapses this data node. Implemented for TreeKeyManagerItem. */ + collapse(): void { + if (this.isExpandable) { + this._tree.collapse(this._data); + } + } + + /** Expands this data node. Implemented for TreeKeyManagerItem. */ + expand(): void { + if (this.isExpandable) { + this._tree.expand(this._data); + } + } + + _setTabFocusable() { + this._elementRef.nativeElement.setAttribute('tabindex', '0'); + } + + _setTabUnfocusable() { + this._elementRef.nativeElement.setAttribute('tabindex', '-1'); + } + + _setActiveItem() { + if (this.isDisabled) { + return; + } + this._tree._keyManager.setActiveItem(this); + } + + _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.css b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.css new file mode 100644 index 000000000000..a88255f0d954 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.css @@ -0,0 +1,4 @@ +.example-tree-node { + display: flex; + align-items: center; +} 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 new file mode 100644 index 000000000000..ba87ac2f0e6c --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.html @@ -0,0 +1,25 @@ + + + + + + {{node.name}} + + + + + {{node.name}} + + 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 new file mode 100644 index 000000000000..fbd197ca15b6 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-children-accessor/cdk-tree-flat-children-accessor-example.ts @@ -0,0 +1,61 @@ +import {ArrayDataSource} from '@angular/cdk/collections'; +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'; + +function flattenNodes(nodes: NestedFoodNode[]): NestedFoodNode[] { + const flattenedNodes = []; + for (const node of nodes) { + flattenedNodes.push(node); + if (node.children) { + flattenedNodes.push(...flattenNodes(node.children)); + } + } + return flattenedNodes; +} + +/** + * @title Tree with flat nodes + */ +@Component({ + 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) + tree!: CdkTree; + + childrenAccessor = (dataNode: NestedFoodNode) => timer(100).pipe(mapTo(dataNode.children ?? [])); + + dataSource = new ArrayDataSource(NESTED_DATA); + + hasChild = (_: number, node: NestedFoodNode) => !!node.children?.length; + + getParentNode(node: NestedFoodNode) { + for (const parent of flattenNodes(NESTED_DATA)) { + if (parent.children?.includes(node)) { + return parent; + } + } + + return null; + } + + shouldRender(node: NestedFoodNode) { + let parent = this.getParentNode(node); + while (parent) { + if (!this.tree.isExpanded(parent)) { + return false; + } + parent = this.getParentNode(parent); + } + return true; + } +} diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.css b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.css new file mode 100644 index 000000000000..a88255f0d954 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.css @@ -0,0 +1,4 @@ +.example-tree-node { + display: flex; + align-items: center; +} diff --git a/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.html b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.html new file mode 100644 index 000000000000..f9a30e47122d --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.html @@ -0,0 +1,27 @@ + + + + + + {{node.name}} + + + + + {{node.name}} + + 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 new file mode 100644 index 000000000000..863f5dc7197c --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-flat-level-accessor/cdk-tree-flat-level-accessor-example.ts @@ -0,0 +1,47 @@ +import {ArrayDataSource} from '@angular/cdk/collections'; +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'; + +/** + * @title Tree with flat nodes + */ +@Component({ + 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) + tree: CdkTree; + + levelAccessor = (dataNode: FlatFoodNode) => dataNode.level; + + dataSource = new ArrayDataSource(FLAT_DATA); + + hasChild = (_: number, node: FlatFoodNode) => node.expandable; + + getParentNode(node: FlatFoodNode) { + const nodeIndex = FLAT_DATA.indexOf(node); + + // Determine the node's parent by finding the first preceding node that's + // one level shallower. + for (let i = nodeIndex - 1; i >= 0; i--) { + if (FLAT_DATA[i].level === node.level - 1) { + return FLAT_DATA[i]; + } + } + + return null; + } + + shouldRender(node: FlatFoodNode): boolean { + // This node should render if it is a root node or if all of its ancestors are expanded. + const parent = this.getParentNode(node); + return !parent || (!!this.tree?.isExpanded(parent) && this.shouldRender(parent)); + } +} 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..e6ba174d9e88 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 @@ @@ -9,11 +10,13 @@ + {{node.name}} +
+ + + + {{node.name}} +
+ +
+
+
diff --git a/src/components-examples/cdk/tree/cdk-tree-nested-children-accessor/cdk-tree-nested-children-accessor-example.ts b/src/components-examples/cdk/tree/cdk-tree-nested-children-accessor/cdk-tree-nested-children-accessor-example.ts new file mode 100644 index 000000000000..1263ac27008b --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-nested-children-accessor/cdk-tree-nested-children-accessor-example.ts @@ -0,0 +1,53 @@ +import {ArrayDataSource} from '@angular/cdk/collections'; +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 {NestedFoodNode, NESTED_DATA} from '../tree-data'; + +function flattenNodes(nodes: NestedFoodNode[]): NestedFoodNode[] { + const flattenedNodes = []; + for (const node of nodes) { + flattenedNodes.push(node); + if (node.children) { + flattenedNodes.push(...flattenNodes(node.children)); + } + } + return flattenedNodes; +} + +/** + * @title Tree with nested nodes, using childAccessor + */ +@Component({ + selector: 'cdk-tree-nested-children-accessor-example', + templateUrl: 'cdk-tree-nested-children-accessor-example.html', + styleUrls: ['cdk-tree-nested-children-accessor-example.css'], + standalone: true, + imports: [CdkTreeModule, MatButtonModule, MatIconModule], +}) +export class CdkTreeNestedChildrenAccessorExample { + @ViewChild(CdkTree) tree: CdkTree; + + childrenAccessor = (dataNode: NestedFoodNode) => dataNode.children ?? []; + + dataSource = new ArrayDataSource(NESTED_DATA); + + hasChild = (_: number, node: NestedFoodNode) => !!node.children && node.children.length > 0; + + getParentNode(node: NestedFoodNode) { + for (const parent of flattenNodes(NESTED_DATA)) { + if (parent.children?.includes(node)) { + return parent; + } + } + + return null; + } + + shouldRender(node: NestedFoodNode): boolean { + // This node should render if it is a root node or if all of its ancestors are expanded. + const parent = this.getParentNode(node); + return !parent || (!!this.tree?.isExpanded(parent) && this.shouldRender(parent)); + } +} diff --git a/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.css b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.css new file mode 100644 index 000000000000..988fa23745aa --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.css @@ -0,0 +1,18 @@ +.example-tree-invisible { + display: none; +} + +.example-tree ul, +.example-tree li { + margin-top: 0; + margin-bottom: 0; + list-style-type: none; +} +.example-tree-node { + display: block; + line-height: 40px; +} + +.example-tree-node .example-tree-node { + padding-left: 40px; +} diff --git a/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.html b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.html new file mode 100644 index 000000000000..f18c9eaf7a3d --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.html @@ -0,0 +1,25 @@ + + + + + + {{node.name}} + + + + + {{node.name}} +
+ +
+
+
diff --git a/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.ts b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.ts new file mode 100644 index 000000000000..c5fdc8b35985 --- /dev/null +++ b/src/components-examples/cdk/tree/cdk-tree-nested-level-accessor/cdk-tree-nested-level-accessor-example.ts @@ -0,0 +1,46 @@ +import {ArrayDataSource} from '@angular/cdk/collections'; +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 {FLAT_DATA, FlatFoodNode} from '../tree-data'; + +/** + * @title Tree with nested nodes + */ +@Component({ + selector: 'cdk-tree-nested-level-accessor-example', + templateUrl: 'cdk-tree-nested-level-accessor-example.html', + styleUrls: ['cdk-tree-nested-level-accessor-example.css'], + standalone: true, + imports: [CdkTreeModule, MatButtonModule, MatIconModule], +}) +export class CdkTreeNestedLevelAccessorExample { + @ViewChild(CdkTree) tree: CdkTree; + + levelAccessor = (dataNode: FlatFoodNode) => dataNode.level; + + dataSource = new ArrayDataSource(FLAT_DATA); + + hasChild = (_: number, node: FlatFoodNode) => node.expandable; + + getParentNode(node: FlatFoodNode) { + const nodeIndex = FLAT_DATA.indexOf(node); + + // Determine the node's parent by finding the first preceding node that's + // one level shallower. + for (let i = nodeIndex - 1; i >= 0; i--) { + if (FLAT_DATA[i].level === node.level - 1) { + return FLAT_DATA[i]; + } + } + + return null; + } + + shouldRender(node: FlatFoodNode): boolean { + // This node should render if it is a root node or if all of its ancestors are expanded. + const parent = this.getParentNode(node); + return !parent || (!!this.tree?.isExpanded(parent) && this.shouldRender(parent)); + } +} 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 fb9a4132f97f..bd61a2561a19 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,12 +1,17 @@ - + {{node.name}} - + {{node.item}} - + - {{node.item}} + {{node.name}} - + - {{node.item}} + {{node.name}} - - + + Load more of {{node.parent}}... diff --git a/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.ts b/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.ts index c04e28a8e5b1..846bc6b31b98 100644 --- a/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.ts +++ b/src/components-examples/material/tree/tree-loadmore/tree-loadmore-example.ts @@ -11,86 +11,112 @@ import {MatTreeFlatDataSource, MatTreeFlattener, MatTreeModule} from '@angular/m import {BehaviorSubject, Observable} from 'rxjs'; import {MatIconModule} from '@angular/material/icon'; import {MatButtonModule} from '@angular/material/button'; +import {ENTER, SPACE} from '@angular/cdk/keycodes'; const LOAD_MORE = 'LOAD_MORE'; +let loadMoreId = 1; /** Nested node */ -export class LoadmoreNode { - childrenChange = new BehaviorSubject([]); +export class NestedNode { + childrenChange = new BehaviorSubject([]); - get children(): LoadmoreNode[] { + get children(): NestedNode[] { return this.childrenChange.value; } constructor( - public item: string, + public name: string, public hasChildren = false, - public loadMoreParentItem: string | null = null, + public parent: string | null = null, + public isLoadMore = false, ) {} } /** Flat node with expandable and level information */ -export class LoadmoreFlatNode { +export class FlatNode { constructor( - public item: string, + public name: string, public level = 1, public expandable = false, - public loadMoreParentItem: string | null = null, + public parent: string | null = null, + public isLoadMore = false, ) {} } +/** Number of nodes loaded at a time */ +const batchSize = 3; + /** * A database that only load part of the data initially. After user clicks on the `Load more` * button, more data will be loaded. */ @Injectable() export class LoadmoreDatabase { - batchNumber = 5; - dataChange = new BehaviorSubject([]); - nodeMap = new Map(); + /** Map of node name to node */ + nodes = new Map(); + + dataChange = new BehaviorSubject([]); - /** The data */ - rootLevelNodes: string[] = ['Vegetables', 'Fruits']; - dataMap = new Map([ + /** Example data */ + rootNodes: string[] = ['Vegetables', 'Fruits']; + childMap = new Map([ ['Fruits', ['Apple', 'Orange', 'Banana']], ['Vegetables', ['Tomato', 'Potato', 'Onion']], - ['Apple', ['Fuji', 'Macintosh']], + [ + 'Apple', + [ + 'Gala', + 'Braeburn', + 'Fuji', + 'Macintosh', + 'Golden Delicious', + 'Red Delicious', + 'Empire', + 'Granny Smith', + 'Cameo', + 'Baldwin', + 'Jonagold', + ], + ], ['Onion', ['Yellow', 'White', 'Purple', 'Green', 'Shallot', 'Sweet', 'Red', 'Leek']], ]); initialize() { - const data = this.rootLevelNodes.map(name => this._generateNode(name)); + const data = this.rootNodes.map(name => this._generateNode(name, null)); this.dataChange.next(data); } /** Expand a node whose children are not loaded */ - loadMore(item: string, onlyFirstTime = false) { - if (!this.nodeMap.has(item) || !this.dataMap.has(item)) { + loadChildren(name: string, onlyFirstTime = false) { + if (!this.nodes.has(name) || !this.childMap.has(name)) { return; } - const parent = this.nodeMap.get(item)!; - const children = this.dataMap.get(item)!; + const parent = this.nodes.get(name)!; + const children = this.childMap.get(name)!; + if (onlyFirstTime && parent.children!.length > 0) { return; } - const newChildrenNumber = parent.children!.length + this.batchNumber; - const nodes = children.slice(0, newChildrenNumber).map(name => this._generateNode(name)); + + const newChildrenNumber = parent.children!.length + batchSize; + const nodes = children + .slice(0, newChildrenNumber) + .map(name => this._generateNode(name, parent.name)); if (newChildrenNumber < children.length) { - // Need a new load more node - nodes.push(new LoadmoreNode(LOAD_MORE, false, item)); + // Need a new "Load More" node + nodes.push(new NestedNode(`${LOAD_MORE}-${loadMoreId++}`, false, name, true)); } parent.childrenChange.next(nodes); this.dataChange.next(this.dataChange.value); } - private _generateNode(item: string): LoadmoreNode { - if (this.nodeMap.has(item)) { - return this.nodeMap.get(item)!; + private _generateNode(name: string, parent: string | null): NestedNode { + if (!this.nodes.has(name)) { + this.nodes.set(name, new NestedNode(name, this.childMap.has(name), parent)); } - const result = new LoadmoreNode(item, this.dataMap.has(item)); - this.nodeMap.set(item, result); - return result; + + return this.nodes.get(name)!; } } @@ -100,16 +126,17 @@ export class LoadmoreDatabase { @Component({ selector: 'tree-loadmore-example', templateUrl: 'tree-loadmore-example.html', + styleUrl: 'tree-loadmore-example.css', providers: [LoadmoreDatabase], standalone: true, imports: [MatTreeModule, MatButtonModule, MatIconModule], }) export class TreeLoadmoreExample { - nodeMap = new Map(); - treeControl: FlatTreeControl; - treeFlattener: MatTreeFlattener; + nodeMap = new Map(); + treeControl: FlatTreeControl; + treeFlattener: MatTreeFlattener; // Flat tree data source - dataSource: MatTreeFlatDataSource; + dataSource: MatTreeFlatDataSource; constructor(private _database: LoadmoreDatabase) { this.treeFlattener = new MatTreeFlattener( @@ -119,7 +146,8 @@ export class TreeLoadmoreExample { this.getChildren, ); - this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + // TODO(#27626): Remove treeControl. Adopt either levelAccessor or childrenAccessor. + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); @@ -130,39 +158,58 @@ export class TreeLoadmoreExample { _database.initialize(); } - getChildren = (node: LoadmoreNode): Observable => node.childrenChange; + getChildren = (node: NestedNode): Observable => node.childrenChange; - transformer = (node: LoadmoreNode, level: number) => { - const existingNode = this.nodeMap.get(node.item); + transformer = (node: NestedNode, level: number) => { + const existingNode = this.nodeMap.get(node.name); if (existingNode) { return existingNode; } - const newNode = new LoadmoreFlatNode( - node.item, - level, - node.hasChildren, - node.loadMoreParentItem, - ); - this.nodeMap.set(node.item, newNode); + const newNode = new FlatNode(node.name, level, node.hasChildren, node.parent, node.isLoadMore); + this.nodeMap.set(node.name, newNode); return newNode; }; - getLevel = (node: LoadmoreFlatNode) => node.level; + getLevel = (node: FlatNode) => node.level; - isExpandable = (node: LoadmoreFlatNode) => node.expandable; + isExpandable = (node: FlatNode) => node.expandable; - hasChild = (_: number, _nodeData: LoadmoreFlatNode) => _nodeData.expandable; + hasChild = (_: number, node: FlatNode) => node.expandable; - isLoadMore = (_: number, _nodeData: LoadmoreFlatNode) => _nodeData.item === LOAD_MORE; + isLoadMore = (_: number, node: FlatNode) => node.isLoadMore; + + loadChildren(node: FlatNode) { + this._database.loadChildren(node.name, true); + } - /** Load more nodes from data source */ - loadMore(item: string) { - this._database.loadMore(item); + /** Load more nodes when clicking on "Load more" node. */ + loadOnClick(event: MouseEvent, node: FlatNode) { + this._loadSiblings(event.target as HTMLElement, node); } - loadChildren(node: LoadmoreFlatNode) { - this._database.loadMore(node.item, true); + /** Load more nodes on keyboardpress when focused on "Load more" node */ + loadOnKeypress(event: KeyboardEvent, node: FlatNode) { + if (event.keyCode === ENTER || event.keyCode === SPACE) { + this._loadSiblings(event.target as HTMLElement, node); + } + } + + private _loadSiblings(nodeElement: HTMLElement, node: FlatNode) { + if (node.parent) { + // Store a reference to the sibling of the "Load More" node before it is removed from the DOM + const previousSibling = nodeElement.previousElementSibling; + + // Synchronously load data. + this._database.loadChildren(node.parent); + + const focusDesination = previousSibling?.nextElementSibling || previousSibling; + + if (focusDesination) { + // Restore focus. + (focusDesination as HTMLElement).focus(); + } + } } } diff --git a/src/components-examples/material/tree/tree-nested-overview/tree-nested-overview-example.html b/src/components-examples/material/tree/tree-nested-overview/tree-nested-overview-example.html index 210ee85ecbc9..8f9e8036bd13 100644 --- a/src/components-examples/material/tree/tree-nested-overview/tree-nested-overview-example.html +++ b/src/components-examples/material/tree/tree-nested-overview/tree-nested-overview-example.html @@ -2,25 +2,27 @@ - - {{node.name}} + + {{node.name}} - -
- - {{node.name}} -
- -
- + +
+ + {{node.name}} +
+ +
+
diff --git a/src/dev-app/tree/tree-demo.html b/src/dev-app/tree/tree-demo.html index 8b2d4e1fedd6..d3b846ceaa0a 100644 --- a/src/dev-app/tree/tree-demo.html +++ b/src/dev-app/tree/tree-demo.html @@ -7,6 +7,14 @@ CDK Flat tree + + CDK Flat tree (levelAccessor) + + + + CDK Flat tree (childrenAccessor) + + Nested tree @@ -15,6 +23,14 @@ CDK Nested tree + + CDK Nested tree (levelAccessor) + + + + CDK Nested tree (childrenAccessor) + + Dynamic flat tree @@ -23,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 ebcf0e40e575..40e0e5c02b40 100644 --- a/src/dev-app/tree/tree-demo.ts +++ b/src/dev-app/tree/tree-demo.ts @@ -8,7 +8,15 @@ import {Component} from '@angular/core'; import {CdkTreeModule} from '@angular/cdk/tree'; import {CommonModule} from '@angular/common'; -import {CdkTreeFlatExample, CdkTreeNestedExample} from '@angular/components-examples/cdk/tree'; +import { + CdkTreeFlatExample, + CdkTreeNestedExample, + CdkTreeFlatLevelAccessorExample, + CdkTreeNestedLevelAccessorExample, + CdkTreeNestedChildrenAccessorExample, + CdkTreeFlatChildrenAccessorExample, + CdkTreeComplexExample, +} from '@angular/components-examples/cdk/tree'; import { TreeDynamicExample, TreeFlatOverviewExample, @@ -35,6 +43,11 @@ import {MatTreeModule} from '@angular/material/tree'; CdkTreeModule, CdkTreeFlatExample, CdkTreeNestedExample, + CdkTreeFlatChildrenAccessorExample, + CdkTreeFlatLevelAccessorExample, + CdkTreeNestedChildrenAccessorExample, + CdkTreeNestedLevelAccessorExample, + CdkTreeComplexExample, CommonModule, FormsModule, TreeDynamicExample, diff --git a/src/material/tree/data-source/flat-data-source.ts b/src/material/tree/data-source/flat-data-source.ts index 1763019ef080..d65cc367f993 100644 --- a/src/material/tree/data-source/flat-data-source.ts +++ b/src/material/tree/data-source/flat-data-source.ts @@ -44,6 +44,10 @@ import {map, take} from 'rxjs/operators'; * level: 2 * } * and the output flattened type is `F` with additional information. + * + * @deprecated Use MatTree#childrenAccessor and MatTreeNode#isExpandable + * instead. To be removed in a future version. + * @breaking-change 19.0.0 */ export class MatTreeFlattener { constructor( @@ -122,6 +126,10 @@ export class MatTreeFlattener { * to `MatTree`. * The nested tree nodes of type `T` are flattened through `MatTreeFlattener`, and converted * to type `F` for `MatTree` to consume. + * + * @deprecated Use one of levelAccessor or childrenAccessor instead. To be removed in a future + * version. + * @breaking-change 19.0.0 */ export class MatTreeFlatDataSource extends DataSource { private readonly _flattenedData = new BehaviorSubject([]); diff --git a/src/material/tree/node.ts b/src/material/tree/node.ts index 437596f6add5..0ff01547521e 100644 --- a/src/material/tree/node.ts +++ b/src/material/tree/node.ts @@ -23,34 +23,74 @@ import { OnDestroy, OnInit, } from '@angular/core'; -import {CanDisable, HasTabIndex, mixinDisabled, mixinTabIndex} from '@angular/material/core'; +import {CanDisable, HasTabIndex} from '@angular/material/core'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; -const _MatTreeNodeBase = mixinTabIndex(mixinDisabled(CdkTreeNode)); - /** * Wrapper for the CdkTree node with Material design styles. */ @Directive({ selector: 'mat-tree-node', exportAs: 'matTreeNode', - inputs: ['role', 'disabled', 'tabIndex'], + inputs: ['role', 'disabled', 'tabIndex', 'isExpandable', 'isExpanded', 'isDisabled'], + outputs: ['activation', 'expandedChange'], providers: [{provide: CdkTreeNode, useExisting: MatTreeNode}], host: { 'class': 'mat-tree-node', + '[attr.aria-expanded]': '_getAriaExpanded()', + '[attr.aria-level]': 'level + 1', + '[attr.aria-posinset]': '_getPositionInSet()', + '[attr.aria-setsize]': '_getSetSize()', + 'tabindex': '-1', + '(click)': '_setActiveItem()', }, }) export class MatTreeNode - extends _MatTreeNodeBase + extends CdkTreeNode implements CanDisable, HasTabIndex, OnInit, OnDestroy { + /** + * The tabindex of the tree node. + * + * @deprecated MatTreeNode ignores this proprety. Changing tabIndex has no affect. The tree + * automatically determines the appropriate tabindex for the tree node. To be removed in a + * future version. + * @breaking-change 19.0.0 Remove this attribute. + */ + tabIndex: number; + + /** + * The tabindex of the tree node. + * + * @deprecated MatTreeNode ignores this proprety. Changing defaultTabIndex has no affect. The tree + * automatically determines the appropriate tabindex for the tree node. To be removed in a + * future version. + * @breaking-change 19.0.0 Remove this attribute. + */ + defaultTabIndex: number; + + /** + * Whether the component is disabled. + * + * @deprecated This is an alias for `isDisabled`. + * @breaking-change 19.0.0 Remove this input + */ + get disabled(): boolean { + return this.isDisabled ?? false; + } + set disabled(value: BooleanInput) { + this.isDisabled = coerceBooleanProperty(value); + } + constructor( elementRef: ElementRef, tree: CdkTree, + // Ignore tabindex attribute. MatTree manages its own active state using TreeKeyManager. + // Keeping tabIndex in constructor for backwards compatibility with trees created before + // introducing TreeKeyManager. @Attribute('tabindex') tabIndex: string, ) { super(elementRef, tree); - this.tabIndex = Number(tabIndex) || 0; } // This is a workaround for https://github.com/angular/angular/issues/23091 @@ -83,7 +123,8 @@ export class MatTreeNodeDef extends CdkTreeNodeDef { @Directive({ selector: 'mat-nested-tree-node', exportAs: 'matNestedTreeNode', - inputs: ['role', 'disabled', 'tabIndex'], + inputs: ['role', 'disabled', 'tabIndex', 'isExpandable', 'isExpanded', 'isDisabled'], + outputs: ['activation', 'expandedChange'], providers: [ {provide: CdkNestedTreeNode, useExisting: MatNestedTreeNode}, {provide: CdkTreeNode, useExisting: MatNestedTreeNode}, @@ -99,35 +140,29 @@ export class MatNestedTreeNode { @Input('matNestedTreeNode') node: T; - /** Whether the node is disabled. */ - @Input() + /** + * Whether the component is disabled. + * + * @deprecated This is an alias for `isDisabled`. + * @breaking-change 19.0.0 Remove this input + */ get disabled(): boolean { - return this._disabled; + return this.isDisabled ?? false; } set disabled(value: BooleanInput) { - this._disabled = coerceBooleanProperty(value); - } - private _disabled = false; - - /** Tabindex for the node. */ - @Input() - get tabIndex(): number { - return this.disabled ? -1 : this._tabIndex; - } - set tabIndex(value: number) { - // If the specified tabIndex value is null or undefined, fall back to the default value. - this._tabIndex = value != null ? value : 0; + this.isDisabled = coerceBooleanProperty(value); } - private _tabIndex: number; constructor( elementRef: ElementRef, tree: CdkTree, differs: IterableDiffers, + // Ignore tabindex attribute. MatTree manages its own active state using TreeKeyManager. + // Keeping tabIndex in constructor for backwards compatibility with trees created before + // introducing TreeKeyManager. @Attribute('tabindex') tabIndex: string, ) { super(elementRef, tree, differs); - this.tabIndex = Number(tabIndex) || 0; } // This is a workaround for https://github.com/angular/angular/issues/19145 diff --git a/src/material/tree/testing/tree-harness.spec.ts b/src/material/tree/testing/tree-harness.spec.ts index bbe65e8d2b2e..ce4d8f94646c 100644 --- a/src/material/tree/testing/tree-harness.spec.ts +++ b/src/material/tree/testing/tree-harness.spec.ts @@ -227,7 +227,7 @@ interface ExampleFlatNode { {{node.name}} - + @@ -240,7 +240,7 @@ interface ExampleFlatNode { {{node.name}} - + diff --git a/src/material/tree/tree-using-tree-control.spec.ts b/src/material/tree/tree-using-tree-control.spec.ts new file mode 100644 index 000000000000..c96cf1bc6e4c --- /dev/null +++ b/src/material/tree/tree-using-tree-control.spec.ts @@ -0,0 +1,1207 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * 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 {FlatTreeControl, NestedTreeControl, TreeControl} from '@angular/cdk/tree'; +import {Component, ViewChild, Type} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {BehaviorSubject, Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import { + MatTree, + MatTreeFlatDataSource, + MatTreeFlattener, + MatTreeModule, + MatTreeNestedDataSource, +} from './index'; + +describe('MatTree', () => { + /** Represents an indent for expectNestedTreeToMatch */ + const _ = {}; + + let treeElement: HTMLElement; + let underlyingDataSource: FakeDataSource; + + function configureMatTreeTestingModule(declarations: Type[]) { + TestBed.configureTestingModule({ + imports: [MatTreeModule], + declarations: declarations, + }).compileComponents(); + } + + describe('flat tree', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + let component: SimpleMatTreeApp; + + beforeEach(() => { + configureMatTreeTestingModule([SimpleMatTreeApp]); + fixture = TestBed.createComponent(SimpleMatTreeApp); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).withContext('Expect nodes to be defined').toBeDefined(); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + + it('with the right accessibility roles', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + + getNodes(treeElement).forEach(node => { + expect(node.getAttribute('role')).toBe('treeitem'); + }); + }); + + it('with the right data', () => { + expect(underlyingDataSource.data.length).toBe(3); + + const data = underlyingDataSource.data; + expectFlatTreeToMatch( + treeElement, + 28, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + + underlyingDataSource.addChild(data[2]); + fixture.detectChanges(); + + expectFlatTreeToMatch( + treeElement, + 28, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [`_, topping_4 - cheese_4 + base_4`], + ); + }); + }); + + describe('with toggle', () => { + let fixture: ComponentFixture; + let component: MatTreeAppWithToggle; + + beforeEach(() => { + configureMatTreeTestingModule([MatTreeAppWithToggle]); + fixture = TestBed.createComponent(MatTreeAppWithToggle); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('should expand/collapse the node', () => { + expect(underlyingDataSource.data.length).toBe(3); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect no expanded node`) + .toBe(0); + + component.toggleRecursively = false; + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[2]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expectFlatTreeToMatch( + treeElement, + 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node expanded one level`) + .toBe(1); + expectFlatTreeToMatch( + treeElement, + 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [_, `topping_4 - cheese_4 + base_4`], + ); + + (getNodes(treeElement)[3] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node expanded`) + .toBe(2); + expectFlatTreeToMatch( + treeElement, + 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + ); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expectFlatTreeToMatch( + treeElement, + 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + + it('should expand/collapse the node recursively', () => { + expect(underlyingDataSource.data.length).toBe(3); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect no expanded node`) + .toBe(0); + + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[2]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expectFlatTreeToMatch( + treeElement, + 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect nodes expanded`) + .toBe(3); + expectFlatTreeToMatch( + treeElement, + 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + ); + + (getNodes(treeElement)[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node collapsed`) + .toBe(0); + + expectFlatTreeToMatch( + treeElement, + 40, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + }); + + describe('with when node template', () => { + let fixture: ComponentFixture; + let component: WhenNodeMatTreeApp; + + beforeEach(() => { + configureMatTreeTestingModule([WhenNodeMatTreeApp]); + fixture = TestBed.createComponent(WhenNodeMatTreeApp); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expectFlatTreeToMatch( + treeElement, + 28, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [`>>> topping_4 - cheese_4 + base_4`], + ); + }); + }); + }); + + describe('flat tree with undefined or null children', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + configureMatTreeTestingModule([MatTreeWithNullOrUndefinedChild]); + fixture = TestBed.createComponent(MatTreeWithNullOrUndefinedChild); + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).withContext('Expect nodes to be defined').toBeDefined(); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + }); + }); + + describe('nested tree with undefined or null children', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + configureMatTreeTestingModule([MatNestedTreeWithNullOrUndefinedChild]); + fixture = TestBed.createComponent(MatNestedTreeWithNullOrUndefinedChild); + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).withContext('Expect nodes to be defined').toBeDefined(); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + }); + }); + describe('nested tree', () => { + describe('should initialize', () => { + let fixture: ComponentFixture; + let component: NestedMatTreeApp; + + beforeEach(() => { + configureMatTreeTestingModule([NestedMatTreeApp]); + fixture = TestBed.createComponent(NestedMatTreeApp); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with rendered dataNodes', () => { + const nodes = getNodes(treeElement); + + expect(nodes).withContext('Expect nodes to be defined').toBeDefined(); + expect(nodes[0].classList).toContain('customNodeClass'); + }); + + it('with the right accessibility roles', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + + getNodes(treeElement).forEach(node => { + expect(node.getAttribute('role')).toBe('treeitem'); + }); + }); + + it('with the right data', () => { + expect(underlyingDataSource.data.length).toBe(3); + + let data = underlyingDataSource.data; + expectNestedTreeToMatch( + treeElement, + [`${data[0].pizzaTopping} - ${data[0].pizzaCheese} + ${data[0].pizzaBase}`], + [`${data[1].pizzaTopping} - ${data[1].pizzaCheese} + ${data[1].pizzaBase}`], + [`${data[2].pizzaTopping} - ${data[2].pizzaCheese} + ${data[2].pizzaBase}`], + ); + + underlyingDataSource.addChild(data[1]); + fixture.detectChanges(); + + treeElement = fixture.nativeElement.querySelector('mat-tree'); + data = underlyingDataSource.data; + expect(data.length).toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + + it('with nested child data', () => { + expect(underlyingDataSource.data.length).toBe(3); + + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expect(data.length).toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [`topping_3 - cheese_3 + base_3`], + ); + + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expect(data.length).toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [_, _, `topping_6 - cheese_6 + base_6`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + + it('with correct aria-level on nodes', () => { + expect( + getNodes(treeElement).every(node => { + return node.getAttribute('aria-level') === '1'; + }), + ).toBe(true); + + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + const ariaLevels = getNodes(treeElement).map(n => n.getAttribute('aria-level')); + expect(ariaLevels).toEqual(['1', '1', '2', '3', '1']); + }); + }); + + describe('with when node', () => { + let fixture: ComponentFixture; + let component: WhenNodeNestedMatTreeApp; + + beforeEach(() => { + configureMatTreeTestingModule([WhenNodeNestedMatTreeApp]); + fixture = TestBed.createComponent(WhenNodeNestedMatTreeApp); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with the right data', () => { + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + [`>>> topping_4 - cheese_4 + base_4`], + ); + }); + }); + + describe('with toggle', () => { + let fixture: ComponentFixture; + let component: NestedMatTreeAppWithToggle; + + beforeEach(() => { + configureMatTreeTestingModule([NestedMatTreeAppWithToggle]); + fixture = TestBed.createComponent(NestedMatTreeAppWithToggle); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource; + treeElement = fixture.nativeElement.querySelector('mat-tree'); + + fixture.detectChanges(); + }); + + it('with the right aria-expanded attrs', () => { + expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual(['false', 'false', 'false']); + + component.toggleRecursively = false; + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + // 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(['false', 'true', 'false', 'false']); + }); + + it('should expand/collapse the node', () => { + component.toggleRecursively = false; + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1]); + underlyingDataSource.addChild(child); + + fixture.detectChanges(); + + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + + fixture.detectChanges(); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node expanded`) + .toBe(1); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [`topping_3 - cheese_3 + base_3`], + ); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node collapsed`) + .toBe(0); + }); + + it('should expand/collapse the node recursively', () => { + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1]); + underlyingDataSource.addChild(child); + fixture.detectChanges(); + + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node expanded`) + .toBe(3); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [_, `topping_4 - cheese_4 + base_4`], + [_, _, `topping_5 - cheese_5 + base_5`], + [`topping_3 - cheese_3 + base_3`], + ); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(component.treeControl.expansionModel.selected.length) + .withContext(`Expect node collapsed`) + .toBe(0); + expectNestedTreeToMatch( + treeElement, + [`topping_1 - cheese_1 + base_1`], + [`topping_2 - cheese_2 + base_2`], + [`topping_3 - cheese_3 + base_3`], + ); + }); + }); + }); + + describe('accessibility', () => { + let fixture: ComponentFixture; + let component: NestedMatTreeApp; + let nodes: HTMLElement[]; + let tree: MatTree; + + beforeEach(() => { + configureMatTreeTestingModule([NestedMatTreeApp]); + fixture = TestBed.createComponent(NestedMatTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource as FakeDataSource; + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1], false); + underlyingDataSource.addChild(child, false); + underlyingDataSource.addChild(child, false); + fixture.detectChanges(); + + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('mat-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', () => { + underlyingDataSource.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', () => { + underlyingDataSource.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(['false', 'false', 'false', 'false', 'false', 'false']); + 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(underlyingDataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual(['false', 'true', 'false', 'false', 'false', 'false']); + + tree.collapse(underlyingDataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual(['false', 'false', 'false', 'false', 'false', 'false']); + }); + }); + }); +}); + +export class TestData { + pizzaTopping: string; + pizzaCheese: string; + pizzaBase: string; + level: number; + children: TestData[]; + observableChildren: BehaviorSubject; + isSpecial: boolean; + isDisabled?: boolean; + + constructor( + pizzaTopping: string, + pizzaCheese: string, + pizzaBase: string, + children: TestData[] = [], + isSpecial: boolean = false, + ) { + this.pizzaTopping = pizzaTopping; + this.pizzaCheese = pizzaCheese; + this.pizzaBase = pizzaBase; + this.isSpecial = isSpecial; + this.children = children; + this.observableChildren = new BehaviorSubject(this.children); + } +} + +class FakeDataSource { + dataIndex = 0; + _dataChange = new BehaviorSubject([]); + get data() { + return this._dataChange.getValue(); + } + set data(data: TestData[]) { + this._dataChange.next(data); + } + + connect(): Observable { + return this._dataChange; + } + + disconnect() {} + + constructor() { + for (let i = 0; i < 3; i++) { + this.addData(); + } + } + + addChild(parent: TestData, isSpecial: boolean = false) { + const nextIndex = ++this.dataIndex; + const child = new TestData(`topping_${nextIndex}`, `cheese_${nextIndex}`, `base_${nextIndex}`); + + const index = this.data.indexOf(parent); + if (index > -1) { + parent = new TestData( + parent.pizzaTopping, + parent.pizzaCheese, + parent.pizzaBase, + parent.children, + isSpecial, + ); + } + parent.children.push(child); + parent.observableChildren.next(parent.children); + + let copiedData = this.data.slice(); + if (index > -1) { + copiedData.splice(index, 1, parent); + } + this.data = copiedData; + return child; + } + + addData(isSpecial: boolean = false) { + const nextIndex = ++this.dataIndex; + let copiedData = this.data.slice(); + copiedData.push( + new TestData( + `topping_${nextIndex}`, + `cheese_${nextIndex}`, + `base_${nextIndex}`, + [], + isSpecial, + ), + ); + + this.data = copiedData; + } +} + +function getNodes(treeElement: Element): HTMLElement[] { + return [].slice.call(treeElement.querySelectorAll('.mat-tree-node, .mat-nested-tree-node'))!; +} + +function expectFlatTreeToMatch( + treeElement: Element, + expectedPaddingIndent: number = 28, + ...expectedTree: any[] +) { + const missedExpectations: string[] = []; + + function checkNode(node: Element, expectedNode: any[]) { + const actualTextContent = node.textContent!.trim(); + const expectedTextContent = expectedNode[expectedNode.length - 1]; + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`, + ); + } + } + + function checkLevel(node: Element, expectedNode: any[]) { + const rawLevel = (node as HTMLElement).style.paddingLeft; + + // Some browsers return 0, while others return 0px. + const actualLevel = rawLevel === '0' ? '0px' : rawLevel; + if (expectedNode.length === 1) { + if (actualLevel !== `` && actualLevel !== '0px') { + missedExpectations.push(`Expected node level to be 0px but was ${actualLevel}`); + } + } else { + const expectedLevel = `${(expectedNode.length - 1) * expectedPaddingIndent}px`; + if (actualLevel != expectedLevel) { + missedExpectations.push( + `Expected node level to be ${expectedLevel} but was ${actualLevel}`, + ); + } + } + } + + getNodes(treeElement).forEach((node, index) => { + const expected = expectedTree ? expectedTree[index] : null; + + checkLevel(node, expected); + checkNode(node, expected); + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} + +function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { + const missedExpectations: string[] = []; + function checkNodeContent(node: Element, expectedNode: any[]) { + const expectedTextContent = expectedNode[expectedNode.length - 1]; + const actualTextContent = node.childNodes.item(0).textContent!.trim(); + if (actualTextContent !== expectedTextContent) { + missedExpectations.push( + `Expected node contents to be ${expectedTextContent} but was ${actualTextContent}`, + ); + } + } + + function checkNodeDescendants(node: Element, expectedNode: any[], currentIndex: number) { + let expectedDescendant = 0; + + for (let i = currentIndex + 1; i < expectedTree.length; ++i) { + if (expectedTree[i].length > expectedNode.length) { + ++expectedDescendant; + } else if (expectedTree[i].length === expectedNode.length) { + break; + } + } + + const actualDescendant = getNodes(node).length; + if (actualDescendant !== expectedDescendant) { + missedExpectations.push( + `Expected node descendant num to be ${expectedDescendant} but was ${actualDescendant}`, + ); + } + } + + getNodes(treeElement).forEach((node, index) => { + const expected = expectedTree ? expectedTree[index] : null; + + checkNodeDescendants(node, expected, index); + checkNodeContent(node, expected); + }); + + if (missedExpectations.length) { + fail(missedExpectations.join('\n')); + } +} + +function getNodeAttributes(nodes: HTMLElement[], attribute: string) { + return nodes.map(node => node.getAttribute(attribute)); +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + `, +}) +class SimpleMatTreeApp { + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + getChildren = (node: TestData) => node.observableChildren; + transformer = (node: TestData, level: number) => { + node.level = level; + return node; + }; + + treeFlattener = new MatTreeFlattener( + this.transformer, + this.getLevel, + this.isExpandable, + this.getChildren, + ); + + treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +interface FoodNode { + name: string; + children?: FoodNode[] | null; +} + +/** Flat node with expandable and level information */ +interface ExampleFlatNode { + expandable: boolean; + name: string; + level: number; +} + +/** + * Food data with nested structure. + * Each node has a name and an optiona list of children. + */ +const TREE_DATA: FoodNode[] = [ + { + name: 'Fruit', + children: [{name: 'Apple'}, {name: 'Banana'}, {name: 'Fruit loops', children: null}], + }, + { + name: 'Vegetables', + children: [ + { + name: 'Green', + children: [{name: 'Broccoli'}, {name: 'Brussels sprouts'}], + }, + { + name: 'Orange', + children: [{name: 'Pumpkins'}, {name: 'Carrots'}], + }, + ], + }, +]; + +@Component({ + template: ` + + + {{node.name}} + + + `, +}) +class MatTreeWithNullOrUndefinedChild { + private _transformer = (node: FoodNode, level: number) => { + return { + expandable: !!node.children, + name: node.name, + level: level, + }; + }; + + treeControl = new FlatTreeControl( + node => node.level, + node => node.expandable, + ); + + treeFlattener = new MatTreeFlattener( + this._transformer, + node => node.level, + node => node.expandable, + node => node.children, + ); + + dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener, TREE_DATA); + + hasChild = (_: number, node: ExampleFlatNode) => node.expandable; +} + +@Component({ + template: ` + + + {{node.name}} + + + + `, +}) +class MatNestedTreeWithNullOrUndefinedChild { + treeControl: NestedTreeControl; + dataSource: MatTreeNestedDataSource; + + constructor() { + this.treeControl = new NestedTreeControl(this._getChildren); + this.dataSource = new MatTreeNestedDataSource(); + this.dataSource.data = TREE_DATA; + } + + private _getChildren = (node: FoodNode) => node.children; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + `, +}) +class NestedMatTreeApp { + getChildren = (node: TestData) => node.observableChildren; + isExpandable = (node: TestData) => + node.observableChildren.pipe(map(children => children.length > 0)); + + treeControl = new NestedTreeControl(this.getChildren); + + dataSource = new MatTreeNestedDataSource(); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + >>> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} +
+ +
+
+
+ `, +}) +class WhenNodeNestedMatTreeApp { + isSpecial = (_: number, node: TestData) => node.isSpecial; + + getChildren = (node: TestData) => node.observableChildren; + + treeControl: TreeControl = new NestedTreeControl(this.getChildren); + + dataSource = new MatTreeNestedDataSource(); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + `, +}) +class MatTreeAppWithToggle { + toggleRecursively: boolean = true; + + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + getChildren = (node: TestData) => node.observableChildren; + transformer = (node: TestData, level: number) => { + node.level = level; + return node; + }; + + treeFlattener = new MatTreeFlattener( + this.transformer, + this.getLevel, + this.isExpandable, + this.getChildren, + ); + + treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} +
+ +
+
+
+ `, +}) +class NestedMatTreeAppWithToggle { + toggleRecursively: boolean = true; + + getChildren = (node: TestData) => node.observableChildren; + isExpandable = (node: TestData) => + node.observableChildren.pipe(map(children => children.length > 0)); + + treeControl = new NestedTreeControl(this.getChildren); + dataSource = new MatTreeNestedDataSource(); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + >>> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + `, +}) +class WhenNodeMatTreeApp { + isSpecial = (_: number, node: TestData) => node.isSpecial; + + getLevel = (node: TestData) => node.level; + isExpandable = (node: TestData) => node.children.length > 0; + getChildren = (node: TestData) => node.observableChildren; + transformer = (node: TestData, level: number) => { + node.level = level; + return node; + }; + + treeFlattener = new MatTreeFlattener( + this.transformer, + this.getLevel, + this.isExpandable, + this.getChildren, + ); + + treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + + dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + underlyingDataSource = new FakeDataSource(); + + @ViewChild(MatTree) tree: MatTree; + + constructor() { + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } +} diff --git a/src/material/tree/tree.md b/src/material/tree/tree.md index 21c030f9a27c..401ac3e1866a 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 + +#### Writing your tree template + +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. - +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. -### Features +```html + + {{node.key}}: {{node.value}} + +``` + +##### 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. -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. +Spacing can be added either by applying the `matNodePadding` directive or by applying custom styles +based on the `aria-level` attribute. -### TreeControl -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. +##### 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,102 @@ 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. + +In order to use the new accessibility features, migrating to `levelAccessor` and `childrenAccessor` +is required. Trees using `treeControl` do not implement the correct accessibility features for +backwards compatibility. + +#### isExpandable + +In order for the tree to correctly determine whether or not a node is expandable, the `isExpandable` +property must be set on all `mat-tree-node` or `mat-tree-nested-node` that are expandable. + +#### 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.spec.ts b/src/material/tree/tree.spec.ts index 76774f7584de..b0256a1cd2cc 100644 --- a/src/material/tree/tree.spec.ts +++ b/src/material/tree/tree.spec.ts @@ -5,17 +5,11 @@ * 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 {FlatTreeControl, NestedTreeControl, TreeControl} from '@angular/cdk/tree'; import {Component, ViewChild, Type} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {BehaviorSubject, Observable} from 'rxjs'; -import { - MatTree, - MatTreeFlatDataSource, - MatTreeFlattener, - MatTreeModule, - MatTreeNestedDataSource, -} from './index'; +import {map} from 'rxjs/operators'; +import {MatTree, MatTreeModule, MatTreeNestedDataSource} from './index'; describe('MatTree', () => { /** Represents an indent for expectNestedTreeToMatch */ @@ -41,7 +35,7 @@ describe('MatTree', () => { fixture = TestBed.createComponent(SimpleMatTreeApp); component = fixture.componentInstance; - underlyingDataSource = component.underlyingDataSource; + underlyingDataSource = component.dataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); fixture.detectChanges(); @@ -62,38 +56,6 @@ describe('MatTree', () => { }); }); - it('with the right aria-level attrs', () => { - // add a child to the first node - const data = underlyingDataSource.data; - underlyingDataSource.addChild(data[2]); - component.treeControl.expandAll(); - fixture.detectChanges(); - - const ariaLevels = getNodes(treeElement).map(n => n.getAttribute('aria-level')); - expect(ariaLevels).toEqual(['1', '1', '1', '2']); - }); - - it('with the right aria-expanded attrs', () => { - // add a child to the first node - const data = underlyingDataSource.data; - underlyingDataSource.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(underlyingDataSource.data.length).toBe(3); @@ -129,7 +91,7 @@ describe('MatTree', () => { fixture = TestBed.createComponent(MatTreeAppWithToggle); component = fixture.componentInstance; - underlyingDataSource = component.underlyingDataSource; + underlyingDataSource = component.dataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); fixture.detectChanges(); @@ -138,9 +100,9 @@ describe('MatTree', () => { it('should expand/collapse the node', () => { expect(underlyingDataSource.data.length).toBe(3); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect no expanded node`) - .toBe(0); + let numExpandedNodes = + fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect no expanded node`).toBe(0); component.toggleRecursively = false; const data = underlyingDataSource.data; @@ -159,9 +121,8 @@ describe('MatTree', () => { (getNodes(treeElement)[2] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect node expanded one level`) - .toBe(1); + numExpandedNodes = fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).toBe(1); expectFlatTreeToMatch( treeElement, 40, @@ -174,9 +135,8 @@ describe('MatTree', () => { (getNodes(treeElement)[3] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect node expanded`) - .toBe(2); + numExpandedNodes = fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect node expanded`).toBe(2); expectFlatTreeToMatch( treeElement, 40, @@ -202,9 +162,9 @@ describe('MatTree', () => { it('should expand/collapse the node recursively', () => { expect(underlyingDataSource.data.length).toBe(3); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect no expanded node`) - .toBe(0); + let numExpandedNodes = + fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect no expanded node`).toBe(0); const data = underlyingDataSource.data; const child = underlyingDataSource.addChild(data[2]); @@ -222,9 +182,8 @@ describe('MatTree', () => { (getNodes(treeElement)[2] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect nodes expanded`) - .toBe(3); + numExpandedNodes = fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect nodes expanded`).toBe(2); expectFlatTreeToMatch( treeElement, 40, @@ -238,9 +197,8 @@ describe('MatTree', () => { (getNodes(treeElement)[2] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect node collapsed`) - .toBe(0); + numExpandedNodes = fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect node collapsed`).toBe(0); expectFlatTreeToMatch( treeElement, @@ -261,7 +219,7 @@ describe('MatTree', () => { fixture = TestBed.createComponent(WhenNodeMatTreeApp); component = fixture.componentInstance; - underlyingDataSource = component.underlyingDataSource; + underlyingDataSource = component.dataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); fixture.detectChanges(); @@ -463,18 +421,16 @@ describe('MatTree', () => { fixture = TestBed.createComponent(NestedMatTreeAppWithToggle); component = fixture.componentInstance; - underlyingDataSource = component.underlyingDataSource; + underlyingDataSource = component.dataSource; treeElement = fixture.nativeElement.querySelector('mat-tree'); fixture.detectChanges(); }); 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; const data = underlyingDataSource.data; @@ -485,8 +441,11 @@ describe('MatTree', () => { (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', () => { @@ -509,9 +468,9 @@ describe('MatTree', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect node expanded`) - .toBe(1); + let numExpandedNodes = + fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect node expanded`).toBe(1); expectNestedTreeToMatch( treeElement, [`topping_1 - cheese_1 + base_1`], @@ -529,9 +488,8 @@ describe('MatTree', () => { [`topping_2 - cheese_2 + base_2`], [`topping_3 - cheese_3 + base_3`], ); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect node collapsed`) - .toBe(0); + numExpandedNodes = fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect node collapsed`).toBe(0); }); it('should expand/collapse the node recursively', () => { @@ -550,9 +508,9 @@ describe('MatTree', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect node expanded`) - .toBe(3); + let numExpandedNodes = + fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect node expanded`).toBe(2); expectNestedTreeToMatch( treeElement, [`topping_1 - cheese_1 + base_1`], @@ -565,9 +523,8 @@ describe('MatTree', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - expect(component.treeControl.expansionModel.selected.length) - .withContext(`Expect node collapsed`) - .toBe(0); + numExpandedNodes = fixture.nativeElement.querySelectorAll('[aria-expanded="true"]').length; + expect(numExpandedNodes).withContext(`Expect node collapsed`).toBe(0); expectNestedTreeToMatch( treeElement, [`topping_1 - cheese_1 + base_1`], @@ -577,6 +534,145 @@ describe('MatTree', () => { }); }); }); + + describe('accessibility', () => { + let fixture: ComponentFixture; + let component: NestedMatTreeApp; + let nodes: HTMLElement[]; + let tree: MatTree; + + beforeEach(() => { + configureMatTreeTestingModule([NestedMatTreeApp]); + fixture = TestBed.createComponent(NestedMatTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + underlyingDataSource = component.underlyingDataSource as FakeDataSource; + const data = underlyingDataSource.data; + const child = underlyingDataSource.addChild(data[1], false); + underlyingDataSource.addChild(child, false); + underlyingDataSource.addChild(child, false); + fixture.detectChanges(); + + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('mat-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', () => { + underlyingDataSource.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(); + + fixture.detectChanges(); + + expect(document.activeElement).toEqual(nodes[0]); + }); + + it('redirects focus to the first non-disabled item when the tree is focused', () => { + underlyingDataSource.data[0].isDisabled = true; + fixture.detectChanges(); + + treeElement.focus(); + fixture.detectChanges(); + + expect(document.activeElement).toEqual(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(underlyingDataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'true', 'false', null, null, null]); + + tree.collapse(underlyingDataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'false', 'false', null, null, null]); + }); + }); + }); }); export class TestData { @@ -587,6 +683,7 @@ export class TestData { children: TestData[]; observableChildren: BehaviorSubject; isSpecial: boolean; + isDisabled?: boolean; constructor( pizzaTopping: string, @@ -668,7 +765,7 @@ class FakeDataSource { } } -function getNodes(treeElement: Element): Element[] { +function getNodes(treeElement: Element): HTMLElement[] { return [].slice.call(treeElement.querySelectorAll('.mat-tree-node, .mat-nested-tree-node'))!; } @@ -763,9 +860,13 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { } } +function getNodeAttributes(nodes: HTMLElement[], attribute: string) { + return nodes.map(node => node.getAttribute(attribute)); +} + @Component({ template: ` - + @@ -778,31 +879,10 @@ class SimpleMatTreeApp { getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; getChildren = (node: TestData) => node.observableChildren; - transformer = (node: TestData, level: number) => { - node.level = level; - return node; - }; - - treeFlattener = new MatTreeFlattener( - this.transformer, - this.getLevel, - this.isExpandable, - this.getChildren, - ); - - treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); - - underlyingDataSource = new FakeDataSource(); + dataSource = new FakeDataSource(); @ViewChild(MatTree) tree: MatTree; - - constructor() { - this.underlyingDataSource.connect().subscribe(data => { - this.dataSource.data = data; - }); - } } interface FoodNode { @@ -810,13 +890,6 @@ interface FoodNode { children?: FoodNode[] | null; } -/** Flat node with expandable and level information */ -interface ExampleFlatNode { - expandable: boolean; - name: string; - level: number; -} - /** * Food data with nested structure. * Each node has a name and an optiona list of children. @@ -843,7 +916,7 @@ const TREE_DATA: FoodNode[] = [ @Component({ template: ` - + {{node.name}} @@ -852,34 +925,18 @@ const TREE_DATA: FoodNode[] = [ `, }) class MatTreeWithNullOrUndefinedChild { - private _transformer = (node: FoodNode, level: number) => { - return { - expandable: !!node.children, - name: node.name, - level: level, - }; - }; - - treeControl = new FlatTreeControl( - node => node.level, - node => node.expandable, - ); - - treeFlattener = new MatTreeFlattener( - this._transformer, - node => node.level, - node => node.expandable, - node => node.children, - ); - - dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener, TREE_DATA); + childrenAccessor = (node: FoodNode): FoodNode[] => node.children || []; + dataSource: MatTreeNestedDataSource; - hasChild = (_: number, node: ExampleFlatNode) => node.expandable; + constructor() { + this.dataSource = new MatTreeNestedDataSource(); + this.dataSource.data = TREE_DATA; + } } @Component({ template: ` - + {{node.name}} @@ -888,22 +945,21 @@ class MatTreeWithNullOrUndefinedChild { `, }) class MatNestedTreeWithNullOrUndefinedChild { - treeControl: NestedTreeControl; + childrenAccessor = (node: FoodNode): FoodNode[] => node.children || []; dataSource: MatTreeNestedDataSource; constructor() { - this.treeControl = new NestedTreeControl(this._getChildren); this.dataSource = new MatTreeNestedDataSource(); this.dataSource.data = TREE_DATA; } - - private _getChildren = (node: FoodNode) => node.children; } @Component({ template: ` - - + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -911,9 +967,9 @@ class MatNestedTreeWithNullOrUndefinedChild { `, }) class NestedMatTreeApp { - getChildren = (node: TestData) => node.observableChildren; - - treeControl = new NestedTreeControl(this.getChildren); + childrenAccessor = (node: TestData) => node.observableChildren; + isExpandable = (node: TestData) => + node.observableChildren.pipe(map(children => children.length > 0)); dataSource = new MatTreeNestedDataSource(); underlyingDataSource = new FakeDataSource(); @@ -929,7 +985,7 @@ class NestedMatTreeApp { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -937,7 +993,7 @@ class NestedMatTreeApp { >>> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} -
+
@@ -947,9 +1003,11 @@ class NestedMatTreeApp { class WhenNodeNestedMatTreeApp { isSpecial = (_: number, node: TestData) => node.isSpecial; - getChildren = (node: TestData) => node.observableChildren; + childrenAccessor = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); + isExpanded = (node: TestData) => { + return !!this.tree && this.tree.isExpanded(node); + }; dataSource = new MatTreeNestedDataSource(); underlyingDataSource = new FakeDataSource(); @@ -965,8 +1023,10 @@ class WhenNodeNestedMatTreeApp { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -977,42 +1037,23 @@ class WhenNodeNestedMatTreeApp { class MatTreeAppWithToggle { toggleRecursively: boolean = true; - getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; - getChildren = (node: TestData) => node.observableChildren; - transformer = (node: TestData, level: number) => { - node.level = level; - return node; - }; - - treeFlattener = new MatTreeFlattener( - this.transformer, - this.getLevel, - this.isExpandable, - this.getChildren, - ); + childrenAccessor = (node: TestData) => node.observableChildren; - treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - - dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); - underlyingDataSource = new FakeDataSource(); + dataSource: FakeDataSource = new FakeDataSource(); @ViewChild(MatTree) tree: MatTree; - - constructor() { - this.underlyingDataSource.connect().subscribe(data => { - this.dataSource.data = data; - }); - } } @Component({ template: ` - + + [isExpandable]="isExpandable(node) | async" + matTreeNodeToggle + [matTreeNodeToggleRecursive]="toggleRecursively"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} -
+
@@ -1022,24 +1063,22 @@ class MatTreeAppWithToggle { class NestedMatTreeAppWithToggle { toggleRecursively: boolean = true; - getChildren = (node: TestData) => node.observableChildren; + childrenAccessor = (node: TestData) => node.observableChildren; + isExpandable = (node: TestData) => + node.observableChildren.pipe(map(children => children.length > 0)); - treeControl = new NestedTreeControl(this.getChildren); - dataSource = new MatTreeNestedDataSource(); - underlyingDataSource = new FakeDataSource(); + isExpanded = (node: TestData) => { + return !!this.tree && this.tree.isExpanded(node); + }; - @ViewChild(MatTree) tree: MatTree; + dataSource = new FakeDataSource(); - constructor() { - this.underlyingDataSource.connect().subscribe(data => { - this.dataSource.data = data; - }); - } + @ViewChild(MatTree) tree: MatTree; } @Component({ template: ` - + @@ -1056,31 +1095,10 @@ class NestedMatTreeAppWithToggle { class WhenNodeMatTreeApp { isSpecial = (_: number, node: TestData) => node.isSpecial; - getLevel = (node: TestData) => node.level; isExpandable = (node: TestData) => node.children.length > 0; - getChildren = (node: TestData) => node.observableChildren; - transformer = (node: TestData, level: number) => { - node.level = level; - return node; - }; + childrenAccessor = (node: TestData) => node.observableChildren; - treeFlattener = new MatTreeFlattener( - this.transformer, - this.getLevel, - this.isExpandable, - this.getChildren, - ); - - treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); - - dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); - underlyingDataSource = new FakeDataSource(); + dataSource = new FakeDataSource(); @ViewChild(MatTree) tree: MatTree; - - constructor() { - this.underlyingDataSource.connect().subscribe(data => { - this.dataSource.data = data; - }); - } } diff --git a/src/material/tree/tree.ts b/src/material/tree/tree.ts index 201c6506ffec..70bf55328846 100644 --- a/src/material/tree/tree.ts +++ b/src/material/tree/tree.ts @@ -19,7 +19,6 @@ import {MatTreeNodeOutlet} from './outlet'; template: ``, host: { 'class': 'mat-tree', - 'role': 'tree', }, 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 42111b519039..72f9b3069698 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -426,6 +426,63 @@ export interface RegisteredMessage { // @public export function removeAriaReferencedId(el: Element, attr: `aria-${string}`, id: string): void; +// @public +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; + onInitialFocus(): void; + onKeydown(event: KeyboardEvent): void; + // (undocumented) + setActiveItem(index: number, options?: { + emitChangeEvent?: boolean; + }): void; + // (undocumented) + setActiveItem(item: T, options?: { + emitChangeEvent?: boolean; + }): void; + // (undocumented) + setActiveItem(itemOrIndex: number | T, options?: { + emitChangeEvent?: boolean; + }): void; + readonly tabOut: Subject; +} + +// @public +export interface TreeKeyManagerItem { + activate(): void; + collapse(): void; + expand(): void; + focus(): void; + getChildren(): TreeKeyManagerItem[] | Observable; + getLabel?(): string; + getParent(): TreeKeyManagerItem | null; + isDisabled?: (() => boolean) | boolean; + isExpanded: (() => boolean) | boolean; +} + +// @public +export interface TreeKeyManagerOptions { + activationFollowsFocus?: boolean; + horizontalOrientation?: 'rtl' | 'ltr'; + // (undocumented) + items: Observable | QueryList | T[]; + skipPredicate?: (item: T) => boolean; + trackBy?: (treeItem: T) => unknown; + typeAheadDebounceInterval?: true | number; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/tools/public_api_guard/cdk/tree.md b/tools/public_api_guard/cdk/tree.md index 8ca2370635de..9b64647f75d1 100644 --- a/tools/public_api_guard/cdk/tree.md +++ b/tools/public_api_guard/cdk/tree.md @@ -13,7 +13,7 @@ import { CollectionViewer } from '@angular/cdk/collections'; import { DataSource } from '@angular/cdk/collections'; import { Directionality } from '@angular/cdk/bidi'; import { ElementRef } from '@angular/core'; -import { FocusableOption } from '@angular/cdk/a11y'; +import { EventEmitter } from '@angular/core'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; import { IterableDiffer } from '@angular/core'; @@ -27,9 +27,11 @@ import { SelectionModel } from '@angular/cdk/collections'; import { Subject } from 'rxjs'; import { TemplateRef } from '@angular/core'; import { TrackByFunction } from '@angular/core'; +import { TreeKeyManager } from '@angular/cdk/a11y'; +import { TreeKeyManagerItem } from '@angular/cdk/a11y'; import { ViewContainerRef } from '@angular/core'; -// @public +// @public @deprecated export abstract class BaseTreeControl implements TreeControl { collapse(dataNode: T): void; collapseAll(): void; @@ -76,30 +78,60 @@ export class CdkNestedTreeNode extends CdkTreeNode implements Af } // @public -export class CdkTree implements AfterContentChecked, CollectionViewer, OnDestroy, OnInit { - constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef); +export class CdkTree implements AfterContentChecked, AfterContentInit, CollectionViewer, OnDestroy, OnInit { + constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _dir: Directionality, _elementRef: ElementRef); + childrenAccessor?: (dataNode: T) => T[] | Observable; + collapse(dataNode: T): void; + collapseAll(): void; + collapseDescendants(dataNode: T): void; get dataSource(): DataSource | Observable | T[]; set dataSource(dataSource: DataSource | Observable | T[]); + expand(dataNode: T): void; + expandAll(): void; + expandDescendants(dataNode: T): void; + expansionKey?: (dataNode: T) => K; + _focusInitialTreeItem(): void; + _getChildrenAccessor(): ((dataNode: T) => T[] | Observable | null | undefined) | undefined; + _getDirectChildren(dataNode: T): Observable; + _getLevel(node: T): number | undefined; + _getLevelAccessor(): ((dataNode: T) => number) | undefined; + _getNodeChildren(node: CdkTreeNode): Observable[]>; _getNodeDef(data: T, i: number): CdkTreeNodeDef; + _getNodeParent(node: CdkTreeNode): CdkTreeNode | null | undefined; + _getPositionInSet(dataNode: T): number; + _getSetSize(dataNode: T): number; insertNode(nodeData: T, index: number, viewContainer?: ViewContainerRef, parentData?: T): void; + isExpanded(dataNode: T): boolean; + _keyManager: TreeKeyManager>; + levelAccessor?: (dataNode: T) => number; // (undocumented) ngAfterContentChecked(): void; // (undocumented) + ngAfterContentInit(): void; + // (undocumented) ngOnDestroy(): void; // (undocumented) ngOnInit(): void; _nodeDefs: QueryList>; // (undocumented) _nodeOutlet: CdkTreeNodeOutlet; + _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; trackBy: TrackByFunction; - treeControl: TreeControl; + // @deprecated + treeControl?: TreeControl; + _unregisterNode(node: CdkTreeNode): void; readonly viewChange: BehaviorSubject<{ start: number; end: number; }>; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-tree", ["cdkTree"], { "dataSource": { "alias": "dataSource"; "required": false; }; "treeControl": { "alias": "treeControl"; "required": false; }; "trackBy": { "alias": "trackBy"; "required": false; }; }, {}, ["_nodeDefs"], never, false, never>; + 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>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -115,8 +147,13 @@ export class CdkTreeModule { } // @public -export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit { +export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { constructor(_elementRef: ElementRef, _tree: CdkTree); + activate(): void; + readonly activation: EventEmitter; + // (undocumented) + _changeDetectorRef: ChangeDetectorRef; + collapse(): void; get data(): T; set data(value: T); // (undocumented) @@ -125,9 +162,25 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit protected readonly _destroyed: Subject; // (undocumented) protected _elementRef: ElementRef; + // (undocumented) + _emitExpansionState(expanded: boolean): void; + expand(): void; + readonly expandedChange: EventEmitter; focus(): void; + _getAriaExpanded(): string | null; + // (undocumented) + getChildren(): CdkTreeNode[] | Observable[]>; + // (undocumented) + getParent(): CdkTreeNode | null; + _getPositionInSet(): number; + _getSetSize(): number; + isDisabled?: boolean; + get isExpandable(): boolean | '' | null; + set isExpandable(isExpandable: boolean | '' | null); + _isExpandable(): boolean; // (undocumented) get isExpanded(): boolean; + set isExpanded(isExpanded: boolean); // (undocumented) get level(): number; static mostRecentTreeNode: CdkTreeNode | null; @@ -139,11 +192,15 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit get role(): 'treeitem' | 'group'; set role(_role: 'treeitem' | 'group'); // (undocumented) - protected _setRoleFromData(): void; + _setActiveItem(): void; + // (undocumented) + _setTabFocusable(): void; + // (undocumented) + _setTabUnfocusable(): void; // (undocumented) protected _tree: CdkTree; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "cdk-tree-node", ["cdkTreeNode"], { "role": { "alias": "role"; "required": false; }; }, {}, never, never, false, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "cdk-tree-node", ["cdkTreeNode"], { "role": { "alias": "role"; "required": false; }; "isExpandable": { "alias": "isExpandable"; "required": false; }; "isExpanded": { "alias": "isExpanded"; "required": false; }; "isDisabled": { "alias": "isDisabled"; "required": false; }; }, { "activation": "activation"; "expandedChange": "expandedChange"; }, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } @@ -217,6 +274,8 @@ export class CdkTreeNodeToggle { // (undocumented) _toggle(event: Event): void; // (undocumented) + _toggleOnEnterOrSpace(event: KeyboardEvent): void; + // (undocumented) protected _tree: CdkTree; // (undocumented) protected _treeNode: CdkTreeNode; @@ -226,7 +285,7 @@ export class CdkTreeNodeToggle { static ɵfac: i0.ɵɵFactoryDeclaration, never>; } -// @public +// @public @deprecated export class FlatTreeControl extends BaseTreeControl { constructor(getLevel: (dataNode: T) => number, isExpandable: (dataNode: T) => boolean, options?: FlatTreeControlOptions | undefined); expandAll(): void; @@ -246,7 +305,7 @@ export interface FlatTreeControlOptions { } // @public -export function getTreeControlFunctionsMissingError(): Error; +export function getMultipleTreeControlsError(): Error; // @public export function getTreeControlMissingError(): Error; @@ -260,7 +319,7 @@ export function getTreeMultipleDefaultNodeDefsError(): Error; // @public export function getTreeNoValidDataSourceError(): Error; -// @public +// @public @deprecated export class NestedTreeControl extends BaseTreeControl { constructor(getChildren: (dataNode: T) => Observable | T[] | undefined | null, options?: NestedTreeControlOptions | undefined); expandAll(): void; @@ -274,11 +333,12 @@ export class NestedTreeControl extends BaseTreeControl { // @public export interface NestedTreeControlOptions { + isExpandable?: (dataNode: T) => boolean; // (undocumented) trackBy?: (dataNode: T) => K; } -// @public +// @public @deprecated export interface TreeControl { collapse(dataNode: T): void; collapseAll(): void; diff --git a/tools/public_api_guard/material/tree.md b/tools/public_api_guard/material/tree.md index b2775c442e9d..69c092f082a5 100644 --- a/tools/public_api_guard/material/tree.md +++ b/tools/public_api_guard/material/tree.md @@ -4,7 +4,6 @@ ```ts -import { _AbstractConstructor } from '@angular/material/core'; import { AfterContentInit } from '@angular/core'; import { BooleanInput } from '@angular/cdk/coercion'; import { CanDisable } from '@angular/material/core'; @@ -16,7 +15,6 @@ import { CdkTreeNodeOutlet } from '@angular/cdk/tree'; import { CdkTreeNodePadding } from '@angular/cdk/tree'; import { CdkTreeNodeToggle } from '@angular/cdk/tree'; import { CollectionViewer } from '@angular/cdk/collections'; -import { _Constructor } from '@angular/material/core'; import { DataSource } from '@angular/cdk/collections'; import { ElementRef } from '@angular/core'; import { FlatTreeControl } from '@angular/cdk/tree'; @@ -35,6 +33,7 @@ import { ViewContainerRef } from '@angular/core'; // @public export class MatNestedTreeNode extends CdkNestedTreeNode implements AfterContentInit, OnDestroy, OnInit { constructor(elementRef: ElementRef, tree: CdkTree, differs: IterableDiffers, tabIndex: string); + // @deprecated get disabled(): boolean; set disabled(value: BooleanInput); // (undocumented) @@ -45,10 +44,8 @@ export class MatNestedTreeNode extends CdkNestedTreeNode impleme ngOnInit(): void; // (undocumented) node: T; - get tabIndex(): number; - set tabIndex(value: number); // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "mat-nested-tree-node", ["matNestedTreeNode"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "node": { "alias": "matNestedTreeNode"; "required": false; }; }, {}, never, never, false, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "mat-nested-tree-node", ["matNestedTreeNode"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "isExpandable": { "alias": "isExpandable"; "required": false; }; "isExpanded": { "alias": "isExpanded"; "required": false; }; "isDisabled": { "alias": "isDisabled"; "required": false; }; "node": { "alias": "matNestedTreeNode"; "required": false; }; }, { "activation": "activation"; "expandedChange": "expandedChange"; }, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, [null, null, null, { attribute: "tabindex"; }]>; } @@ -63,7 +60,7 @@ export class MatTree extends CdkTree { static ɵfac: i0.ɵɵFactoryDeclaration, never>; } -// @public +// @public @deprecated export class MatTreeFlatDataSource extends DataSource { constructor(_treeControl: FlatTreeControl, _treeFlattener: MatTreeFlattener, initialData?: T[]); // (undocumented) @@ -75,7 +72,7 @@ export class MatTreeFlatDataSource extends DataSource { disconnect(): void; } -// @public +// @public @deprecated export class MatTreeFlattener { constructor(transformFunction: (node: T, level: number) => F, getLevel: (node: F) => number, isExpandable: (node: F) => boolean, getChildren: (node: T) => Observable | T[] | undefined | null); expandFlattenedNodes(nodes: F[], treeControl: TreeControl): F[]; @@ -115,14 +112,21 @@ export class MatTreeNestedDataSource extends DataSource { } // @public -export class MatTreeNode extends _MatTreeNodeBase implements CanDisable, HasTabIndex, OnInit, OnDestroy { +export class MatTreeNode extends CdkTreeNode implements CanDisable, HasTabIndex, OnInit, OnDestroy { constructor(elementRef: ElementRef, tree: CdkTree, tabIndex: string); + // @deprecated + defaultTabIndex: number; + // @deprecated + get disabled(): boolean; + set disabled(value: BooleanInput); // (undocumented) ngOnDestroy(): void; // (undocumented) ngOnInit(): void; + // @deprecated + tabIndex: number; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "mat-tree-node", ["matTreeNode"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; }, {}, never, never, false, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "mat-tree-node", ["matTreeNode"], { "role": { "alias": "role"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "isExpandable": { "alias": "isExpandable"; "required": false; }; "isExpanded": { "alias": "isExpanded"; "required": false; }; "isDisabled": { "alias": "isDisabled"; "required": false; }; }, { "activation": "activation"; "expandedChange": "expandedChange"; }, never, never, false, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, [null, null, { attribute: "tabindex"; }]>; }