diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index c8ca83c0d9a1..9483cc1d0c39 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -275,6 +275,7 @@ export class ComboboxTreePattern extends TreePattern implements ComboboxTr setValue: (value: V | undefined) => void; tabIndex: SignalLike<-1 | 0>; toggle: (item?: TreeItemPattern) => void; + toggleExpansion: (item?: TreeItemPattern) => void; unfocus: () => void; } @@ -834,23 +835,21 @@ export class ToolbarWidgetPattern implements ListItem { } // @public -export interface TreeInputs extends Omit, V>, 'items'> { - allItems: SignalLike[]>; +export interface TreeInputs extends Omit, V>, 'multiExpandable'> { currentType: SignalLike<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>; id: SignalLike; nav: SignalLike; } // @public -export interface TreeItemInputs extends Omit, 'index'>, Omit { - children: SignalLike[]>; +export interface TreeItemInputs extends Omit>, 'index' | 'parent' | 'visible' | 'expandable'> { hasChildren: SignalLike; parent: SignalLike | TreePattern>; tree: SignalLike>; } // @public -export class TreeItemPattern implements ListItem, ExpansionItem { +export class TreeItemPattern implements TreeItem> { constructor(inputs: TreeItemInputs); readonly active: SignalLike; readonly children: SignalLike[]>; @@ -859,13 +858,12 @@ export class TreeItemPattern implements ListItem, ExpansionItem { readonly element: SignalLike; readonly expandable: SignalLike; readonly expanded: WritableSignalLike; - readonly expansionBehavior: ListExpansion; readonly id: SignalLike; readonly index: SignalLike; // (undocumented) readonly inputs: TreeItemInputs; readonly level: SignalLike; - readonly parent: SignalLike | TreePattern>; + readonly parent: SignalLike | undefined>; readonly posinset: SignalLike; readonly searchTerm: SignalLike; readonly selectable: SignalLike; @@ -882,19 +880,16 @@ export class TreePattern implements TreeInputs { constructor(inputs: TreeInputs); readonly activeDescendant: SignalLike; readonly activeItem: WritableSignalLike | undefined>; - readonly allItems: SignalLike[]>; readonly children: SignalLike[]>; - collapse(opts?: SelectOptions): void; readonly collapseKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">; + _collapseOrParent(opts?: SelectOptions): void; readonly currentType: SignalLike<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>; readonly disabled: SignalLike; readonly dynamicSpaceKey: SignalLike<"" | " ">; readonly element: SignalLike; - expand(opts?: SelectOptions): void; readonly expanded: () => boolean; readonly expandKey: SignalLike<"ArrowRight" | "ArrowLeft" | "ArrowDown">; - expandSiblings(item?: TreeItemPattern): void; - readonly expansionBehavior: ListExpansion; + _expandOrFirstChild(opts?: SelectOptions): void; readonly focusMode: SignalLike<'roving' | 'activedescendant'>; readonly followFocus: SignalLike; protected _getItem(event: Event): TreeItemPattern | undefined; @@ -903,9 +898,9 @@ export class TreePattern implements TreeInputs { // (undocumented) readonly inputs: TreeInputs; readonly isRtl: SignalLike; + readonly items: SignalLike[]>; readonly keydown: SignalLike>; readonly level: () => number; - readonly listBehavior: List, V>; readonly multi: SignalLike; readonly nav: SignalLike; readonly nextKey: SignalLike<"ArrowRight" | "ArrowLeft" | "ArrowDown">; @@ -919,12 +914,11 @@ export class TreePattern implements TreeInputs { readonly softDisabled: SignalLike; readonly tabIndex: SignalLike<-1 | 0>; readonly textDirection: SignalLike<'ltr' | 'rtl'>; - toggleExpansion(item?: TreeItemPattern): void; + readonly treeBehavior: Tree, V>; readonly typeaheadDelay: SignalLike; readonly typeaheadRegexp: RegExp; readonly values: WritableSignalLike; readonly visible: () => boolean; - readonly visibleItems: SignalLike[]>; readonly wrap: SignalLike; } diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index 0cbeca485a38..fd4dc585f5b8 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -780,6 +780,13 @@ describe('Combobox', () => { it('should select and commit on click', () => { click(inputElement); + + // Iterate to the parent node and expand it so the child is visible + down(); // Winter + down(); // Spring + right(); // Expand Spring + fixture.detectChanges(); + const item = getTreeItem('April')!; click(item); fixture.detectChanges(); diff --git a/src/aria/private/behaviors/list-focus/list-focus.ts b/src/aria/private/behaviors/list-focus/list-focus.ts index 60de40b19407..eea98fc70ae9 100644 --- a/src/aria/private/behaviors/list-focus/list-focus.ts +++ b/src/aria/private/behaviors/list-focus/list-focus.ts @@ -40,6 +40,7 @@ export interface ListFocusInputs { /** Whether disabled items in the list should be focusable. */ softDisabled: SignalLike; + /** The html element that should receive focus. */ element: SignalLike; } diff --git a/src/aria/private/behaviors/list-navigation/list-navigation.spec.ts b/src/aria/private/behaviors/list-navigation/list-navigation.spec.ts index e230e8b7e576..7ac8fa4ced49 100644 --- a/src/aria/private/behaviors/list-navigation/list-navigation.spec.ts +++ b/src/aria/private/behaviors/list-navigation/list-navigation.spec.ts @@ -254,4 +254,55 @@ describe('List Navigation', () => { expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[4]); }); }); + + describe('with items subset', () => { + it('should navigate only within the provided subset for next/prev', () => { + const nav = getNavigation(); + const allItems = nav.inputs.items(); + const subset = [allItems[0], allItems[2], allItems[4]]; + + // Start at 0 + expect(nav.inputs.activeItem()).toBe(allItems[0]); + + // next(subset) -> 2 (skip 1) + nav.next({focusElement: false, items: subset}); + expect(nav.inputs.activeItem()).toBe(allItems[2]); + + // next(subset) -> 4 (skip 3) + nav.next({focusElement: false, items: subset}); + expect(nav.inputs.activeItem()).toBe(allItems[4]); + + // prev(subset) -> 2 (skip 3) + nav.prev({focusElement: false, items: subset}); + expect(nav.inputs.activeItem()).toBe(allItems[2]); + }); + + it('should wrap within the subset', () => { + const nav = getNavigation({wrap: signal(true)}); + const allItems = nav.inputs.items(); + const subset = [allItems[0], allItems[2], allItems[4]]; + + nav.goto(allItems[4]); + + // next(subset) -> 0 (wrap) + nav.next({focusElement: false, items: subset}); + expect(nav.inputs.activeItem()).toBe(allItems[0]); + + // prev(subset) -> 4 (wrap) + nav.prev({focusElement: false, items: subset}); + expect(nav.inputs.activeItem()).toBe(allItems[4]); + }); + + it('should find first/last within the subset', () => { + const nav = getNavigation(); + const allItems = nav.inputs.items(); + const subset = [allItems[1], allItems[2], allItems[3]]; + + nav.first({focusElement: false, items: subset}); + expect(nav.inputs.activeItem()).toBe(allItems[1]); + + nav.last({focusElement: false, items: subset}); + expect(nav.inputs.activeItem()).toBe(allItems[3]); + }); + }); }); diff --git a/src/aria/private/behaviors/list-navigation/list-navigation.ts b/src/aria/private/behaviors/list-navigation/list-navigation.ts index fb63c70386ac..e5d65374f283 100644 --- a/src/aria/private/behaviors/list-navigation/list-navigation.ts +++ b/src/aria/private/behaviors/list-navigation/list-navigation.ts @@ -24,54 +24,71 @@ export interface ListNavigationInputs extends List textDirection: SignalLike<'rtl' | 'ltr'>; } +/** Options for list navigation. */ +export interface ListNavigationOpts { + /** + * Whether to focus the item's element. + * Defaults to true. + */ + focusElement?: boolean; + + /** + * The list of items to navigate through. + * Defaults to the list of items from the inputs. + */ + items?: T[]; +} + /** Controls navigation for a list of items. */ export class ListNavigation { constructor(readonly inputs: ListNavigationInputs & {focusManager: ListFocus}) {} /** Navigates to the given item. */ - goto(item?: T, opts?: {focusElement?: boolean}): boolean { + goto(item?: T, opts?: ListNavigationOpts): boolean { return item ? this.inputs.focusManager.focus(item, opts) : false; } /** Navigates to the next item in the list. */ - next(opts?: {focusElement?: boolean}): boolean { + next(opts?: ListNavigationOpts): boolean { return this._advance(1, opts); } /** Peeks the next item in the list. */ - peekNext(): T | undefined { - return this._peek(1); + peekNext(opts?: ListNavigationOpts): T | undefined { + return this._peek(1, opts); } /** Navigates to the previous item in the list. */ - prev(opts?: {focusElement?: boolean}): boolean { + prev(opts?: ListNavigationOpts): boolean { return this._advance(-1, opts); } /** Peeks the previous item in the list. */ - peekPrev(): T | undefined { - return this._peek(-1); + peekPrev(opts?: ListNavigationOpts): T | undefined { + return this._peek(-1, opts); } /** Navigates to the first item in the list. */ - first(opts?: {focusElement?: boolean}): boolean { - const item = this.peekFirst(); + first(opts?: ListNavigationOpts): boolean { + const item = this.peekFirst(opts); return item ? this.goto(item, opts) : false; } /** Navigates to the last item in the list. */ - last(opts?: {focusElement?: boolean}): boolean { - const item = this.peekLast(); + last(opts?: ListNavigationOpts): boolean { + const item = this.peekLast(opts); return item ? this.goto(item, opts) : false; } /** Gets the first focusable item from the given list of items. */ - peekFirst(items: T[] = this.inputs.items()): T | undefined { + peekFirst(opts?: ListNavigationOpts): T | undefined { + const items = opts?.items ?? this.inputs.items(); return items.find(i => this.inputs.focusManager.isFocusable(i)); } /** Gets the last focusable item from the given list of items. */ - peekLast(items: T[] = this.inputs.items()): T | undefined { + peekLast(opts?: ListNavigationOpts): T | undefined { + const items = opts?.items ?? this.inputs.items(); for (let i = items.length - 1; i >= 0; i--) { if (this.inputs.focusManager.isFocusable(items[i])) { return items[i]; @@ -81,16 +98,21 @@ export class ListNavigation { } /** Advances to the next or previous focusable item in the list based on the given delta. */ - private _advance(delta: 1 | -1, opts?: {focusElement?: boolean}): boolean { - const item = this._peek(delta); + private _advance(delta: 1 | -1, opts?: ListNavigationOpts): boolean { + const item = this._peek(delta, opts); return item ? this.goto(item, opts) : false; } /** Peeks the next or previous focusable item in the list based on the given delta. */ - private _peek(delta: 1 | -1): T | undefined { - const items = this.inputs.items(); + private _peek(delta: 1 | -1, opts?: ListNavigationOpts): T | undefined { + const items = opts?.items ?? this.inputs.items(); const itemCount = items.length; - const startIndex = this.inputs.focusManager.activeIndex(); + const activeItem = this.inputs.focusManager.inputs.activeItem(); + const startIndex = + opts?.items && activeItem + ? items.indexOf(activeItem) + : this.inputs.focusManager.activeIndex(); + const step = (i: number) => this.inputs.wrap() ? (i + delta + itemCount) % itemCount : i + delta; diff --git a/src/aria/private/behaviors/list-selection/list-selection.ts b/src/aria/private/behaviors/list-selection/list-selection.ts index 540164b487be..c5b08f17a15b 100644 --- a/src/aria/private/behaviors/list-selection/list-selection.ts +++ b/src/aria/private/behaviors/list-selection/list-selection.ts @@ -53,6 +53,7 @@ export class ListSelection, V> { !item || item.disabled() || !item.selectable() || + !this.inputs.focusManager.isFocusable(item as T) || this.inputs.values().includes(item.value()) ) { return; @@ -138,7 +139,7 @@ export class ListSelection, V> { toggleAll() { const selectableValues = this.inputs .items() - .filter(i => !i.disabled() && i.selectable()) + .filter(i => !i.disabled() && i.selectable() && this.inputs.focusManager.isFocusable(i)) .map(i => i.value()); selectableValues.every(i => this.inputs.values().includes(i)) diff --git a/src/aria/private/behaviors/list/list.spec.ts b/src/aria/private/behaviors/list/list.spec.ts index 6e4cc1296216..39a6c98eb138 100644 --- a/src/aria/private/behaviors/list/list.spec.ts +++ b/src/aria/private/behaviors/list/list.spec.ts @@ -245,6 +245,39 @@ describe('List Behavior', () => { list.prev(); expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); }); + + describe('with items subset', () => { + it('should navigate next/prev within subset', () => { + const {list, items} = getDefaultPatterns(); + const subset = [items[0], items[2], items[4]]; + + // Start at 0 + expect(list.inputs.activeItem()).toBe(items[0]); + + // next(subset) -> 2 (skip 1) + list.next({items: subset}); + expect(list.inputs.activeItem()).toBe(items[2]); + + // next(subset) -> 4 (skip 3) + list.next({items: subset}); + expect(list.inputs.activeItem()).toBe(items[4]); + + // prev(subset) -> 2 (skip 3) + list.prev({items: subset}); + expect(list.inputs.activeItem()).toBe(items[2]); + }); + + it('should verify first/last within subset', () => { + const {list, items} = getDefaultPatterns(); + const subset = [items[1], items[2], items[3]]; + + list.first({items: subset}); + expect(list.inputs.activeItem()).toBe(items[1]); + + list.last({items: subset}); + expect(list.inputs.activeItem()).toBe(items[3]); + }); + }); }); describe('Selection', () => { diff --git a/src/aria/private/behaviors/list/list.ts b/src/aria/private/behaviors/list/list.ts index cd9ac11d914d..2fd702f481ff 100644 --- a/src/aria/private/behaviors/list/list.ts +++ b/src/aria/private/behaviors/list/list.ts @@ -25,13 +25,14 @@ import { } from '../list-typeahead/list-typeahead'; /** The operations that the list can perform after navigation. */ -interface NavOptions { +export interface NavOptions { toggle?: boolean; select?: boolean; selectOne?: boolean; selectRange?: boolean; anchor?: boolean; focusElement?: boolean; + items?: T[]; } /** Represents an item in the list. */ @@ -106,27 +107,27 @@ export class List, V> { } /** Navigates to the first option in the list. */ - first(opts?: NavOptions) { + first(opts?: NavOptions) { this._navigate(opts, () => this.navigationBehavior.first(opts)); } /** Navigates to the last option in the list. */ - last(opts?: NavOptions) { + last(opts?: NavOptions) { this._navigate(opts, () => this.navigationBehavior.last(opts)); } /** Navigates to the next option in the list. */ - next(opts?: NavOptions) { + next(opts?: NavOptions) { this._navigate(opts, () => this.navigationBehavior.next(opts)); } /** Navigates to the previous option in the list. */ - prev(opts?: NavOptions) { + prev(opts?: NavOptions) { this._navigate(opts, () => this.navigationBehavior.prev(opts)); } /** Navigates to the given item in the list. */ - goto(item: T, opts?: NavOptions) { + goto(item: T, opts?: NavOptions) { this._navigate(opts, () => this.navigationBehavior.goto(item, opts)); } diff --git a/src/aria/private/behaviors/tree/BUILD.bazel b/src/aria/private/behaviors/tree/BUILD.bazel new file mode 100644 index 000000000000..2bfeeb1b7eeb --- /dev/null +++ b/src/aria/private/behaviors/tree/BUILD.bazel @@ -0,0 +1,35 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "tree", + srcs = ["tree.ts"], + deps = [ + "//:node_modules/@angular/core", + "//src/aria/private/behaviors/expansion", + "//src/aria/private/behaviors/list", + "//src/aria/private/behaviors/list-focus", + "//src/aria/private/behaviors/list-navigation", + "//src/aria/private/behaviors/list-selection", + "//src/aria/private/behaviors/list-typeahead", + "//src/aria/private/behaviors/signal-like", + ], +) + +ng_project( + name = "unit_test_sources", + testonly = True, + srcs = ["tree.spec.ts"], + deps = [ + ":tree", + "//:node_modules/@angular/core", + "//src/aria/private/behaviors/list-focus", + "//src/aria/private/behaviors/signal-like", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/private/behaviors/tree/tree.spec.ts b/src/aria/private/behaviors/tree/tree.spec.ts new file mode 100644 index 000000000000..7083cf2dde0d --- /dev/null +++ b/src/aria/private/behaviors/tree/tree.spec.ts @@ -0,0 +1,547 @@ +/** + * @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 {signal, WritableSignalLike} from '../signal-like/signal-like'; +import {Tree, TreeInputs, TreeItem} from './tree'; + +interface TestItem extends TreeItem> { + value: WritableSignalLike; + id: WritableSignalLike; + disabled: WritableSignalLike; + selectable: WritableSignalLike; + searchTerm: WritableSignalLike; + index: WritableSignalLike; + children: WritableSignalLike[]>; + parent: WritableSignalLike | undefined>; + visible: WritableSignalLike; + expanded: WritableSignalLike; + expandable: WritableSignalLike; + focusable: WritableSignalLike; +} + +type TestInputs = Partial, V>> & { + numItems?: number; +}; + +describe('Tree Behavior', () => { + function getTree(inputs: TestInputs = {}): Tree, V> { + const items = inputs.items || signal([]); + const focusInputs = { + activeItem: signal | undefined>(undefined), + disabled: signal(false), + softDisabled: signal(true), + focusMode: signal('roving' as const), + element: signal({focus: () => {}} as HTMLElement), + ...inputs, + items, + }; + + return new Tree, V>({ + ...focusInputs, + values: signal([]), + multi: signal(false), + multiExpandable: signal(true), + selectionMode: signal('follow'), + wrap: signal(true), + orientation: signal('vertical'), + textDirection: signal('ltr'), + typeaheadDelay: signal(200), + ...inputs, + }); + } + + function getItems(values: V[]): TestItem[] { + const items = values.map( + (value, index) => + ({ + value: signal(value), + id: signal(`item-${index}`), + element: signal(document.createElement('div')), + disabled: signal(false), + selectable: signal(true), + searchTerm: signal(String(value)), + index: signal(index), + children: signal[]>([]), + parent: signal | undefined>(undefined), + visible: signal(true), + expanded: signal(false), + expandable: signal(true), + focusable: signal(true), + }) as TestItem, + ); + + return items; + } + + function buildHierarchy(items: TestItem[], hierarchy: {[key: number]: number[]}) { + Object.entries(hierarchy).forEach(([parentIdx, childIndices]) => { + const parent = items[Number(parentIdx)]; + const children = childIndices.map(i => items[i]); + parent.children.set(children); + children.forEach(child => child.parent.set(parent)); + }); + } + + function getTreeAndItems(values: V[], inputs: Partial> = {}) { + const items = signal[]>([]); + const tree = getTree({...inputs, items}); + items.set(getItems(values)); + tree.inputs.activeItem.set(tree.inputs.items()[0]); + return {tree, items: items()}; + } + + function getDefaultPatterns(inputs: Partial> = {}) { + return getTreeAndItems([0, 1, 2, 3, 4, 5, 6, 7, 8], inputs); + } + + describe('with focusMode: "activedescendant"', () => { + it('should set the list tab index to 0', () => { + const {tree} = getDefaultPatterns({focusMode: signal('activedescendant')}); + expect(tree.tabIndex()).toBe(0); + }); + + it('should set the active descendant to the active item id', () => { + const {tree} = getDefaultPatterns({focusMode: signal('activedescendant')}); + expect(tree.activeDescendant()).toBe('item-0'); + tree.next(); + expect(tree.activeDescendant()).toBe('item-1'); + }); + + it('should set item tab index to -1', () => { + const {tree, items} = getDefaultPatterns({focusMode: signal('activedescendant')}); + expect(tree.getItemTabindex(items[0])).toBe(-1); + }); + }); + + describe('with focusMode: "roving"', () => { + it('should set the list tab index to -1', () => { + const {tree} = getDefaultPatterns({focusMode: signal('roving')}); + expect(tree.tabIndex()).toBe(-1); + }); + + it('should not set the active descendant', () => { + const {tree} = getDefaultPatterns({focusMode: signal('roving')}); + expect(tree.activeDescendant()).toBeUndefined(); + }); + + it('should set the active item tab index to 0 and others to -1', () => { + const {tree, items} = getDefaultPatterns({focusMode: signal('roving')}); + expect(tree.getItemTabindex(items[0])).toBe(0); + expect(tree.getItemTabindex(items[1])).toBe(-1); + tree.next(); + expect(tree.getItemTabindex(items[0])).toBe(-1); + expect(tree.getItemTabindex(items[1])).toBe(0); + }); + }); + + describe('with disabled: true and softDisabled is false', () => { + it('should report disabled state', () => { + const {tree} = getDefaultPatterns({disabled: signal(true), softDisabled: signal(false)}); + expect(tree.disabled()).toBe(true); + }); + + it('should not change active index on navigation', () => { + const {tree} = getDefaultPatterns({disabled: signal(true), softDisabled: signal(false)}); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.next(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.last(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + }); + + it('should not select items', () => { + const {tree} = getDefaultPatterns({disabled: signal(true), softDisabled: signal(false)}); + tree.next({selectOne: true}); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should have a tab index of 0', () => { + const {tree} = getDefaultPatterns({disabled: signal(true), softDisabled: signal(false)}); + expect(tree.tabIndex()).toBe(0); + }); + }); + + describe('with disabled: true', () => { + it('should report disabled state', () => { + const {tree} = getDefaultPatterns({disabled: signal(true)}); + expect(tree.disabled()).toBe(true); + }); + + it('should not change active index on navigation', () => { + const {tree} = getDefaultPatterns({disabled: signal(true)}); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.next(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.last(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + }); + + it('should not select items', () => { + const {tree} = getDefaultPatterns({disabled: signal(true)}); + tree.next({selectOne: true}); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should have a tab index of 0', () => { + const {tree} = getDefaultPatterns({disabled: signal(true)}); + expect(tree.tabIndex()).toBe(0); + }); + }); + + describe('Navigation', () => { + it('should navigate to the next item with next()', () => { + const {tree} = getDefaultPatterns(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.next(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[1]); + }); + + it('should navigate to the previous item with prev()', () => { + const {tree, items} = getDefaultPatterns(); + tree.inputs.activeItem.set(items[1]); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[1]); + tree.prev(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + }); + + it('should navigate to the first item with first()', () => { + const {tree, items} = getDefaultPatterns(); + tree.inputs.activeItem.set(items[8]); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[8]); + tree.first(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + }); + + it('should navigate to the last item with last()', () => { + const {tree} = getDefaultPatterns(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.last(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[8]); + }); + + it('should skip disabled items when softDisabled is false', () => { + const {tree, items} = getDefaultPatterns({softDisabled: signal(false)}); + items[1].disabled.set(true); // Disable second item + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.next(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[2]); // Should skip to '2' + tree.prev(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); // Should skip back to '0' + }); + + it('should not skip disabled items when navigating', () => { + const {tree, items} = getDefaultPatterns(); + items[1].disabled.set(true); // Disable second item + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.next(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[1]); // Should land on second item even though it's disabled + }); + + it('should not wrap with wrap: false', () => { + const {tree} = getDefaultPatterns({wrap: signal(false)}); + tree.last(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[8]); + tree.next(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[8]); // Stays at the end + tree.first(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.prev(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); // Stays at the beginning + }); + + it('should navigate with orientation: "horizontal"', () => { + const {tree} = getDefaultPatterns({orientation: signal('horizontal')}); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + tree.next(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[1]); + tree.prev(); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + }); + + describe('with items subset', () => { + it('should navigate next/prev within subset', () => { + const {tree, items} = getDefaultPatterns(); + const subset = [items[0], items[2], items[4]]; + + // Start at 0 + expect(tree.inputs.activeItem()).toBe(items[0]); + + // next(subset) -> 2 (skip 1) + tree.next({items: subset}); + expect(tree.inputs.activeItem()).toBe(items[2]); + + // next(subset) -> 4 (skip 3) + tree.next({items: subset}); + expect(tree.inputs.activeItem()).toBe(items[4]); + + // prev(subset) -> 2 (skip 3) + tree.prev({items: subset}); + expect(tree.inputs.activeItem()).toBe(items[2]); + }); + + it('should verify first/last within subset', () => { + const {tree, items} = getDefaultPatterns(); + const subset = [items[1], items[2], items[3]]; + + tree.first({items: subset}); + expect(tree.inputs.activeItem()).toBe(items[1]); + + tree.last({items: subset}); + expect(tree.inputs.activeItem()).toBe(items[3]); + }); + }); + + describe('Tree hierarchy', () => { + it('should navigate to first child', () => { + const {tree, items} = getDefaultPatterns(); + // 0 -> [1, 2] + buildHierarchy(items, {0: [1, 2]}); + + tree.goto(items[0]); + tree.firstChild(); + expect(tree.inputs.activeItem()).toBe(items[1]); + }); + + it('should navigate to last child', () => { + const {tree, items} = getDefaultPatterns(); + // 0 -> [1, 2] + buildHierarchy(items, {0: [1, 2]}); + + tree.goto(items[0]); + tree.lastChild(); + expect(tree.inputs.activeItem()).toBe(items[2]); + }); + + it('should navigate to next sibling', () => { + const {tree, items} = getDefaultPatterns(); + // 0 -> [1, 2, 3] + buildHierarchy(items, {0: [1, 2, 3]}); + + tree.goto(items[1]); + tree.nextSibling(); + expect(tree.inputs.activeItem()).toBe(items[2]); + + tree.nextSibling(); + expect(tree.inputs.activeItem()).toBe(items[3]); + }); + + it('should navigate to previous sibling', () => { + const {tree, items} = getDefaultPatterns(); + // 0 -> [1, 2, 3] + buildHierarchy(items, {0: [1, 2, 3]}); + + tree.goto(items[3]); + tree.prevSibling(); + expect(tree.inputs.activeItem()).toBe(items[2]); + + tree.prevSibling(); + expect(tree.inputs.activeItem()).toBe(items[1]); + }); + + it('should navigate to parent', () => { + const {tree, items} = getDefaultPatterns(); + // 0 -> [1] + buildHierarchy(items, {0: [1]}); + + tree.goto(items[1]); + tree.parent(); + expect(tree.inputs.activeItem()).toBe(items[0]); + }); + + it('should not navigate to first child if no children', () => { + const {tree, items} = getDefaultPatterns(); + tree.goto(items[0]); + tree.firstChild(); + expect(tree.inputs.activeItem()).toBe(items[0]); // Stays same + }); + + it('should skipping invisible items (subset navigation)', () => { + const {tree, items} = getDefaultPatterns(); + // 0 -> p + // 1 -> c (invisible) + // 2 -> c + buildHierarchy(items, {0: [1, 2]}); + items[1].visible.set(false); + + tree.goto(items[0]); + // firstChild should skip 1 and go to 2 + tree.firstChild(); + expect(tree.inputs.activeItem()).toBe(items[2]); + }); + }); + }); + + describe('Selection', () => { + describe('single select', () => { + it('should not select when navigating', () => { + const {tree} = getDefaultPatterns({values: signal([]), multi: signal(false)}); + tree.next(); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should select an item when navigating with selectOne:true', () => { + const {tree} = getTreeAndItems([0, 1], {values: signal([]), multi: signal(false)}); + + tree.next({selectOne: true}); + expect(tree.inputs.values()).toEqual([1]); + }); + + it('should not select a non-selectable item when navigating with selectOne:true', () => { + const {tree, items} = getDefaultPatterns({values: signal([]), multi: signal(false)}); + items[1].selectable.set(false); + tree.next({selectOne: true}); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should toggle an item when navigating with toggle:true', () => { + const {tree, items} = getDefaultPatterns({values: signal([]), multi: signal(false)}); + tree.goto(items[1], {selectOne: true}); + expect(tree.inputs.values()).toEqual([1]); + + tree.goto(items[1], {toggle: true}); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should not toggle a non-selectable item when navigating with toggle:true', () => { + const {tree, items} = getDefaultPatterns({values: signal([]), multi: signal(false)}); + items[1].selectable.set(false); + tree.goto(items[1], {toggle: true}); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should only allow one selected item', () => { + const {tree} = getDefaultPatterns({values: signal([]), multi: signal(false)}); + tree.next({selectOne: true}); + expect(tree.inputs.values()).toEqual([1]); + tree.next({selectOne: true}); + expect(tree.inputs.values()).toEqual([2]); + }); + }); + + describe('multi select', () => { + it('should not select when navigating', () => { + const {tree} = getDefaultPatterns({values: signal([]), multi: signal(true)}); + tree.next(); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should select an item with toggle:true', () => { + const {tree} = getDefaultPatterns({values: signal([]), multi: signal(true)}); + tree.next({toggle: true}); + expect(tree.inputs.values()).toEqual([1]); + }); + + it('should not select a non-selectable item with toggle:true', () => { + const {tree, items} = getDefaultPatterns({values: signal([]), multi: signal(true)}); + items[1].selectable.set(false); + tree.next({toggle: true}); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should allow multiple selected items', () => { + const {tree} = getDefaultPatterns({values: signal([]), multi: signal(true)}); + tree.next({toggle: true}); + tree.next({toggle: true}); + expect(tree.inputs.values()).toEqual([1, 2]); + }); + + it('should select a range of items with selectRange:true', () => { + const {tree} = getDefaultPatterns({values: signal([]), multi: signal(true)}); + tree.anchor(0); + tree.next({selectRange: true}); + expect(tree.inputs.values()).toEqual([0, 1]); // Apple (0), Apricot (1) + tree.next({selectRange: true}); + expect(tree.inputs.values()).toEqual([0, 1, 2]); + tree.prev({selectRange: true}); + expect(tree.inputs.values()).toEqual([0, 1]); + tree.prev({selectRange: true}); + expect(tree.inputs.values()).toEqual([0]); + }); + + it('should not wrap when range selecting', () => { + const {tree} = getDefaultPatterns({values: signal([]), multi: signal(true)}); + tree.anchor(0); + tree.prev({selectRange: true}); + expect(tree.inputs.activeItem()).toBe(tree.inputs.items()[0]); + expect(tree.inputs.values()).toEqual([]); + }); + + it('should not select disabled items in a range', () => { + const {tree, items} = getDefaultPatterns({values: signal([]), multi: signal(true)}); + items[1].disabled.set(true); + tree.anchor(0); + tree.goto(items[3], {selectRange: true}); + expect(tree.inputs.values()).toEqual([0, 2, 3]); // Skips 1 + }); + + it('should not select non-selectable items in a range', () => { + const {tree, items} = getDefaultPatterns({values: signal([]), multi: signal(true)}); + items[1].selectable.set(false); + tree.anchor(0); + tree.goto(items[3], {selectRange: true}); + expect(tree.inputs.values()).toEqual([0, 2, 3]); // Skips 1 + }); + }); + }); + + describe('Typeahead', () => { + function delay(amount: number) { + return new Promise(resolve => setTimeout(resolve, amount)); + } + + it('should navigate to an item via typeahead', async () => { + const {tree, items} = getTreeAndItems(['Apple', 'Apricot', 'Banana', 'Cherry']); + tree.goto(items[2]); // Start at Banana + expect(tree.inputs.activeItem()).toBe(items[2]); + + tree.search('A'); // "A" -> Apple (0) + expect(tree.inputs.activeItem()).toBe(items[0]); // Moved to Apple + + tree.search('p'); // "Ap" -> Apple (0) + expect(tree.inputs.activeItem()).toBe(items[0]); + + tree.search('r'); // "Apr" -> Apricot (1) + expect(tree.inputs.activeItem()).toBe(items[1]); + + await delay(500); // Reset + tree.search('B'); + expect(tree.inputs.activeItem()).toBe(items[2]); // Banana + }); + + it('should respect typeaheadDelay', async () => { + const {tree, items} = getTreeAndItems(['Apple', 'Apricot', 'Banana'], { + typeaheadDelay: signal(100), + }); + tree.goto(items[2]); // Start at Banana + + tree.search('A'); + expect(tree.inputs.activeItem()).toBe(items[0]); // Apple + + await delay(50); // < 100 + tree.search('p'); // "Ap" -> Apple + expect(tree.inputs.activeItem()).toBe(items[0]); + + await delay(150); // > 100, Reset + tree.search('B'); + expect(tree.inputs.activeItem()).toBe(items[2]); // Banana + }); + + it('should select an item via typeahead', () => { + const {tree} = getTreeAndItems(['Apple', 'Banana'], {multi: signal(false)}); + tree.search('b', {selectOne: true}); + expect(tree.inputs.values()).toEqual(['Banana']); + }); + + it('should not select a non-selectable item via typeahead', () => { + const {tree, items} = getTreeAndItems(['Apple', 'Banana'], {multi: signal(false)}); + items[1].selectable.set(false); + tree.search('b', {selectOne: true}); + expect(tree.inputs.values()).toEqual([]); + }); + }); +}); diff --git a/src/aria/private/behaviors/tree/tree.ts b/src/aria/private/behaviors/tree/tree.ts new file mode 100644 index 000000000000..7dd6040c2fd2 --- /dev/null +++ b/src/aria/private/behaviors/tree/tree.ts @@ -0,0 +1,325 @@ +/** + * @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, SignalLike} from '../signal-like/signal-like'; +import {ExpansionItem, ListExpansion, ListExpansionInputs} from '../expansion/expansion'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; +import { + ListNavigation, + ListNavigationInputs, + ListNavigationItem, +} from '../list-navigation/list-navigation'; +import { + ListSelection, + ListSelectionInputs, + ListSelectionItem, +} from '../list-selection/list-selection'; +import { + ListTypeahead, + ListTypeaheadInputs, + ListTypeaheadItem, +} from '../list-typeahead/list-typeahead'; +import {NavOptions} from '../list/list'; + +/** Represents an item in the tree. */ +export interface TreeItem> + extends + ListTypeaheadItem, + ListNavigationItem, + ListSelectionItem, + ListFocusItem, + ExpansionItem { + /** The children of this item. */ + children: SignalLike; + + /** The parent of this item. */ + parent: SignalLike; + + /** Whether this item is visible. */ + visible: SignalLike; +} + +/** The necessary inputs for the tree behavior. */ +export type TreeInputs, V> = ListFocusInputs & + ListNavigationInputs & + ListSelectionInputs & + ListTypeaheadInputs & + ListExpansionInputs; + +/** Controls focus for a tree, ensuring that only visible items are focusable. */ +class TreeListFocus, V> extends ListFocus { + override isFocusable(item: T): boolean { + return super.isFocusable(item) && item.visible(); + } +} + +/** Controls the state of a tree. */ +export class Tree, V> { + /** Controls navigation for the tree. */ + navigationBehavior: ListNavigation; + + /** Controls selection for the tree. */ + selectionBehavior: ListSelection; + + /** Controls typeahead for the tree. */ + typeaheadBehavior: ListTypeahead; + + /** Controls focus for the tree. */ + focusBehavior: ListFocus; + + /** Controls expansion for the tree. */ + expansionBehavior: ListExpansion; + + /** Whether the tree is disabled. */ + disabled = computed(() => this.focusBehavior.isListDisabled()); + + /** The id of the current active item. */ + activeDescendant = computed(() => this.focusBehavior.getActiveDescendant()); + + /** The tab index of the tree. */ + tabIndex = computed(() => this.focusBehavior.getListTabIndex()); + + /** The index of the currently active item in the tree (within the flattened list). */ + activeIndex = computed(() => this.focusBehavior.activeIndex()); + + /** The uncommitted index for selecting a range of options. */ + private _anchorIndex = signal(0); + + /** Whether the list should wrap. */ + private _wrap = signal(true); + + constructor(readonly inputs: TreeInputs) { + this.focusBehavior = new TreeListFocus(inputs); + this.selectionBehavior = new ListSelection({...inputs, focusManager: this.focusBehavior}); + this.typeaheadBehavior = new ListTypeahead({...inputs, focusManager: this.focusBehavior}); + this.expansionBehavior = new ListExpansion(inputs); + this.navigationBehavior = new ListNavigation({ + ...inputs, + focusManager: this.focusBehavior, + wrap: computed(() => this._wrap() && this.inputs.wrap()), + }); + } + + /** Returns the tab index for the given item. */ + getItemTabindex(item: T) { + return this.focusBehavior.getItemTabIndex(item); + } + + /** Navigates to the first option in the tree. */ + first(opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.first(opts)); + } + + /** Navigates to the last option in the tree. */ + last(opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.last(opts)); + } + + /** Navigates to the next option in the tree. */ + next(opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.next(opts)); + } + + /** Navigates to the previous option in the tree. */ + prev(opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.prev(opts)); + } + + /** Navigates to the first child of the current active item. */ + firstChild(opts?: NavOptions) { + this._navigate(opts, () => { + const item = this.inputs.activeItem(); + const items = item?.children?.() ?? []; + return this.navigationBehavior.first({items, ...opts}); + }); + } + + /** Navigates to the last child of the current active item. */ + lastChild(opts?: NavOptions) { + this._navigate(opts, () => { + const item = this.inputs.activeItem(); + const items = item?.children?.() ?? []; + return this.navigationBehavior.last({items, ...opts}); + }); + } + + /** Navigates to the next sibling of the current active item. */ + nextSibling(opts?: NavOptions) { + this._navigate(opts, () => { + const item = this.inputs.activeItem(); + const items = item?.parent?.()?.children?.() ?? []; + return this.navigationBehavior.next({items, ...opts}); + }); + } + + /** Navigates to the previous sibling of the current active item. */ + prevSibling(opts?: NavOptions) { + this._navigate(opts, () => { + const item = this.inputs.activeItem(); + const items = item?.parent?.()?.children?.() ?? []; + return this.navigationBehavior.prev({items, ...opts}); + }); + } + + /** Navigates to the parent of the current active item. */ + parent(opts?: NavOptions) { + this._navigate(opts, () => + this.navigationBehavior.goto(this.inputs.activeItem()?.parent?.(), opts), + ); + } + + /** Navigates to the given item in the tree. */ + goto(item: T, opts?: NavOptions) { + this._navigate(opts, () => this.navigationBehavior.goto(item, opts)); + } + + /** Removes focus from the tree. */ + unfocus() { + this.inputs.activeItem.set(undefined); + } + + /** Marks the given index as the potential start of a range selection. */ + anchor(index: number) { + this._anchorIndex.set(index); + } + + /** Handles typeahead search navigation for the tree. */ + search(char: string, opts?: NavOptions) { + this._navigate(opts, () => this.typeaheadBehavior.search(char)); + } + + /** Checks if the tree is currently typing for typeahead search. */ + isTyping() { + return this.typeaheadBehavior.isTyping(); + } + + /** Selects the currently active item in the tree. */ + select(item?: T) { + this.selectionBehavior.select(item); + } + + /** Sets the selection to only the current active item. */ + selectOne() { + this.selectionBehavior.selectOne(); + } + + /** Deselects the currently active item in the tree. */ + deselect(item?: T) { + this.selectionBehavior.deselect(item); + } + + /** Deselects all items in the tree. */ + deselectAll() { + this.selectionBehavior.deselectAll(); + } + + /** Toggles the currently active item in the tree. */ + toggle(item?: T) { + this.selectionBehavior.toggle(item); + } + + /** Toggles the currently active item in the tree, deselecting all other items. */ + toggleOne() { + this.selectionBehavior.toggleOne(); + } + + /** Toggles the selection of all items in the tree. */ + toggleAll() { + this.selectionBehavior.toggleAll(); + } + + /** Toggles the expansion of the given item. */ + toggleExpansion(item?: T) { + item ??= this.inputs.activeItem(); + if (!item || !this.isFocusable(item)) return; + + if (this.isExpandable(item)) { + this.expansionBehavior.toggle(item); + } + } + + /** Expands the given item. */ + expand(item: T) { + if (this.isExpandable(item)) { + this.expansionBehavior.open(item); + } + } + + /** Collapses the given item. */ + collapse(item: T) { + this.expansionBehavior.close(item); + } + + /** Expands all sibling items of the given item (or active item). */ + expandSiblings(item?: T) { + item ??= this.inputs.activeItem(); + if (!item) return; + + const parent = item.parent?.(); + // TODO: This assumes that items without a parent are root items. + const siblings = parent ? parent.children?.() : this.inputs.items().filter(i => !i.parent?.()); + siblings?.forEach(s => this.expand(s)); + } + + /** Expands all items in the tree. */ + expandAll() { + this.expansionBehavior.openAll(); + } + + /** Collapses all items in the tree. */ + collapseAll() { + this.expansionBehavior.closeAll(); + } + + /** Checks if the given item is able to receive focus. */ + isFocusable(item: T) { + return this.focusBehavior.isFocusable(item); + } + + /** Checks if the given item is expandable. */ + isExpandable(item: T) { + return this.expansionBehavior.isExpandable(item); + } + + /** Handles updating selection for the tree. */ + updateSelection(opts: NavOptions = {anchor: true}) { + if (opts.toggle) { + this.selectionBehavior.toggle(); + } + if (opts.select) { + this.selectionBehavior.select(); + } + if (opts.selectOne) { + this.selectionBehavior.selectOne(); + } + if (opts.selectRange) { + this.selectionBehavior.selectRange(); + } + if (!opts.anchor) { + this.anchor(this.selectionBehavior.rangeStartIndex()); + } + } + + /** + * Safely performs a navigation operation. + */ + private _navigate(opts: NavOptions = {}, operation: () => boolean) { + if (opts?.selectRange) { + this._wrap.set(false); + this.selectionBehavior.rangeStartIndex.set(this._anchorIndex()); + } + + const moved = operation(); + + if (moved) { + this.updateSelection(opts); + } + + this._wrap.set(true); + } +} diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index 7b71471193b6..bff407d696c5 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -78,7 +78,17 @@ function _type( if (popup instanceof ComboboxListboxPattern) { (popup.inputs.items as WritableSignalLike).set(options); } else if (popup instanceof ComboboxTreePattern) { - (popup.inputs.allItems as WritableSignalLike).set(options); + (popup.inputs.items as WritableSignalLike).set(options); + // Auto-expand parents of matched items so they are visible + options.forEach(option => { + if (option instanceof TreeItemPattern) { + let parent = option.parent(); + while (parent instanceof TreeItemPattern) { + (parent.expanded as WritableSignalLike).set(true); + parent = parent.parent(); + } + } + }); } firstMatch.set(options[0]?.value()); combobox.onFilter(); @@ -164,7 +174,7 @@ function getTreePattern( const tree = new ComboboxTreePattern({ id: signal('tree-1'), - allItems: items, + items, values: signal(initialValue ? [initialValue] : []), combobox: signal(combobox) as any, activeItem: signal(undefined), @@ -182,6 +192,8 @@ function getTreePattern( currentType: signal('false'), }); + class TestTreeItemPattern extends TreeItemPattern {} + // Recursive function to create tree items function createTreeItems( data: TreeItemData[], @@ -190,9 +202,9 @@ function getTreePattern( return data.map((node, index) => { const element = document.createElement('div'); element.role = 'treeitem'; - const treeItem = new TreeItemPattern({ + const treeItem = new TestTreeItemPattern({ value: signal(node.value), - id: signal('tree-item-' + tree.inputs.allItems().length), + id: signal('tree-item-' + tree.inputs.items().length), disabled: signal(false), selectable: signal(true), expanded: signal(false), @@ -204,7 +216,7 @@ function getTreePattern( children: signal([]), }); - (tree.inputs.allItems as WritableSignalLike[]>).update(items => + (tree.inputs.items as WritableSignalLike[]>).update(items => items.concat(treeItem), ); @@ -686,11 +698,17 @@ describe('Combobox with Tree Pattern', () => { it('should expand a closed node on ArrowRight', () => { const {combobox, tree} = getPatterns(); - const before = tree.visibleItems().map(i => i.searchTerm()); + const before = tree.inputs + .items() + .filter(i => i.visible()) + .map(i => i.searchTerm()); expect(before).toEqual(['Fruit', 'Vegetables', 'Grains']); combobox.onKeydown(down()); combobox.onKeydown(right()); - const after = tree.visibleItems().map(i => i.searchTerm()); + const after = tree.inputs + .items() + .filter(i => i.visible()) + .map(i => i.searchTerm()); expect(after).toEqual(['Fruit', 'Apple', 'Banana', 'Cantaloupe', 'Vegetables', 'Grains']); }); @@ -707,7 +725,10 @@ describe('Combobox with Tree Pattern', () => { combobox.onKeydown(down()); combobox.onKeydown(right()); combobox.onKeydown(left()); - const after = tree.visibleItems().map(i => i.searchTerm()); + const after = tree.inputs + .items() + .filter(i => i.visible()) + .map(i => i.searchTerm()); expect(after).toEqual(['Fruit', 'Vegetables', 'Grains']); expect(tree.inputs.activeItem()?.searchTerm()).toBe('Fruit'); }); @@ -756,7 +777,7 @@ describe('Combobox with Tree Pattern', () => { }); it('should select and commit on click', () => { - combobox.onClick(clickTreeItem(tree.inputs.allItems(), 0)); + combobox.onClick(clickTreeItem(tree.inputs.items(), 0)); expect(tree.inputs.values()).toEqual(['Fruit']); expect(inputEl.value).toBe('Fruit'); }); @@ -764,7 +785,7 @@ describe('Combobox with Tree Pattern', () => { it('should select and commit to input on Enter', () => { combobox.onKeydown(down()); combobox.onKeydown(enter()); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]); expect(tree.inputs.values()).toEqual(['Fruit']); expect(inputEl.value).toBe('Fruit'); }); @@ -816,8 +837,11 @@ describe('Combobox with Tree Pattern', () => { }); it('should select and commit on click', () => { - combobox.onClick(clickTreeItem(tree.inputs.allItems(), 2)); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[2]); + // Expand Fruit: Down -> Right + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onClick(clickTreeItem(tree.inputs.items(), 2)); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[2]); expect(tree.inputs.values()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); @@ -833,7 +857,7 @@ describe('Combobox with Tree Pattern', () => { it('should select the first item on arrow down when collapsed', () => { combobox.onKeydown(down()); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]); expect(tree.inputs.values()).toEqual(['Fruit']); }); @@ -873,8 +897,10 @@ describe('Combobox with Tree Pattern', () => { }); it('should select and commit on click', () => { - combobox.onClick(clickTreeItem(tree.inputs.allItems(), 2)); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[2]); + combobox.onKeydown(down()); + combobox.onKeydown(right()); + combobox.onClick(clickTreeItem(tree.inputs.items(), 2)); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[2]); expect(tree.inputs.values()).toEqual(['Banana']); expect(inputEl.value).toBe('Banana'); }); @@ -890,7 +916,7 @@ describe('Combobox with Tree Pattern', () => { it('should select the first item on arrow down when collapsed', () => { combobox.onKeydown(down()); - expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]); + expect(tree.getSelectedItems()[0]).toBe(tree.inputs.items()[0]); expect(tree.inputs.values()).toEqual(['Fruit']); }); @@ -925,7 +951,6 @@ describe('Combobox with Tree Pattern', () => { }); it('should commit the selected option on focusout', () => { - combobox.onKeydown(down()); type('App'); combobox.onFocusOut(new FocusEvent('focusout')); expect(inputEl.value).toBe('Apple'); @@ -945,7 +970,7 @@ describe('Combobox with Tree Pattern', () => { const {combobox, tree, inputEl} = getPatterns({readonly: true}); combobox.onClick(clickInput(inputEl)); expect(combobox.expanded()).toBe(true); - combobox.onClick(clickTreeItem(tree.inputs.allItems(), 0)); + combobox.onClick(clickTreeItem(tree.inputs.items(), 0)); expect(tree.inputs.values()).toEqual(['Fruit']); expect(inputEl.value).toBe('Fruit'); expect(combobox.expanded()).toBe(false); diff --git a/src/aria/private/toolbar/toolbar.ts b/src/aria/private/toolbar/toolbar.ts index b39abaf06411..1bdd4b78fdcc 100644 --- a/src/aria/private/toolbar/toolbar.ts +++ b/src/aria/private/toolbar/toolbar.ts @@ -101,7 +101,9 @@ export class ToolbarPattern { if (currGroup !== nextGroup) { this.listBehavior.goto( - this.listBehavior.navigationBehavior.peekFirst(currGroup.inputs.items())!, + this.listBehavior.navigationBehavior.peekFirst({ + items: currGroup.inputs.items(), + })!, ); return; @@ -121,7 +123,9 @@ export class ToolbarPattern { if (currGroup !== nextGroup) { this.listBehavior.goto( - this.listBehavior.navigationBehavior.peekLast(currGroup.inputs.items())!, + this.listBehavior.navigationBehavior.peekLast({ + items: currGroup.inputs.items(), + })!, ); return; @@ -186,7 +190,9 @@ export class ToolbarPattern { * Otherwise, sets the active index to the first focusable widget. */ setDefaultState() { - const firstItem = this.listBehavior.navigationBehavior.peekFirst(this.inputs.items()); + const firstItem = this.listBehavior.navigationBehavior.peekFirst({ + items: this.inputs.items(), + }); if (firstItem) { this.inputs.activeItem.set(firstItem); diff --git a/src/aria/private/tree/BUILD.bazel b/src/aria/private/tree/BUILD.bazel index 6f1779bf56d2..91d1b20dd507 100644 --- a/src/aria/private/tree/BUILD.bazel +++ b/src/aria/private/tree/BUILD.bazel @@ -12,8 +12,8 @@ ts_project( "//:node_modules/@angular/core", "//src/aria/private/behaviors/event-manager", "//src/aria/private/behaviors/expansion", - "//src/aria/private/behaviors/list", "//src/aria/private/behaviors/signal-like", + "//src/aria/private/behaviors/tree", "//src/aria/private/combobox", ], ) diff --git a/src/aria/private/tree/combobox-tree.ts b/src/aria/private/tree/combobox-tree.ts index ee45cd82fb51..fccec240ea6e 100644 --- a/src/aria/private/tree/combobox-tree.ts +++ b/src/aria/private/tree/combobox-tree.ts @@ -19,6 +19,9 @@ export class ComboboxTreePattern extends TreePattern implements ComboboxTreeControls, V> { + /** Toggles to expand or collapse a tree item. */ + toggleExpansion = (item?: TreeItemPattern) => this.treeBehavior.toggleExpansion(item); + /** Whether the currently focused item is collapsible. */ isItemCollapsible = () => this.inputs.activeItem()?.parent() instanceof TreeItemPattern; @@ -26,13 +29,13 @@ export class ComboboxTreePattern role = () => 'tree' as const; /* The id of the active (focused) item in the tree. */ - activeId = computed(() => this.listBehavior.activeDescendant()); + activeId = computed(() => this.treeBehavior.activeDescendant()); /** Returns the currently active (focused) item in the tree. */ getActiveItem = () => this.inputs.activeItem(); /** The list of items in the tree. */ - items = computed(() => this.inputs.allItems()); + override items = computed(() => this.inputs.items()); /** The tab index for the tree. Always -1 because the combobox handles focus. */ override tabIndex: SignalLike<-1 | 0> = () => -1; @@ -57,47 +60,47 @@ export class ComboboxTreePattern override setDefaultState(): void {} /** Navigates to the specified item in the tree. */ - focus = (item: TreeItemPattern) => this.listBehavior.goto(item); + focus = (item: TreeItemPattern) => this.treeBehavior.goto(item); /** Navigates to the next focusable item in the tree. */ - next = () => this.listBehavior.next(); + next = () => this.treeBehavior.next(); /** Navigates to the previous focusable item in the tree. */ - prev = () => this.listBehavior.prev(); + prev = () => this.treeBehavior.prev(); /** Navigates to the last focusable item in the tree. */ - last = () => this.listBehavior.last(); + last = () => this.treeBehavior.last(); /** Navigates to the first focusable item in the tree. */ - first = () => this.listBehavior.first(); + first = () => this.treeBehavior.first(); /** Unfocuses the currently focused item in the tree. */ - unfocus = () => this.listBehavior.unfocus(); + unfocus = () => this.treeBehavior.unfocus(); // TODO: handle non-selectable parent nodes. /** Selects the specified item in the tree or the current active item if not provided. */ - select = (item?: TreeItemPattern) => this.listBehavior.select(item); + select = (item?: TreeItemPattern) => this.treeBehavior.select(item); /** Toggles the selection state of the given item in the tree or the current active item if not provided. */ - toggle = (item?: TreeItemPattern) => this.listBehavior.toggle(item); + toggle = (item?: TreeItemPattern) => this.treeBehavior.toggle(item); /** Clears the selection in the tree. */ - clearSelection = () => this.listBehavior.deselectAll(); + clearSelection = () => this.treeBehavior.deselectAll(); /** Retrieves the TreeItemPattern associated with a pointer event. */ getItem = (e: PointerEvent) => this._getItem(e); /** Retrieves the currently selected items in the tree */ - getSelectedItems = () => this.inputs.allItems().filter(item => item.selected()); + getSelectedItems = () => this.inputs.items().filter(item => item.selected()); /** Sets the value of the combobox tree. */ setValue = (value: V | undefined) => this.inputs.values.set(value ? [value] : []); - /** Expands the currently focused item if it is expandable. */ - expandItem = () => this.expand(); + /** Expands the currently focused item if it is expandable, or navigates to the first child. */ + expandItem = () => this._expandOrFirstChild(); - /** Collapses the currently focused item if it is expandable. */ - collapseItem = () => this.collapse(); + /** Collapses the currently focused item if it is expandable, or navigates to the parent. */ + collapseItem = () => this._collapseOrParent(); /** Whether the specified item or the currently active item is expandable. */ isItemExpandable(item: TreeItemPattern | undefined = this.inputs.activeItem()) { @@ -105,10 +108,10 @@ export class ComboboxTreePattern } /** Expands all of the tree items. */ - expandAll = () => this.items().forEach(item => this.expansionBehavior.open(item)); + expandAll = () => this.treeBehavior.expandAll(); /** Collapses all of the tree items. */ - collapseAll = () => this.items().forEach(item => item.expansionBehavior.close(item)); + collapseAll = () => this.treeBehavior.collapseAll(); /** Whether the currently active item is selectable. */ isItemSelectable = (item: TreeItemPattern | undefined = this.inputs.activeItem()) => { diff --git a/src/aria/private/tree/tree.spec.ts b/src/aria/private/tree/tree.spec.ts index 8fdc395c2837..708b406ec7ce 100644 --- a/src/aria/private/tree/tree.spec.ts +++ b/src/aria/private/tree/tree.spec.ts @@ -18,7 +18,7 @@ type WritableSignalOverrides = { : never; }; -type TestTreeInputs = Omit & WritableSignalOverrides>, 'allItems'>; +type TestTreeInputs = Omit & WritableSignalOverrides>, 'items'>; type TestTreeItemInputs = TreeItemInputs & WritableSignalOverrides>; const a = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 65, 'A', mods); @@ -62,11 +62,11 @@ interface TestTreeItem { describe('Tree Pattern', () => { function createTree(treeData: TestTreeItem[], treeInputs: TestTreeInputs) { - const allItems = signal[]>([]); + const items = signal[]>([]); const itemPatternInputsMap = new Map>(); const tree = new TreePattern({ ...treeInputs, - allItems, + items, }); let nextId = 0; @@ -75,7 +75,7 @@ describe('Tree Pattern', () => { treeData: TestTreeItem[], parent: TreeItemPattern | TreePattern, ): TreeItemPattern[] { - const items: TreeItemPattern[] = []; + const levelItems: TreeItemPattern[] = []; for (const node of treeData) { const itemId = `treeitem-${nextId++}`; @@ -97,24 +97,24 @@ describe('Tree Pattern', () => { const item = new TreeItemPattern(itemPatternInputs); itemPatternInputsMap.set(itemId, itemPatternInputs); - allItems.set([...allItems(), item]); - items.push(item); + items.set([...items(), item]); + levelItems.push(item); const childItems = buildItems(node.children ?? [], item); itemPatternInputs.children.set(childItems); } - return items; + return levelItems; } // Build tree items recursively. buildItems(treeData, tree as TreePattern); - tree.activeItem.set(allItems()[0]); + tree.activeItem.set(items()[0]); - return {tree, allItems, itemPatternInputsMap}; + return {tree, items, itemPatternInputsMap}; } - function getItemByValue(allItems: TreeItemPattern[], value: V) { - return allItems.find(i => i.value() === value)!; + function getItemByValue(items: TreeItemPattern[], value: V) { + return items.find(i => i.value() === value)!; } const treeExample: TestTreeItem[] = [ @@ -162,29 +162,29 @@ describe('Tree Pattern', () => { }); it('should correctly compute level', () => { - const {allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); - const item0_0 = getItemByValue(allItems(), 'Item 0-0'); + const {items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), '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'); + const {items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), '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'); + const {items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); + const item0_1 = getItemByValue(items(), 'Item 0-1'); expect(item0.posinset()).toBe(1); expect(item1.posinset()).toBe(2); @@ -216,16 +216,16 @@ describe('Tree Pattern', () => { }); it('should have undefined selected state', () => { - const {allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); treeInputs.values.set(['Item 0']); expect(item0.selected()).toBeUndefined(); }); it('should correctly compute current state', () => { - const {allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); - const item1 = getItemByValue(allItems(), 'Item 1'); + const {items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); treeInputs.values.set(['Item 0']); expect(item0.current()).toBe('page'); @@ -238,8 +238,8 @@ describe('Tree Pattern', () => { }); it('should have undefined current state when non-selectable', () => { - const {allItems, itemPatternInputsMap} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {items, itemPatternInputsMap} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); treeInputs.values.set(['Item 0']); expect(item0.current()).toBe('page'); itemPatternInputsMap.get(item0.id())!.selectable.set(false); @@ -272,9 +272,9 @@ describe('Tree Pattern', () => { }); it('should correctly compute active state', () => { - const {allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); - const item1 = getItemByValue(allItems(), 'Item 1'); + const {items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); treeInputs.activeItem.set(item0); expect(item0.active()).toBe(true); @@ -286,17 +286,17 @@ describe('Tree Pattern', () => { }); it('should correctly compute tab index state', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); - expect(item0.tabIndex()).toBe(tree.listBehavior.getItemTabindex(item0)); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + expect(item0.tabIndex()).toBe(tree.treeBehavior.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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); + tree.treeBehavior.goto(item0); expect(tree.activeItem()).toBe(item0); tree.onKeydown(down()); @@ -305,10 +305,10 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item1); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); + tree.treeBehavior.goto(item1); expect(tree.activeItem()).toBe(item1); tree.onKeydown(up()); @@ -317,10 +317,10 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); + tree.treeBehavior.goto(item0); expect(tree.activeItem()).toBe(item0); tree.onKeydown(right()); @@ -329,10 +329,10 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item1); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); + tree.treeBehavior.goto(item1); expect(tree.activeItem()).toBe(item1); tree.onKeydown(left()); @@ -342,9 +342,9 @@ describe('Tree Pattern', () => { it('should navigate next on ArrowLeft (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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); treeInputs.activeItem.set(item0); expect(tree.activeItem()).toBe(item0); @@ -355,10 +355,10 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item1); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); + tree.treeBehavior.goto(item1); expect(tree.activeItem()).toBe(item1); tree.onKeydown(right()); @@ -366,10 +366,10 @@ describe('Tree Pattern', () => { }); 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.listBehavior.goto(item2); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item2 = getItemByValue(items(), 'Item 2'); + tree.treeBehavior.goto(item2); expect(tree.activeItem()).toBe(item2); tree.onKeydown(home()); @@ -377,10 +377,10 @@ describe('Tree Pattern', () => { }); 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item2 = getItemByValue(items(), 'Item 2'); + tree.treeBehavior.goto(item0); expect(tree.activeItem()).toBe(item0); tree.onKeydown(end()); @@ -394,10 +394,10 @@ describe('Tree Pattern', () => { {value: 'Item B', disabled: true, selectable: true, expanded: false}, {value: 'Item C', disabled: false, selectable: true, expanded: false}, ]; - const {tree, allItems} = createTree(localTreeExample, treeInputs); - const itemA = getItemByValue(allItems(), 'Item A'); - const itemC = getItemByValue(allItems(), 'Item C'); - tree.listBehavior.goto(itemA); + const {tree, items} = createTree(localTreeExample, treeInputs); + const itemA = getItemByValue(items(), 'Item A'); + const itemC = getItemByValue(items(), 'Item C'); + tree.treeBehavior.goto(itemA); expect(tree.activeItem()).toBe(itemA); tree.onKeydown(down()); @@ -411,10 +411,10 @@ describe('Tree Pattern', () => { {value: 'Item B', disabled: true, selectable: true, expanded: false}, {value: 'Item C', disabled: false, selectable: true, expanded: false}, ]; - const {tree, allItems} = createTree(localTreeExample, treeInputs); - const itemA = getItemByValue(allItems(), 'Item A'); - const itemB = getItemByValue(allItems(), 'Item B'); - tree.listBehavior.goto(itemA); + const {tree, items} = createTree(localTreeExample, treeInputs); + const itemA = getItemByValue(items(), 'Item A'); + const itemB = getItemByValue(items(), 'Item B'); + tree.treeBehavior.goto(itemA); expect(tree.activeItem()).toBe(itemA); tree.onKeydown(down()); @@ -423,9 +423,9 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + tree.treeBehavior.goto(item0); expect(tree.activeItem()).toBe(item0); tree.onKeydown(down()); @@ -458,9 +458,9 @@ describe('Tree Pattern', () => { }); it('should correctly compute selected state', () => { - const {allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); - const item1 = getItemByValue(allItems(), 'Item 1'); + const {items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); treeInputs.values.set(['Item 0']); expect(item0.selected()).toBe(true); @@ -472,17 +472,17 @@ describe('Tree Pattern', () => { }); it('should have undefined selected state when non-selectable', () => { - const {allItems, itemPatternInputsMap} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {items, itemPatternInputsMap} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); treeInputs.values.set(['Item 0']); itemPatternInputsMap.get(item0.id())!.selectable.set(false); expect(item0.selected()).toBeUndefined(); }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); tree.onKeydown(down()); expect(tree.activeItem()).toBe(item1); @@ -629,8 +629,8 @@ describe('Tree Pattern', () => { }); it('should select a range of visible items on Shift + ArrowDown/ArrowUp', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); item0.expanded.set(true); tree.onKeydown(shift()); @@ -645,8 +645,8 @@ describe('Tree Pattern', () => { }); it('should not allow wrapping while Shift is held down', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); tree.onKeydown(shift()); tree.onKeydown(up({shift: true})); @@ -655,8 +655,8 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); item0.expanded.set(true); tree.onKeydown(down()); @@ -671,11 +671,11 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); item0.expanded.set(true); - tree.listBehavior.goto(item1); + tree.treeBehavior.goto(item1); tree.onKeydown(shift()); tree.onKeydown(home({control: true, shift: true})); @@ -683,11 +683,11 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); item0.expanded.set(true); - tree.listBehavior.goto(item0_0); + tree.treeBehavior.goto(item0_0); tree.onKeydown(shift()); tree.onKeydown(end({control: true, shift: true})); @@ -712,10 +712,10 @@ describe('Tree Pattern', () => { {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.softDisabled.set(true); - const {tree, allItems} = createTree(localTreeData, treeInputs); - const itemA = getItemByValue(allItems(), 'A'); + const {tree, items} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(items(), 'A'); - tree.listBehavior.goto(itemA); + tree.treeBehavior.goto(itemA); tree.onKeydown(shift()); tree.onKeydown(down({shift: true})); tree.onKeydown(down({shift: true})); @@ -728,10 +728,10 @@ describe('Tree Pattern', () => { {value: 'B', disabled: false, selectable: false, expanded: false}, {value: 'C', disabled: false, selectable: true, expanded: false}, ]; - const {tree, allItems} = createTree(localTreeData, treeInputs); - const itemA = getItemByValue(allItems(), 'A'); + const {tree, items} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(items(), 'A'); - tree.listBehavior.goto(itemA); + tree.treeBehavior.goto(itemA); tree.onKeydown(shift()); tree.onKeydown(down({shift: true})); tree.onKeydown(down({shift: true})); @@ -739,8 +739,8 @@ describe('Tree Pattern', () => { }); it('should select all visible items on Ctrl + A', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); item0.expanded.set(true); tree.onKeydown(a({control: true})); @@ -754,8 +754,8 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); item0.expanded.set(true); tree.onKeydown(a({control: true})); @@ -796,8 +796,8 @@ describe('Tree Pattern', () => { it('should navigate without selecting if the Ctrl key is pressed', () => { treeInputs.values.set(['Item 0']); - const {tree, allItems} = createTree(treeExample, treeInputs); - const item1 = getItemByValue(allItems(), 'Item 1'); + const {tree, items} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(items(), 'Item 1'); tree.onKeydown(down({control: true})); expect(tree.inputs.values()).toEqual(['Item 0']); @@ -818,8 +818,8 @@ describe('Tree Pattern', () => { it('should select a range of visible items on Shift + ArrowDown/ArrowUp', () => { treeInputs.values.set(['Item 0']); - const {tree, allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); item0.expanded.set(true); tree.onKeydown(shift()); @@ -830,9 +830,9 @@ describe('Tree Pattern', () => { }); it('should not allow wrapping while Shift is held down', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); - tree.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + tree.treeBehavior.goto(item0); tree.onKeydown(shift()); tree.onKeydown(up({shift: true})); @@ -841,10 +841,10 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); item0.expanded.set(true); - tree.listBehavior.goto(item0); + tree.treeBehavior.goto(item0); tree.onKeydown(down({control: true})); tree.onKeydown(down({control: true})); @@ -854,11 +854,11 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); item0.expanded.set(true); - tree.listBehavior.goto(item1); + tree.treeBehavior.goto(item1); tree.onKeydown(shift()); tree.onKeydown(home({control: true, shift: true})); @@ -866,11 +866,11 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); item0.expanded.set(true); - tree.listBehavior.goto(item0_0); + tree.treeBehavior.goto(item0_0); tree.onKeydown(shift()); tree.onKeydown(end({control: true, shift: true})); @@ -892,9 +892,9 @@ describe('Tree Pattern', () => { {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.softDisabled.set(false); - const {tree, allItems} = createTree(localTreeData, treeInputs); + const {tree, items} = createTree(localTreeData, treeInputs); treeInputs.values.set(['A']); - tree.listBehavior.goto(getItemByValue(allItems(), 'A')); + tree.treeBehavior.goto(getItemByValue(items(), 'A')); tree.onKeydown(down()); expect(tree.inputs.values()).toEqual(['C']); @@ -906,9 +906,9 @@ describe('Tree Pattern', () => { {value: 'B', disabled: false, selectable: false, expanded: false}, {value: 'C', disabled: false, selectable: true, expanded: false}, ]; - const {tree, allItems} = createTree(localTreeData, treeInputs); + const {tree, items} = createTree(localTreeData, treeInputs); treeInputs.values.set(['A']); - tree.listBehavior.goto(getItemByValue(allItems(), 'A')); + tree.treeBehavior.goto(getItemByValue(items(), 'A')); tree.onKeydown(down()); tree.onKeydown(down()); @@ -916,11 +916,11 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); item0.expanded.set(true); - tree.listBehavior.goto(item0_0); + tree.treeBehavior.goto(item0_0); tree.onKeydown(a({control: true})); expect(tree.inputs.values()).toEqual([ @@ -961,8 +961,8 @@ describe('Tree Pattern', () => { }); it('should navigate and select a single item on click', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item1 = getItemByValue(allItems(), 'Item 1'); + const {tree, items} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(items(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element()!)); expect(tree.activeItem()).toBe(item1); @@ -971,8 +971,8 @@ describe('Tree Pattern', () => { 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(items(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element()!)); expect(tree.inputs.values()).toEqual([]); @@ -1003,8 +1003,8 @@ describe('Tree Pattern', () => { }); it('should navigate and toggle selection on click', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item1 = getItemByValue(allItems(), 'Item 1'); + const {tree, items} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(items(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element()!)); expect(tree.activeItem()).toBe(item1); @@ -1012,8 +1012,8 @@ describe('Tree Pattern', () => { }); it('should not deselect item on click', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item1 = getItemByValue(allItems(), 'Item 1'); + const {tree, items} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(items(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element()!)); expect(tree.activeItem()).toBe(item1); @@ -1026,8 +1026,8 @@ describe('Tree Pattern', () => { 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(items(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element()!)); expect(tree.inputs.values()).toEqual([]); @@ -1058,9 +1058,9 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); tree.onPointerdown(createClickEvent(item0.element()!)); expect(tree.inputs.values()).toEqual(['Item 0']); @@ -1073,9 +1073,9 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); item0.expanded.set(true); tree.onKeydown(shift()); @@ -1108,9 +1108,9 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); tree.onPointerdown(createClickEvent(item0.element()!)); expect(tree.inputs.values()).toEqual(['Item 0']); @@ -1119,9 +1119,9 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); tree.onPointerdown(createClickEvent(item0.element()!)); // Select and expand Item 0 tree.onPointerdown(createClickEvent(item1.element()!, {control: true})); @@ -1131,9 +1131,9 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item2 = getItemByValue(items(), 'Item 2'); tree.onPointerdown(createClickEvent(item0.element()!)); // Select and expand Item 0 tree.onKeydown(shift()); @@ -1148,10 +1148,10 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); item0.expanded.set(true); tree.onKeydown(shift()); @@ -1166,8 +1166,8 @@ describe('Tree Pattern', () => { const localTreeData: TestTreeItem[] = [ {value: 'A', disabled: true, selectable: true, expanded: false}, ]; - const {tree, allItems} = createTree(localTreeData, treeInputs); - const itemA = getItemByValue(allItems(), 'A'); + const {tree, items} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(items(), 'A'); tree.onPointerdown(createClickEvent(itemA.element()!)); expect(tree.inputs.values()).toEqual([]); @@ -1178,8 +1178,8 @@ describe('Tree Pattern', () => { const localTreeData: TestTreeItem[] = [ {value: 'A', disabled: false, selectable: false, expanded: false}, ]; - const {tree, allItems} = createTree(localTreeData, treeInputs); - const itemA = getItemByValue(allItems(), 'A'); + const {tree, items} = createTree(localTreeData, treeInputs); + const itemA = getItemByValue(items(), 'A'); tree.onPointerdown(createClickEvent(itemA.element()!)); expect(tree.inputs.values()).toEqual([]); }); @@ -1210,7 +1210,7 @@ describe('Tree Pattern', () => { }); it('should correctly compute visible state', () => { - const {allItems} = createTree( + const {items} = createTree( [ { value: 'Item 0', @@ -1238,9 +1238,9 @@ describe('Tree Pattern', () => { ], treeInputs, ); - const item0 = getItemByValue(allItems(), 'Item 0'); - const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - const item0_0_0 = getItemByValue(allItems(), 'Item 0-0-0'); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); + const item0_0_0 = getItemByValue(items(), 'Item 0-0-0'); expect(item0_0.visible()).toBe(false); expect(item0_0_0.visible()).toBe(false); @@ -1256,8 +1256,8 @@ describe('Tree Pattern', () => { }); it('should correctly compute expanded state', () => { - const {allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); expect(item0.expanded()).toBe(false); item0.expanded.set(true); @@ -1266,9 +1266,9 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + tree.treeBehavior.goto(item0); expect(item0.expanded()).toBe(false); tree.onKeydown(right()); @@ -1277,10 +1277,10 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); + tree.treeBehavior.goto(item0); item0.expanded.set(true); tree.onKeydown(right()); @@ -1289,9 +1289,9 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item1); + const {tree, items} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(items(), 'Item 1'); + tree.treeBehavior.goto(item1); tree.onKeydown(right()); expect(tree.activeItem()).toBe(item1); @@ -1299,9 +1299,9 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + tree.treeBehavior.goto(item0); item0.expanded.set(true); expect(item0.expanded()).toBe(true); @@ -1311,11 +1311,11 @@ describe('Tree Pattern', () => { 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); item0.expanded.set(true); - tree.listBehavior.goto(item0_0); + tree.treeBehavior.goto(item0_0); tree.onKeydown(left()); expect(tree.activeItem()).toBe(item0); @@ -1323,9 +1323,9 @@ describe('Tree Pattern', () => { 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + tree.treeBehavior.goto(item0); tree.onKeydown(left()); expect(tree.activeItem()).toBe(item0); @@ -1333,11 +1333,11 @@ describe('Tree Pattern', () => { }); 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item1 = getItemByValue(items(), 'Item 1'); + const item2 = getItemByValue(items(), 'Item 2'); + tree.treeBehavior.goto(item0); tree.onKeydown(asterisk({shift: true})); expect(item0.expanded()).toBe(true); @@ -1346,8 +1346,8 @@ describe('Tree Pattern', () => { }); it('should toggle expansion on pointerdown (click)', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); expect(item0.expanded()).toBe(false); tree.onPointerdown(createClickEvent(item0.element()!)); @@ -1357,8 +1357,8 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item1 = getItemByValue(items(), 'Item 1'); expect(item1.expanded()).toBe(false); tree.onPointerdown(createClickEvent(item1.element()!)); @@ -1366,8 +1366,8 @@ describe('Tree Pattern', () => { }); 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'); + const {tree, items, itemPatternInputsMap} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); itemPatternInputsMap.get(item0.id())!.disabled.set(true); tree.onPointerdown(createClickEvent(item0.element()!)); @@ -1376,8 +1376,8 @@ describe('Tree Pattern', () => { 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); tree.onPointerdown(createClickEvent(item0.element()!)); expect(item0.expanded()).toBe(false); @@ -1391,10 +1391,10 @@ describe('Tree Pattern', () => { it('should navigate and select 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); + tree.treeBehavior.goto(item0); item0.expanded.set(true); tree.onKeydown(right()); @@ -1404,11 +1404,11 @@ describe('Tree Pattern', () => { it('should navigate and select 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); item0.expanded.set(true); - tree.listBehavior.goto(item0_0); + tree.treeBehavior.goto(item0_0); tree.onKeydown(left()); expect(tree.activeItem()).toBe(item0); @@ -1424,10 +1424,10 @@ describe('Tree Pattern', () => { it('should navigate without select the first child on Ctrl + 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.listBehavior.goto(item0); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); + tree.treeBehavior.goto(item0); item0.expanded.set(true); tree.inputs.values.set(['Item 1']); // pre-select something else @@ -1438,11 +1438,11 @@ describe('Tree Pattern', () => { it('should navigate without select the parent on Ctrl + 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'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); + const item0_0 = getItemByValue(items(), 'Item 0-0'); item0.expanded.set(true); - tree.listBehavior.goto(item0_0); + tree.treeBehavior.goto(item0_0); tree.inputs.values.set(['Item 1']); // pre-select something else tree.onKeydown(left({control: true})); @@ -1480,10 +1480,10 @@ describe('Tree Pattern', () => { {value: 'A', disabled: false, selectable: true, expanded: false}, {value: 'B', disabled: false, selectable: true, expanded: false}, ]; - const {tree, allItems} = createTree(localTreeData, treeInputs); + const {tree, items} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeItem()).toBe(allItems()[0]); + expect(treeInputs.activeItem()).toBe(items()[0]); }); it('should set activeIndex to the first visible focusable disabled item if softDisabled is true and no selection', () => { @@ -1492,10 +1492,10 @@ describe('Tree Pattern', () => { {value: 'B', disabled: false, selectable: true, expanded: false}, ]; treeInputs.softDisabled.set(true); - const {tree, allItems} = createTree(localTreeData, treeInputs); + const {tree, items} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeItem()).toBe(allItems()[0]); + expect(treeInputs.activeItem()).toBe(items()[0]); }); it('should set activeIndex to the first selected visible focusable item', () => { @@ -1505,10 +1505,10 @@ describe('Tree Pattern', () => { {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.values.set(['B']); - const {tree, allItems} = createTree(localTreeData, treeInputs); + const {tree, items} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeItem()).toBe(allItems()[1]); + expect(treeInputs.activeItem()).toBe(items()[1]); }); it('should prioritize the first selected item in visible order', () => { @@ -1518,10 +1518,10 @@ describe('Tree Pattern', () => { {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.values.set(['C', 'A']); - const {tree, allItems} = createTree(localTreeData, treeInputs); + const {tree, items} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeItem()).toBe(allItems()[0]); + expect(treeInputs.activeItem()).toBe(items()[0]); }); it('should skip a selected disabled item if softDisabled is false', () => { @@ -1532,10 +1532,10 @@ describe('Tree Pattern', () => { ]; treeInputs.values.set(['B']); treeInputs.softDisabled.set(false); - const {tree, allItems} = createTree(localTreeData, treeInputs); + const {tree, items} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeItem()).toBe(allItems()[0]); + expect(treeInputs.activeItem()).toBe(items()[0]); }); it('should select a selected disabled item if softDisabled is true', () => { @@ -1546,19 +1546,19 @@ describe('Tree Pattern', () => { ]; treeInputs.values.set(['B']); treeInputs.softDisabled.set(true); - const {tree, allItems} = createTree(localTreeData, treeInputs); + const {tree, items} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeItem()).toBe(allItems()[1]); + expect(treeInputs.activeItem()).toBe(items()[1]); }); it('should set activeIndex to first visible focusable item if selected item is not visible', () => { - const {tree, allItems} = createTree(treeExample, treeInputs); - const item0 = getItemByValue(allItems(), 'Item 0'); + const {tree, items} = createTree(treeExample, treeInputs); + const item0 = getItemByValue(items(), 'Item 0'); treeInputs.values.set(['Item 0-0']); expect(item0.expanded()).toBe(false); - expect(getItemByValue(allItems(), 'Item 0-0').visible()).toBe(false); + expect(getItemByValue(items(), 'Item 0-0').visible()).toBe(false); tree.setDefaultState(); expect(treeInputs.activeItem()).toBe(item0); }); diff --git a/src/aria/private/tree/tree.ts b/src/aria/private/tree/tree.ts index 8829fdfa7230..e88d1955a7c5 100644 --- a/src/aria/private/tree/tree.ts +++ b/src/aria/private/tree/tree.ts @@ -7,22 +7,20 @@ */ import {SignalLike, computed, WritableSignalLike} from '../behaviors/signal-like/signal-like'; -import {List, ListInputs, ListItem} from '../behaviors/list/list'; -import {ExpansionItem, ListExpansion} from '../behaviors/expansion/expansion'; +import {Tree, TreeItem, TreeInputs as TreeBehaviorInputs} from '../behaviors/tree/tree'; import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/event-manager'; /** Represents the required inputs for a tree item. */ -export interface TreeItemInputs - extends Omit, 'index'>, Omit { +export interface TreeItemInputs extends Omit< + TreeItem>, + 'index' | 'parent' | 'visible' | 'expandable' +> { /** 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>; } @@ -30,7 +28,7 @@ export interface TreeItemInputs /** * Represents an item in a Tree. */ -export class TreeItemPattern implements ListItem, ExpansionItem { +export class TreeItemPattern implements TreeItem> { /** A unique identifier for this item. */ readonly id: SignalLike = () => this.inputs.id(); @@ -50,16 +48,16 @@ export class TreeItemPattern implements ListItem, ExpansionItem { readonly tree: SignalLike> = () => this.inputs.tree(); /** The parent item. */ - readonly parent: SignalLike | TreePattern> = () => this.inputs.parent(); + readonly parent: SignalLike | undefined> = computed(() => { + const parent = this.inputs.parent(); + return parent instanceof TreeItemPattern ? parent : undefined; + }); /** The children items. */ - readonly children: SignalLike[]> = () => this.inputs.children(); + readonly children: SignalLike[]> = () => this.inputs.children() ?? []; /** The position of this item among its siblings. */ - readonly index = computed(() => this.tree().visibleItems().indexOf(this)); - - /** Controls expansion for child items. */ - readonly expansionBehavior: ListExpansion; + readonly index = computed(() => this.tree().inputs.items().indexOf(this)); /** Whether the item is expandable. It's expandable if children item exist. */ readonly expandable: SignalLike = () => this.inputs.hasChildren(); @@ -71,24 +69,24 @@ export class TreeItemPattern implements ListItem, ExpansionItem { readonly expanded: WritableSignalLike; /** The level of the current item in a tree. */ - readonly level: SignalLike = computed(() => this.parent().level() + 1); + readonly level: SignalLike = computed(() => this.inputs.parent().level() + 1); /** Whether this item is visible. */ readonly visible: SignalLike = computed( - () => this.parent().expanded() && this.parent().visible(), + () => this.inputs.parent().expanded() && this.inputs.parent().visible(), ); /** The number of items under the same parent at the same level. */ - readonly setsize = computed(() => this.parent().children().length); + readonly setsize = computed(() => this.inputs.parent().children().length); /** The position of this item among its siblings (1-based). */ - readonly posinset = computed(() => this.parent().children().indexOf(this) + 1); + readonly posinset = computed(() => this.inputs.parent().children().indexOf(this) + 1); /** Whether the item is active. */ readonly active = computed(() => this.tree().activeItem() === this); /** The tab index of the item. */ - readonly tabIndex = computed(() => this.tree().listBehavior.getItemTabindex(this)); + readonly tabIndex = computed(() => this.tree().treeBehavior.getItemTabindex(this)); /** Whether the item is selected. */ readonly selected: SignalLike = computed(() => { @@ -114,12 +112,6 @@ export class TreeItemPattern implements ListItem, ExpansionItem { constructor(readonly inputs: TreeItemInputs) { this.expanded = inputs.expanded; - this.expansionBehavior = new ListExpansion({ - ...inputs, - multiExpandable: () => true, - items: this.children, - disabled: computed(() => this.tree()?.disabled() ?? false), - }); } } @@ -132,13 +124,13 @@ interface SelectOptions { } /** Represents the required inputs for a tree. */ -export interface TreeInputs extends Omit, V>, 'items'> { +export interface TreeInputs extends Omit< + TreeBehaviorInputs, V>, + 'multiExpandable' +> { /** A unique identifier for the tree. */ id: SignalLike; - /** All items in the tree, in document order (DFS-like, a flattened list). */ - allItems: SignalLike[]>; - /** Whether the tree is in navigation mode. */ nav: SignalLike; @@ -148,11 +140,8 @@ export interface TreeInputs extends Omit, V>, ' /** Controls the state and interactions of a tree view. */ export class TreePattern implements TreeInputs { - /** The list behavior for the tree. */ - readonly listBehavior: List, V>; - - /** Controls expansion for direct children of the tree root (top-level items). */ - readonly expansionBehavior: ListExpansion; + /** The tree behavior for the tree. */ + readonly treeBehavior: Tree, V>; /** The root level is 0. */ readonly level = () => 0; @@ -164,19 +153,16 @@ export class TreePattern implements TreeInputs { readonly visible = () => true; /** The tab index of the tree. */ - readonly tabIndex: SignalLike<-1 | 0> = computed(() => this.listBehavior.tabIndex()); + readonly tabIndex: SignalLike<-1 | 0> = computed(() => this.treeBehavior.tabIndex()); /** The id of the current active item. */ - readonly activeDescendant = computed(() => this.listBehavior.activeDescendant()); + readonly activeDescendant = computed(() => this.treeBehavior.activeDescendant()); /** The direct children of the root (top-level tree items). */ readonly children = computed(() => - this.inputs.allItems().filter(item => item.level() === this.level() + 1), + this.inputs.items().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'); @@ -216,7 +202,7 @@ export class TreePattern implements TreeInputs { }); /** Represents the space key. Does nothing when the user is actively using typeahead. */ - readonly dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' ')); + readonly dynamicSpaceKey = computed(() => (this.treeBehavior.isTyping() ? '' : ' ')); /** Regular expression to match characters for typeahead. */ readonly typeaheadRegexp = /^.$/; @@ -224,62 +210,62 @@ export class TreePattern implements TreeInputs { /** The keydown event manager for the tree. */ readonly keydown = computed(() => { const manager = new KeyboardEventManager(); - const list = this.listBehavior; + const tree = this.treeBehavior; manager - .on(this.prevKey, () => list.prev({selectOne: this.followFocus()})) - .on(this.nextKey, () => list.next({selectOne: this.followFocus()})) - .on('Home', () => list.first({selectOne: this.followFocus()})) - .on('End', () => list.last({selectOne: this.followFocus()})) - .on(this.typeaheadRegexp, e => list.search(e.key, {selectOne: this.followFocus()})) - .on(this.expandKey, () => this.expand({selectOne: this.followFocus()})) - .on(this.collapseKey, () => this.collapse({selectOne: this.followFocus()})) - .on(Modifier.Shift, '*', () => this.expandSiblings()); + .on(this.prevKey, () => tree.prev({selectOne: this.followFocus()})) + .on(this.nextKey, () => tree.next({selectOne: this.followFocus()})) + .on('Home', () => tree.first({selectOne: this.followFocus()})) + .on('End', () => tree.last({selectOne: this.followFocus()})) + .on(this.typeaheadRegexp, e => tree.search(e.key, {selectOne: this.followFocus()})) + .on(Modifier.Shift, '*', () => tree.expandSiblings()) + .on(this.expandKey, () => this._expandOrFirstChild({selectOne: this.followFocus()})) + .on(this.collapseKey, () => this._collapseOrParent({selectOne: this.followFocus()})); if (this.inputs.multi()) { manager // TODO: Tracking the anchor by index can break if the // tree is expanded or collapsed causing the index to change. - .on(Modifier.Any, 'Shift', () => list.anchor(this.listBehavior.activeIndex())) - .on(Modifier.Shift, this.prevKey, () => list.prev({selectRange: true})) - .on(Modifier.Shift, this.nextKey, () => list.next({selectRange: true})) + .on(Modifier.Any, 'Shift', () => tree.anchor(this.treeBehavior.activeIndex())) + .on(Modifier.Shift, this.prevKey, () => tree.prev({selectRange: true})) + .on(Modifier.Shift, this.nextKey, () => tree.next({selectRange: true})) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => - list.first({selectRange: true, anchor: false}), + tree.first({selectRange: true, anchor: false}), ) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => - list.last({selectRange: true, anchor: false}), + tree.last({selectRange: true, anchor: false}), ) - .on(Modifier.Shift, 'Enter', () => list.updateSelection({selectRange: true, anchor: false})) + .on(Modifier.Shift, 'Enter', () => tree.updateSelection({selectRange: true, anchor: false})) .on(Modifier.Shift, this.dynamicSpaceKey, () => - list.updateSelection({selectRange: true, anchor: false}), + tree.updateSelection({selectRange: true, anchor: false}), ); } if (!this.followFocus() && this.inputs.multi()) { manager - .on(this.dynamicSpaceKey, () => list.toggle()) - .on('Enter', () => list.toggle(), {preventDefault: !this.nav()}) - .on([Modifier.Ctrl, Modifier.Meta], 'A', () => list.toggleAll()); + .on(this.dynamicSpaceKey, () => tree.toggle()) + .on('Enter', () => tree.toggle(), {preventDefault: !this.nav()}) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => tree.toggleAll()); } if (!this.followFocus() && !this.inputs.multi()) { - manager.on(this.dynamicSpaceKey, () => list.selectOne()); - manager.on('Enter', () => list.selectOne(), {preventDefault: !this.nav()}); + manager.on(this.dynamicSpaceKey, () => tree.selectOne()); + manager.on('Enter', () => tree.selectOne(), {preventDefault: !this.nav()}); } if (this.inputs.multi() && this.followFocus()) { manager - .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => list.prev()) - .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => list.next()) - .on([Modifier.Ctrl, Modifier.Meta], this.expandKey, () => this.expand()) - .on([Modifier.Ctrl, Modifier.Meta], this.collapseKey, () => this.collapse()) - .on([Modifier.Ctrl, Modifier.Meta], ' ', () => list.toggle()) - .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => list.toggle()) - .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => list.first()) - .on([Modifier.Ctrl, Modifier.Meta], 'End', () => list.last()) + .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => tree.prev()) + .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => tree.next()) + .on([Modifier.Ctrl, Modifier.Meta], this.expandKey, () => this._expandOrFirstChild()) + .on([Modifier.Ctrl, Modifier.Meta], this.collapseKey, () => this._collapseOrParent()) + .on([Modifier.Ctrl, Modifier.Meta], ' ', () => tree.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => tree.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => tree.first()) + .on([Modifier.Ctrl, Modifier.Meta], 'End', () => tree.last()) .on([Modifier.Ctrl, Modifier.Meta], 'A', () => { - list.toggleAll(); - list.select(); // Ensure the currect item remains selected. + tree.toggleAll(); + tree.select(); // Ensure the currect item remains selected. }); } @@ -326,7 +312,7 @@ export class TreePattern implements TreeInputs { > = () => this.inputs.currentType(); /** All items in the tree, in document order (DFS-like, a flattened list). */ - readonly allItems: SignalLike[]> = () => this.inputs.allItems(); + readonly items: SignalLike[]> = () => this.inputs.items(); /** The focus strategy used by the tree. */ readonly focusMode: SignalLike<'roving' | 'activedescendant'> = () => this.inputs.focusMode(); @@ -365,16 +351,10 @@ export class TreePattern implements TreeInputs { this.activeItem = inputs.activeItem; this.values = inputs.values; - this.listBehavior = new List({ + this.treeBehavior = new Tree, V>({ ...inputs, - items: this.visibleItems, multi: this.multi, - }); - - this.expansionBehavior = new ListExpansion({ multiExpandable: () => true, - items: this.children, - disabled: this.disabled, }); } @@ -387,9 +367,9 @@ export class TreePattern implements TreeInputs { setDefaultState() { let firstItem: TreeItemPattern | undefined; - for (const item of this.allItems()) { + for (const item of this.inputs.items()) { if (!item.visible()) continue; - if (!this.listBehavior.isFocusable(item)) continue; + if (!this.treeBehavior.isFocusable(item)) continue; if (firstItem === undefined) { firstItem = item; @@ -425,57 +405,27 @@ export class TreePattern implements TreeInputs { const item = this._getItem(e); if (!item) return; - this.listBehavior.goto(item, opts); - this.toggleExpansion(item); + this.treeBehavior.goto(item, opts); + this.treeBehavior.toggleExpansion(item); } - /** Toggles to expand or collapse a tree item. */ - toggleExpansion(item?: TreeItemPattern) { - item ??= this.activeItem(); - if (!item || !this.listBehavior.isFocusable(item)) return; - - if (!item.expandable()) return; - if (item.expanded()) { - this.collapse(); + /** Expands the active item if possible, otherwise navigates to the first child. */ + _expandOrFirstChild(opts?: SelectOptions) { + const item = this.treeBehavior.inputs.activeItem(); + if (item && this.treeBehavior.isExpandable(item) && !item.expanded()) { + this.treeBehavior.expand(item); } else { - this.expansionBehavior.open(item); - } - } - - /** Expands a tree item. */ - expand(opts?: SelectOptions) { - const item = this.activeItem(); - if (!item || !this.listBehavior.isFocusable(item)) return; - - if (item.expandable() && !item.expanded()) { - this.expansionBehavior.open(item); - } else if ( - item.expanded() && - item.children().some(item => this.listBehavior.isFocusable(item)) - ) { - this.listBehavior.next(opts); + this.treeBehavior.firstChild(opts); } } - /** Expands all sibling tree items including itself. */ - expandSiblings(item?: TreeItemPattern) { - item ??= this.activeItem(); - const siblings = item?.parent()?.children(); - siblings?.forEach(item => this.expansionBehavior.open(item)); - } - - /** Collapses a tree item. */ - collapse(opts?: SelectOptions) { - const item = this.activeItem(); - if (!item || !this.listBehavior.isFocusable(item)) return; - - if (item.expandable() && item.expanded()) { - this.expansionBehavior.close(item); - } else if (item.parent() && item.parent() !== this) { - const parentItem = item.parent(); - if (parentItem instanceof TreeItemPattern && this.listBehavior.isFocusable(parentItem)) { - this.listBehavior.goto(parentItem, opts); - } + /** Collapses the active item if possible, otherwise navigates to the parent. */ + _collapseOrParent(opts?: SelectOptions) { + const item = this.treeBehavior.inputs.activeItem(); + if (item && this.treeBehavior.isExpandable(item) && item.expanded()) { + this.treeBehavior.collapse(item); + } else { + this.treeBehavior.parent(opts); } } @@ -485,6 +435,6 @@ export class TreePattern implements TreeInputs { return; } const element = event.target.closest('[role="treeitem"]'); - return this.inputs.allItems().find(i => i.element() === element); + return this.inputs.items().find(i => i.element() === element); } } diff --git a/src/aria/tree/tree-item.ts b/src/aria/tree/tree-item.ts index 9fadb6a97ca9..728ec42701d5 100644 --- a/src/aria/tree/tree-item.ts +++ b/src/aria/tree/tree-item.ts @@ -151,7 +151,7 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr ...this, tree: treePattern, parent: parentPattern, - children: computed(() => this._group()?._childPatterns() ?? []), + children: computed(() => this._group()?._childPatterns()), hasChildren: computed(() => !!this._group()), element: () => this.element, searchTerm: () => this.searchTerm() ?? '', diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index deafffde5b8c..837031088499 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -158,7 +158,7 @@ export class Tree { const inputs = { ...this, id: this.id, - allItems: computed(() => + items: computed(() => [...this._unorderedItems()].sort(sortDirectives).map(item => item._pattern), ), activeItem: signal | undefined>(undefined), @@ -181,18 +181,18 @@ export class Tree { }); afterRenderEffect(() => { - const items = inputs.allItems(); + const items = inputs.items(); const activeItem = untracked(() => inputs.activeItem()); if (!items.some(i => i === activeItem) && activeItem) { - this._pattern.listBehavior.unfocus(); + this._pattern.treeBehavior.unfocus(); } }); afterRenderEffect(() => { if (!(this._pattern instanceof ComboboxTreePattern)) return; - const items = inputs.allItems(); + const items = inputs.items(); const values = untracked(() => this.values()); if (items && values.some(v => !items.some(i => i.value() === v))) {