Skip to content

Commit f89c21b

Browse files
committed
refactor(multiple): create tree behavior
* Create the new Tree Behavior class. * Refactor the existing Tree Pattern class to use the new behavior.
1 parent 8cff9c5 commit f89c21b

19 files changed

Lines changed: 1452 additions & 451 deletions

File tree

goldens/aria/private/index.api.md

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export class ComboboxTreePattern<V> extends TreePattern<V> implements ComboboxTr
275275
setValue: (value: V | undefined) => void;
276276
tabIndex: SignalLike<-1 | 0>;
277277
toggle: (item?: TreeItemPattern<V>) => void;
278+
toggleExpansion: (item?: TreeItemPattern<V>) => void;
278279
unfocus: () => void;
279280
}
280281

@@ -834,23 +835,21 @@ export class ToolbarWidgetPattern<V> implements ListItem<V> {
834835
}
835836

836837
// @public
837-
export interface TreeInputs<V> extends Omit<ListInputs<TreeItemPattern<V>, V>, 'items'> {
838-
allItems: SignalLike<TreeItemPattern<V>[]>;
838+
export interface TreeInputs<V> extends Omit<TreeInputs$1<TreeItemPattern<V>, V>, 'multiExpandable'> {
839839
currentType: SignalLike<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>;
840840
id: SignalLike<string>;
841841
nav: SignalLike<boolean>;
842842
}
843843

844844
// @public
845-
export interface TreeItemInputs<V> extends Omit<ListItem<V>, 'index'>, Omit<ExpansionItem, 'expandable'> {
846-
children: SignalLike<TreeItemPattern<V>[]>;
845+
export interface TreeItemInputs<V> extends Omit<TreeItem<V, TreeItemPattern<V>>, 'index' | 'parent' | 'visible' | 'expandable'> {
847846
hasChildren: SignalLike<boolean>;
848847
parent: SignalLike<TreeItemPattern<V> | TreePattern<V>>;
849848
tree: SignalLike<TreePattern<V>>;
850849
}
851850

852851
// @public
853-
export class TreeItemPattern<V> implements ListItem<V>, ExpansionItem {
852+
export class TreeItemPattern<V> implements TreeItem<V, TreeItemPattern<V>> {
854853
constructor(inputs: TreeItemInputs<V>);
855854
readonly active: SignalLike<boolean>;
856855
readonly children: SignalLike<TreeItemPattern<V>[]>;
@@ -859,13 +858,12 @@ export class TreeItemPattern<V> implements ListItem<V>, ExpansionItem {
859858
readonly element: SignalLike<HTMLElement>;
860859
readonly expandable: SignalLike<boolean>;
861860
readonly expanded: WritableSignalLike<boolean>;
862-
readonly expansionBehavior: ListExpansion;
863861
readonly id: SignalLike<string>;
864862
readonly index: SignalLike<number>;
865863
// (undocumented)
866864
readonly inputs: TreeItemInputs<V>;
867865
readonly level: SignalLike<number>;
868-
readonly parent: SignalLike<TreeItemPattern<V> | TreePattern<V>>;
866+
readonly parent: SignalLike<TreeItemPattern<V> | undefined>;
869867
readonly posinset: SignalLike<number>;
870868
readonly searchTerm: SignalLike<string>;
871869
readonly selectable: SignalLike<boolean>;
@@ -882,19 +880,16 @@ export class TreePattern<V> implements TreeInputs<V> {
882880
constructor(inputs: TreeInputs<V>);
883881
readonly activeDescendant: SignalLike<string | undefined>;
884882
readonly activeItem: WritableSignalLike<TreeItemPattern<V> | undefined>;
885-
readonly allItems: SignalLike<TreeItemPattern<V>[]>;
886883
readonly children: SignalLike<TreeItemPattern<V>[]>;
887-
collapse(opts?: SelectOptions): void;
888884
readonly collapseKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">;
885+
_collapseOrParent(opts?: SelectOptions): void;
889886
readonly currentType: SignalLike<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>;
890887
readonly disabled: SignalLike<boolean>;
891888
readonly dynamicSpaceKey: SignalLike<"" | " ">;
892889
readonly element: SignalLike<HTMLElement>;
893-
expand(opts?: SelectOptions): void;
894890
readonly expanded: () => boolean;
895891
readonly expandKey: SignalLike<"ArrowRight" | "ArrowLeft" | "ArrowDown">;
896-
expandSiblings(item?: TreeItemPattern<V>): void;
897-
readonly expansionBehavior: ListExpansion;
892+
_expandOrFirstChild(opts?: SelectOptions): void;
898893
readonly focusMode: SignalLike<'roving' | 'activedescendant'>;
899894
readonly followFocus: SignalLike<boolean>;
900895
protected _getItem(event: Event): TreeItemPattern<V> | undefined;
@@ -903,9 +898,9 @@ export class TreePattern<V> implements TreeInputs<V> {
903898
// (undocumented)
904899
readonly inputs: TreeInputs<V>;
905900
readonly isRtl: SignalLike<boolean>;
901+
readonly items: SignalLike<TreeItemPattern<V>[]>;
906902
readonly keydown: SignalLike<KeyboardEventManager<KeyboardEvent>>;
907903
readonly level: () => number;
908-
readonly listBehavior: List<TreeItemPattern<V>, V>;
909904
readonly multi: SignalLike<boolean>;
910905
readonly nav: SignalLike<boolean>;
911906
readonly nextKey: SignalLike<"ArrowRight" | "ArrowLeft" | "ArrowDown">;
@@ -919,12 +914,11 @@ export class TreePattern<V> implements TreeInputs<V> {
919914
readonly softDisabled: SignalLike<boolean>;
920915
readonly tabIndex: SignalLike<-1 | 0>;
921916
readonly textDirection: SignalLike<'ltr' | 'rtl'>;
922-
toggleExpansion(item?: TreeItemPattern<V>): void;
917+
readonly treeBehavior: Tree<TreeItemPattern<V>, V>;
923918
readonly typeaheadDelay: SignalLike<number>;
924919
readonly typeaheadRegexp: RegExp;
925920
readonly values: WritableSignalLike<V[]>;
926921
readonly visible: () => boolean;
927-
readonly visibleItems: SignalLike<TreeItemPattern<V>[]>;
928922
readonly wrap: SignalLike<boolean>;
929923
}
930924

src/aria/combobox/combobox.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,13 @@ describe('Combobox', () => {
780780

781781
it('should select and commit on click', () => {
782782
click(inputElement);
783+
784+
// Iterate to the parent node and expand it so the child is visible
785+
down(); // Winter
786+
down(); // Spring
787+
right(); // Expand Spring
788+
fixture.detectChanges();
789+
783790
const item = getTreeItem('April')!;
784791
click(item);
785792
fixture.detectChanges();

src/aria/private/behaviors/list-focus/list-focus.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface ListFocusInputs<T extends ListFocusItem> {
4040
/** Whether disabled items in the list should be focusable. */
4141
softDisabled: SignalLike<boolean>;
4242

43+
/** The html element that should receive focus. */
4344
element: SignalLike<HTMLElement | undefined>;
4445
}
4546

src/aria/private/behaviors/list-navigation/list-navigation.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,55 @@ describe('List Navigation', () => {
254254
expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[4]);
255255
});
256256
});
257+
258+
describe('with items subset', () => {
259+
it('should navigate only within the provided subset for next/prev', () => {
260+
const nav = getNavigation();
261+
const allItems = nav.inputs.items();
262+
const subset = [allItems[0], allItems[2], allItems[4]];
263+
264+
// Start at 0
265+
expect(nav.inputs.activeItem()).toBe(allItems[0]);
266+
267+
// next(subset) -> 2 (skip 1)
268+
nav.next({focusElement: false, items: subset});
269+
expect(nav.inputs.activeItem()).toBe(allItems[2]);
270+
271+
// next(subset) -> 4 (skip 3)
272+
nav.next({focusElement: false, items: subset});
273+
expect(nav.inputs.activeItem()).toBe(allItems[4]);
274+
275+
// prev(subset) -> 2 (skip 3)
276+
nav.prev({focusElement: false, items: subset});
277+
expect(nav.inputs.activeItem()).toBe(allItems[2]);
278+
});
279+
280+
it('should wrap within the subset', () => {
281+
const nav = getNavigation({wrap: signal(true)});
282+
const allItems = nav.inputs.items();
283+
const subset = [allItems[0], allItems[2], allItems[4]];
284+
285+
nav.goto(allItems[4]);
286+
287+
// next(subset) -> 0 (wrap)
288+
nav.next({focusElement: false, items: subset});
289+
expect(nav.inputs.activeItem()).toBe(allItems[0]);
290+
291+
// prev(subset) -> 4 (wrap)
292+
nav.prev({focusElement: false, items: subset});
293+
expect(nav.inputs.activeItem()).toBe(allItems[4]);
294+
});
295+
296+
it('should find first/last within the subset', () => {
297+
const nav = getNavigation();
298+
const allItems = nav.inputs.items();
299+
const subset = [allItems[1], allItems[2], allItems[3]];
300+
301+
nav.first({focusElement: false, items: subset});
302+
expect(nav.inputs.activeItem()).toBe(allItems[1]);
303+
304+
nav.last({focusElement: false, items: subset});
305+
expect(nav.inputs.activeItem()).toBe(allItems[3]);
306+
});
307+
});
257308
});

src/aria/private/behaviors/list-navigation/list-navigation.ts

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,54 +24,71 @@ export interface ListNavigationInputs<T extends ListNavigationItem> extends List
2424
textDirection: SignalLike<'rtl' | 'ltr'>;
2525
}
2626

27+
/** Options for list navigation. */
28+
export interface ListNavigationOpts<T> {
29+
/**
30+
* Whether to focus the item's element.
31+
* Defaults to true.
32+
*/
33+
focusElement?: boolean;
34+
35+
/**
36+
* The list of items to navigate through.
37+
* Defaults to the list of items from the inputs.
38+
*/
39+
items?: T[];
40+
}
41+
2742
/** Controls navigation for a list of items. */
2843
export class ListNavigation<T extends ListNavigationItem> {
2944
constructor(readonly inputs: ListNavigationInputs<T> & {focusManager: ListFocus<T>}) {}
3045

3146
/** Navigates to the given item. */
32-
goto(item?: T, opts?: {focusElement?: boolean}): boolean {
47+
goto(item?: T, opts?: ListNavigationOpts<T>): boolean {
3348
return item ? this.inputs.focusManager.focus(item, opts) : false;
3449
}
3550

3651
/** Navigates to the next item in the list. */
37-
next(opts?: {focusElement?: boolean}): boolean {
52+
next(opts?: ListNavigationOpts<T>): boolean {
3853
return this._advance(1, opts);
3954
}
4055

4156
/** Peeks the next item in the list. */
42-
peekNext(): T | undefined {
43-
return this._peek(1);
57+
peekNext(opts?: ListNavigationOpts<T>): T | undefined {
58+
return this._peek(1, opts);
4459
}
4560

4661
/** Navigates to the previous item in the list. */
47-
prev(opts?: {focusElement?: boolean}): boolean {
62+
prev(opts?: ListNavigationOpts<T>): boolean {
4863
return this._advance(-1, opts);
4964
}
5065

5166
/** Peeks the previous item in the list. */
52-
peekPrev(): T | undefined {
53-
return this._peek(-1);
67+
peekPrev(opts?: ListNavigationOpts<T>): T | undefined {
68+
return this._peek(-1, opts);
5469
}
5570

5671
/** Navigates to the first item in the list. */
57-
first(opts?: {focusElement?: boolean}): boolean {
58-
const item = this.peekFirst();
72+
first(opts?: ListNavigationOpts<T>): boolean {
73+
const item = this.peekFirst(opts);
5974
return item ? this.goto(item, opts) : false;
6075
}
6176

6277
/** Navigates to the last item in the list. */
63-
last(opts?: {focusElement?: boolean}): boolean {
64-
const item = this.peekLast();
78+
last(opts?: ListNavigationOpts<T>): boolean {
79+
const item = this.peekLast(opts);
6580
return item ? this.goto(item, opts) : false;
6681
}
6782

6883
/** Gets the first focusable item from the given list of items. */
69-
peekFirst(items: T[] = this.inputs.items()): T | undefined {
84+
peekFirst(opts?: ListNavigationOpts<T>): T | undefined {
85+
const items = opts?.items ?? this.inputs.items();
7086
return items.find(i => this.inputs.focusManager.isFocusable(i));
7187
}
7288

7389
/** Gets the last focusable item from the given list of items. */
74-
peekLast(items: T[] = this.inputs.items()): T | undefined {
90+
peekLast(opts?: ListNavigationOpts<T>): T | undefined {
91+
const items = opts?.items ?? this.inputs.items();
7592
for (let i = items.length - 1; i >= 0; i--) {
7693
if (this.inputs.focusManager.isFocusable(items[i])) {
7794
return items[i];
@@ -81,16 +98,21 @@ export class ListNavigation<T extends ListNavigationItem> {
8198
}
8299

83100
/** Advances to the next or previous focusable item in the list based on the given delta. */
84-
private _advance(delta: 1 | -1, opts?: {focusElement?: boolean}): boolean {
85-
const item = this._peek(delta);
101+
private _advance(delta: 1 | -1, opts?: ListNavigationOpts<T>): boolean {
102+
const item = this._peek(delta, opts);
86103
return item ? this.goto(item, opts) : false;
87104
}
88105

89106
/** Peeks the next or previous focusable item in the list based on the given delta. */
90-
private _peek(delta: 1 | -1): T | undefined {
91-
const items = this.inputs.items();
107+
private _peek(delta: 1 | -1, opts?: ListNavigationOpts<T>): T | undefined {
108+
const items = opts?.items ?? this.inputs.items();
92109
const itemCount = items.length;
93-
const startIndex = this.inputs.focusManager.activeIndex();
110+
const activeItem = this.inputs.focusManager.inputs.activeItem();
111+
const startIndex =
112+
opts?.items && activeItem
113+
? items.indexOf(activeItem)
114+
: this.inputs.focusManager.activeIndex();
115+
94116
const step = (i: number) =>
95117
this.inputs.wrap() ? (i + delta + itemCount) % itemCount : i + delta;
96118

src/aria/private/behaviors/list-selection/list-selection.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
5353
!item ||
5454
item.disabled() ||
5555
!item.selectable() ||
56+
!this.inputs.focusManager.isFocusable(item as T) ||
5657
this.inputs.values().includes(item.value())
5758
) {
5859
return;
@@ -138,7 +139,7 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
138139
toggleAll() {
139140
const selectableValues = this.inputs
140141
.items()
141-
.filter(i => !i.disabled() && i.selectable())
142+
.filter(i => !i.disabled() && i.selectable() && this.inputs.focusManager.isFocusable(i))
142143
.map(i => i.value());
143144

144145
selectableValues.every(i => this.inputs.values().includes(i))

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,39 @@ describe('List Behavior', () => {
245245
list.prev();
246246
expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]);
247247
});
248+
249+
describe('with items subset', () => {
250+
it('should navigate next/prev within subset', () => {
251+
const {list, items} = getDefaultPatterns();
252+
const subset = [items[0], items[2], items[4]];
253+
254+
// Start at 0
255+
expect(list.inputs.activeItem()).toBe(items[0]);
256+
257+
// next(subset) -> 2 (skip 1)
258+
list.next({items: subset});
259+
expect(list.inputs.activeItem()).toBe(items[2]);
260+
261+
// next(subset) -> 4 (skip 3)
262+
list.next({items: subset});
263+
expect(list.inputs.activeItem()).toBe(items[4]);
264+
265+
// prev(subset) -> 2 (skip 3)
266+
list.prev({items: subset});
267+
expect(list.inputs.activeItem()).toBe(items[2]);
268+
});
269+
270+
it('should verify first/last within subset', () => {
271+
const {list, items} = getDefaultPatterns();
272+
const subset = [items[1], items[2], items[3]];
273+
274+
list.first({items: subset});
275+
expect(list.inputs.activeItem()).toBe(items[1]);
276+
277+
list.last({items: subset});
278+
expect(list.inputs.activeItem()).toBe(items[3]);
279+
});
280+
});
248281
});
249282

250283
describe('Selection', () => {

src/aria/private/behaviors/list/list.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@ import {
2525
} from '../list-typeahead/list-typeahead';
2626

2727
/** The operations that the list can perform after navigation. */
28-
interface NavOptions {
28+
export interface NavOptions<T = any> {
2929
toggle?: boolean;
3030
select?: boolean;
3131
selectOne?: boolean;
3232
selectRange?: boolean;
3333
anchor?: boolean;
3434
focusElement?: boolean;
35+
items?: T[];
3536
}
3637

3738
/** Represents an item in the list. */
@@ -106,27 +107,27 @@ export class List<T extends ListItem<V>, V> {
106107
}
107108

108109
/** Navigates to the first option in the list. */
109-
first(opts?: NavOptions) {
110+
first(opts?: NavOptions<T>) {
110111
this._navigate(opts, () => this.navigationBehavior.first(opts));
111112
}
112113

113114
/** Navigates to the last option in the list. */
114-
last(opts?: NavOptions) {
115+
last(opts?: NavOptions<T>) {
115116
this._navigate(opts, () => this.navigationBehavior.last(opts));
116117
}
117118

118119
/** Navigates to the next option in the list. */
119-
next(opts?: NavOptions) {
120+
next(opts?: NavOptions<T>) {
120121
this._navigate(opts, () => this.navigationBehavior.next(opts));
121122
}
122123

123124
/** Navigates to the previous option in the list. */
124-
prev(opts?: NavOptions) {
125+
prev(opts?: NavOptions<T>) {
125126
this._navigate(opts, () => this.navigationBehavior.prev(opts));
126127
}
127128

128129
/** Navigates to the given item in the list. */
129-
goto(item: T, opts?: NavOptions) {
130+
goto(item: T, opts?: NavOptions<T>) {
130131
this._navigate(opts, () => this.navigationBehavior.goto(item, opts));
131132
}
132133

0 commit comments

Comments
 (0)