Skip to content

Commit ed1c999

Browse files
committed
refactor(aria/tree): simplify expansion logic
1 parent c5e2921 commit ed1c999

File tree

6 files changed

+127
-106
lines changed

6 files changed

+127
-106
lines changed

src/aria/private/behaviors/tree/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ ts_project(
77
srcs = ["tree.ts"],
88
deps = [
99
"//:node_modules/@angular/core",
10+
"//src/aria/private/behaviors/expansion",
1011
"//src/aria/private/behaviors/list",
1112
"//src/aria/private/behaviors/list-focus",
1213
"//src/aria/private/behaviors/list-navigation",

src/aria/private/behaviors/tree/tree.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ interface TestItem<V = number> extends TreeItem<V, TestItem<V>> {
1717
searchTerm: WritableSignalLike<string>;
1818
index: WritableSignalLike<number>;
1919
children: WritableSignalLike<TestItem<V>[]>;
20-
parent?: TestItem<V>;
20+
parent: WritableSignalLike<TestItem<V> | undefined>;
2121
visible: WritableSignalLike<boolean>;
2222
expanded: WritableSignalLike<boolean>;
23+
expandable: WritableSignalLike<boolean>;
2324
focusable: WritableSignalLike<boolean>;
2425
}
2526

@@ -44,6 +45,7 @@ describe('Tree Behavior', () => {
4445
...focusInputs,
4546
values: signal([]),
4647
multi: signal(false),
48+
multiExpandable: signal(true),
4749
selectionMode: signal('follow'),
4850
wrap: signal(true),
4951
orientation: signal('vertical'),
@@ -65,8 +67,10 @@ describe('Tree Behavior', () => {
6567
searchTerm: signal(String(value)),
6668
index: signal(index),
6769
children: signal<TestItem<V>[]>([]),
70+
parent: signal<TestItem<V> | undefined>(undefined),
6871
visible: signal(true),
6972
expanded: signal(false),
73+
expandable: signal(true),
7074
focusable: signal(true),
7175
}) as TestItem<V>,
7276
);
@@ -79,7 +83,7 @@ describe('Tree Behavior', () => {
7983
const parent = items[Number(parentIdx)];
8084
const children = childIndices.map(i => items[i]);
8185
parent.children.set(children);
82-
children.forEach(child => (child.parent = parent));
86+
children.forEach(child => child.parent.set(parent));
8387
});
8488
}
8589

src/aria/private/behaviors/tree/tree.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {computed, signal, SignalLike} from '../signal-like/signal-like';
10+
import {ExpansionItem, ListExpansion, ListExpansionInputs} from '../expansion/expansion';
1011
import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus';
1112
import {
1213
ListNavigation,
@@ -28,15 +29,17 @@ import {NavOptions} from '../list/list';
2829

2930
/** Represents an item in the tree. */
3031
export interface TreeItem<V, T extends TreeItem<V, T>>
31-
extends ListTypeaheadItem, ListNavigationItem, ListSelectionItem<V>, ListFocusItem {
32+
extends
33+
ListTypeaheadItem,
34+
ListNavigationItem,
35+
ListSelectionItem<V>,
36+
ListFocusItem,
37+
ExpansionItem {
3238
/** The children of this item. */
33-
children?: SignalLike<T[]>;
34-
35-
/** Whether this item is expanded. */
36-
expanded?: SignalLike<boolean>;
39+
children: SignalLike<T[] | undefined>;
3740

3841
/** The parent of this item. */
39-
parent?: T;
42+
parent: SignalLike<T | undefined>;
4043

4144
/** Whether this item is visible. */
4245
visible: SignalLike<boolean>;
@@ -46,7 +49,8 @@ export interface TreeItem<V, T extends TreeItem<V, T>>
4649
export type TreeInputs<T extends TreeItem<V, T>, V> = ListFocusInputs<T> &
4750
ListNavigationInputs<T> &
4851
ListSelectionInputs<T, V> &
49-
ListTypeaheadInputs<T>;
52+
ListTypeaheadInputs<T> &
53+
ListExpansionInputs;
5054

5155
/** Controls the state of a tree. */
5256
export class Tree<T extends TreeItem<V, T>, V> {
@@ -62,6 +66,9 @@ export class Tree<T extends TreeItem<V, T>, V> {
6266
/** Controls focus for the tree. */
6367
focusBehavior: ListFocus<T>;
6468

69+
/** Controls expansion for the tree. */
70+
expansionBehavior: ListExpansion;
71+
6572
/** Whether the tree is disabled. */
6673
disabled = computed(() => this.focusBehavior.isListDisabled());
6774

@@ -81,10 +88,11 @@ export class Tree<T extends TreeItem<V, T>, V> {
8188
private _wrap = signal(true);
8289

8390
constructor(readonly inputs: TreeInputs<T, V>) {
84-
this.focusBehavior = new ListFocus(inputs);
85-
this.selectionBehavior = new ListSelection({...inputs, focusManager: this.focusBehavior});
86-
this.typeaheadBehavior = new ListTypeahead({...inputs, focusManager: this.focusBehavior});
87-
this.navigationBehavior = new ListNavigation({
91+
this.focusBehavior = new ListFocus<T>(inputs);
92+
this.selectionBehavior = new ListSelection<T, V>({...inputs, focusManager: this.focusBehavior});
93+
this.typeaheadBehavior = new ListTypeahead<T>({...inputs, focusManager: this.focusBehavior});
94+
this.expansionBehavior = new ListExpansion(inputs);
95+
this.navigationBehavior = new ListNavigation<T>({
8896
...inputs,
8997
focusManager: this.focusBehavior,
9098
wrap: computed(() => this._wrap() && this.inputs.wrap()),
@@ -138,7 +146,11 @@ export class Tree<T extends TreeItem<V, T>, V> {
138146
nextSibling(opts?: NavOptions<T>) {
139147
this._navigate(opts, () => {
140148
const item = this.inputs.activeItem();
141-
const items = item?.parent?.children?.()?.filter(c => c.visible() !== false) ?? [];
149+
const items =
150+
item
151+
?.parent?.()
152+
?.children?.()
153+
?.filter(c => c.visible() !== false) ?? [];
142154
return this.navigationBehavior.next({items, ...opts});
143155
});
144156
}
@@ -147,15 +159,19 @@ export class Tree<T extends TreeItem<V, T>, V> {
147159
prevSibling(opts?: NavOptions<T>) {
148160
this._navigate(opts, () => {
149161
const item = this.inputs.activeItem();
150-
const items = item?.parent?.children?.()?.filter(c => c.visible() !== false) ?? [];
162+
const items =
163+
item
164+
?.parent?.()
165+
?.children?.()
166+
?.filter(c => c.visible() !== false) ?? [];
151167
return this.navigationBehavior.prev({items, ...opts});
152168
});
153169
}
154170

155171
/** Navigates to the parent of the current active item. */
156172
parent(opts?: NavOptions<T>) {
157173
this._navigate(opts, () =>
158-
this.navigationBehavior.goto(this.inputs.activeItem()?.parent, opts),
174+
this.navigationBehavior.goto(this.inputs.activeItem()?.parent?.(), opts),
159175
);
160176
}
161177

@@ -219,11 +235,59 @@ export class Tree<T extends TreeItem<V, T>, V> {
219235
this.selectionBehavior.toggleAll();
220236
}
221237

238+
/** Toggles the expansion of the given item. */
239+
toggleExpansion(item?: T) {
240+
item ??= this.inputs.activeItem();
241+
if (!item || !this.isFocusable(item)) return;
242+
243+
if (this.isExpandable(item)) {
244+
this.expansionBehavior.toggle(item);
245+
}
246+
}
247+
248+
/** Expands the given item. */
249+
expand(item: T) {
250+
if (this.isExpandable(item)) {
251+
this.expansionBehavior.open(item);
252+
}
253+
}
254+
255+
/** Collapses the given item. */
256+
collapse(item: T) {
257+
this.expansionBehavior.close(item);
258+
}
259+
260+
/** Expands all sibling items of the given item (or active item). */
261+
expandSiblings(item?: T) {
262+
item ??= this.inputs.activeItem();
263+
if (!item) return;
264+
265+
const parent = item.parent?.();
266+
// TODO: This assumes that items without a parent are root items.
267+
const siblings = parent ? parent.children?.() : this.inputs.items().filter(i => !i.parent?.());
268+
siblings?.forEach(s => this.expand(s));
269+
}
270+
271+
/** Expands all items in the tree. */
272+
expandAll() {
273+
this.expansionBehavior.openAll();
274+
}
275+
276+
/** Collapses all items in the tree. */
277+
collapseAll() {
278+
this.expansionBehavior.closeAll();
279+
}
280+
222281
/** Checks if the given item is able to receive focus. */
223282
isFocusable(item: T) {
224283
return this.focusBehavior.isFocusable(item);
225284
}
226285

286+
/** Checks if the given item is expandable. */
287+
isExpandable(item: T) {
288+
return this.expansionBehavior.isExpandable(item);
289+
}
290+
227291
/** Handles updating selection for the tree. */
228292
updateSelection(opts: NavOptions<T> = {anchor: true}) {
229293
if (opts.toggle) {
@@ -265,9 +329,7 @@ export class Tree<T extends TreeItem<V, T>, V> {
265329
* Constructs navigation options with the visible items subset.
266330
*/
267331
private _getNavOpts(opts?: NavOptions<T>): ListNavigationOpts<T> {
268-
const visibleItems = this.inputs.items().filter(i => {
269-
return i.visible() !== false;
270-
});
332+
const visibleItems = this.inputs.items().filter(i => i.visible() !== false);
271333

272334
return {
273335
items: visibleItems,

src/aria/private/tree/combobox-tree.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ export class ComboboxTreePattern<V>
1919
extends TreePattern<V>
2020
implements ComboboxTreeControls<TreeItemPattern<V>, V>
2121
{
22+
/** Toggles to expand or collapse a tree item. */
23+
toggleExpansion = (item?: TreeItemPattern<V>) => this.treeBehavior.toggleExpansion(item);
24+
2225
/** Whether the currently focused item is collapsible. */
23-
isItemCollapsible = () => this.inputs.activeItem()?.parent instanceof TreeItemPattern;
26+
isItemCollapsible = () => this.inputs.activeItem()?.parent() instanceof TreeItemPattern;
2427

2528
/** The ARIA role for the tree. */
2629
role = () => 'tree' as const;
@@ -93,22 +96,22 @@ export class ComboboxTreePattern<V>
9396
/** Sets the value of the combobox tree. */
9497
setValue = (value: V | undefined) => this.inputs.values.set(value ? [value] : []);
9598

96-
/** Expands the currently focused item if it is expandable. */
97-
expandItem = () => this.expand();
99+
/** Expands the currently focused item if it is expandable, or navigates to the first child. */
100+
expandItem = () => this._expandOrFirstChild();
98101

99-
/** Collapses the currently focused item if it is expandable. */
100-
collapseItem = () => this.collapse();
102+
/** Collapses the currently focused item if it is expandable, or navigates to the parent. */
103+
collapseItem = () => this._collapseOrParent();
101104

102105
/** Whether the specified item or the currently active item is expandable. */
103106
isItemExpandable(item: TreeItemPattern<V> | undefined = this.inputs.activeItem()) {
104107
return item ? item.expandable() : false;
105108
}
106109

107110
/** Expands all of the tree items. */
108-
expandAll = () => this.items().forEach(item => this.expansionBehavior.open(item));
111+
expandAll = () => this.treeBehavior.expandAll();
109112

110113
/** Collapses all of the tree items. */
111-
collapseAll = () => this.items().forEach(item => item.expansionBehavior.close(item));
114+
collapseAll = () => this.treeBehavior.collapseAll();
112115

113116
/** Whether the currently active item is selectable. */
114117
isItemSelectable = (item: TreeItemPattern<V> | undefined = this.inputs.activeItem()) => {

0 commit comments

Comments
 (0)