Skip to content

Commit 0f5ae89

Browse files
authored
fix: preserve selection anchor for multi-Select range selection (#10150)
* fix: preserve selection anchor for multi-Select range selection * add test for shift+click in Picker
1 parent 6503fc4 commit 0f5ae89

3 files changed

Lines changed: 110 additions & 2 deletions

File tree

packages/@react-spectrum/s2/test/Picker.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,40 @@ describe('Picker', () => {
180180
expect(tree.getByTestId('custom-value')).toHaveTextContent('Chocolate, Vanilla');
181181
});
182182

183+
it('supports shift+click to select a range in multi-selection', async () => {
184+
let user = userEvent.setup({delay: null, pointerMap});
185+
let items = [
186+
{id: 'chocolate', name: 'Chocolate'},
187+
{id: 'strawberry', name: 'Strawberry'},
188+
{id: 'vanilla', name: 'Vanilla'}
189+
];
190+
let tree = render(
191+
<Picker label="Test picker" selectionMode="multiple" items={items}>
192+
{(item: any) => (
193+
<PickerItem id={item.id} textValue={item.name}>
194+
{item.name}
195+
</PickerItem>
196+
)}
197+
</Picker>
198+
);
199+
200+
let selectTester = testUtilUser.createTester('Select', {
201+
root: tree.container,
202+
interactionType: 'mouse'
203+
});
204+
await selectTester.open();
205+
let options = selectTester.getOptions();
206+
207+
await user.click(options[0]);
208+
await user.keyboard('{Shift>}');
209+
await user.click(options[2]);
210+
await user.keyboard('{/Shift}');
211+
212+
expect(options[0]).toHaveAttribute('aria-selected', 'true');
213+
expect(options[1]).toHaveAttribute('aria-selected', 'true');
214+
expect(options[2]).toHaveAttribute('aria-selected', 'true');
215+
});
216+
183217
it('should warn if the custom render value output has a interactive child', async () => {
184218
using spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) as jest.SpyInstance &
185219
Disposable;

packages/react-aria-components/test/Select.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,50 @@ describe('Select', () => {
846846
expect(trigger).toHaveTextContent('2 selected items');
847847
});
848848

849+
it('supports shift+click to select a range in multi-selection', async () => {
850+
let {getByTestId} = render(<TestSelect selectionMode="multiple" />);
851+
let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')});
852+
853+
await selectTester.open();
854+
let options = selectTester.getOptions();
855+
856+
await user.click(options[0]);
857+
expect(options[0]).toHaveAttribute('aria-selected', 'true');
858+
859+
await user.keyboard('{Shift>}');
860+
await user.click(options[2]);
861+
await user.keyboard('{/Shift}');
862+
863+
expect(options[0]).toHaveAttribute('aria-selected', 'true');
864+
expect(options[1]).toHaveAttribute('aria-selected', 'true');
865+
expect(options[2]).toHaveAttribute('aria-selected', 'true');
866+
});
867+
868+
it('keeps a stable anchor across consecutive shift+clicks', async () => {
869+
let {getByTestId} = render(<TestSelect selectionMode="multiple" />);
870+
let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')});
871+
872+
await selectTester.open();
873+
let options = selectTester.getOptions();
874+
875+
await user.click(options[0]);
876+
877+
await user.keyboard('{Shift>}');
878+
await user.click(options[2]);
879+
expect(options[0]).toHaveAttribute('aria-selected', 'true');
880+
expect(options[1]).toHaveAttribute('aria-selected', 'true');
881+
expect(options[2]).toHaveAttribute('aria-selected', 'true');
882+
883+
// Shift+click again from the same anchor: the range shrinks rather than the
884+
// anchor jumping to the previous target.
885+
await user.click(options[1]);
886+
await user.keyboard('{/Shift}');
887+
888+
expect(options[0]).toHaveAttribute('aria-selected', 'true');
889+
expect(options[1]).toHaveAttribute('aria-selected', 'true');
890+
expect(options[2]).toHaveAttribute('aria-selected', 'false');
891+
});
892+
849893
it('has a value immediately after rendering', async () => {
850894
function Example() {
851895
const ref = useRef(null);

packages/react-stately/src/select/useSelectState.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {FormValidationState, useFormValidationState} from '../form/useFormValida
2929
import {ListState, useListState} from '../list/useListState';
3030
import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState';
3131
import {useControlledState} from '../utils/useControlledState';
32-
import {useMemo, useState} from 'react';
32+
import {useMemo, useRef, useState} from 'react';
3333

3434
export type SelectionMode = 'single' | 'multiple';
3535
export type ValueType<M extends SelectionMode> = M extends 'single' ? Key | null : readonly Key[];
@@ -196,12 +196,27 @@ export function useSelectState<T, M extends SelectionMode = 'single'>(
196196
}
197197
};
198198

199+
// Preserve the selection's anchor (anchorKey/currentKey) across renders. The
200+
// multiple-selection `value` is a plain Key[], so without this the listbox
201+
// would rebuild an anchorless Selection on every render and range selection
202+
// (shift+click / shift+arrow) would collapse to just the clicked item. We keep
203+
// the last Selection produced internally and feed it back while its membership
204+
// still matches `value`.
205+
let lastSelection = useRef<Set<Key> | null>(null);
206+
199207
let listState = useListState({
200208
...props,
201209
selectionMode,
202210
disallowEmptySelection: selectionMode === 'single',
203211
allowDuplicateSelectionEvents: true,
204-
selectedKeys: useMemo(() => convertValue(displayValue), [displayValue]),
212+
selectedKeys: useMemo(() => {
213+
let selectedKeys = convertValue(displayValue);
214+
let last = lastSelection.current;
215+
if (last != null && Array.isArray(selectedKeys) && isSameSelection(last, selectedKeys)) {
216+
return last;
217+
}
218+
return selectedKeys;
219+
}, [displayValue]),
205220
onSelectionChange: (keys: Selection) => {
206221
// impossible, but TS doesn't know that
207222
if (keys === 'all') {
@@ -212,6 +227,9 @@ export function useSelectState<T, M extends SelectionMode = 'single'>(
212227
let key = keys.values().next().value ?? null;
213228
setValue(key);
214229
} else {
230+
// Remember the Selection (with its anchor) so it survives the round-trip
231+
// through the plain `value` array on the next render.
232+
lastSelection.current = keys;
215233
setValue([...keys]);
216234
}
217235
if (shouldCloseOnSelect) {
@@ -278,3 +296,15 @@ function convertValue(value: Key | Key[] | null | undefined) {
278296
}
279297
return Array.isArray(value) ? value : [value];
280298
}
299+
300+
function isSameSelection(selection: Set<Key>, keys: Key[]): boolean {
301+
if (selection.size !== keys.length) {
302+
return false;
303+
}
304+
for (let key of keys) {
305+
if (!selection.has(key)) {
306+
return false;
307+
}
308+
}
309+
return true;
310+
}

0 commit comments

Comments
 (0)