From b608e1f9d8bf57273bf08a719e6c194b0f8b593d Mon Sep 17 00:00:00 2001 From: Cassandra Choi Date: Thu, 25 Mar 2021 10:17:07 -0400 Subject: [PATCH] feat(material/tree): revamp tree data model, accessibility and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterate on Tree component to improve its data model, accessibility and documentation. Introduce levelAcessor and childrenAccessor as an alternative to TreeController. 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. Implement keyboard navigation in Tree component to align with [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. Accessibility improvements * Implement keyboard navigation for CdkTree and MatTree * Implement keyboard usability for CdkTreeNodeToggle. * Improve ARIA semantics for CdkTree, CdkTreeNode, Tree and TreeNode components * Make accessibility fixes to 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 BREAKING CHANGE: Tree component uses a levelAcessor or childrenAccessor as alternative to a controller. - CdkTree requires either a levelAccessor or childrenAccessor as Input. Cannot specify both. - CdkTree optionally takes a expansionKey as Input. expansionKey is a function that returns key of a given node. CdkTree uses key to determine expanded state. - Add isExpanded, toggle, expand and collapse methods to CdkTree - Add toggleDescendants, expandDescendants, and collapseDescendants methods to CdkTree - Add expandAll and collapseAll methods to CdkTree BREAKING CHANGE: By default, tree nodes cannot be expanded and collapsed. Use isExpanded function to specify nodes that can be expanded and collapsed. - Add isExpandable and isExpanded Inputs to CdkTreeNode to specify if argument tree node can be expanded or collapsed. - Add isExpandable function to NestedTreeControlOptions to determine if argument tree node can be expanded or collapsed. BREAKING CHANGE: Add methods and Outputs to CdkTreeNode to manage the activated, expanded and collapsed state. - Add activate, expand and collapse methods to CdkTreeNode - Add activation Output to CdkTreeNode - Add expandedChange Output to CdkTreeNode BREAKING CHANGE: Use isDisabled to set the disabled state of tree nodes. - Add isDisabled Input to CdkTreeNode. By default, nodes are not disabled. BREAKING CHANGE: MatTree and CdkTree components responds to keyboard navigation. - CdkTree and MatTree respond to arrow keys, page up, page down, etc.; Keyboard commands align with [WAI ARIA Tree View Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). - Add TreeKeyManager class - CdkTree consumes TreeKeyManager - Can no longer set the tabindex on MatTreeNode. MatTreeNode ignores the passed value of tabindex. MatTreeNode always has a tabindex attribute of “-1”. BREAKING CHANGE: CdkTreeNodeToggle directive and component respond to keyboard navigation. - CdkTreeNodeToggle toggles expanded state on Enter or Space keypress DEPRECATED: Tree controllers deprecated. Use one of levelAccessor or childrenAccessor instead. To be removed in a future version. * BaseTreeControl deprecated * TreeControl deprecated * FlatTreeControl deprecated * NestedTreeControl deprecated * treeControl Input on CdkTree deprecated in favor of using one of levelAccessor childrenAccessor Inputs. DEPRECATED: Setting tabindex of tree nodes deprecated. * `@Input tabIndex` on `MatTreeNode` deprecated. MatTreeNode ignores Input tabIndex and manages its own focus behavior. * `@Input defaultTabIndex` on `MatTreeNode` deprecated. MatTreeNode ignores Input defaultTabIndex and manages its own focus behavior. * `@Input tabIndex` on `MatNestedTreeNode` deprecated. MatTreeNode ignores Input defaultTabIndex and manages its own focus behavior. DEPRECATED: disabled Input renamed to isDiabled. disabled to be deleted in a future version. * disabled Input deprecated and alias to isDisabled * disabled Input deprecated and alias to isDiabled --- src/cdk/a11y/a11y.md | 60 +- .../a11y/key-manager/tree-key-manager.spec.ts | 1165 +++++++++++ src/cdk/a11y/key-manager/tree-key-manager.ts | 533 +++++ src/cdk/a11y/public-api.ts | 1 + src/cdk/tree/BUILD.bazel | 2 + src/cdk/tree/control/base-tree-control.ts | 7 +- src/cdk/tree/control/flat-tree-control.ts | 7 +- src/cdk/tree/control/nested-tree-control.ts | 13 +- src/cdk/tree/control/tree-control.ts | 3 + src/cdk/tree/nested-node.ts | 24 +- src/cdk/tree/padding.ts | 5 +- src/cdk/tree/toggle.ts | 30 +- src/cdk/tree/tree-errors.ts | 11 +- src/cdk/tree/tree-redesign.spec.ts | 1853 +++++++++++++++++ src/cdk/tree/tree.md | 109 +- src/cdk/tree/tree.spec.ts | 288 +-- src/cdk/tree/tree.ts | 987 ++++++++- 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 +- ...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 | 4 + 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 | 12 +- .../tree-loadmore/tree-loadmore-example.ts | 22 +- .../tree-nested-overview-example.html | 37 +- src/dev-app/tree/tree-demo.html | 16 + src/dev-app/tree/tree-demo.ts | 13 +- src/material/tree/node.ts | 86 +- src/material/tree/tree.md | 187 +- src/material/tree/tree.spec.ts | 205 +- src/material/tree/tree.ts | 1 - tools/public_api_guard/cdk/a11y.md | 46 + tools/public_api_guard/cdk/tree.md | 92 +- tools/public_api_guard/material/tree.md | 26 +- 50 files changed, 6160 insertions(+), 418 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-redesign.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-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 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..f32f4b1423a3 --- /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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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.onClick(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..f15ef34fda52 --- /dev/null +++ b/src/cdk/a11y/key-manager/tree-key-manager.ts @@ -0,0 +1,533 @@ +/** + * @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(); + } + + /** + * Handles a mouse click on a particular tree item. + * @param treeItem The item that was clicked by the user. + */ + onClick(treeItem: T) { + this._setActiveItem(treeItem); + } + + /** 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(); + } + + private _setActiveItem(index: number, options?: {emitChangeEvent?: boolean}): void; + private _setActiveItem(item: T, options?: {emitChangeEvent?: boolean}): void; + private _setActiveItem(itemOrIndex: number | T, options?: {emitChangeEvent?: boolean}): void; + private _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..d6e0ff73a8be 100644 --- a/src/cdk/tree/BUILD.bazel +++ b/src/cdk/tree/BUILD.bazel @@ -35,6 +35,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..af6af76378f0 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 + * @breaking-change 0.0.0-PLACEHOLDER + */ 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..9e361ce4bdc3 100644 --- a/src/cdk/tree/control/flat-tree-control.ts +++ b/src/cdk/tree/control/flat-tree-control.ts @@ -13,7 +13,12 @@ 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 + * @breaking-change 14.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..a94eeedde55a 100644 --- a/src/cdk/tree/control/nested-tree-control.ts +++ b/src/cdk/tree/control/nested-tree-control.ts @@ -11,10 +11,17 @@ 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 + * @breaking-change 14.0.0 + */ export class NestedTreeControl extends BaseTreeControl { /** Construct with nested tree function getChildren. */ constructor( @@ -26,6 +33,10 @@ export class NestedTreeControl extends BaseTreeControl { if (this.options) { this.trackBy = this.options.trackBy; } + + if (this.options?.isExpandable) { + this.isExpandable = this.options.isExpandable; + } } /** diff --git a/src/cdk/tree/control/tree-control.ts b/src/cdk/tree/control/tree-control.ts index f32e0f4c5852..99e82ac2f712 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 + * @breaking-change 14.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..a79be60b1b5e 100644 --- a/src/cdk/tree/nested-node.ts +++ b/src/cdk/tree/nested-node.ts @@ -7,6 +7,7 @@ */ import { AfterContentInit, + ChangeDetectorRef, ContentChildren, Directive, ElementRef, @@ -16,12 +17,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. @@ -62,24 +61,18 @@ export class CdkNestedTreeNode constructor( elementRef: ElementRef, tree: CdkTree, + changeDetectorRef: ChangeDetectorRef, protected _differs: IterableDiffers, ) { - super(elementRef, tree); + super(elementRef, tree, changeDetectorRef); } 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 +81,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(); } @@ -104,7 +98,7 @@ export class CdkNestedTreeNode } if (outlet && this._children) { const viewContainer = outlet.viewContainer; - this._tree.renderNodeChanges(this._children, this._dataDiffer, viewContainer, this._data); + this._tree._renderNodeChanges(this._children, this._dataDiffer, viewContainer, this._data); } else { // Reset the data differ if there's no children nodes displayed this._dataDiffer.diff([]); 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-redesign.spec.ts b/src/cdk/tree/tree-redesign.spec.ts new file mode 100644 index 000000000000..a43a1dc4e999 --- /dev/null +++ b/src/cdk/tree/tree-redesign.spec.ts @@ -0,0 +1,1853 @@ +/** + * @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 {combineLatest, BehaviorSubject, Observable} from 'rxjs'; +import {map} from 'rxjs/operators'; +import {CdkTreeModule, CdkTreeNodePadding} from './index'; +import {CdkTree, CdkTreeNode} from './tree'; +import {createKeyboardEvent} from '@angular/cdk/testing/testbed/fake-events'; +import {ENTER} from '@angular/cdk/keycodes'; + +/** + * 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; + 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(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).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(); + + 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(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(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).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)!.dispatchEvent( + createKeyboardEvent('keydown', ENTER), + ); + fixture.detectChanges(); + + 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(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).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 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([null, null, null]); + + 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([null, 'true', 'false', null]); + }); + + it('should expand/collapse the node multiple times', () => { + 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(); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).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(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).length) + .withContext(`Expect node collapsed`) + .toBe(0); + + (getNodes(treeElement)[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).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(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).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(getExpandedNodes(component.dataSource?.getRecursiveData(), component.tree).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() { + 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]) => { + 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): TestData { + const nextIndex = ++this.dataIndex; + + let copiedData = this.data.slice(); + 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; + } +} + +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, + 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; + + dataSource: FakeDataSource | null = new FakeDataSource(); + indent: number | string = 28; + + @ViewChild(CdkTree) tree: CdkTree; + @ViewChildren(CdkTreeNodePadding) paddingNodes: QueryList>; + + expandAll() { + this.tree.expandAll(); + } +} + +@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; + + dataSource: FakeDataSource | null = new FakeDataSource(); + + @ViewChild(CdkTree) tree: CdkTree; +} + +@Component({ + template: ` + + + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} + + + + `, +}) +class StaticNestedCdkTreeApp { + getChildren = (node: TestData) => node.children; + + dataSource: FakeDataSource; + + @ViewChild(CdkTree) tree: CdkTree; + + constructor() { + const dataSource = new FakeDataSource(); + 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; + + dataSource: FakeDataSource | null = new FakeDataSource(); + + @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; + + dataSource: FakeDataSource | null = new FakeDataSource(); + + @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) => + node.observableChildren.pipe(map(children => children.length > 0)); + + dataSource: FakeDataSource | null = new FakeDataSource(); + + @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; + + dataSource: FakeDataSource | null = new FakeDataSource(); + + @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; + + dataSource: FakeDataSource = new FakeDataSource(); + + 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; + + dataSource: FakeDataSource = new FakeDataSource(); + + 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; + + dataSource: FakeDataSource = new FakeDataSource(); + + 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; + + dataSource: FakeDataSource = new FakeDataSource(); + + 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; + + dataSource: FakeDataSource = new FakeDataSource(); + + 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; + + dataSource: FakeDataSource = new FakeDataSource(); + + @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; + + dataSource: FakeDataSource = new FakeDataSource(); + + 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..4d1061d50b91 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,25 @@ 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. + +#### 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..23b3bd9d38db 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, @@ -22,13 +22,11 @@ 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'; describe('CdkTree', () => { /** Represents an indent for expectNestedTreeToMatch */ @@ -118,46 +116,6 @@ describe('CdkTree', () => { expect(nodes[0].classList).toContain('customNodeClass'); }); - it('with the right accessibility roles', () => { - expect(treeElement.getAttribute('role')).toBe('tree'); - - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('role') === 'treeitem'; - }), - ).toBe(true); - }); - - it('with the right aria-levels', () => { - // add a child to the first node - let data = dataSource.data; - dataSource.addChild(data[0], true); - - const ariaLevels = getNodes(treeElement).map(n => n.getAttribute('aria-level')); - expect(ariaLevels).toEqual(['2', '3', '2', '2']); - }); - - it('with the right aria-expanded attrs', () => { - // add a child to the first node - let data = dataSource.data; - dataSource.addChild(data[2]); - fixture.detectChanges(); - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'false'; - }), - ).toBe(true); - - component.treeControl.expandAll(); - fixture.detectChanges(); - - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'true'; - }), - ).toBe(true); - }); - it('with the right data', () => { expect(dataSource.data.length).toBe(3); @@ -798,11 +756,9 @@ describe('CdkTree', () => { }); it('with the right aria-expanded attrs', () => { - expect( - getNodes(treeElement).every(node => { - return node.getAttribute('aria-expanded') === 'false'; - }), - ).toBe(true); + expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, null, null]); component.toggleRecursively = false; let data = dataSource.data; @@ -813,8 +769,11 @@ describe('CdkTree', () => { (getNodes(treeElement)[1] as HTMLElement).click(); fixture.detectChanges(); - const ariaExpanded = getNodes(treeElement).map(n => n.getAttribute('aria-expanded')); - expect(ariaExpanded).toEqual(['false', 'true', 'false', 'false']); + // NB: only four elements are present here; children are not present + // in DOM unless the parent node is expanded. + expect(getNodeAttributes(getNodes(treeElement), 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'true', 'false', null]); }); it('should expand/collapse the node multiple times', () => { @@ -1127,32 +1086,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 +1117,135 @@ describe('CdkTree', () => { expect(depthElements.length).toBe(5); }); }); + + describe('accessibility', () => { + let fixture: ComponentFixture; + let component: StaticNestedCdkTreeApp; + let nodes: HTMLElement[]; + + beforeEach(() => { + configureCdkTreeTestingModule([StaticNestedCdkTreeApp]); + fixture = TestBed.createComponent(StaticNestedCdkTreeApp); + fixture.detectChanges(); + + component = fixture.componentInstance; + dataSource = component.dataSource as FakeDataSource; + tree = component.tree; + treeElement = fixture.nativeElement.querySelector('cdk-tree'); + nodes = getNodes(treeElement); + }); + + describe('focus management', () => { + it('the tree is tabbable when no element is active', () => { + expect(treeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('the tree is not tabbable when an element is active', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(treeElement.getAttribute('tabindex')).toBe(null); + }); + + it('sets tabindex on the latest activated item, with all others "-1"', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']); + + // activate the first child by clicking on it + nodes[0].click(); + + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['0', '-1', '-1', '-1', '-1', '-1']); + }); + + it('maintains tabindex when component is blurred', () => { + // activate the second child by clicking on it + nodes[1].click(); + + expect(document.activeElement).toBe(nodes[1]); + // blur the currently active element (which we just checked is the above node) + nodes[1].blur(); + + expect(treeElement.getAttribute('tabindex')).toBe(null); + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '0', '-1', '-1', '-1', '-1']); + }); + + it('ignores clicks on disabled items', () => { + dataSource.data[0].isDisabled = true; + fixture.detectChanges(); + + // attempt to click on the first child + nodes[0].click(); + + expect(treeElement.getAttribute('tabindex')).toBe('0'); + expect(getNodeAttributes(nodes, 'tabindex')).toEqual(['-1', '-1', '-1', '-1', '-1', '-1']); + }); + + describe('when no item is currently active', () => { + it('redirects focus to the first item when the tree is focused', () => { + treeElement.focus(); + + expect(document.activeElement).toBe(nodes[0]); + }); + + it('redirects focus to the first non-disabled item when the tree is focused', () => { + dataSource.data[0].isDisabled = true; + fixture.detectChanges(); + + treeElement.focus(); + + expect(document.activeElement).toBe(nodes[1]); + }); + }); + }); + + describe('tree role & attributes', () => { + it('sets the tree role on the tree element', () => { + expect(treeElement.getAttribute('role')).toBe('tree'); + }); + + it('sets the treeitem role on all nodes', () => { + expect(getNodeAttributes(nodes, 'role')).toEqual([ + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + 'treeitem', + ]); + }); + + it('sets aria attributes for tree nodes', () => { + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'false', 'false', null, null, null]); + expect(getNodeAttributes(nodes, 'aria-level')) + .withContext('aria-level attributes') + .toEqual(['1', '1', '2', '3', '3', '1']); + expect(getNodeAttributes(nodes, 'aria-posinset')) + .withContext('aria-posinset attributes') + .toEqual(['1', '2', '1', '1', '2', '3']); + expect(getNodeAttributes(nodes, 'aria-setsize')) + .withContext('aria-setsize attributes') + .toEqual(['3', '3', '1', '2', '2', '3']); + }); + + it('changes aria-expanded status when expanded or collapsed', () => { + tree.expand(dataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'true', 'false', null, null, null]); + + tree.collapse(dataSource.data[1]); + fixture.detectChanges(); + expect(getNodeAttributes(nodes, 'aria-expanded')) + .withContext('aria-expanded attributes') + .toEqual([null, 'false', 'false', null, null, null]); + }); + }); + }); }); export class TestData { @@ -1192,6 +1254,7 @@ export class TestData { pizzaBase: string; level: number; children: TestData[]; + isDisabled?: boolean; readonly observableChildren: BehaviorSubject; constructor(pizzaTopping: string, pizzaCheese: string, pizzaBase: string, level: number = 1) { @@ -1357,6 +1420,10 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { } } +function getNodeAttributes(nodes: HTMLElement[], attribute: string) { + return nodes.map(node => node.getAttribute(attribute)); +} + @Component({ template: ` @@ -1418,7 +1485,10 @@ class NestedCdkTreeApp { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1428,7 +1498,9 @@ class NestedCdkTreeApp { class StaticNestedCdkTreeApp { getChildren = (node: TestData) => node.children; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); + treeControl: TreeControl = new NestedTreeControl(this.getChildren, { + isExpandable: node => node.children.length > 0, + }); dataSource: FakeDataSource; @@ -1498,7 +1570,8 @@ class CdkTreeAppWithToggle { template: ` + cdkTreeNodeToggle + [cdkTreeNodeToggleRecursive]="toggleRecursively"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
@@ -1512,7 +1585,9 @@ class NestedCdkTreeAppWithToggle { getChildren = (node: TestData) => node.observableChildren; - treeControl: TreeControl = new NestedTreeControl(this.getChildren); + treeControl: TreeControl = new NestedTreeControl(this.getChildren, { + isExpandable: node => node.children.length > 0, + }); dataSource: FakeDataSource | null = new FakeDataSource(this.treeControl); @ViewChild(CdkTree) tree: CdkTree; @@ -1646,59 +1721,6 @@ class ObservableDataSourceNestedCdkTreeApp { @ViewChild(CdkTree) tree: CdkTree; } -@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: ` diff --git a/src/cdk/tree/tree.ts b/src/cdk/tree/tree.ts index 96ad12200f4e..296e124245d2 100644 --- a/src/cdk/tree/tree.ts +++ b/src/cdk/tree/tree.ts @@ -5,22 +5,33 @@ * 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, @@ -29,24 +40,47 @@ import { } 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 +93,8 @@ import {coerceNumberProperty} from '@angular/cdk/coercion'; host: { 'class': 'cdk-tree', 'role': 'tree', + '(keydown)': '_sendKeydownToKeyManager($event)', + '(focus)': '_focusInitialTreeItem()', }, encapsulation: ViewEncapsulation.None, @@ -68,7 +104,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 +120,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 +153,29 @@ 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` + * @breaking-change 14.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 +185,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 +212,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 +280,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 +318,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,17 +385,77 @@ 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( + _renderNodeChanges( data: readonly T[], dataDiffer: IterableDiffer = this._dataDiffer, viewContainer: ViewContainerRef = this._nodeOutlet.viewContainer, @@ -243,7 +476,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 +518,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 +552,525 @@ 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; + this._getAllNodes() + .pipe(takeUntil(this._onDestroy)) + .subscribe(children => { + expansionModel.select(...children.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; + this._getAllNodes() + .pipe(takeUntil(this._onDestroy)) + .subscribe(children => { + expansionModel.deselect(...children.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 nodes in the tree, using the cached nodes. */ + private _getAllNodes(): Observable { + return this._flattenedNodes; + } + + /** 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,13 +1081,20 @@ 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()', }, }) -export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit { +export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { /** * The role of the tree node. - * @deprecated The correct role is 'treeitem', 'group' should not be used. This input will be + * + * @deprecated This will be ignored; the tree will automatically determine the appropriate role for the tree node. This input will be * removed in a future version. * @breaking-change 12.0.0 Remove this input */ @@ -332,10 +1103,49 @@ export class CdkTreeNode implements FocusableOption, OnDestroy, OnInit } set role(_role: 'treeitem' | 'group') { - // TODO: move to host after View Engine deprecation - this._elementRef.nativeElement.setAttribute('role', _role); + // ignore any role setting, we handle this internally. + } + + /** + * 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 +1158,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 +1168,68 @@ 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 (typeof this._tree.treeControl?.isExpandable === 'function') { + return this._tree.treeControl.isExpandable(this._data); + } + 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); + } + + /** + * 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) { + constructor( + protected _elementRef: ElementRef, + protected _tree: CdkTree, + public _changeDetectorRef: ChangeDetectorRef, + ) { 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 +1244,60 @@ 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.activation.next(this._data); + } + + /** Collapses this data node. Implemented for TreeKeyManagerItem. */ + collapse(): void { + if (!this._isExpandable()) { + return; + } + this._tree.collapse(this._data); + } + + /** Expands this data node. Implemented for TreeKeyManagerItem. */ + expand(): void { + if (!this._isExpandable()) { + return; + } + 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.role = 'treeitem'; + this._tree._keyManager.onClick(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}} +
+ +
+ +
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..ab0a0879a417 --- /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} 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 { + 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}} - + + + Load more... 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..e552e659ac63 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,6 +11,7 @@ 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'; @@ -100,6 +101,7 @@ 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], @@ -158,8 +160,24 @@ export class TreeLoadmoreExample { isLoadMore = (_: number, _nodeData: LoadmoreFlatNode) => _nodeData.item === LOAD_MORE; /** Load more nodes from data source */ - loadMore(item: string) { - this._database.loadMore(item); + loadMoreOnClick(event: MouseEvent, node: LoadmoreFlatNode) { + if (node.loadMoreParentItem) { + // TODO: set focus to an appropriate location + this._database.loadMore(node.loadMoreParentItem); + } + } + + loadMoreOnEnterOrSpace(event: KeyboardEvent, node: LoadmoreFlatNode) { + const item = node.loadMoreParentItem; + + if (item && (event.keyCode === ENTER || event.keyCode === SPACE)) { + // TODO: set focus to an appropriate location + this._database.loadMore(item); + + // Prevent default behavior so that the tree node doesn't handle the keypress instead of this + // button. + event.preventDefault(); + } } loadChildren(node: LoadmoreFlatNode) { 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..0be398dd2701 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,28 @@ - - {{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..2e2b98e87f39 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,10 @@ CDK Nested tree + + CDK Nested tree (levelAccessor) + + Dynamic flat tree @@ -23,4 +35,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..c1b875b24390 100644 --- a/src/dev-app/tree/tree-demo.ts +++ b/src/dev-app/tree/tree-demo.ts @@ -8,7 +8,14 @@ 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, + CdkTreeFlatChildrenAccessorExample, + CdkTreeComplexExample, +} from '@angular/components-examples/cdk/tree'; import { TreeDynamicExample, TreeFlatOverviewExample, @@ -35,6 +42,10 @@ import {MatTreeModule} from '@angular/material/tree'; CdkTreeModule, CdkTreeFlatExample, CdkTreeNestedExample, + CdkTreeFlatChildrenAccessorExample, + CdkTreeFlatLevelAccessorExample, + CdkTreeNestedLevelAccessorExample, + CdkTreeComplexExample, CommonModule, FormsModule, TreeDynamicExample, diff --git a/src/material/tree/node.ts b/src/material/tree/node.ts index 437596f6add5..182748933b8f 100644 --- a/src/material/tree/node.ts +++ b/src/material/tree/node.ts @@ -16,6 +16,7 @@ import { import { AfterContentInit, Attribute, + ChangeDetectorRef, Directive, ElementRef, Input, @@ -23,33 +24,70 @@ 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 This will be ignored; the tree will automatically determine the appropriate tabindex for the tree node. This input will be + * removed in a future version. + * @breaking-change 12.0.0 Remove this input + */ + tabIndex: number; + + /** + * The tabindex of the tree node. + * + * @deprecated This will be ignored; the tree will automatically determine the appropriate tabindex for the tree node. This input will be + * removed in a future version. + * @breaking-change 12.0.0 Remove this input + */ + defaultTabIndex: number; + + /** + * Whether the component is disabled. + * + * @deprecated This is an alias for `isDisabled`. + * @breaking-change 12.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, + changeDetectorRef: ChangeDetectorRef, @Attribute('tabindex') tabIndex: string, ) { - super(elementRef, tree); + super(elementRef, tree, changeDetectorRef); this.tabIndex = Number(tabIndex) || 0; } @@ -83,7 +121,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,34 +138,39 @@ 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 12.0.0 Remove this input + */ get disabled(): boolean { - return this._disabled; + return this.isDisabled ?? false; } set disabled(value: BooleanInput) { - this._disabled = coerceBooleanProperty(value); + this.isDisabled = coerceBooleanProperty(value); } - private _disabled = false; - /** Tabindex for the node. */ - @Input() + /** + * The tabindex of the tree node. + * + * @deprecated This will be ignored; the tree will automatically determine the appropriate tabindex for the tree node. This input will be + * removed in a future version. + * @breaking-change 12.0.0 Remove this 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; + return Number(this._elementRef.nativeElement.getAttribute('tabindex') ?? '-1'); } - private _tabIndex: number; + set tabIndex(value: number) {} constructor( elementRef: ElementRef, tree: CdkTree, differs: IterableDiffers, + changeDetectorRef: ChangeDetectorRef, @Attribute('tabindex') tabIndex: string, ) { - super(elementRef, tree, differs); + super(elementRef, tree, changeDetectorRef, differs); this.tabIndex = Number(tabIndex) || 0; } diff --git a/src/material/tree/tree.md b/src/material/tree/tree.md index 21c030f9a27c..0f9bab5e8eb7 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 -### Features +#### Writing your tree template -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. +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. -### TreeControl +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. + +```html + + {{node.key}}: {{node.value}} + +``` -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. +##### 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. + +Spacing can be added either by applying the `matNodePadding` directive or by applying custom styles +based on the `aria-level` attribute. + + +##### 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,93 @@ 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. + +#### 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..29786cc29266 100644 --- a/src/material/tree/tree.spec.ts +++ b/src/material/tree/tree.spec.ts @@ -9,6 +9,7 @@ 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, @@ -62,38 +63,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); @@ -470,11 +439,9 @@ describe('MatTree', () => { }); 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 +452,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', () => { @@ -577,6 +547,142 @@ 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(); + + 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([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 +693,7 @@ export class TestData { children: TestData[]; observableChildren: BehaviorSubject; isSpecial: boolean; + isDisabled?: boolean; constructor( pizzaTopping: string, @@ -668,7 +775,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,6 +870,10 @@ function expectNestedTreeToMatch(treeElement: Element, ...expectedTree: any[]) { } } +function getNodeAttributes(nodes: HTMLElement[], attribute: string) { + return nodes.map(node => node.getAttribute(attribute)); +} + @Component({ template: ` @@ -903,7 +1014,9 @@ class MatNestedTreeWithNullOrUndefinedChild { @Component({ template: ` - + {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -912,6 +1025,8 @@ class MatNestedTreeWithNullOrUndefinedChild { }) class NestedMatTreeApp { getChildren = (node: TestData) => node.observableChildren; + isExpandable = (node: TestData) => + node.observableChildren.pipe(map(children => children.length > 0)); treeControl = new NestedTreeControl(this.getChildren); @@ -967,6 +1082,8 @@ class WhenNodeNestedMatTreeApp { template: ` {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}} @@ -1010,7 +1127,9 @@ class MatTreeAppWithToggle { template: ` + [isExpandable]="isExpandable(node) | async" + matTreeNodeToggle + [matTreeNodeToggleRecursive]="toggleRecursively"> {{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
@@ -1023,6 +1142,8 @@ 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(); 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..3e12818295b6 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -426,6 +426,52 @@ 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; + onClick(treeItem: T): void; + onInitialFocus(): void; + onKeydown(event: KeyboardEvent): 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..69e97c45d380 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; @@ -56,7 +58,7 @@ export const CDK_TREE_NODE_OUTLET_NODE: InjectionToken<{}>; // @public export class CdkNestedTreeNode extends CdkTreeNode implements AfterContentInit, OnDestroy, OnInit { - constructor(elementRef: ElementRef, tree: CdkTree, _differs: IterableDiffers); + constructor(elementRef: ElementRef, tree: CdkTree, changeDetectorRef: ChangeDetectorRef, _differs: IterableDiffers); protected _children: T[]; protected _clear(): void; // (undocumented) @@ -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; - renderNodeChanges(data: readonly T[], dataDiffer?: IterableDiffer, viewContainer?: ViewContainerRef, parentData?: T): void; + _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 { - constructor(_elementRef: ElementRef, _tree: CdkTree); +export class CdkTreeNode implements OnDestroy, OnInit, TreeKeyManagerItem { + constructor(_elementRef: ElementRef, _tree: CdkTree, _changeDetectorRef: ChangeDetectorRef); + 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..df212e6c2618 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'; @@ -15,8 +14,8 @@ import { CdkTreeNodeDef } from '@angular/cdk/tree'; import { CdkTreeNodeOutlet } from '@angular/cdk/tree'; import { CdkTreeNodePadding } from '@angular/cdk/tree'; import { CdkTreeNodeToggle } from '@angular/cdk/tree'; +import { ChangeDetectorRef } from '@angular/core'; 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'; @@ -34,7 +33,8 @@ import { ViewContainerRef } from '@angular/core'; // @public export class MatNestedTreeNode extends CdkNestedTreeNode implements AfterContentInit, OnDestroy, OnInit { - constructor(elementRef: ElementRef, tree: CdkTree, differs: IterableDiffers, tabIndex: string); + constructor(elementRef: ElementRef, tree: CdkTree, differs: IterableDiffers, changeDetectorRef: ChangeDetectorRef, tabIndex: string); + // @deprecated get disabled(): boolean; set disabled(value: BooleanInput); // (undocumented) @@ -45,12 +45,13 @@ export class MatNestedTreeNode extends CdkNestedTreeNode impleme ngOnInit(): void; // (undocumented) node: T; + // @deprecated 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"; }]>; + static ɵfac: i0.ɵɵFactoryDeclaration, [null, null, null, null, { attribute: "tabindex"; }]>; } // @public @@ -115,16 +116,23 @@ export class MatTreeNestedDataSource extends DataSource { } // @public -export class MatTreeNode extends _MatTreeNodeBase implements CanDisable, HasTabIndex, OnInit, OnDestroy { - constructor(elementRef: ElementRef, tree: CdkTree, tabIndex: string); +export class MatTreeNode extends CdkTreeNode implements CanDisable, HasTabIndex, OnInit, OnDestroy { + constructor(elementRef: ElementRef, tree: CdkTree, changeDetectorRef: ChangeDetectorRef, 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"; }]>; + static ɵfac: i0.ɵɵFactoryDeclaration, [null, null, null, { attribute: "tabindex"; }]>; } // @public