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; +}