Skip to content

Commit d159f66

Browse files
committed
fixup! fix(cdk-experimental/listbox): change shift+nav behavior
1 parent 0c6b0f5 commit d159f66

File tree

3 files changed

+59
-35
lines changed

3 files changed

+59
-35
lines changed

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

Lines changed: 29 additions & 13 deletions
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 {computed, signal} from '@angular/core';
9+
import {signal} from '@angular/core';
1010
import {SignalLike, WritableSignalLike} from '../signal-like/signal-like';
1111
import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation';
1212

@@ -36,11 +36,11 @@ export interface ListSelectionInputs<T extends ListSelectionItem<V>, V> {
3636

3737
/** Controls selection for a list of items. */
3838
export class ListSelection<T extends ListSelectionItem<V>, V> {
39-
/** An item representing the boundary for range selection. */
40-
anchorItem = signal<T | undefined>(undefined);
39+
/** The start index to use for range selection. */
40+
rangeStartIndex = signal<number>(0);
4141

42-
/** The index of the current anchor item. */
43-
anchorIndex = computed(() => this.inputs.items().findIndex(i => i === this.anchorItem()));
42+
/** The end index to use for range selection. */
43+
rangeEndIndex = signal<number>(0);
4444

4545
/** The navigation controller of the parent list. */
4646
navigation: ListNavigation<T>;
@@ -61,8 +61,9 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
6161
this.deselectAll();
6262
}
6363

64+
const index = this.inputs.items().findIndex(i => i === item);
6465
if (opts.anchor) {
65-
this.dropAnchor();
66+
this.beginRangeSelection(index);
6667
}
6768
this.inputs.value.update(values => values.concat(item.value()));
6869
}
@@ -95,10 +96,10 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
9596
}
9697

9798
for (const item of this.inputs.items()) {
98-
this.select(item);
99+
this.select(item, {anchor: false});
99100
}
100101

101-
this.dropAnchor();
102+
this.beginRangeSelection();
102103
}
103104

