diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index 29e68600535a..fa9ffec1b1e4 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -15,5 +15,6 @@ ts_project( "//src/cdk-experimental/ui-patterns/listbox", "//src/cdk-experimental/ui-patterns/radio", "//src/cdk-experimental/ui-patterns/tabs", + "//src/cdk-experimental/ui-patterns/tree", ], ) diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.ts index 2d378148fe47..2d3a8ebe711c 100644 --- a/src/cdk-experimental/ui-patterns/accordion/accordion.ts +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.ts @@ -27,7 +27,7 @@ import {SignalLike} from '../behaviors/signal-like/signal-like'; export type AccordionGroupInputs = Omit< ListNavigationInputs & ListFocusInputs & - ListExpansionInputs, + Omit, 'focusMode' >; @@ -43,7 +43,7 @@ export class AccordionGroupPattern { focusManager: ListFocus; /** Controls expansion for the group. */ - expansionManager: ListExpansion; + expansionManager: ListExpansion; constructor(readonly inputs: AccordionGroupInputs) { this.wrap = inputs.wrap; @@ -66,8 +66,6 @@ export class AccordionGroupPattern { }); this.expansionManager = new ListExpansion({ ...inputs, - focusMode, - focusManager: this.focusManager, }); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts index 0610f5a7c5d3..f1029267fcbe 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts @@ -6,27 +6,21 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Signal, WritableSignal, signal} from '@angular/core'; +import {WritableSignal, signal} from '@angular/core'; import {ListExpansion, ListExpansionInputs, ExpansionItem} from './expansion'; -import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; -import {getListFocus as getListFocusManager} from '../list-focus/list-focus.spec'; - -type TestItem = ListFocusItem & - ExpansionItem & { - id: WritableSignal; - disabled: WritableSignal; - element: WritableSignal; - expandable: WritableSignal; - expansionId: WritableSignal; - }; - -type TestInputs = Partial, 'items' | 'focusManager'>> & - Partial< - Pick, 'focusMode' | 'disabled' | 'activeIndex' | 'skipDisabled'> - > & { - numItems?: number; - initialExpandedIds?: string[]; - }; + +type TestItem = ExpansionItem & { + id: WritableSignal; + disabled: WritableSignal; + expandable: WritableSignal; + expansionId: WritableSignal; +}; + +type TestInputs = Partial> & { + numItems?: number; + initialExpandedIds?: string[]; + expansionDisabled?: boolean; +}; function createItems(length: number): WritableSignal { return signal( @@ -34,7 +28,6 @@ function createItems(length: number): WritableSignal { const itemId = `item-${i}`; return { id: signal(itemId), - element: signal(document.createElement('div') as HTMLElement), disabled: signal(false), expandable: signal(true), expansionId: signal(itemId), @@ -44,39 +37,24 @@ function createItems(length: number): WritableSignal { } function getExpansion(inputs: TestInputs = {}): { - expansion: ListExpansion; + expansion: ListExpansion; items: TestItem[]; - focusManager: ListFocus; } { const numItems = inputs.numItems ?? 3; const items = createItems(numItems); - const listFocusManagerInputs: Partial> & {items: Signal} = { - items: items, - activeIndex: inputs.activeIndex ?? signal(0), - disabled: inputs.disabled ?? signal(false), - skipDisabled: inputs.skipDisabled ?? signal(true), - focusMode: inputs.focusMode ?? signal('roving'), - }; - - const focusManager = getListFocusManager(listFocusManagerInputs as any) as ListFocus; - - const expansion = new ListExpansion({ + const expansion = new ListExpansion({ items: items, - activeIndex: focusManager.inputs.activeIndex, - disabled: focusManager.inputs.disabled, - skipDisabled: focusManager.inputs.skipDisabled, - focusMode: focusManager.inputs.focusMode, - multiExpandable: inputs.multiExpandable ?? signal(false), - expandedIds: signal([]), - focusManager, + disabled: signal(inputs.expansionDisabled ?? false), + multiExpandable: signal(inputs.multiExpandable?.() ?? false), + expandedIds: signal([]), }); if (inputs.initialExpandedIds) { expansion.expandedIds.set(inputs.initialExpandedIds); } - return {expansion, items: items(), focusManager}; + return {expansion, items: items()}; } describe('Expansion', () => { @@ -112,8 +90,8 @@ describe('Expansion', () => { expect(expansion.expandedIds()).toEqual([]); }); - it('should not open an item if it is not focusable (disabled and skipDisabled is true)', () => { - const {expansion, items} = getExpansion({skipDisabled: signal(true)}); + it('should not open an item if it is disabled', () => { + const {expansion, items} = getExpansion(); items[1].disabled.set(true); expansion.open(items[1]); expect(expansion.expandedIds()).toEqual([]); @@ -134,11 +112,8 @@ describe('Expansion', () => { expect(expansion.expandedIds()).toEqual(['item-0']); }); - it('should not close an item if it is not focusable (disabled and skipDisabled is true)', () => { - const {expansion, items} = getExpansion({ - initialExpandedIds: ['item-0'], - skipDisabled: signal(true), - }); + it('should not close an item if it is disabled', () => { + const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); items[0].disabled.set(true); expansion.close(items[0]); expect(expansion.expandedIds()).toEqual(['item-0']); @@ -181,7 +156,7 @@ describe('Expansion', () => { expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']); }); - it('should not expand items that are not focusable (disabled and skipDisabled is true)', () => { + it('should not expand items that are disabled', () => { const {expansion, items} = getExpansion({ numItems: 3, multiExpandable: signal(true), @@ -222,9 +197,8 @@ describe('Expansion', () => { expect(expansion.expandedIds()).toEqual(['item-1']); }); - it('should not close items that are not focusable (disabled and skipDisabled is true)', () => { + it('should not close items that are disabled', () => { const {expansion, items} = getExpansion({ - skipDisabled: signal(true), multiExpandable: signal(true), initialExpandedIds: ['item-0', 'item-1', 'item-2'], }); @@ -235,22 +209,21 @@ describe('Expansion', () => { }); describe('#isExpandable', () => { - it('should return true if an item is focusable and expandable is true', () => { + it('should return true if an item is not disabled and expandable is true', () => { const {expansion, items} = getExpansion(); items[0].expandable.set(true); items[0].disabled.set(false); expect(expansion.isExpandable(items[0])).toBeTrue(); }); - it('should return false if an item is disabled and skipDisabled is false', () => { - const {expansion, items} = getExpansion({skipDisabled: signal(false)}); + it('should return false if an item is disabled', () => { + const {expansion, items} = getExpansion(); items[0].disabled.set(true); expect(expansion.isExpandable(items[0])).toBeFalse(); }); - it('should return false if an item is disabled and skipDisabled is true', () => { - const {expansion, items} = getExpansion({skipDisabled: signal(true)}); - items[0].disabled.set(true); + it('should return false if the expansion behavior is disabled', () => { + const {expansion, items} = getExpansion({expansionDisabled: true}); expect(expansion.isExpandable(items[0])).toBeFalse(); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts index c2363c8e4447..a105744f9d70 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts @@ -5,17 +5,19 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {computed, signal} from '@angular/core'; +import {computed} from '@angular/core'; import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; -import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; /** Represents an item that can be expanded or collapsed. */ -export interface ExpansionItem extends ListFocusItem { +export interface ExpansionItem { /** Whether the item is expandable. */ expandable: SignalLike; /** Used to uniquely identify an expansion item. */ expansionId: SignalLike; + + /** Whether the expansion is disabled. */ + disabled: SignalLike; } export interface ExpansionControl extends ExpansionItem {} @@ -30,10 +32,9 @@ export class ExpansionControl { /** Whether this item can be expanded. */ readonly isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this)); - constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) { + constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) { this.expansionId = inputs.expansionId; this.expandable = inputs.expandable; - this.element = inputs.element; this.disabled = inputs.disabled; } @@ -54,28 +55,31 @@ export class ExpansionControl { } /** Represents the required inputs for an expansion behavior. */ -export interface ListExpansionInputs extends ListFocusInputs { +export interface ListExpansionInputs { /** Whether multiple items can be expanded at once. */ multiExpandable: SignalLike; /** An array of ids of the currently expanded items. */ expandedIds: WritableSignalLike; + + /** An array of expansion items. */ + items: SignalLike; + + /** Whether all expansions are disabled. */ + disabled: SignalLike; } /** Manages the expansion state of a list of items. */ -export class ListExpansion { +export class ListExpansion { /** A signal holding an array of ids of the currently expanded items. */ expandedIds: WritableSignalLike; - /** The currently active (focused) item in the list. */ - activeItem = computed(() => this.inputs.focusManager.activeItem()); - - constructor(readonly inputs: ListExpansionInputs & {focusManager: ListFocus}) { - this.expandedIds = inputs.expandedIds ?? signal([]); + constructor(readonly inputs: ListExpansionInputs) { + this.expandedIds = inputs.expandedIds; } - /** Opens the specified item, or the currently active item if none is specified. */ - open(item: T = this.activeItem()) { + /** Opens the specified item. */ + open(item: ExpansionItem) { if (!this.isExpandable(item)) return; if (this.isExpanded(item)) return; if (!this.inputs.multiExpandable()) { @@ -84,18 +88,15 @@ export class ListExpansion { this.expandedIds.update(ids => ids.concat(item.expansionId())); } - /** Closes the specified item, or the currently active item if none is specified. */ - close(item: T = this.activeItem()) { + /** Closes the specified item. */ + close(item: ExpansionItem) { if (this.isExpandable(item)) { this.expandedIds.update(ids => ids.filter(id => id !== item.expansionId())); } } - /** - * Toggles the expansion state of the specified item, - * or the currently active item if none is specified. - */ - toggle(item: T = this.activeItem()) { + /** Toggles the expansion state of the specified item. */ + toggle(item: ExpansionItem) { this.expandedIds().includes(item.expansionId()) ? this.close(item) : this.open(item); } @@ -116,12 +117,12 @@ export class ListExpansion { } /** Checks whether the specified item is expandable / collapsible. */ - isExpandable(item: T) { + isExpandable(item: ExpansionItem) { return !this.inputs.disabled() && !item.disabled() && item.expandable(); } /** Checks whether the specified item is currently expanded. */ - isExpanded(item: T): boolean { + isExpanded(item: ExpansionItem): boolean { return this.expandedIds().includes(item.expansionId()); } } diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 9f9b08f2804d..9342ba6aa01e 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -128,9 +128,7 @@ interface SelectOptions { export type TabListInputs = ListNavigationInputs & Omit, 'multi'> & ListFocusInputs & - Omit, 'multiExpandable' | 'expandedIds'> & { - disabled: SignalLike; - }; + Omit; /** Controls the state of a tablist. */ export class TabListPattern { @@ -144,7 +142,7 @@ export class TabListPattern { focusManager: ListFocus; /** Controls expansion for the tablist. */ - expansionManager: ListExpansion; + expansionManager: ListExpansion; /** Whether the tablist is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -210,7 +208,6 @@ export class TabListPattern { ...inputs, multiExpandable: () => false, expandedIds: this.inputs.value, - focusManager: this.focusManager, }); } @@ -266,7 +263,7 @@ export class TabListPattern { private _select(opts?: SelectOptions) { if (opts?.select) { this.selection.selectOne(); - this.expansionManager.open(); + this.expansionManager.open(this.focusManager.activeItem()); } } diff --git a/src/cdk-experimental/ui-patterns/tree/BUILD.bazel b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel new file mode 100644 index 000000000000..1a53f80c42eb --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel @@ -0,0 +1,39 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "tree", + srcs = [ + "tree.ts", + ], + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/expansion", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/list-typeahead", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tree.spec.ts", + ], + deps = [ + ":tree", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts new file mode 100644 index 000000000000..9a6c7dbab8c9 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts @@ -0,0 +1,1153 @@ +/** + * @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.dev/license + */ + +import {WritableSignal, signal} from '@angular/core'; +import {TreeInputs, TreeItemInputs, TreeItemPattern, TreePattern} from './tree'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +// Converts the SignalLike type to WritableSignal type for controlling test inputs. +type WritableSignalOverrides = { + [K in keyof O as O[K] extends SignalLike ? K : never]: O[K] extends SignalLike + ? WritableSignal + : never; +}; + +type TestTreeInputs = Omit & WritableSignalOverrides>, 'allItems'>; +type TestTreeItemInputs = TreeItemInputs & WritableSignalOverrides>; + +const a = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 65, 'A', mods); +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); +const asterisk = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 106, '*', mods); +const shift = () => createKeyboardEvent('keydown', 16, 'Shift', {shift: true}); + +function createClickEvent(element: HTMLElement, mods?: ModifierKeys): PointerEvent { + return { + target: element, + shiftKey: mods?.shift, + ctrlKey: mods?.control, + metaKey: mods?.meta, + button: 0, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as PointerEvent; +} + +function createTreeItemElement(id: string): HTMLElement { + const element = document.createElement('div'); + element.role = 'treeitem'; + element.id = id; + return element; +} + +interface TestTreeItem { + value: V; + children?: TestTreeItem[]; + disabled: boolean; +} + +describe('Tree Pattern', () => { + function createTree(treeData: TestTreeItem[], treeInputs: TestTreeInputs) { + const allItems = signal[]>([]); + const itemPatternInputsMap = new Map>(); + const tree = new TreePattern({ + ...treeInputs, + allItems, + }); + + let nextId = 0; + + function buildItems( + treeData: TestTreeItem[], + parent: TreeItemPattern | TreePattern, + ): TreeItemPattern[] { + const items: TreeItemPattern[] = []; + + for (const node of treeData) { + const itemId = `treeitem-${nextId++}`; + const element = createTreeItemElement(itemId); + const itemPatternInputs: TestTreeItemInputs = { + id: signal(itemId), + value: signal(node.value), + element: signal(element), + disabled: signal(node.disabled), + searchTerm: signal(String(node.value)), + parent: signal(parent), + hasChildren: signal((node.children ?? []).length > 0), + children: signal[]>([]), + tree: signal(tree), + }; + + const item = new TreeItemPattern(itemPatternInputs); + itemPatternInputsMap.set(itemId, itemPatternInputs); + + allItems.set([...allItems(), item]); + items.push(item); + + const childItems = buildItems(node.children ?? [], item); + itemPatternInputs.children.set(childItems); + } + return items; + } + + // Build tree items recursively. + buildItems(treeData, tree as TreePattern); + + return {tree, allItems, itemPatternInputsMap}; + } + + function getItemByValue(allItems: TreeItemPattern[], value: V) { + return allItems.find(i => i.value() === value)!; + } + + const treeExample: TestTreeItem[] = [ + { + value: 'Item 0', + children: [ + {value: 'Item 0-0', disabled: false}, + {value: 'Item 0-1', disabled: false}, + ], + disabled: false, + }, + {value: 'Item 1', disabled: false}, + { + value: 'Item 2', + children: [{value: 'Item 2-0', disabled: false}], + disabled: false, + }, + ]; + + describe('TreeItemPattern properties', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should correctly compute level', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + + expect(item0.level()).toBe(1); + expect(item0_0.level()).toBe(2); + }); + + it('should correctly compute setsize', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + + expect(item0.setsize()).toBe(3); + expect(item0_0.setsize()).toBe(2); + }); + + it('should correctly compute posinset', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + const item0_1 = getItemByValue(allItems(), 'Item 0-1'); + + expect(item0.posinset()).toBe(1); + expect(item1.posinset()).toBe(2); + expect(item0_0.posinset()).toBe(1); + expect(item0_1.posinset()).toBe(2); + }); + }); + + describe('Keyboard Navigation', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should correctly compute active state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + treeInputs.activeIndex.set(0); + expect(item0.active()).toBe(true); + expect(item1.active()).toBe(false); + + treeInputs.activeIndex.set(1); + expect(item0.active()).toBe(false); + expect(item1.active()).toBe(true); + }); + + it('should correctly compute tabindex state', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + expect(item0.tabindex()).toBe(tree.focusManager.getItemTabindex(item0)); + }); + + it('should navigate next on ArrowDown (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item0); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should navigate prev on ArrowUp (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item1); + + expect(tree.focusManager.activeItem()).toBe(item1); + tree.onKeydown(up()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should navigate next on ArrowRight (horizontal)', () => { + treeInputs.orientation.set('horizontal'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item0); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(right()); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should navigate prev on ArrowLeft (horizontal)', () => { + treeInputs.orientation.set('horizontal'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item1); + + expect(tree.focusManager.activeItem()).toBe(item1); + tree.onKeydown(left()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should navigate next on ArrowLeft (horizontal & rtl)', () => { + treeInputs.orientation.set('horizontal'); + treeInputs.textDirection.set('rtl'); + treeInputs.activeIndex.set(0); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(left()); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should navigate prev on ArrowRight (horizontal & rtl)', () => { + treeInputs.orientation.set('horizontal'); + treeInputs.textDirection.set('rtl'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item1); + + expect(tree.focusManager.activeItem()).toBe(item1); + tree.onKeydown(right()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should navigate to the first visible item on Home', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item2 = getItemByValue(allItems(), 'Item 2'); + tree.navigationManager.goto(item2); + + expect(tree.focusManager.activeItem()).toBe(item2); + tree.onKeydown(home()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should navigate to the last visible item on End', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item2 = getItemByValue(allItems(), 'Item 2'); + tree.navigationManager.goto(item0); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(end()); + expect(tree.focusManager.activeItem()).toBe(item2); + }); + + it('should skip disabled items when skipDisabled is true', () => { + treeInputs.skipDisabled.set(true); + const localTreeExample: TestTreeItem[] = [ + {value: 'Item A', disabled: false}, + {value: 'Item B', disabled: true}, + {value: 'Item C', disabled: false}, + ]; + const {tree, allItems} = createTree(localTreeExample, treeInputs); + const itemA = getItemByValue(allItems(), 'Item A'); + const itemC = getItemByValue(allItems(), 'Item C'); + tree.navigationManager.goto(itemA); + + expect(tree.focusManager.activeItem()).toBe(itemA); + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(itemC); + }); + + it('should not skip disabled items when skipDisabled is false', () => { + treeInputs.skipDisabled.set(false); + const localTreeExample: TestTreeItem[] = [ + {value: 'Item A', disabled: false}, + {value: 'Item B', disabled: true}, + {value: 'Item C', disabled: false}, + ]; + const {tree, allItems} = createTree(localTreeExample, treeInputs); + const itemA = getItemByValue(allItems(), 'Item A'); + const itemB = getItemByValue(allItems(), 'Item B'); + tree.navigationManager.goto(itemA); + + expect(tree.focusManager.activeItem()).toBe(itemA); + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(itemB); + }); + + it('should not navigate when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + + expect(tree.focusManager.activeItem()).toBe(item0); + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + }); + + describe('Keyboard Selection', () => { + describe('follows focus & single select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should correctly compute selected state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + treeInputs.value.set(['Item 0']); + expect(item0.selected()).toBe(true); + expect(item1.selected()).toBe(false); + + treeInputs.value.set(['Item 1']); + expect(item0.selected()).toBe(false); + expect(item1.selected()).toBe(true); + }); + + it('should select an item on navigation', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onKeydown(down()); + expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.inputs.value()).toEqual(['Item 1']); + + tree.onKeydown(up()); + expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.inputs.value()).toEqual(['Item 0']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & single select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should select an item on Space', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual(['Item 0']); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should select an item on Enter', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 0']); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should only allow one selected item', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 0']); + + tree.onKeydown(down()); + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual([]); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & multi select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(true), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should select an item on Space', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual(['Item 0']); + }); + + it('should select an item on Enter', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 0']); + }); + + it('should allow multiple selected items', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(enter()); + tree.onKeydown(down()); + tree.onKeydown(enter()); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 1']); + }); + + it('should select a range of visible items on Shift + ArrowDown/ArrowUp', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(shift()); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0']); + + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1']); + + tree.onKeydown(up({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0']); + }); + + it('should not allow wrapping while Shift is held down', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + + tree.onKeydown(shift()); + tree.onKeydown(up({shift: true})); + expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should select a range of visible items on Shift + Space (or Enter)', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(down()); + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual(['Item 0-0']); + + tree.onKeydown(down()); + tree.onKeydown(down()); + tree.onKeydown(shift()); + tree.onKeydown(space({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0-0', 'Item 0-1', 'Item 1']); + }); + + it('should select the focused item and all visible items up to the first on Ctrl + Shift + Home', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + item0.expansion.open(); + tree.navigationManager.goto(item1); + + tree.onKeydown(shift()); + tree.onKeydown(home({control: true, shift: true})); + expect(tree.inputs.value()).toEqual(['Item 1', 'Item 0-1', 'Item 0-0', 'Item 0']); + }); + + it('should select the focused item and all visible items down to the last on Ctrl + Shift + End', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + tree.navigationManager.goto(item0_0); + + tree.onKeydown(shift()); + tree.onKeydown(end({control: true, shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(space()); + expect(tree.inputs.value()).toEqual([]); + + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should not select disabled items on Shift + ArrowUp / ArrowDown', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: true}, + {value: 'C', disabled: false}, + ]; + treeInputs.skipDisabled.set(false); + const {tree, allItems} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(allItems(), 'A'); + + tree.navigationManager.goto(itemA); + tree.onKeydown(shift()); + tree.onKeydown(down({shift: true})); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['A', 'C']); + }); + + it('should select all visible items on Ctrl + A', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + }); + + it('should deselect all visible items on Ctrl + A if all are selected', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(a({control: true})); + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('follows focus & multi select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(true), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should select an item on navigation', () => { + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should navigate without selecting if the Ctrl key is pressed', () => { + treeInputs.value.set(['Item 0']); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onKeydown(down({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0']); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should toggle an item selection state on Ctrl + Space', () => { + treeInputs.value.set(['Item 0']); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(down({control: true})); + tree.onKeydown(space({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 1']); + + tree.onKeydown(space({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0']); + }); + + it('should select a range of visible items on Shift + ArrowDown/ArrowUp', () => { + treeInputs.value.set(['Item 0']); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + + tree.onKeydown(shift()); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0']); + tree.onKeydown(down({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1']); + }); + + it('should not allow wrapping while Shift is held down', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + + tree.onKeydown(shift()); + tree.onKeydown(up({shift: true})); + expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should select a range of visible items on Shift + Space (or Enter)', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + item0.expansion.open(); + tree.navigationManager.goto(item0); + + tree.onKeydown(down({control: true})); + tree.onKeydown(down({control: true})); + tree.onKeydown(shift()); + tree.onKeydown(space({shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1']); + }); + + it('should select the focused item and all visible items up to the first on Ctrl + Shift + Home', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + item0.expansion.open(); + tree.navigationManager.goto(item1); + + tree.onKeydown(shift()); + tree.onKeydown(home({control: true, shift: true})); + expect(tree.inputs.value()).toEqual(['Item 1', 'Item 0-1', 'Item 0-0', 'Item 0']); + }); + + it('should select the focused item and all visible items down to the last on Ctrl + Shift + End', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + tree.navigationManager.goto(item0_0); + + tree.onKeydown(shift()); + tree.onKeydown(end({control: true, shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree} = createTree(treeExample, treeInputs); + + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should not select disabled items on navigation', () => { + const localTreeData: TestTreeItem[] = [ + {value: 'A', disabled: false}, + {value: 'B', disabled: true}, + {value: 'C', disabled: false}, + ]; + treeInputs.skipDisabled.set(true); + const {tree, allItems} = createTree(localTreeData, treeInputs); + treeInputs.value.set(['A']); + tree.navigationManager.goto(getItemByValue(allItems(), 'A')); + + tree.onKeydown(down()); + expect(tree.inputs.value()).toEqual(['C']); + }); + + it('should deselect all except the focused item on Ctrl + A if all are selected', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + tree.navigationManager.goto(item0_0); + + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + tree.onKeydown(a({control: true})); + expect(tree.inputs.value()).toEqual(['Item 0-0']); + }); + }); + }); + + describe('Pointer Events', () => { + describe('follows focus & single select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should navigate and select a single item on click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & single select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should navigate and toggle selection on click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.inputs.value()).toEqual(['Item 1']); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.inputs.value()).toEqual([]); + }); + + it('should not change selection when the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.inputs.value()).toEqual([]); + }); + }); + + describe('explicit focus & multi select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(true), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should navigate and toggle selection on click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(tree.inputs.value()).toEqual(['Item 0']); + + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 1']); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should navigate and select range from anchor on shift + click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + item0.expansion.open(); + + tree.onKeydown(shift()); + tree.onPointerdown(createClickEvent(item1.element(), {shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1']); + }); + }); + + describe('follows focus & multi select', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(true), + orientation: signal('vertical'), + selectionMode: signal('follow'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should navigate and select a single item on click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(tree.inputs.value()).toEqual(['Item 0']); + tree.onPointerdown(createClickEvent(item1.element())); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should navigate and toggle selection on ctrl + click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + + tree.onPointerdown(createClickEvent(item0.element())); // Select and expand Item 0 + tree.onPointerdown(createClickEvent(item1.element(), {control: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 1']); + tree.onPointerdown(createClickEvent(item0.element(), {control: true})); + expect(tree.inputs.value()).toEqual(['Item 1']); + }); + + it('should navigate and select range from anchor on shift + click', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item2 = getItemByValue(allItems(), 'Item 2'); + + tree.onPointerdown(createClickEvent(item0.element())); // Select and expand Item 0 + tree.onKeydown(shift()); + tree.onPointerdown(createClickEvent(item2.element(), {shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); + }); + + it('should select a new range on subsequent shift + clicks, deselecting previous range', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + + tree.onKeydown(shift()); + tree.onPointerdown(createClickEvent(item1.element(), {shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1']); + + tree.onPointerdown(createClickEvent(item0_0.element(), {shift: true})); + expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0']); + }); + + it('should not select disabled items on click', () => { + const localTreeData: TestTreeItem[] = [{value: 'A', disabled: true}]; + const {tree, allItems} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(allItems(), 'A'); + + tree.onPointerdown(createClickEvent(itemA.element())); + expect(tree.inputs.value()).toEqual([]); + expect(tree.focusManager.activeItem()).toBe(itemA); + }); + }); + }); + + describe('Expansion/Collapse', () => { + let treeInputs: TestTreeInputs; + + beforeEach(() => { + treeInputs = { + activeIndex: signal(0), + disabled: signal(false), + focusMode: signal('roving'), + multi: signal(false), + orientation: signal('vertical'), + selectionMode: signal('explicit'), + skipDisabled: signal(true), + textDirection: signal('ltr'), + typeaheadDelay: signal(0), + value: signal([]), + wrap: signal(false), + }; + }); + + it('should correctly compute visible state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + + expect(item0_0.visible()).toBe(false); + item0.expansion.open(); + expect(item0_0.visible()).toBe(true); + item0.expansion.close(); + expect(item0_0.visible()).toBe(false); + }); + + it('should correctly compute expanded state', () => { + const {allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + + expect(item0.expanded()).toBe(false); + item0.expansion.open(); + expect(item0.expanded()).toBe(true); + }); + + it('should expand an item on expandKey if collapsed (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + + expect(item0.expanded()).toBe(false); + tree.onKeydown(right()); + expect(item0.expanded()).toBe(true); + }); + + it('should move focus to the first child on expandKey if expanded and has children (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + tree.navigationManager.goto(item0); + item0.expansion.open(); + + tree.onKeydown(right()); + expect(tree.focusManager.activeItem()).toBe(item0_0); + }); + + it('should do nothing on expandKey if expanded and has no children (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + tree.navigationManager.goto(item1); + + tree.onKeydown(right()); + expect(tree.focusManager.activeItem()).toBe(item1); + }); + + it('should collapse an item on collapseKey if expanded (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + item0.expansion.open(); + + expect(item0.expanded()).toBe(true); + tree.onKeydown(left()); + expect(item0.expanded()).toBe(false); + }); + + it('should move focus to the parent on collapseKey if collapsed (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + item0.expansion.open(); + tree.navigationManager.goto(item0_0); + + tree.onKeydown(left()); + expect(tree.focusManager.activeItem()).toBe(item0); + }); + + it('should do nothing on collapseKey if collapsed and is a root item (vertical)', () => { + treeInputs.orientation.set('vertical'); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + tree.navigationManager.goto(item0); + + tree.onKeydown(left()); + expect(tree.focusManager.activeItem()).toBe(item0); + expect(item0.expanded()).toBe(false); + }); + + it('should expand all sibling items on Shift + Asterisk (*)', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + const item1 = getItemByValue(allItems(), 'Item 1'); + const item2 = getItemByValue(allItems(), 'Item 2'); + tree.navigationManager.goto(item0); + + tree.onKeydown(asterisk({shift: true})); + expect(item0.expanded()).toBe(true); + expect(item1.expanded()).toBe(false); + expect(item2.expanded()).toBe(true); + }); + + it('should toggle expansion on pointerdown (click)', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + + expect(item0.expanded()).toBe(false); + tree.onPointerdown(createClickEvent(item0.element())); + expect(item0.expanded()).toBe(true); + tree.onPointerdown(createClickEvent(item0.element())); + expect(item0.expanded()).toBe(false); + }); + + it('should not toggle expansion on pointerdown if the item is not expandable', () => { + const {tree, allItems} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(allItems(), 'Item 1'); + + expect(item1.expanded()).toBe(false); + tree.onPointerdown(createClickEvent(item1.element())); + expect(item1.expanded()).toBe(false); + }); + + it('should not toggle expansion on pointerdown if the item is disabled', () => { + const {tree, allItems, itemPatternInputsMap} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + itemPatternInputsMap.get(item0.id())!.disabled.set(true); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(item0.expanded()).toBe(false); + }); + + it('should not toggle expansion on pointerdown if the tree is disabled', () => { + treeInputs.disabled.set(true); + const {tree, allItems} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(allItems(), 'Item 0'); + + tree.onPointerdown(createClickEvent(item0.element())); + expect(item0.expanded()).toBe(false); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts new file mode 100644 index 000000000000..e7d6c48cda3e --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -0,0 +1,519 @@ +/** + * @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.dev/license + */ + +import {computed, signal} from '@angular/core'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import { + ListNavigation, + ListNavigationInputs, + ListNavigationItem, +} from '../behaviors/list-navigation/list-navigation'; +import { + ListSelection, + ListSelectionInputs, + ListSelectionItem, +} from '../behaviors/list-selection/list-selection'; +import { + ListTypeahead, + ListTypeaheadInputs, + ListTypeaheadItem, +} from '../behaviors/list-typeahead/list-typeahead'; +import {ExpansionItem, ExpansionControl, ListExpansion} from '../behaviors/expansion/expansion'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import {ModifierKey as Modifier} from '../behaviors/event-manager/event-manager'; + +/** Represents the required inputs for a tree item. */ +export interface TreeItemInputs + extends ListFocusItem, + ListNavigationItem, + ListSelectionItem, + ListTypeaheadItem { + /** The parent item. */ + parent: SignalLike | TreePattern>; + + /** Whether this item has children. Children can be lazily loaded. */ + hasChildren: SignalLike; + + /** The children items. */ + children: SignalLike[]>; + + /** The tree pattern this item belongs to. */ + tree: SignalLike>; +} + +export interface TreeItemPattern extends TreeItemInputs {} +/** + * Represents an item in a Tree. + */ +export class TreeItemPattern implements ExpansionItem { + /** The unique identifier used by the expansion behavior. */ + readonly expansionId: SignalLike; + + /** Controls expansion for child items. */ + readonly expansionManager: ListExpansion; + + /** Controls expansion for this item. */ + readonly expansion: ExpansionControl; + + /** Whether the item is expandable. It's expandable if children item exist. */ + readonly expandable: SignalLike; + + /** The level of the current item in a tree. */ + readonly level: SignalLike = computed(() => this.parent().level() + 1); + + /** Whether this item is currently expanded. */ + readonly expanded = computed(() => this.expansion.isExpanded()); + + /** Whether this item is visible. */ + readonly visible = computed(() => this.parent().expanded()); + + /** The number of items under the same parent at the same level. */ + readonly setsize = computed(() => this.parent().children().length); + + /** The position of this item among its siblings (1-based). */ + readonly posinset = computed(() => this.parent().children().indexOf(this) + 1); + + /** Whether the item is active. */ + readonly active = computed(() => this.tree().focusManager.activeItem() === this); + + /** The tabindex of the item. */ + readonly tabindex = computed(() => this.tree().focusManager.getItemTabindex(this)); + + /** Whether the item is selected. */ + readonly selected = computed(() => this.tree().value().includes(this.value())); + + constructor(readonly inputs: TreeItemInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.element = inputs.element; + this.disabled = inputs.disabled; + this.searchTerm = inputs.searchTerm; + this.expansionId = inputs.id; + this.tree = inputs.tree; + this.parent = inputs.parent; + this.children = inputs.children; + this.expandable = inputs.hasChildren; + this.expansion = new ExpansionControl({ + ...inputs, + expandable: this.expandable, + expansionId: this.expansionId, + expansionManager: this.parent().expansionManager, + }); + this.expansionManager = new ListExpansion({ + ...inputs, + multiExpandable: () => true, + // TODO(ok7sai): allow pre-expanded tree items. + expandedIds: signal([]), + items: this.children, + disabled: computed(() => this.tree()?.disabled() ?? false), + }); + } +} + +/** The selection operations that the tree can perform. */ +interface SelectOptions { + toggle?: boolean; + selectOne?: boolean; + selectRange?: boolean; + anchor?: boolean; +} + +/** Represents the required inputs for a tree. */ +export interface TreeInputs + extends Omit< + ListFocusInputs> & + ListNavigationInputs> & + ListSelectionInputs, V> & + ListTypeaheadInputs>, + 'items' + > { + /** All items in the tree, in document order (DFS-like, a flattened list). */ + allItems: SignalLike[]>; +} + +export interface TreePattern extends TreeInputs {} +/** Controls the state and interactions of a tree view. */ +export class TreePattern { + /** Controls focus for the all visible tree items. */ + readonly focusManager: ListFocus>; + + /** Controls navigation for all visible tree items. */ + readonly navigationManager: ListNavigation>; + + /** Controls selection for all visible tree items. */ + readonly selectionManager: ListSelection, V>; + + /** Controls typeahead for all visible tree items. */ + readonly typeaheadManager: ListTypeahead>; + + /** Controls expansion for direct children of the tree root (top-level items). */ + readonly expansionManager: ListExpansion; + + /** The root level is 0. */ + readonly level = () => 0; + + /** The root is always expanded. */ + readonly expanded = () => true; + + /** The tabindex of the tree. */ + readonly tabindex = computed(() => this.focusManager.getListTabindex()); + + /** The id of the current active item. */ + readonly activedescendant = computed(() => this.focusManager.getActiveDescendant()); + + /** Whether the tree is performing a range selection. */ + readonly inSelection = signal(false); + + /** The direct children of the root (top-level tree items). */ + readonly children = computed(() => + this.inputs.allItems().filter(item => item.level() === this.level() + 1), + ); + + /** All currently visible tree items. An item is visible if their parent is expanded. */ + readonly visibleItems = computed(() => this.inputs.allItems().filter(item => item.visible())); + + /** Whether the tree selection follows focus. */ + readonly followFocus = computed(() => this.inputs.selectionMode() === 'follow'); + + /** The key for navigating to the previous item. */ + readonly prevKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key for navigating to the next item. */ + readonly nextKey = computed(() => { + if (this.inputs.orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The key for collapsing an item or moving to its parent. */ + readonly collapseKey = computed(() => { + if (this.inputs.orientation() === 'horizontal') { + return 'ArrowUp'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key for expanding an item or moving to its first child. */ + readonly expandKey = computed(() => { + if (this.inputs.orientation() === 'horizontal') { + return 'ArrowDown'; + } + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** Represents the space key. Does nothing when the user is actively using typeahead. */ + readonly dynamicSpaceKey = computed(() => (this.typeaheadManager.isTyping() ? '' : ' ')); + + /** Regular expression to match characters for typeahead. */ + readonly typeaheadRegexp = /^.$/; + + /** Uncommitted tree item for selecting a range of tree items. */ + readonly anchorItem = signal | undefined>(undefined); + + /** + * Uncommitted tree item index for selecting a range of tree items. + * + * The index is computed in case the tree item position is changed caused by tree expansions. + */ + readonly anchorIndex = computed(() => + this.anchorItem() ? this.visibleItems().indexOf(this.anchorItem()!) : -1, + ); + + /** The keydown event manager for the tree. */ + readonly keydown = computed(() => { + const manager = new KeyboardEventManager(); + + if (!this.followFocus()) { + manager + .on(this.prevKey, () => this.prev()) + .on(this.nextKey, () => this.next()) + .on('Home', () => this.first()) + .on('End', () => this.last()) + .on(this.typeaheadRegexp, e => this.search(e.key)); + } + + if (this.followFocus()) { + manager + .on(this.prevKey, () => this.prev({selectOne: true})) + .on(this.nextKey, () => this.next({selectOne: true})) + .on('Home', () => this.first({selectOne: true})) + .on('End', () => this.last({selectOne: true})) + .on(this.typeaheadRegexp, e => this.search(e.key, {selectOne: true})); + } + + if (this.inputs.multi()) { + manager + .on(Modifier.Any, 'Shift', () => this.anchorItem.set(this.focusManager.activeItem())) + .on(Modifier.Shift, this.prevKey, () => this.prev({selectRange: true})) + .on(Modifier.Shift, this.nextKey, () => this.next({selectRange: true})) + .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => + this.first({selectRange: true, anchor: false}), + ) + .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => + this.last({selectRange: true, anchor: false}), + ) + .on(Modifier.Shift, 'Enter', () => + this._updateSelection({selectRange: true, anchor: false}), + ) + .on(Modifier.Shift, this.dynamicSpaceKey, () => + this._updateSelection({selectRange: true, anchor: false}), + ); + } + + if (!this.followFocus() && this.inputs.multi()) { + manager + .on(this.dynamicSpaceKey, () => this.selectionManager.toggle()) + .on('Enter', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.selectionManager.toggleAll()); + } + + if (!this.followFocus() && !this.inputs.multi()) { + manager.on(this.dynamicSpaceKey, () => this.selectionManager.toggleOne()); + manager.on('Enter', () => this.selectionManager.toggleOne()); + } + + if (this.inputs.multi() && this.followFocus()) { + manager + .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.prev()) + .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.next()) + .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.selectionManager.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.first()) + .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.last()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => { + this.selectionManager.toggleAll(); + this.selectionManager.select(); // Ensure the currect item remains selected. + }); + } + + manager + .on(this.expandKey, () => this.expand()) + .on(this.collapseKey, () => this.collapse()) + .on(Modifier.Shift, '*', () => this.expandSiblings()); + + return manager; + }); + + /** The pointerdown event manager for the tree. */ + pointerdown = computed(() => { + const manager = new PointerEventManager(); + + if (this.multi()) { + manager.on(Modifier.Shift, e => this.goto(e, {selectRange: true})); + } + + if (!this.multi() && this.followFocus()) { + return manager.on(e => this.goto(e, {selectOne: true})); + } + + if (!this.multi() && !this.followFocus()) { + return manager.on(e => this.goto(e, {toggle: true})); + } + + if (this.multi() && this.followFocus()) { + return manager + .on(e => this.goto(e, {selectOne: true})) + .on(Modifier.Ctrl, e => this.goto(e, {toggle: true})); + } + + if (this.multi() && !this.followFocus()) { + return manager.on(e => this.goto(e, {toggle: true})); + } + + return manager; + }); + + constructor(readonly inputs: TreeInputs) { + this.allItems = inputs.allItems; + this.focusMode = inputs.focusMode; + this.disabled = inputs.disabled; + this.activeIndex = inputs.activeIndex; + this.skipDisabled = inputs.skipDisabled; + this.wrap = inputs.wrap; + this.orientation = inputs.orientation; + this.textDirection = inputs.textDirection; + this.multi = inputs.multi; + this.value = inputs.value; + this.selectionMode = inputs.selectionMode; + this.typeaheadDelay = inputs.typeaheadDelay; + this.focusManager = new ListFocus({ + ...inputs, + items: this.visibleItems, + }); + this.navigationManager = new ListNavigation({ + ...inputs, + wrap: computed(() => this.inputs.wrap() && !this.inSelection()), + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.selectionManager = new ListSelection({ + ...inputs, + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.typeaheadManager = new ListTypeahead({ + ...inputs, + items: this.visibleItems, + focusManager: this.focusManager, + }); + this.expansionManager = new ListExpansion({ + multiExpandable: () => true, + // TODO(ok7sai): allow pre-expanded tree items. + expandedIds: signal([]), + items: this.children, + disabled: this.disabled, + }); + } + + // TODO(ok7sai): add `setDefaultState` method. + + /** Handles keydown events on the tree. */ + onKeydown(event: KeyboardEvent) { + if (!this.disabled()) { + this.keydown().handle(event); + } + } + + /** Handles pointerdown events on the tree. */ + onPointerdown(event: PointerEvent) { + if (!this.disabled()) { + this.pointerdown().handle(event); + } + } + + /** Navigates to the first visible tree item in the tree. */ + first(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.first()); + } + + /** Navigates to the last visible tree item in the tree. */ + last(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.last()); + } + + /** Navigates to the next visible tree item in the tree. */ + next(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.next()); + } + + /** Navigates to the previous visible tree item in the tree. */ + prev(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationManager.prev()); + } + + /** Navigates to the given tree item in the tree. */ + goto(event: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(event); + this._navigate(opts, () => this.navigationManager.goto(item)); + this.toggleExpansion(item); + } + + /** Handles typeahead search navigation for the tree. */ + search(char: string, opts?: SelectOptions) { + this._navigate(opts, () => this.typeaheadManager.search(char)); + } + + /** Toggles to expand or collapse a tree item. */ + toggleExpansion(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (!item.expandable()) return; + if (item.expanded()) { + this.collapse(); + } else { + item.expansion.open(); + } + } + + /** Expands a tree item. */ + expand(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (item.expandable() && !item.expanded()) { + item.expansion.open(); + } else if (item.expanded() && item.children().length > 0) { + const firstChild = item.children()[0]; + if (this.focusManager.isFocusable(firstChild)) { + this.navigationManager.goto(firstChild); + } + } + } + + /** Expands all sibling tree items including itself. */ + expandSiblings(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + const siblings = item.parent()?.children(); + siblings?.forEach(item => this.expand(item)); + } + + /** Collapses a tree item. */ + collapse(item?: TreeItemPattern) { + item ??= this.focusManager.activeItem(); + if (!item || !this.focusManager.isFocusable(item)) return; + + if (item.expandable() && item.expanded()) { + item.expansion.close(); + } else if (item.parent() && item.parent() !== this) { + const parentItem = item.parent(); + if (parentItem instanceof TreeItemPattern && this.focusManager.isFocusable(parentItem)) { + this.navigationManager.goto(parentItem); + } + } + } + + /** Safely performs a navigation operation. */ + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + if (opts?.selectRange) { + this.inSelection.set(true); + this.selectionManager.rangeStartIndex.set(this.anchorIndex()); + } + + const moved = operation(); + + if (moved) { + this._updateSelection(opts); + } + + this.inSelection.set(false); + } + + /** Handles updating selection for the tree. */ + private _updateSelection(opts: SelectOptions = {anchor: true}) { + if (opts.toggle) { + this.selectionManager.toggle(); + } + if (opts.selectOne) { + this.selectionManager.selectOne(); + } + if (opts.selectRange) { + this.selectionManager.selectRange(); + } + if (!opts.anchor) { + this.anchorItem.set(this.visibleItems()[this.selectionManager.rangeStartIndex()]); + } + } + + /** Retrieves the TreeItemPattern associated with a DOM event, if any. */ + private _getItem(event: Event): TreeItemPattern | undefined { + if (!(event.target instanceof HTMLElement)) { + return; + } + const element = event.target.closest('[role="treeitem"]'); + return this.inputs.allItems().find(i => i.element() === element); + } +}