Skip to content

Commit 2e34b20

Browse files
authored
fix(cdk-experimental/ui-patterns): add missing event handlers (#30786)
* Add missing event handlers for alternative multiselection strategy
1 parent 9398f9e commit 2e34b20

File tree

2 files changed

+237
-23
lines changed

2 files changed

+237
-23
lines changed

src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts

+235-23
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,21 @@ import {signal} from '@angular/core';
1010
import {ListboxInputs, ListboxPattern} from './listbox';
1111
import {OptionPattern} from './option';
1212
import {createKeyboardEvent} from '@angular/cdk/testing/private';
13+
import {ModifierKeys} from '@angular/cdk/testing';
1314

1415
type TestInputs = ListboxInputs<string>;
1516
type TestOption = OptionPattern<string>;
1617
type TestListbox = ListboxPattern<string>;
1718

19+
const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods);
20+
const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods);
21+
const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods);
22+
const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods);
23+
const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods);
24+
const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods);
25+
const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods);
26+
const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods);
27+
1828
describe('Listbox Pattern', () => {
1929
function getListbox(inputs: Partial<TestInputs> & Pick<TestInputs, 'items'>) {
2030
return new ListboxPattern({
@@ -70,85 +80,287 @@ describe('Listbox Pattern', () => {
7080
);
7181
}
7282

73-
describe('Navigation', () => {
83+
describe('Keyboard Navigation', () => {
7484
it('should navigate next on ArrowDown', () => {
7585
const {listbox} = getDefaultPatterns();
76-
const event = createKeyboardEvent('keydown', 40, 'ArrowDown');
7786
expect(listbox.inputs.activeIndex()).toBe(0);
78-
listbox.onKeydown(event);
87+
listbox.onKeydown(down());
7988
expect(listbox.inputs.activeIndex()).toBe(1);
8089
});
8190

8291
it('should navigate prev on ArrowUp', () => {
83-
const event = createKeyboardEvent('keydown', 38, 'ArrowUp');
84-
const {listbox} = getDefaultPatterns({
85-
activeIndex: signal(1),
86-
});
92+
const {listbox} = getDefaultPatterns({activeIndex: signal(1)});
8793
expect(listbox.inputs.activeIndex()).toBe(1);
88-
listbox.onKeydown(event);
94+
listbox.onKeydown(up());
8995
expect(listbox.inputs.activeIndex()).toBe(0);
9096
});
9197

9298
it('should navigate next on ArrowRight (horizontal)', () => {
93-
const event = createKeyboardEvent('keydown', 39, 'ArrowRight');
94-
const {listbox} = getDefaultPatterns({
95-
orientation: signal('horizontal'),
96-
});
99+
const {listbox} = getDefaultPatterns({orientation: signal('horizontal')});
97100
expect(listbox.inputs.activeIndex()).toBe(0);
98-
listbox.onKeydown(event);
101+
listbox.onKeydown(right());
99102
expect(listbox.inputs.activeIndex()).toBe(1);
100103
});
101104

102105
it('should navigate prev on ArrowLeft (horizontal)', () => {
103-
const event = createKeyboardEvent('keydown', 37, 'ArrowLeft');
104106
const {listbox} = getDefaultPatterns({
105107
activeIndex: signal(1),
106108
orientation: signal('horizontal'),
107109
});
108110
expect(listbox.inputs.activeIndex()).toBe(1);
109-
listbox.onKeydown(event);
111+
listbox.onKeydown(left());
110112
expect(listbox.inputs.activeIndex()).toBe(0);
111113
});
112114

113115
it('should navigate next on ArrowLeft (horizontal & rtl)', () => {
114-
const event = createKeyboardEvent('keydown', 38, 'ArrowLeft');
115116
const {listbox} = getDefaultPatterns({
116117
textDirection: signal('rtl'),
117118
orientation: signal('horizontal'),
118119
});
119120
expect(listbox.inputs.activeIndex()).toBe(0);
120-
listbox.onKeydown(event);
121+
listbox.onKeydown(left());
121122
expect(listbox.inputs.activeIndex()).toBe(1);
122123
});
123124

124125
it('should navigate prev on ArrowRight (horizontal & rtl)', () => {
125-
const event = createKeyboardEvent('keydown', 39, 'ArrowRight');
126126
const {listbox} = getDefaultPatterns({
127127
activeIndex: signal(1),
128128
textDirection: signal('rtl'),
129129
orientation: signal('horizontal'),
130130
});
131131
expect(listbox.inputs.activeIndex()).toBe(1);
132-
listbox.onKeydown(event);
132+
listbox.onKeydown(right());
133133
expect(listbox.inputs.activeIndex()).toBe(0);
134134
});
135135

136136
it('should navigate to the first option on Home', () => {
137-
const event = createKeyboardEvent('keydown', 36, 'Home');
138137
const {listbox} = getDefaultPatterns({
139138
activeIndex: signal(8),
140139
});
141140
expect(listbox.inputs.activeIndex()).toBe(8);
142-
listbox.onKeydown(event);
141+
listbox.onKeydown(home());
143142
expect(listbox.inputs.activeIndex()).toBe(0);
144143
});
145144

146145
it('should navigate to the last option on End', () => {
147-
const event = createKeyboardEvent('keydown', 35, 'End');
148146
const {listbox} = getDefaultPatterns();
149147
expect(listbox.inputs.activeIndex()).toBe(0);
150-
listbox.onKeydown(event);
148+
listbox.onKeydown(end());
151149
expect(listbox.inputs.activeIndex()).toBe(8);
152150
});
153151
});
152+
153+
describe('Keyboard Selection', () => {
154+
describe('follows focus & single select', () => {
155+
it('should select an option on navigation', () => {
156+
const {listbox} = getDefaultPatterns({
157+
value: signal(['Apple']),
158+
multiselectable: signal(false),
159+
selectionMode: signal('follow'),
160+
});
161+
162+
expect(listbox.inputs.activeIndex()).toBe(0);
163+
expect(listbox.inputs.value()).toEqual(['Apple']);
164+
165+
listbox.onKeydown(down());
166+
expect(listbox.inputs.activeIndex()).toBe(1);
167+
expect(listbox.inputs.value()).toEqual(['Apricot']);
168+
169+
listbox.onKeydown(up());
170+
expect(listbox.inputs.activeIndex()).toBe(0);
171+
expect(listbox.inputs.value()).toEqual(['Apple']);
172+
173+
listbox.onKeydown(end());
174+
expect(listbox.inputs.activeIndex()).toBe(8);
175+
expect(listbox.inputs.value()).toEqual(['Cranberry']);
176+
177+
listbox.onKeydown(home());
178+
expect(listbox.inputs.activeIndex()).toBe(0);
179+
expect(listbox.inputs.value()).toEqual(['Apple']);
180+
});
181+
});
182+
183+
describe('explicit focus & single select', () => {
184+
let listbox: TestListbox;
185+
186+
beforeEach(() => {
187+
listbox = getDefaultPatterns({
188+
value: signal([]),
189+
selectionMode: signal('explicit'),
190+
multiselectable: signal(false),
191+
}).listbox;
192+
});
193+
194+
it('should select an option on Space', () => {
195+
listbox.onKeydown(space());
196+
expect(listbox.inputs.value()).toEqual(['Apple']);
197+
});
198+
199+
it('should select an option on Enter', () => {
200+
listbox.onKeydown(enter());
201+
expect(listbox.inputs.value()).toEqual(['Apple']);
202+
});
203+
204+
it('should only allow one selected option', () => {
205+
listbox.onKeydown(enter());
206+
listbox.onKeydown(down());
207+
listbox.onKeydown(enter());
208+
expect(listbox.inputs.value()).toEqual(['Apricot']);
209+
});
210+
});
211+
212+
describe('explicit focus & multi select', () => {
213+
let listbox: TestListbox;
214+
215+
beforeEach(() => {
216+
listbox = getDefaultPatterns({
217+
value: signal([]),
218+
selectionMode: signal('explicit'),
219+
multiselectable: signal(true),
220+
}).listbox;
221+
});
222+
223+
it('should select an option on Space', () => {
224+
listbox.onKeydown(space());
225+
expect(listbox.inputs.value()).toEqual(['Apple']);
226+
});
227+
228+
it('should select an option on Enter', () => {
229+
listbox.onKeydown(enter());
230+
expect(listbox.inputs.value()).toEqual(['Apple']);
231+
});
232+
233+
it('should allow multiple selected options', () => {
234+
listbox.onKeydown(enter());
235+
listbox.onKeydown(down());
236+
listbox.onKeydown(enter());
237+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']);
238+
});
239+
240+
it('should toggle the selected state of the next option on Shift + ArrowDown', () => {
241+
listbox.onKeydown(down({shift: true}));
242+
listbox.onKeydown(down({shift: true}));
243+
expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana']);
244+
});
245+
246+
it('should toggle the selected state of the next option on Shift + ArrowUp', () => {
247+
listbox.onKeydown(down());
248+
listbox.onKeydown(down());
249+
listbox.onKeydown(up({shift: true}));
250+
listbox.onKeydown(up({shift: true}));
251+
expect(listbox.inputs.value()).toEqual(['Apricot', 'Apple']);
252+
});
253+
254+
it('should select contiguous items from the most recently selected item to the focused item on Shift + Space (or Enter)', () => {
255+
listbox.onKeydown(down());
256+
listbox.onKeydown(space()); // Apricot
257+
listbox.onKeydown(down());
258+
listbox.onKeydown(down());
259+
listbox.onKeydown(space({shift: true}));
260+
expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana', 'Blackberry']);
261+
});
262+
263+
it('should select the focused option and all options up to the first option on Ctrl + Shift + Home', () => {
264+
listbox.onKeydown(down());
265+
listbox.onKeydown(down());
266+
listbox.onKeydown(down());
267+
listbox.onKeydown(home({control: true, shift: true}));
268+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana', 'Blackberry']);
269+
});
270+
271+
it('should select the focused option and all options down to the last option on Ctrl + Shift + End', () => {
272+
listbox.onKeydown(down());
273+
listbox.onKeydown(down());
274+
listbox.onKeydown(down());
275+
listbox.onKeydown(down());
276+
listbox.onKeydown(down());
277+
listbox.onKeydown(end({control: true, shift: true}));
278+
expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']);
279+
});
280+
});
281+
282+
describe('follows focus & multi select', () => {
283+
let listbox: TestListbox;
284+
285+
beforeEach(() => {
286+
listbox = getDefaultPatterns({
287+
value: signal(['Apple']),
288+
multiselectable: signal(true),
289+
selectionMode: signal('follow'),
290+
}).listbox;
291+
});
292+
293+
it('should select an option on navigation', () => {
294+
expect(listbox.inputs.value()).toEqual(['Apple']);
295+
listbox.onKeydown(down());
296+
expect(listbox.inputs.value()).toEqual(['Apricot']);
297+
listbox.onKeydown(up());
298+
expect(listbox.inputs.value()).toEqual(['Apple']);
299+
listbox.onKeydown(end());
300+
expect(listbox.inputs.value()).toEqual(['Cranberry']);
301+
listbox.onKeydown(home());
302+
expect(listbox.inputs.value()).toEqual(['Apple']);
303+
});
304+
305+
it('should navigate without selecting an option if the Ctrl key is pressed', () => {
306+
expect(listbox.inputs.value()).toEqual(['Apple']);
307+
listbox.onKeydown(down({control: true}));
308+
expect(listbox.inputs.value()).toEqual(['Apple']);
309+
listbox.onKeydown(up({control: true}));
310+
expect(listbox.inputs.value()).toEqual(['Apple']);
311+
listbox.onKeydown(end({control: true}));
312+
expect(listbox.inputs.value()).toEqual(['Apple']);
313+
listbox.onKeydown(home({control: true}));
314+
});
315+
316+
it('should toggle an options selection state on Ctrl + Space', () => {
317+
listbox.onKeydown(down({control: true}));
318+
listbox.onKeydown(down({control: true}));
319+
listbox.onKeydown(space({control: true}));
320+
expect(listbox.inputs.value()).toEqual(['Apple', 'Banana']);
321+
});
322+
323+
it('should toggle the selected state of the next option on Shift + ArrowDown', () => {
324+
listbox.onKeydown(down({shift: true}));
325+
listbox.onKeydown(down({shift: true}));
326+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']);
327+
});
328+
329+
it('should toggle the selected state of the next option on Shift + ArrowUp', () => {
330+
listbox.onKeydown(down());
331+
listbox.onKeydown(down());
332+
listbox.onKeydown(up({shift: true}));
333+
listbox.onKeydown(up({shift: true}));
334+
expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']);
335+
});
336+
337+
it('should select contiguous items from the most recently selected item to the focused item on Shift + Space (or Enter)', () => {
338+
listbox.onKeydown(down({control: true}));
339+
listbox.onKeydown(down({control: true}));
340+
listbox.onKeydown(down()); // Blackberry
341+
listbox.onKeydown(down({control: true}));
342+
listbox.onKeydown(down({control: true}));
343+
listbox.onKeydown(space({shift: true}));
344+
expect(listbox.inputs.value()).toEqual(['Blackberry', 'Blueberry', 'Cantaloupe']);
345+
});
346+
347+
it('should select the focused option and all options up to the first option on Ctrl + Shift + Home', () => {
348+
listbox.onKeydown(down({control: true}));
349+
listbox.onKeydown(down({control: true}));
350+
listbox.onKeydown(down());
351+
listbox.onKeydown(home({control: true, shift: true}));
352+
expect(listbox.inputs.value()).toEqual(['Blackberry', 'Apple', 'Apricot', 'Banana']);
353+
});
354+
355+
it('should select the focused option and all options down to the last option on Ctrl + Shift + End', () => {
356+
listbox.onKeydown(down({control: true}));
357+
listbox.onKeydown(down({control: true}));
358+
listbox.onKeydown(down({control: true}));
359+
listbox.onKeydown(down({control: true}));
360+
listbox.onKeydown(down());
361+
listbox.onKeydown(end({control: true, shift: true}));
362+
expect(listbox.inputs.value()).toEqual(['Cantaloupe', 'Cherry', 'Clementine', 'Cranberry']);
363+
});
364+
});
365+
});
154366
});

src/cdk-experimental/ui-patterns/listbox/listbox.ts

+2
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export class ListboxPattern<V> {
137137
manager
138138
.on(Modifier.Ctrl, this.prevKey, () => this.prev())
139139
.on(Modifier.Ctrl, this.nextKey, () => this.next())
140+
.on(Modifier.Ctrl, ' ', () => this._updateSelection({toggle: true}))
141+
.on(Modifier.Ctrl, 'Enter', () => this._updateSelection({toggle: true}))
140142
.on(Modifier.Ctrl, 'Home', () => this.first()) // TODO: Not in spec but prob should be.
141143
.on(Modifier.Ctrl, 'End', () => this.last()); // TODO: Not in spec but prob should be.
142144
}

0 commit comments

Comments
 (0)