From 5f6aa4a3d971a249ce2b0c2683f655f6464f5755 Mon Sep 17 00:00:00 2001 From: Peter Lorenz Date: Tue, 2 Jun 2026 16:26:17 +0200 Subject: [PATCH 1/2] fix: preserve selection anchor for multi-Select range selection --- .../react-aria-components/test/Select.test.js | 44 +++++++++++++++++++ .../src/select/useSelectState.ts | 34 +++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) 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; +} From 4748ee9a70e550f63a950201dbc2a096137700fb Mon Sep 17 00:00:00 2001 From: Peter Lorenz Date: Tue, 2 Jun 2026 16:48:27 +0200 Subject: [PATCH 2/2] add test for shift+click in Picker --- .../@react-spectrum/s2/test/Picker.test.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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;