104105
/** Deselects all items in the list. */
@@ -130,9 +131,11 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
130131
* Deselects all items that were previously within the
131132
* selected range that are now outside of the selected range
132133
*/
133-
selectFromAnchor() {
134-
const itemsInRange = this._getItemsFromActive(this.anchorIndex());
135-
const itemsOutOfRange = this._getItemsFromActive(this.navigation.prevActiveIndex());
134+
selectRange() {
135+
const itemsInRange = this._getItemsFromActive(this.rangeStartIndex());
136+
const itemsOutOfRange = this._getItemsFromActive(this.rangeEndIndex()).filter(
137+
i => !itemsInRange.includes(i),
138+
);
136139

137140
for (const item of itemsOutOfRange) {
138141
this.deselect(item);
@@ -141,11 +144,19 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
141144
for (const item of itemsInRange) {
142145
this.select(item, {anchor: false});
143146
}
147+
148+
if (itemsInRange.length) {
149+
const item = itemsInRange.pop();
150+
const index = this.inputs.items().findIndex(i => i === item);
151+
this.rangeEndIndex.set(index);
152+
}
144153
}
145154

146155
/** Sets the anchor to the current active index. */
147-
dropAnchor() {
148-
this.anchorItem.set(this.inputs.navigation.activeItem());
156+
beginRangeSelection(index: number = this.navigation.inputs.activeIndex()) {
157+
this.rangeStartIndex.set(index);
158+
this.rangeEndIndex.set(index);
159+
console.log('range start:', index);
149160
}
150161

151162
private _getItemsFromActive(index: number) {
@@ -160,6 +171,11 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
160171
for (let i = lower; i <= upper; i++) {
161172
items.push(this.inputs.items()[i]);
162173
}
174+
175+
if (this.inputs.navigation.inputs.activeIndex() < index) {
176+
return items.reverse();
177+
}
178+
163179
return items;
164180
}
165181
}

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

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface SelectOptions {
2525
toggleOne?: boolean;
2626
selectOne?: boolean;
2727
toggleAll?: boolean;
28-
selectFromAnchor?: boolean;
28+
selectRange?: boolean;
2929
}
3030

3131
/** Represents the required inputs for a listbox. */
@@ -135,41 +135,48 @@ export class ListboxPattern<V> {
135135
}
136136

137137
if (this.inputs.multi()) {
138-
// When the user holds down the shift key, they are selecting a range starting from the
139-
// index where the shift key begins being held down. Note that this is very different from
140-
// selecting from the current active or focused index.
138+
// The following block implements range selection using a keyboard.
139+
//
140+
// Range selection is started by holding shift and navigating to the next item in a list.
141+
//
142+
// Wrapping is disabled during range selection since any decision on how to
143+
// continue the range after wrapping would lead to an unintuitive user experience.
141144
manager
142145
.on(Modifier.Any, 'Shift', () => this.shiftAnchorIndex.set(this.inputs.activeIndex()))
143146
.on(Modifier.Shift, this.prevKey, () => {
144147
if (this.inputs.activeIndex() === this.shiftAnchorIndex()) {
145-
this.selection.dropAnchor();
148+
this.selection.beginRangeSelection();
146149
}
147150
this.wrap.set(false);
148-
this.prev({selectFromAnchor: true});
151+
this.prev({selectRange: true});
149152
this.wrap.set(true);
150153
})
151154
.on(Modifier.Shift, this.nextKey, () => {
152155
if (this.inputs.activeIndex() === this.shiftAnchorIndex()) {
153-
this.selection.dropAnchor();
156+
this.selection.beginRangeSelection();
154157
}
155158
this.wrap.set(false);
156-
this.next({selectFromAnchor: true});
159+
this.next({selectRange: true});
157160
this.wrap.set(true);
158161
});
159162

160163
manager
161-
.on(Modifier.Shift, 'Enter', () => this._updateSelection({selectFromAnchor: true}))
164+
.on(Modifier.Shift, 'Enter', () => {
165+
this._updateSelection({selectRange: true});
166+
this.shiftAnchorIndex.set(this.selection.rangeStartIndex());
167+
})
168+
.on(Modifier.Shift, this.dynamicSpaceKey, () => {
169+
this._updateSelection({selectRange: true});
170+
this.shiftAnchorIndex.set(this.selection.rangeStartIndex());
171+
})
162172
.on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => {
163-
this.selection.dropAnchor(); // Make the current focused option the anchor.
164-
this.first({selectFromAnchor: true});
173+
this.selection.beginRangeSelection(); // Make the current focused option the anchor.
174+
this.first({selectRange: true});
165175
})
166176
.on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => {
167-
this.selection.dropAnchor(); // Make the current focused option the anchor.
168-
this.last({selectFromAnchor: true});
169-
})
170-
.on(Modifier.Shift, this.dynamicSpaceKey, () =>
171-
this._updateSelection({selectFromAnchor: true}),
172-
);
177+
this.selection.beginRangeSelection(); // Make the current focused option the anchor.
178+
this.last({selectRange: true});
179+
});
173180
}
174181

175182
if (!this.followFocus() && this.inputs.multi()) {
@@ -212,9 +219,9 @@ export class ListboxPattern<V> {
212219
if (this.multi()) {
213220
manager.on(Modifier.Shift, e => {
214221
if (this.inputs.activeIndex() === this.shiftAnchorIndex()) {
215-
this.selection.dropAnchor();
222+
this.selection.beginRangeSelection();
216223
}
217-
this.goto(e, {selectFromAnchor: true});
224+
this.goto(e, {selectRange: true});
218225
});
219226
}
220227

@@ -330,8 +337,8 @@ export class ListboxPattern<V> {
330337
if (opts?.toggleAll) {
331338
this.selection.toggleAll();
332339
}
333-
if (opts?.selectFromAnchor) {
334-
this.selection.selectFromAnchor();
340+
if (opts?.selectRange) {
341+
this.selection.selectRange();
335342
}
336343
}
337344

src/components-examples/cdk-experimental/listbox/cdk-listbox/cdk-listbox-example.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
}
77

88
.example-listbox {
9-
gap: 8px;
9+
gap: 4px;
1010
margin: 0;
1111
padding: 8px;
1212
max-height: 50vh;
@@ -16,6 +16,7 @@
1616
list-style: none;
1717
flex-direction: column;
1818
overflow: scroll;
19+
user-select: none;
1920
}
2021

2122
.example-listbox[aria-orientation='horizontal'] {

0 commit comments

Comments
 (0)