Skip to content

Commit 228aaf1

Browse files
authored
feat(cdk-experimental/ui-patterns): create List behavior (#31601)
* feat(cdk-experimental/ui-patterns): create List behavior - Adds a new `List` behavior class to `@angular/cdk-experimental/ui-patterns/behaviors`. - This new behavior composes the existing `ListFocus`, `ListNavigation`, `ListSelection`, and `ListTypeahead` behaviors into a single, cohesive class for managing the state and interactions of a list-based component. - This behavior is intended to be unify the copy/pasted logic in `ListboxPattern`, `TreePattern`, `RadioGroupPattern`, and any other patterns where it makes sense. The `List` behavior provides a simplified, high-level API for common list operations, including: - Navigation (`next`, `prev`, `first`, `last`) - Selection (single, multi, range) - Typeahead search - Focus management (`roving` and `activedescendant`) * refactor(cdk-experimental/ui-patterns): switch listbox to use the list behavior * refactor(cdk-experimental/ui-patterns): switch tabs to use the list behavior * refactor(cdk-experimental/ui-patterns): switch radio-group to use the list behavior * refactor(cdk-experimental/ui-patterns): switch tree to use the list behavior
1 parent 7674e58 commit 228aaf1

File tree

15 files changed

+879
-636
lines changed

15 files changed

+879
-636
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "list",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/cdk-experimental/ui-patterns/behaviors/event-manager",
14+
"//src/cdk-experimental/ui-patterns/behaviors/list-focus",
15+
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
16+
"//src/cdk-experimental/ui-patterns/behaviors/list-selection",
17+
"//src/cdk-experimental/ui-patterns/behaviors/list-typeahead",
18+
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
19+
],
20+
)
21+
22+
ts_project(
23+
name = "unit_test_sources",
24+
testonly = True,
25+
srcs = glob(["**/*.spec.ts"]),
26+
deps = [
27+
":list",
28+
"//:node_modules/@angular/core",
29+
"//src/cdk/keycodes",
30+
"//src/cdk/testing/private",
31+
],
32+
)
33+
34+
ng_web_test_suite(
35+
name = "unit_tests",
36+
deps = [":unit_test_sources"],
37+
)
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {signal, WritableSignal} from '@angular/core';
10+
import {List, ListItem, ListInputs} from './list';
11+
import {fakeAsync, tick} from '@angular/core/testing';
12+
13+
type TestItem<V> = ListItem<V> & {
14+
disabled: WritableSignal<boolean>;
15+
searchTerm: WritableSignal<string>;
16+
value: WritableSignal<V>;
17+
};
18+
19+
type TestInputs<V> = ListInputs<TestItem<V>, V>;
20+
type TestList<V> = List<TestItem<V>, V>;
21+
22+
describe('List Behavior', () => {
23+
function getList<V>(inputs: Partial<TestInputs<V>> & Pick<TestInputs<V>, 'items'>): TestList<V> {
24+
return new List({
25+
value: inputs.value ?? signal([]),
26+
activeIndex: inputs.activeIndex ?? signal(0),
27+
typeaheadDelay: inputs.typeaheadDelay ?? signal(0.5),
28+
wrap: inputs.wrap ?? signal(true),
29+
disabled: inputs.disabled ?? signal(false),
30+
multi: inputs.multi ?? signal(false),
31+
textDirection: inputs.textDirection ?? signal('ltr'),
32+
orientation: inputs.orientation ?? signal('vertical'),
33+
focusMode: inputs.focusMode ?? signal('roving'),
34+
skipDisabled: inputs.skipDisabled ?? signal(true),
35+
selectionMode: signal('explicit'),
36+
...inputs,
37+
});
38+
}
39+
40+
function getItems<V>(values: V[]): TestItem<V>[] {
41+
return values.map((value, index) => ({
42+
value: signal(value),
43+
id: signal(`item-${index}`),
44+
element: signal(document.createElement('div')),
45+
disabled: signal(false),
46+
searchTerm: signal(String(value)),
47+
}));
48+
}
49+
50+
function getListAndItems<V>(values: V[], inputs: Partial<TestInputs<V>> = {}) {
51+
const items = signal<TestItem<V>[]>([]);
52+
const list = getList<V>({...inputs, items});
53+
items.set(getItems(values));
54+
return {list, items: items()};
55+
}
56+
57+
function getDefaultPatterns(inputs: Partial<TestInputs<string>> = {}) {
58+
return getListAndItems(
59+
[
60+
'Apple',
61+
'Apricot',
62+
'Banana',
63+
'Blackberry',
64+
'Blueberry',
65+
'Cantaloupe',
66+
'Cherry',
67+
'Clementine',
68+
'Cranberry',
69+
],
70+
inputs,
71+
);
72+
}
73+
74+
describe('with focusMode: "activedescendant"', () => {
75+
it('should set the list tabindex to 0', () => {
76+
const {list} = getDefaultPatterns({focusMode: signal('activedescendant')});
77+
expect(list.tabindex()).toBe(0);
78+
});
79+
80+
it('should set the active descendant to the active item id', () => {
81+
const {list} = getDefaultPatterns({focusMode: signal('activedescendant')});
82+
expect(list.activedescendant()).toBe('item-0');
83+
list.next();
84+
expect(list.activedescendant()).toBe('item-1');
85+
});
86+
87+
it('should set item tabindex to -1', () => {
88+
const {list, items} = getDefaultPatterns({focusMode: signal('activedescendant')});
89+
expect(list.getItemTabindex(items[0])).toBe(-1);
90+
});
91+
});
92+
93+
describe('with focusMode: "roving"', () => {
94+
it('should set the list tabindex to -1', () => {
95+
const {list} = getDefaultPatterns({focusMode: signal('roving')});
96+
expect(list.tabindex()).toBe(-1);
97+
});
98+
99+
it('should not set the active descendant', () => {
100+
const {list} = getDefaultPatterns({focusMode: signal('roving')});
101+
expect(list.activedescendant()).toBeUndefined();
102+
});
103+
104+
it('should set the active item tabindex to 0 and others to -1', () => {
105+
const {list, items} = getDefaultPatterns({focusMode: signal('roving')});
106+
expect(list.getItemTabindex(items[0])).toBe(0);
107+
expect(list.getItemTabindex(items[1])).toBe(-1);
108+
list.next();
109+
expect(list.getItemTabindex(items[0])).toBe(-1);
110+
expect(list.getItemTabindex(items[1])).toBe(0);
111+
});
112+
});
113+
114+
describe('with disabled: true', () => {
115+
let list: TestList<string>;
116+
117+
beforeEach(() => {
118+
const patterns = getDefaultPatterns({disabled: signal(true)});
119+
list = patterns.list;
120+
});
121+
122+
it('should report disabled state', () => {
123+
expect(list.disabled()).toBe(true);
124+
});
125+
126+
it('should not change active index on navigation', () => {
127+
expect(list.inputs.activeIndex()).toBe(0);
128+
list.next();
129+
expect(list.inputs.activeIndex()).toBe(0);
130+
list.last();
131+
expect(list.inputs.activeIndex()).toBe(0);
132+
});
133+
134+
it('should not select items', () => {
135+
list.next({selectOne: true});
136+
expect(list.inputs.value()).toEqual([]);
137+
});
138+
139+
it('should have a tabindex of 0', () => {
140+
expect(list.tabindex()).toBe(0);
141+
});
142+
});
143+
144+
describe('Navigation', () => {
145+
it('should navigate to the next item with next()', () => {
146+
const {list} = getDefaultPatterns();
147+
expect(list.inputs.activeIndex()).toBe(0);
148+
list.next();
149+
expect(list.inputs.activeIndex()).toBe(1);
150+
});
151+
152+
it('should navigate to the previous item with prev()', () => {
153+
const {list} = getDefaultPatterns({activeIndex: signal(1)});
154+
expect(list.inputs.activeIndex()).toBe(1);
155+
list.prev();
156+
expect(list.inputs.activeIndex()).toBe(0);
157+
});
158+
159+
it('should navigate to the first item with first()', () => {
160+
const {list} = getDefaultPatterns({activeIndex: signal(8)});
161+
expect(list.inputs.activeIndex()).toBe(8);
162+
list.first();
163+
expect(list.inputs.activeIndex()).toBe(0);
164+
});
165+
166+
it('should navigate to the last item with last()', () => {
167+
const {list} = getDefaultPatterns();
168+
expect(list.inputs.activeIndex()).toBe(0);
169+
list.last();
170+
expect(list.inputs.activeIndex()).toBe(8);
171+
});
172+
173+
it('should skip disabled items when navigating', () => {
174+
const {list, items} = getDefaultPatterns();
175+
items[1].disabled.set(true); // Disable second item
176+
expect(list.inputs.activeIndex()).toBe(0);
177+
list.next();
178+
expect(list.inputs.activeIndex()).toBe(2); // Should skip to 'Banana'
179+
list.prev();
180+
expect(list.inputs.activeIndex()).toBe(0); // Should skip back to 'Apple'
181+
});
182+
183+
it('should not skip disabled items when skipDisabled is false', () => {
184+
const {list, items} = getDefaultPatterns({skipDisabled: signal(false)});
185+
items[1].disabled.set(true); // Disable second item
186+
expect(list.inputs.activeIndex()).toBe(0);
187+
list.next();
188+
expect(list.inputs.activeIndex()).toBe(1); // Should land on second item even though it's disabled
189+
});
190+
191+
it('should not wrap with wrap: false', () => {
192+
const {list} = getDefaultPatterns({wrap: signal(false)});
193+
list.last();
194+
expect(list.inputs.activeIndex()).toBe(8);
195+
list.next();
196+
expect(list.inputs.activeIndex()).toBe(8); // Stays at the end
197+
list.first();
198+
expect(list.inputs.activeIndex()).toBe(0);
199+
list.prev();
200+
expect(list.inputs.activeIndex()).toBe(0); // Stays at the beginning
201+
});
202+
203+
// The navigation behavior itself doesn't change for horizontal, but we test it for completeness.
204+
it('should navigate with orientation: "horizontal"', () => {
205+
const {list} = getDefaultPatterns({orientation: signal('horizontal')});
206+
expect(list.inputs.activeIndex()).toBe(0);
207+
list.next();
208+
expect(list.inputs.activeIndex()).toBe(1);
209+
list.prev();
210+
expect(list.inputs.activeIndex()).toBe(0);
211+
});
212+
});
213+
214+
describe('Selection', () => {
215+
describe('single select', () => {
216+
let list: TestList<string>;
217+
let items: TestItem<string>[];
218+
219+
beforeEach(() => {
220+
const patterns = getDefaultPatterns({
221+
value: signal([]),
222+
multi: signal(false),
223+
});
224+
list = patterns.list;
225+
items = patterns.items;
226+
});
227+
228+
it('should not select when navigating', () => {
229+
list.next();
230+
expect(list.inputs.value()).toEqual([]);
231+
});
232+
233+
it('should select an item when navigating with selectOne:true', () => {
234+
list.next({selectOne: true});
235+
expect(list.inputs.value()).toEqual(['Apricot']);
236+
});
237+
238+
it('should toggle an item when navigating with toggle:true', () => {
239+
list.goto(items[1], {selectOne: true});
240+
expect(list.inputs.value()).toEqual(['Apricot']);
241+
242+
list.goto(items[1], {toggle: true});
243+
expect(list.inputs.value()).toEqual([]);
244+
});
245+
246+
it('should only allow one selected item', () => {
247+
list.next({selectOne: true});
248+
expect(list.inputs.value()).toEqual(['Apricot']);
249+
list.next({selectOne: true});
250+
expect(list.inputs.value()).toEqual(['Banana']);
251+
});
252+
});
253+
254+
describe('multi select', () => {
255+
let list: TestList<string>;
256+
let items: TestItem<string>[];
257+
258+
beforeEach(() => {
259+
const patterns = getDefaultPatterns({
260+
value: signal([]),
261+
multi: signal(true),
262+
});
263+
list = patterns.list;
264+
items = patterns.items;
265+
});
266+
267+
it('should not select when navigating', () => {
268+
list.next();
269+
expect(list.inputs.value()).toEqual([]);
270+
});
271+
272+
it('should select an item with toggle:true', () => {
273+
list.next({toggle: true});
274+
expect(list.inputs.value()).toEqual(['Apricot']);
275+
});
276+
277+
it('should allow multiple selected items', () => {
278+
list.next({toggle: true});
279+
list.next({toggle: true});
280+
expect(list.inputs.value()).toEqual(['Apricot', 'Banana']);
281+
});
282+
283+
it('should select a range of items with selectRange:true', () => {
284+
list.anchor(0);
285+
list.next({selectRange: true});
286+
expect(list.inputs.value()).toEqual(['Apple', 'Apricot']);
287+
list.next({selectRange: true});
288+
expect(list.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']);
289+
list.prev({selectRange: true});
290+
expect(list.inputs.value()).toEqual(['Apple', 'Apricot']);
291+
list.prev({selectRange: true});
292+
expect(list.inputs.value()).toEqual(['Apple']);
293+
});
294+
295+
it('should not wrap when range selecting', () => {
296+
list.anchor(0);
297+
list.prev({selectRange: true});
298+
expect(list.inputs.activeIndex()).toBe(0);
299+
expect(list.inputs.value()).toEqual([]);
300+
});
301+
302+
it('should not select disabled items in a range', () => {
303+
items[1].disabled.set(true);
304+
list.anchor(0);
305+
list.goto(items[3], {selectRange: true});
306+
expect(list.inputs.value()).toEqual(['Apple', 'Banana', 'Blackberry']);
307+
});
308+
});
309+
});
310+
311+
describe('Typeahead', () => {
312+
it('should navigate to an item via typeahead', fakeAsync(() => {
313+
const {list} = getDefaultPatterns();
314+
expect(list.inputs.activeIndex()).toBe(0);
315+
list.search('b');
316+
expect(list.inputs.activeIndex()).toBe(2); // Banana
317+
list.search('l');
318+
expect(list.inputs.activeIndex()).toBe(3); // Blackberry
319+
list.search('u');
320+
expect(list.inputs.activeIndex()).toBe(4); // Blueberry
321+
322+
tick(500); // Default delay
323+
324+
list.search('c');
325+
expect(list.inputs.activeIndex()).toBe(5); // Cantaloupe
326+
}));
327+
328+
it('should respect typeaheadDelay', fakeAsync(() => {
329+
const {list} = getDefaultPatterns({typeaheadDelay: signal(0.1)});
330+
list.search('b');
331+
expect(list.inputs.activeIndex()).toBe(2); // Banana
332+
tick(50); // Less than delay
333+
list.search('l');
334+
expect(list.inputs.activeIndex()).toBe(3); // Blackberry
335+
tick(101); // More than delay
336+
list.search('c');
337+
expect(list.inputs.activeIndex()).toBe(5); // Cantaloupe
338+
}));
339+
340+
it('should select an item via typeahead', () => {
341+
const {list} = getDefaultPatterns({multi: signal(false)});
342+
list.search('b', {selectOne: true});
343+
expect(list.inputs.value()).toEqual(['Banana']);
344+
});
345+
});
346+
});

0 commit comments

Comments
 (0)