Skip to content

Commit 385da11

Browse files
committed
feat(multiple): add subset support to list nav
1 parent c16f559 commit 385da11

File tree

5 files changed

+136
-27
lines changed

5 files changed

+136
-27
lines changed

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: 36 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,17 @@ 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 = activeItem ? items.indexOf(activeItem) : -1;
94112
const step = (i: number) =>
95113
this.inputs.wrap() ? (i + delta + itemCount) % itemCount : i + delta;
96114

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

src/aria/private/toolbar/toolbar.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ export class ToolbarPattern<V> {
101101

102102
if (currGroup !== nextGroup) {
103103
this.listBehavior.goto(
104-
this.listBehavior.navigationBehavior.peekFirst(currGroup.inputs.items())!,
104+
this.listBehavior.navigationBehavior.peekFirst({
105+
items: currGroup.inputs.items(),
106+
})!,
105107
);
106108

107109
return;
@@ -121,7 +123,9 @@ export class ToolbarPattern<V> {
121123

122124
if (currGroup !== nextGroup) {
123125
this.listBehavior.goto(
124-
this.listBehavior.navigationBehavior.peekLast(currGroup.inputs.items())!,
126+
this.listBehavior.navigationBehavior.peekLast({
127+
items: currGroup.inputs.items(),
128+
})!,
125129
);
126130

127131
return;
@@ -186,7 +190,9 @@ export class ToolbarPattern<V> {
186190
* Otherwise, sets the active index to the first focusable widget.
187191
*/
188192
setDefaultState() {
189-
const firstItem = this.listBehavior.navigationBehavior.peekFirst(this.inputs.items());
193+
const firstItem = this.listBehavior.navigationBehavior.peekFirst({
194+
items: this.inputs.items(),
195+
});
190196

191197
if (firstItem) {
192198
this.inputs.activeItem.set(firstItem);

0 commit comments

Comments
 (0)