diff --git a/packages/@react-spectrum/s2/test/Picker.test.tsx b/packages/@react-spectrum/s2/test/Picker.test.tsx
index 05d59184f1e..20192fd6b39 100644
--- a/packages/@react-spectrum/s2/test/Picker.test.tsx
+++ b/packages/@react-spectrum/s2/test/Picker.test.tsx
@@ -180,6 +180,40 @@ describe('Picker', () => {
expect(tree.getByTestId('custom-value')).toHaveTextContent('Chocolate, Vanilla');
});
+ it('supports shift+click to select a range in multi-selection', async () => {
+ let user = userEvent.setup({delay: null, pointerMap});
+ let items = [
+ {id: 'chocolate', name: 'Chocolate'},
+ {id: 'strawberry', name: 'Strawberry'},
+ {id: 'vanilla', name: 'Vanilla'}
+ ];
+ let tree = render(
+
+ {(item: any) => (
+
+ {item.name}
+
+ )}
+
+ );
+
+ let selectTester = testUtilUser.createTester('Select', {
+ root: tree.container,
+ interactionType: 'mouse'
+ });
+ await selectTester.open();
+ let options = selectTester.getOptions();
+
+ await user.click(options[0]);
+ await user.keyboard('{Shift>}');
+ await user.click(options[2]);
+ await user.keyboard('{/Shift}');
+
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
+ expect(options[1]).toHaveAttribute('aria-selected', 'true');
+ expect(options[2]).toHaveAttribute('aria-selected', 'true');
+ });
+
it('should warn if the custom render value output has a interactive child', async () => {
using spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) as jest.SpyInstance &
Disposable;
diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js
index df4906037d2..56e6ac0a251 100644
--- a/packages/react-aria-components/test/Select.test.js
+++ b/packages/react-aria-components/test/Select.test.js
@@ -846,6 +846,50 @@ describe('Select', () => {
expect(trigger).toHaveTextContent('2 selected items');
});
+ it('supports shift+click to select a range in multi-selection', async () => {
+ let {getByTestId} = render();
+ let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')});
+
+ await selectTester.open();
+ let options = selectTester.getOptions();
+
+ await user.click(options[0]);
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
+
+ await user.keyboard('{Shift>}');
+ await user.click(options[2]);
+ await user.keyboard('{/Shift}');
+
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
+ expect(options[1]).toHaveAttribute('aria-selected', 'true');
+ expect(options[2]).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('keeps a stable anchor across consecutive shift+clicks', async () => {
+ let {getByTestId} = render();
+ let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')});
+
+ await selectTester.open();
+ let options = selectTester.getOptions();
+
+ await user.click(options[0]);
+
+ await user.keyboard('{Shift>}');
+ await user.click(options[2]);
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
+ expect(options[1]).toHaveAttribute('aria-selected', 'true');
+ expect(options[2]).toHaveAttribute('aria-selected', 'true');
+
+ // Shift+click again from the same anchor: the range shrinks rather than the
+ // anchor jumping to the previous target.
+ await user.click(options[1]);
+ await user.keyboard('{/Shift}');
+
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
+ expect(options[1]).toHaveAttribute('aria-selected', 'true');
+ expect(options[2]).toHaveAttribute('aria-selected', 'false');
+ });
+
it('has a value immediately after rendering', async () => {
function Example() {
const ref = useRef(null);
diff --git a/packages/react-stately/src/select/useSelectState.ts b/packages/react-stately/src/select/useSelectState.ts
index beb206ddc2c..adb6264a413 100644
--- a/packages/react-stately/src/select/useSelectState.ts
+++ b/packages/react-stately/src/select/useSelectState.ts
@@ -29,7 +29,7 @@ import {FormValidationState, useFormValidationState} from '../form/useFormValida
import {ListState, useListState} from '../list/useListState';
import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState';
import {useControlledState} from '../utils/useControlledState';
-import {useMemo, useState} from 'react';
+import {useMemo, useRef, useState} from 'react';
export type SelectionMode = 'single' | 'multiple';
export type ValueType = M extends 'single' ? Key | null : readonly Key[];
@@ -196,12 +196,27 @@ export function useSelectState(
}
};
+ // Preserve the selection's anchor (anchorKey/currentKey) across renders. The
+ // multiple-selection `value` is a plain Key[], so without this the listbox
+ // would rebuild an anchorless Selection on every render and range selection
+ // (shift+click / shift+arrow) would collapse to just the clicked item. We keep
+ // the last Selection produced internally and feed it back while its membership
+ // still matches `value`.
+ let lastSelection = useRef | null>(null);
+
let listState = useListState({
...props,
selectionMode,
disallowEmptySelection: selectionMode === 'single',
allowDuplicateSelectionEvents: true,
- selectedKeys: useMemo(() => convertValue(displayValue), [displayValue]),
+ selectedKeys: useMemo(() => {
+ let selectedKeys = convertValue(displayValue);
+ let last = lastSelection.current;
+ if (last != null && Array.isArray(selectedKeys) && isSameSelection(last, selectedKeys)) {
+ return last;
+ }
+ return selectedKeys;
+ }, [displayValue]),
onSelectionChange: (keys: Selection) => {
// impossible, but TS doesn't know that
if (keys === 'all') {
@@ -212,6 +227,9 @@ export function useSelectState(
let key = keys.values().next().value ?? null;
setValue(key);
} else {
+ // Remember the Selection (with its anchor) so it survives the round-trip
+ // through the plain `value` array on the next render.
+ lastSelection.current = keys;
setValue([...keys]);
}
if (shouldCloseOnSelect) {
@@ -278,3 +296,15 @@ function convertValue(value: Key | Key[] | null | undefined) {
}
return Array.isArray(value) ? value : [value];
}
+
+function isSameSelection(selection: Set, keys: Key[]): boolean {
+ if (selection.size !== keys.length) {
+ return false;
+ }
+ for (let key of keys) {
+ if (!selection.has(key)) {
+ return false;
+ }
+ }
+ return true;
+}