Skip to content

Commit 276df30

Browse files
committed
fix(cdk-experimental/listbox): change shift+nav behavior
1 parent 15d39c5 commit 276df30

File tree

3 files changed

+69
-15
lines changed

3 files changed

+69
-15
lines changed

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

Lines changed: 6 additions & 0 deletions
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 = 0b10000,
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/listbox/listbox.spec.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home',
2424
const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods);
2525
const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods);
2626
const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods);
27+
const shift = () => createKeyboardEvent('keydown', 16, 'Shift', {shift: true});
2728

2829
describe('Listbox Pattern', () => {
2930
function getListbox(inputs: Partial<TestInputs> & Pick<TestInputs, 'items'>) {
@@ -279,18 +280,25 @@ describe('Listbox Pattern', () => {
279280
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']);
280281
});
281282

282-
it('should toggle the selected state of the next option on Shift + ArrowDown', () => {
283+
it('should select a range of options on Shift + ArrowDown/ArrowUp', () => {
284+
listbox.onKeydown(shift());
283285
listbox.onKeydown(down({shift: true}));
286+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']);
284287
listbox.onKeydown(down({shift: true}));
285-
expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana']);
288+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']);
289+
listbox.onKeydown(up({shift: true}));
290+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']);
291+
listbox.onKeydown(up({shift: true}));
292+
expect(listbox.inputs.value()).toEqual(['Apple']);
286293
});
287294

288-
it('should toggle the selected state of the next option on Shift + ArrowUp', () => {
289-
listbox.onKeydown(down());
290-
listbox.onKeydown(down());
295+
it('should not allow wrapping while Shift is held down', () => {
296+
listbox.onKeydown(shift());
297+
listbox.onKeydown(up({shift: true}));
291298
listbox.onKeydown(up({shift: true}));
292299
listbox.onKeydown(up({shift: true}));
293-
expect(listbox.inputs.value()).toEqual(['Apricot', 'Apple']);
300+
listbox.onKeydown(up({shift: true}));
301+
expect(listbox.inputs.value()).toEqual(['Apple']);
294302
});
295303

296304
it('should select contiguous items from the most recently selected item to the focused item on Shift + Space (or Enter)', () => {
@@ -385,18 +393,25 @@ describe('Listbox Pattern', () => {
385393
expect(listbox.inputs.value()).toEqual(['Apple', 'Banana']);
386394
});
387395

388-
it('should toggle the selected state of the next option on Shift + ArrowDown', () => {
396+
it('should select a range of options on Shift + ArrowDown/ArrowUp', () => {
397+
listbox.onKeydown(shift());
389398
listbox.onKeydown(down({shift: true}));
399+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']);
390400
listbox.onKeydown(down({shift: true}));
391401
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']);
402+
listbox.onKeydown(up({shift: true}));
403+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']);
404+
listbox.onKeydown(up({shift: true}));
405+
expect(listbox.inputs.value()).toEqual(['Apple']);
392406
});
393407

394-
it('should toggle the selected state of the next option on Shift + ArrowUp', () => {
395-
listbox.onKeydown(down());
396-
listbox.onKeydown(down());
408+
it('should not allow wrapping while Shift is held down', () => {
409+
listbox.onKeydown(shift());
410+
listbox.onKeydown(up({shift: true}));
397411
listbox.onKeydown(up({shift: true}));
398412
listbox.onKeydown(up({shift: true}));
399-
expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']);
413+
listbox.onKeydown(up({shift: true}));
414+
expect(listbox.inputs.value()).toEqual(['Apple']);
400415
});
401416

402417
it('should select contiguous items from the most recently selected item to the focused item on Shift + Space (or Enter)', () => {

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

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/li
1414
import {ListTypeahead, ListTypeaheadInputs} from '../behaviors/list-typeahead/list-typeahead';
1515
import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation';
1616
import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus';
17-
import {computed} from '@angular/core';
17+
import {computed, signal} from '@angular/core';
1818
import {SignalLike} from '../behaviors/signal-like/signal-like';
1919

2020
/** The selection operations that the listbox can perform. */
@@ -76,6 +76,9 @@ export class ListboxPattern<V> {
7676
/** Whether the listbox selection follows focus. */
7777
followFocus = computed(() => this.inputs.selectionMode() === 'follow');
7878

79+
/** Whether the listbox should wrap. Used to disable wrapping while range selecting. */
80+
wrap = signal(true);
81+
7982
/** The key used to navigate to the previous item in the list. */
8083
prevKey = computed(() => {
8184
if (this.inputs.orientation() === 'vertical') {
@@ -98,6 +101,9 @@ export class ListboxPattern<V> {
98101
/** The regexp used to decide if a key should trigger typeahead. */
99102
typeaheadRegexp = /^.$/; // TODO: Ignore spaces?
100103

104+
/** The index where the shift key started to be held down. */
105+
shiftAnchorIndex = signal(0);
106+
101107
/** The keydown event manager for the listbox. */
102108
keydown = computed(() => {
103109
const manager = new KeyboardEventManager();
@@ -130,10 +136,34 @@ export class ListboxPattern<V> {
130136
}
131137

132138
if (this.inputs.multi()) {
139+
// When the user holds down the shift key, they are selecting a range starting from the
140+
// index where the shift key begins being held down. Note that this is very different from
141+
// selecting from the current active or focused index.
142+
manager
143+
.on(Modifier.Any, 'Shift', () => this.shiftAnchorIndex.set(this.inputs.activeIndex()))
144+
.on(Modifier.Shift, this.prevKey, () => {
145+
if (this.inputs.activeIndex() === this.shiftAnchorIndex()) {
146+
this.selection.select();
147+
} else if (this.inputs.activeIndex() > this.shiftAnchorIndex()) {
148+
this.selection.deselect();
149+
}
150+
this.wrap.set(false);
151+
this.prev({select: true});
152+
this.wrap.set(true);
153+
})
154+
.on(Modifier.Shift, this.nextKey, () => {
155+
if (this.inputs.activeIndex() === this.shiftAnchorIndex()) {
156+
this.selection.select();
157+
} else if (this.inputs.activeIndex() < this.shiftAnchorIndex()) {
158+
this.selection.deselect();
159+
}
160+
this.wrap.set(false);
161+
this.next({select: true});
162+
this.wrap.set(true);
163+
});
164+
133165
manager
134166
.on(Modifier.Shift, 'Enter', () => this._updateSelection({selectFromAnchor: true}))
135-
.on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true}))
136-
.on(Modifier.Shift, this.nextKey, () => this.next({toggle: true}))
137167
.on([Modifier.Ctrl, Modifier.Meta], 'A', () => this._updateSelection({selectAll: true}))
138168
.on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () =>
139169
this.first({selectFromActive: true}),
@@ -207,7 +237,10 @@ export class ListboxPattern<V> {
207237
this.orientation = inputs.orientation;
208238
this.multi = inputs.multi;
209239

210-
this.navigation = new ListNavigation(inputs);
240+
this.navigation = new ListNavigation({
241+
...inputs,
242+
wrap: computed(() => this.wrap() && this.inputs.wrap()),
243+
});
211244
this.selection = new ListSelection({...inputs, navigation: this.navigation});
212245
this.typeahead = new ListTypeahead({...inputs, navigation: this.navigation});
213246
this.focusManager = new ListFocus({...inputs, navigation: this.navigation});

0 commit comments

Comments
 (0)