Skip to content

Commit e0446ec

Browse files
authored
fix(cdk-experimental/listbox): change shift+nav behavior (#30854)
* fix(cdk-experimental/listbox): change shift+nav behavior * fixup! fix(cdk-experimental/listbox): change shift+nav behavior * fixup! fix(cdk-experimental/listbox): change shift+nav behavior
1 parent 48894ad commit e0446ec

File tree

11 files changed

+652
-180
lines changed

11 files changed

+652
-180
lines changed

src/cdk-experimental/ui-patterns/behaviors/event-manager/event-manager.ts

+6
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export enum ModifierKey {
4747
Shift = 0b10,
4848
Alt = 0b100,
4949
Meta = 0b1000,
50+
Any = 'Any',
5051
}
5152

5253
export type ModifierInputs = ModifierKey | ModifierKey[];
@@ -99,5 +100,10 @@ export function getModifiers(event: EventWithModifiers): number {
99100
export function hasModifiers(event: EventWithModifiers, modifiers: ModifierInputs): boolean {
100101
const eventModifiers = getModifiers(event);
101102
const modifiersList = Array.isArray(modifiers) ? modifiers : [modifiers];
103+
104+
if (modifiersList.includes(ModifierKey.Any)) {
105+
return true;
106+
}
107+
102108
return modifiersList.some(modifiers => eventModifiers === modifiers);
103109
}

src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts

+21-18
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {signal} from '@angular/core';
9+
import {computed, signal} from '@angular/core';
1010
import {SignalLike, WritableSignalLike} from '../signal-like/signal-like';
1111

1212
/** Represents an item in a collection, such as a listbox option, than can be navigated to. */
@@ -41,45 +41,47 @@ export class ListNavigation<T extends ListNavigationItem> {
4141
/** The last index that was active. */
4242
prevActiveIndex = signal(0);
4343

44+
/** The current active item. */
45+
activeItem = computed(() => this.inputs.items()[this.inputs.activeIndex()]);
46+
4447
constructor(readonly inputs: ListNavigationInputs<T>) {}
4548

4649
/** Navigates to the given item. */
47-
goto(item: T) {
48-
if (this.isFocusable(item)) {
50+
goto(item?: T): boolean {
51+
if (item && this.isFocusable(item)) {
4952
this.prevActiveIndex.set(this.inputs.activeIndex());
5053
const index = this.inputs.items().indexOf(item);
5154
this.inputs.activeIndex.set(index);
55+
return true;
5256
}
57+
return false;
5358
}
5459

5560
/** Navigates to the next item in the list. */
56-
next() {
57-
this._advance(1);
61+
next(): boolean {
62+
return this._advance(1);
5863
}
5964

6065
/** Navigates to the previous item in the list. */
61-
prev() {
62-
this._advance(-1);
66+
prev(): boolean {
67+
return this._advance(-1);
6368
}
6469

6570
/** Navigates to the first item in the list. */
66-
first() {
71+
first(): boolean {
6772
const item = this.inputs.items().find(i => this.isFocusable(i));
68-
69-
if (item) {
70-
this.goto(item);
71-
}
73+
return item ? this.goto(item) : false;
7274
}
7375

7476
/** Navigates to the last item in the list. */
75-
last() {
77+
last(): boolean {
7678
const items = this.inputs.items();
7779
for (let i = items.length - 1; i >= 0; i--) {
7880
if (this.isFocusable(items[i])) {
79-
this.goto(items[i]);
80-
return;
81+
return this.goto(items[i]);
8182
}
8283
}
84+
return false;
8385
}
8486

8587
/** Returns true if the given item can be navigated to. */
@@ -88,7 +90,7 @@ export class ListNavigation<T extends ListNavigationItem> {
8890
}
8991

9092
/** Advances to the next or previous focusable item in the list based on the given delta. */
91-
private _advance(delta: 1 | -1) {
93+
private _advance(delta: 1 | -1): boolean {
9294
const items = this.inputs.items();
9395
const itemCount = items.length;
9496
const startIndex = this.inputs.activeIndex();
@@ -100,9 +102,10 @@ export class ListNavigation<T extends ListNavigationItem> {
100102
// when the index goes out of bounds.
101103
for (let i = step(startIndex); i !== startIndex && i < itemCount && i >= 0; i = step(i)) {
102104
if (this.isFocusable(items[i])) {
103-
this.goto(items[i]);
104-
return;
105+
return this.goto(items[i]);
105106
}
106107
}
108+
109+
return false;
107110
}
108111
}

src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts

+187-5
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,36 @@ describe('List Selection', () => {
157157
});
158158
});
159159

160+
describe('#toggleOne', () => {
161+
it('should select an unselected item', () => {
162+
const items = getItems([0, 1, 2, 3, 4]);
163+
const nav = getNavigation(items);
164+
const selection = getSelection(items, nav);
165+
166+
selection.toggleOne(); // [0]
167+
expect(selection.inputs.value()).toEqual([0]);
168+
});
169+
170+
it('should deselect a selected item', () => {
171+
const items = getItems([0, 1, 2, 3, 4]);
172+
const nav = getNavigation(items);
173+
const selection = getSelection(items, nav);
174+
selection.select(); // [0]
175+
selection.toggleOne(); // []
176+
expect(selection.inputs.value().length).toBe(0);
177+
});
178+
179+
it('should only leave one item selected', () => {
180+
const items = getItems([0, 1, 2, 3, 4]);
181+
const nav = getNavigation(items);
182+
const selection = getSelection(items, nav);
183+
selection.select(); // [0]
184+
nav.next();
185+
selection.toggleOne(); // [1]
186+
expect(selection.inputs.value()).toEqual([1]);
187+
});
188+
});
189+
160190
describe('#selectAll', () => {
161191
it('should select all items', () => {
162192
const items = getItems([0, 1, 2, 3, 4]);
@@ -185,7 +215,71 @@ describe('List Selection', () => {
185215
});
186216
});
187217

188-
describe('#selectFromAnchor', () => {
218+
describe('#toggleAll', () => {
219+
it('should select all items', () => {
220+
const items = getItems([0, 1, 2, 3, 4]);
221+
const nav = getNavigation(items);
222+
const selection = getSelection(items, nav);
223+
selection.toggleAll();
224+
expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]);
225+
});
226+
227+
it('should deselect all if all items are selected', () => {
228+
const items = getItems([0, 1, 2, 3, 4]);
229+
const nav = getNavigation(items);
230+
const selection = getSelection(items, nav);
231+
selection.selectAll();
232+
selection.toggleAll();
233+
expect(selection.inputs.value()).toEqual([]);
234+
});
235+
236+
it('should ignore disabled items when determining if all items are selected', () => {
237+
const items = getItems([0, 1, 2, 3, 4]);
238+
const nav = getNavigation(items);
239+
const selection = getSelection(items, nav);
240+
items()[0].disabled.set(true);
241+
selection.toggleAll();
242+
expect(selection.inputs.value()).toEqual([1, 2, 3, 4]);
243+
selection.toggleAll();
244+
expect(selection.inputs.value()).toEqual([]);
245+
});
246+
});
247+
248+
describe('#selectOne', () => {
249+
it('should select a single item', () => {
250+
const items = getItems([0, 1, 2, 3, 4]);
251+
const nav = getNavigation(items);
252+
const selection = getSelection(items, nav);
253+
254+
selection.selectOne(); // [0]
255+
nav.next();
256+
selection.selectOne(); // [1]
257+
expect(selection.inputs.value()).toEqual([1]);
258+
});
259+
260+
it('should not select disabled items', () => {
261+
const items = getItems([0, 1, 2, 3, 4]);
262+
const nav = getNavigation(items);
263+
const selection = getSelection(items, nav);
264+
items()[0].disabled.set(true);
265+
266+
selection.select(); // []
267+
expect(selection.inputs.value()).toEqual([]);
268+
});
269+
270+
it('should do nothing to already selected items', () => {
271+
const items = getItems([0, 1, 2, 3, 4]);
272+
const nav = getNavigation(items);
273+
const selection = getSelection(items, nav);
274+
275+
selection.selectOne(); // [0]
276+
selection.selectOne(); // [0]
277+
278+
expect(selection.inputs.value()).toEqual([0]);
279+
});
280+
});
281+
282+
describe('#selectRange', () => {
189283
it('should select all items from an anchor at a lower index', () => {
190284
const items = getItems([0, 1, 2, 3, 4]);
191285
const nav = getNavigation(items);
@@ -194,7 +288,7 @@ describe('List Selection', () => {
194288
selection.select(); // [0]
195289
nav.next();
196290
nav.next();
197-
selection.selectFromPrevSelectedItem(); // [0, 1, 2]
291+
selection.selectRange(); // [0, 1, 2]
198292

199293
expect(selection.inputs.value()).toEqual([0, 1, 2]);
200294
});
@@ -209,10 +303,98 @@ describe('List Selection', () => {
209303
selection.select(); // [3]
210304
nav.prev();
211305
nav.prev();
212-
selection.selectFromPrevSelectedItem(); // [3, 1, 2]
306+
selection.selectRange(); // [3, 2, 1]
307+
308+
expect(selection.inputs.value()).toEqual([3, 2, 1]);
309+
});
310+
311+
it('should deselect items within the range when the range is changed', () => {
312+
const items = getItems([0, 1, 2, 3, 4]);
313+
const nav = getNavigation(items);
314+
const selection = getSelection(items, nav);
315+
316+
nav.next();
317+
nav.next();
318+
selection.select(); // [2]
319+
expect(selection.inputs.value()).toEqual([2]);
213320

214-
// TODO(wagnermaciel): Order the values when inserting them.
215-
expect(selection.inputs.value()).toEqual([3, 1, 2]);
321+
nav.next();
322+
nav.next();
323+
selection.selectRange(); // [2, 3, 4]
324+
expect(selection.inputs.value()).toEqual([2, 3, 4]);
325+
326+
nav.first();
327+
selection.selectRange(); // [2, 1, 0]
328+
expect(selection.inputs.value()).toEqual([2, 1, 0]);
329+
});
330+
331+
it('should not select a disabled item', () => {
332+
const items = getItems([0, 1, 2, 3, 4]);
333+
const nav = getNavigation(items);
334+
const selection = getSelection(items, nav);
335+
items()[1].disabled.set(true);
336+
337+
selection.select(); // [0]
338+
expect(selection.inputs.value()).toEqual([0]);
339+
340+
nav.next();
341+
selection.selectRange(); // [0]
342+
expect(selection.inputs.value()).toEqual([0]);
343+
344+
nav.next();
345+
selection.selectRange(); // [0, 2]
346+
expect(selection.inputs.value()).toEqual([0, 2]);
347+
});
348+
349+
it('should not deselect a disabled item', () => {
350+
const items = getItems([0, 1, 2, 3, 4]);
351+
const nav = getNavigation(items);
352+
const selection = getSelection(items, nav);
353+
354+
selection.select(items()[1]);
355+
items()[1].disabled.set(true);
356+
357+
selection.select(); // [0]
358+
expect(selection.inputs.value()).toEqual([1, 0]);
359+
360+
nav.next();
361+
nav.next();
362+
selection.selectRange(); // [0, 1, 2]
363+
expect(selection.inputs.value()).toEqual([1, 0, 2]);
364+
365+
nav.prev();
366+
nav.prev();
367+
selection.selectRange(); // [0]
368+
expect(selection.inputs.value()).toEqual([1, 0]);
369+
});
370+
});
371+
372+
describe('#beginRangeSelection', () => {
373+
it('should set where a range is starting from', () => {
374+
const items = getItems([0, 1, 2, 3, 4]);
375+
const nav = getNavigation(items);
376+
const selection = getSelection(items, nav);
377+
378+
nav.next();
379+
nav.next();
380+
selection.beginRangeSelection();
381+
expect(selection.inputs.value()).toEqual([]);
382+
nav.next();
383+
nav.next();
384+
selection.selectRange(); // [2, 3, 4]
385+
expect(selection.inputs.value()).toEqual([2, 3, 4]);
386+
});
387+
388+
it('should be able to select a range starting on a disabled item', () => {
389+
const items = getItems([0, 1, 2, 3, 4]);
390+
const nav = getNavigation(items);
391+
const selection = getSelection(items, nav);
392+
items()[0].disabled.set(true);
393+
selection.beginRangeSelection(0);
394+
nav.next();
395+
nav.next();
396+
selection.selectRange();
397+
expect(selection.inputs.value()).toEqual([1, 2]);
216398
});
217399
});
218400
});

0 commit comments

Comments
 (0)