From dc6b2038c4035e374ede4e3fafae6c282a46dec5 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 18 Aug 2025 14:24:35 -0400 Subject: [PATCH 1/2] feat: Add support for multiple selection to Select --- .../@react-aria/select/src/HiddenSelect.tsx | 84 +++++++---- packages/@react-aria/select/src/useSelect.ts | 19 ++- packages/@react-aria/test-utils/src/select.ts | 4 +- .../picker/test/Picker.test.js | 3 +- packages/@react-spectrum/s2/src/Picker.tsx | 11 +- .../s2/stories/Picker.stories.tsx | 2 +- packages/@react-stately/combobox/package.json | 1 - .../combobox/src/useComboBoxState.ts | 13 +- packages/@react-stately/select/package.json | 1 + .../select/src/useSelectState.ts | 127 ++++++++++++++-- packages/@react-types/select/src/index.d.ts | 33 ++++- .../react-aria-components/docs/Select.mdx | 67 +++++++-- packages/react-aria-components/src/Select.tsx | 100 +++++++++---- .../stories/Select.stories.tsx | 31 ++-- .../react-aria-components/test/Select.test.js | 139 +++++++++++++++++- packages/react-aria/src/index.ts | 2 +- yarn.lock | 2 +- 17 files changed, 507 insertions(+), 132 deletions(-) diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx index 1900d6231ae..61e9455b571 100644 --- a/packages/@react-aria/select/src/HiddenSelect.tsx +++ b/packages/@react-aria/select/src/HiddenSelect.tsx @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import {FocusableElement, RefObject} from '@react-types/shared'; +import {FocusableElement, Key, RefObject} from '@react-types/shared'; import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react'; import {selectData} from './useSelect'; +import {SelectionMode} from '@react-types/select'; import {SelectState} from '@react-stately/select'; import {useFormReset} from '@react-aria/utils'; import {useFormValidation} from '@react-aria/form'; @@ -41,9 +42,9 @@ export interface AriaHiddenSelectProps { isDisabled?: boolean } -export interface HiddenSelectProps extends AriaHiddenSelectProps { +export interface HiddenSelectProps extends AriaHiddenSelectProps { /** State for the select. */ - state: SelectState, + state: SelectState, /** A ref to the trigger element. */ triggerRef: RefObject @@ -70,7 +71,7 @@ export interface HiddenSelectAria { * can be used in combination with `useSelect` to support browser form autofill, mobile form * navigation, and native HTML form submission. */ -export function useHiddenSelect(props: AriaHiddenSelectOptions, state: SelectState, triggerRef: RefObject): HiddenSelectAria { +export function useHiddenSelect(props: AriaHiddenSelectOptions, state: SelectState, triggerRef: RefObject): HiddenSelectAria { let data = selectData.get(state) || {}; let {autoComplete, name = data.name, form = data.form, isDisabled = data.isDisabled} = props; let {validationBehavior, isRequired} = data; @@ -83,14 +84,23 @@ export function useHiddenSelect(props: AriaHiddenSelectOptions, state: Select } }); - useFormReset(props.selectRef, state.defaultSelectedKey, state.setSelectedKey); + useFormReset(props.selectRef, state.defaultValue, state.setValue); useFormValidation({ validationBehavior, focus: () => triggerRef.current?.focus() }, state, props.selectRef); - // eslint-disable-next-line react-hooks/exhaustive-deps - let onChange = useCallback((e: React.ChangeEvent | React.FormEvent) => state.setSelectedKey(e.currentTarget.value), [state.setSelectedKey]); + let setValue = state.setValue; + let onChange = useCallback((e: React.ChangeEvent) => { + if (e.target.multiple) { + setValue(Array.from( + e.target.selectedOptions, + (option) => option.value + ) as any); + } else { + setValue(e.currentTarget.value as any); + } + }, [setValue]); // In Safari, the whereas other browsers @@ -114,10 +124,11 @@ export function useHiddenSelect(props: AriaHiddenSelectOptions, state: Select tabIndex: -1, autoComplete, disabled: isDisabled, + multiple: state.selectionManager.selectionMode === 'multiple', required: validationBehavior === 'native' && isRequired, name, form, - value: state.selectedKey ?? '', + value: (state.value as string | string[]) ?? '', onChange, onInput: onChange } @@ -128,7 +139,7 @@ export function useHiddenSelect(props: AriaHiddenSelectOptions, state: Select * Renders a hidden native ` rather than + // so that an empty value blocks HTML form submission when the field is required. + return ( + {/** Ignore react warning. */}} /> + ); + } - if (validationBehavior === 'native') { - // Use a hidden rather than - // so that an empty value blocks HTML form submission when the field is required. return ( - {/** Ignore react warning. */}} /> + ); - } + }); - return ( - - ); + return <>{res}; } return null; diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts index 38aee11f510..daebc1d3910 100644 --- a/packages/@react-aria/select/src/useSelect.ts +++ b/packages/@react-aria/select/src/useSelect.ts @@ -12,7 +12,7 @@ import {AriaButtonProps} from '@react-types/button'; import {AriaListBoxOptions} from '@react-aria/listbox'; -import {AriaSelectProps} from '@react-types/select'; +import {AriaSelectProps, SelectionMode} from '@react-types/select'; import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils'; import {DOMAttributes, KeyboardDelegate, RefObject, ValidationResult} from '@react-types/shared'; import {FocusEvent, useMemo} from 'react'; @@ -24,7 +24,7 @@ import {useCollator} from '@react-aria/i18n'; import {useField} from '@react-aria/label'; import {useMenuTrigger} from '@react-aria/menu'; -export interface AriaSelectOptions extends Omit, 'children'> { +export interface AriaSelectOptions extends Omit, 'children'> { /** * An optional keyboard delegate implementation for type to select, * to override the default. @@ -32,7 +32,7 @@ export interface AriaSelectOptions extends Omit, 'children keyboardDelegate?: KeyboardDelegate } -export interface SelectAria extends ValidationResult { +export interface SelectAria extends ValidationResult { /** Props for the label element. */ labelProps: DOMAttributes, @@ -52,7 +52,7 @@ export interface SelectAria extends ValidationResult { errorMessageProps: DOMAttributes, /** Props for the hidden select element. */ - hiddenSelectProps: HiddenSelectProps + hiddenSelectProps: HiddenSelectProps } interface SelectData { @@ -63,7 +63,7 @@ interface SelectData { validationBehavior?: 'aria' | 'native' } -export const selectData: WeakMap, SelectData> = new WeakMap, SelectData>(); +export const selectData: WeakMap, SelectData> = new WeakMap, SelectData>(); /** * Provides the behavior and accessibility implementation for a select component. @@ -71,7 +71,7 @@ export const selectData: WeakMap, SelectData> = new WeakMap(props: AriaSelectOptions, state: SelectState, ref: RefObject): SelectAria { +export function useSelect(props: AriaSelectOptions, state: SelectState, ref: RefObject): SelectAria { let { keyboardDelegate, isDisabled, @@ -96,6 +96,10 @@ export function useSelect(props: AriaSelectOptions, state: SelectState, ); let onKeyDown = (e: KeyboardEvent) => { + if (state.selectionManager.selectionMode === 'multiple') { + return; + } + switch (e.key) { case 'ArrowLeft': { // prevent scrolling containers @@ -138,6 +142,9 @@ export function useSelect(props: AriaSelectOptions, state: SelectState, typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture; delete typeSelectProps.onKeyDownCapture; + if (state.selectionManager.selectionMode === 'multiple') { + typeSelectProps = {}; + } let domProps = filterDOMProps(props, {labelable: true}); let triggerProps = mergeProps(typeSelectProps, menuTriggerProps, fieldProps); diff --git a/packages/@react-aria/test-utils/src/select.ts b/packages/@react-aria/test-utils/src/select.ts index 57dda4173c7..4cce164f53f 100644 --- a/packages/@react-aria/test-utils/src/select.ts +++ b/packages/@react-aria/test-utils/src/select.ts @@ -184,6 +184,8 @@ export class SelectTester { throw new Error('Target option not found in the listbox.'); } + let isMultiSelect = listbox.getAttribute('aria-multiselectable') === 'true'; + if (interactionType === 'keyboard') { if (option?.getAttribute('aria-disabled') === 'true') { return; @@ -203,7 +205,7 @@ export class SelectTester { } } - if (option?.getAttribute('href') == null) { + if (!isMultiSelect && option?.getAttribute('href') == null) { await waitFor(() => { if (document.activeElement !== this._trigger) { throw new Error(`Expected the document.activeElement after selecting an option to be the select component trigger but got ${document.activeElement}`); diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 86991594106..99cedf78a05 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -1508,8 +1508,7 @@ describe('Picker', function () { expect(document.activeElement).toBe(items[1]); await selectTester.selectOption({option: 'Two'}); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(onSelectionChange).toHaveBeenCalledWith('two'); + expect(onSelectionChange).not.toHaveBeenCalled(); expect(document.activeElement).toBe(picker); expect(picker).toHaveTextContent('Two'); diff --git a/packages/@react-spectrum/s2/src/Picker.tsx b/packages/@react-spectrum/s2/src/Picker.tsx index cc450d41578..26345ba8a9a 100644 --- a/packages/@react-spectrum/s2/src/Picker.tsx +++ b/packages/@react-spectrum/s2/src/Picker.tsx @@ -91,8 +91,9 @@ export interface PickerStyleProps { isQuiet?: boolean } -export interface PickerProps extends - Omit, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>, +type SelectionMode = 'single' | 'multiple'; +export interface PickerProps extends + Omit, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>, PickerStyleProps, StyleProps, SpectrumLabelableProps, @@ -262,7 +263,7 @@ let InsideSelectValueContext = createContext(false); /** * Pickers allow users to choose a single option from a collapsible list of options when space is limited. */ -export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Picker(props: PickerProps, ref: FocusableRef) { +export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Picker(props: PickerProps, ref: FocusableRef) { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); [props, ref] = useSpectrumContextProps(props, ref, PickerContext); let domRef = useFocusableRef(ref); @@ -507,7 +508,7 @@ const PickerButton = createHideableComponent(function PickerButton ( <> * {display: none;}')}> - {({defaultChildren}) => { + {({selectedItems, defaultChildren, selectedText}) => { return ( - {defaultChildren} + {selectedItems.length <= 1 ? defaultChildren : {selectedText}} ); }} diff --git a/packages/@react-spectrum/s2/stories/Picker.stories.tsx b/packages/@react-spectrum/s2/stories/Picker.stories.tsx index 7685f4f5c43..1cafa577106 100644 --- a/packages/@react-spectrum/s2/stories/Picker.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Picker.stories.tsx @@ -41,7 +41,7 @@ const meta: Meta> = { decorators: [StaticColorDecorator], tags: ['autodocs'], argTypes: { - ...categorizeArgTypes('Events', ['onOpenChange', 'onSelectionChange']), + ...categorizeArgTypes('Events', ['onOpenChange', 'onChange']), label: {control: {type: 'text'}}, description: {control: {type: 'text'}}, errorMessage: {control: {type: 'text'}}, diff --git a/packages/@react-stately/combobox/package.json b/packages/@react-stately/combobox/package.json index f7d8baa9e0e..8d6f0730f1a 100644 --- a/packages/@react-stately/combobox/package.json +++ b/packages/@react-stately/combobox/package.json @@ -30,7 +30,6 @@ "@react-stately/form": "^3.2.0", "@react-stately/list": "^3.12.4", "@react-stately/overlays": "^3.6.18", - "@react-stately/select": "^3.7.0", "@react-stately/utils": "^3.10.8", "@react-types/combobox": "^3.13.7", "@react-types/shared": "^3.31.0", diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index 8278ca9a05b..e2cce7de048 100644 --- a/packages/@react-stately/combobox/src/useComboBoxState.ts +++ b/packages/@react-stately/combobox/src/useComboBoxState.ts @@ -14,13 +14,14 @@ import {Collection, CollectionStateBase, FocusStrategy, Key, Node} from '@react- import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {getChildNodes} from '@react-stately/collections'; -import {ListCollection, useSingleSelectListState} from '@react-stately/list'; -import {SelectState} from '@react-stately/select'; +import {ListCollection, SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; +import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useControlledState} from '@react-stately/utils'; -import {useOverlayTriggerState} from '@react-stately/overlays'; -export interface ComboBoxState extends SelectState, FormValidationState{ +export interface ComboBoxState extends SingleSelectListState, OverlayTriggerState, FormValidationState { + /** The default selected key. */ + readonly defaultSelectedKey: Key | null, /** The current value of the combo box input. */ inputValue: string, /** The default value of the combo box input. */ @@ -31,6 +32,10 @@ export interface ComboBoxState extends SelectState, FormValidationState{ commit(): void, /** Controls which item will be auto focused when the menu opens. */ readonly focusStrategy: FocusStrategy | null, + /** Whether the select is currently focused. */ + readonly isFocused: boolean, + /** Sets whether the select is focused. */ + setFocused(isFocused: boolean): void, /** Opens the menu. */ open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void, /** Toggles the menu. */ diff --git a/packages/@react-stately/select/package.json b/packages/@react-stately/select/package.json index 0ecdf5c36b2..e339095715a 100644 --- a/packages/@react-stately/select/package.json +++ b/packages/@react-stately/select/package.json @@ -29,6 +29,7 @@ "@react-stately/form": "^3.2.0", "@react-stately/list": "^3.12.4", "@react-stately/overlays": "^3.6.18", + "@react-stately/utils": "^3.10.8", "@react-types/select": "^3.10.0", "@react-types/shared": "^3.31.0", "@swc/helpers": "^0.5.0" diff --git a/packages/@react-stately/select/src/useSelectState.ts b/packages/@react-stately/select/src/useSelectState.ts index cdab4312f40..e172b750c44 100644 --- a/packages/@react-stately/select/src/useSelectState.ts +++ b/packages/@react-stately/select/src/useSelectState.ts @@ -10,19 +10,53 @@ * governing permissions and limitations under the License. */ -import {CollectionStateBase, FocusStrategy, Key} from '@react-types/shared'; +import {CollectionStateBase, FocusStrategy, Key, Node, Selection} from '@react-types/shared'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; +import {ListState, useListState} from '@react-stately/list'; import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays'; -import {SelectProps} from '@react-types/select'; -import {SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; -import {useState} from 'react'; +import {SelectionMode, SelectProps, ValueType} from '@react-types/select'; +import {useControlledState} from '@react-stately/utils'; +import {useMemo, useState} from 'react'; -export interface SelectStateOptions extends Omit, 'children'>, CollectionStateBase {} +export interface SelectStateOptions extends Omit, 'children'>, CollectionStateBase {} -export interface SelectState extends SingleSelectListState, OverlayTriggerState, FormValidationState { - /** The default selected key. */ +export interface SelectState extends ListState, OverlayTriggerState, FormValidationState { + /** + * The key for the first selected item. + * @deprecated + */ + readonly selectedKey: Key | null, + + /** + * The default selected key. + * @deprecated + */ readonly defaultSelectedKey: Key | null, + /** + * Sets the selected key. + * @deprecated + */ + setSelectedKey(key: Key | null): void, + + /** The current select value. */ + readonly value: ValueType, + + /** The default select value. */ + readonly defaultValue: ValueType, + + /** Sets the select value. */ + setValue(value: Key | Key[] | null): void, + + /** + * The value of the first selected item. + * @deprecated + */ + readonly selectedItem: Node | null, + + /** The value of the selected items. */ + readonly selectedItems: Node[], + /** Whether the select is currently focused. */ readonly isFocused: boolean, @@ -44,34 +78,85 @@ export interface SelectState extends SingleSelectListState, OverlayTrigger * of items from props, handles the open state for the popup menu, and manages * multiple selection state. */ -export function useSelectState(props: SelectStateOptions): SelectState { +export function useSelectState(props: SelectStateOptions): SelectState { + let {selectionMode = 'single' as M} = props; let triggerState = useOverlayTriggerState(props); let [focusStrategy, setFocusStrategy] = useState(null); - let listState = useSingleSelectListState({ + let defaultValue = useMemo(() => { + return props.defaultValue ?? (selectionMode === 'single' ? props.defaultSelectedKey ?? null : []) as ValueType; + }, [props.defaultValue, props.defaultSelectedKey, selectionMode]); + let value = useMemo(() => { + return props.value ?? (selectionMode === 'single' ? props.selectedKey : undefined) as ValueType; + }, [props.value, props.selectedKey, selectionMode]); + let [controlledValue, setControlledValue] = useControlledState>(value as any, defaultValue as any, props.onChange); + let setValue = (value: Key | Key[] | null) => { + if (selectionMode === 'single') { + let key = Array.isArray(value) ? value[0] ?? null : value; + setControlledValue(key as ValueType); + if (key !== controlledValue) { + props.onSelectionChange?.(key); + } + } else { + let keys: Key[] = []; + if (Array.isArray(value)) { + keys = value; + } else if (value != null) { + keys = [value]; + } + + setControlledValue(keys as ValueType); + } + }; + + let listState = useListState({ ...props, - onSelectionChange: (key) => { - if (props.onSelectionChange != null) { - props.onSelectionChange(key); + selectionMode, + disallowEmptySelection: selectionMode === 'single', + allowDuplicateSelectionEvents: true, + selectedKeys: useMemo(() => convertValue(controlledValue), [controlledValue]), + onSelectionChange: (keys: Selection) => { + // impossible, but TS doesn't know that + if (keys === 'all') { + return; + } + + if (selectionMode === 'single') { + let key = keys.values().next().value ?? null; + setValue(key); + triggerState.close(); + } else { + setValue([...keys]); } - triggerState.close(); validationState.commitValidation(); } }); + let selectedKey = listState.selectionManager.firstSelectedKey; + let selectedItems = useMemo(() => { + return [...listState.selectionManager.selectedKeys].map(key => listState.collection.getItem(key)).filter(item => item != null); + }, [listState.selectionManager.selectedKeys, listState.collection]); + let validationState = useFormValidationState({ ...props, - value: listState.selectedKey + value: Array.isArray(controlledValue) && controlledValue.length === 0 ? null : controlledValue as any }); let [isFocused, setFocused] = useState(false); - let [initialSelectedKey] = useState(listState.selectedKey); + let [initialValue] = useState(controlledValue); return { ...validationState, ...listState, ...triggerState, - defaultSelectedKey: props.defaultSelectedKey ?? initialSelectedKey, + value: controlledValue, + defaultValue: defaultValue ?? initialValue, + setValue, + selectedKey, + setSelectedKey: setValue, + selectedItem: selectedItems[0] ?? null, + selectedItems, + defaultSelectedKey: props.defaultSelectedKey ?? (props.selectionMode === 'single' ? initialValue as Key : null), focusStrategy, open(focusStrategy: FocusStrategy | null = null) { // Don't open if the collection is empty. @@ -90,3 +175,13 @@ export function useSelectState(props: SelectStateOptions): setFocused }; } + +function convertValue(value: Key | Key[] | null | undefined) { + if (value === undefined) { + return undefined; + } + if (value === null) { + return []; + } + return Array.isArray(value) ? value : [value]; +} diff --git a/packages/@react-types/select/src/index.d.ts b/packages/@react-types/select/src/index.d.ts index 22b6f976801..97a3366a065 100644 --- a/packages/@react-types/select/src/index.d.ts +++ b/packages/@react-types/select/src/index.d.ts @@ -27,10 +27,35 @@ import { SpectrumLabelableProps, StyleProps, TextInputBase, - Validation + Validation, + ValueBase } from '@react-types/shared'; -export interface SelectProps extends CollectionBase, Omit, Validation, HelpTextProps, LabelableProps, TextInputBase, Omit, FocusableProps { +export type SelectionMode = 'single' | 'multiple'; +export type ValueType = M extends 'single' ? Key | null : Key[]; +type ValidationType = M extends 'single' ? Key : Key[]; + +export interface SelectProps extends CollectionBase, Omit, ValueBase>, Validation>, HelpTextProps, LabelableProps, TextInputBase, FocusableProps { + /** + * Whether single or multiple selection is enabled. + * @default 'single' + */ + selectionMode?: M, + /** + * The currently selected key in the collection (controlled). + * @deprecated + */ + selectedKey?: Key | null, + /** + * The initial selected key in the collection (uncontrolled). + * @deprecated + */ + defaultSelectedKey?: Key, + /** + * Handler that is called when the selection changes. + * @deprecated + */ + onSelectionChange?: (key: Key | null) => void, /** Sets the open state of the menu. */ isOpen?: boolean, /** Sets the default open state of the menu. */ @@ -39,7 +64,7 @@ export interface SelectProps extends CollectionBase, Omit void } -export interface AriaSelectProps extends SelectProps, DOMProps, AriaLabelingProps, FocusableDOMProps { +export interface AriaSelectProps extends SelectProps, DOMProps, AriaLabelingProps, FocusableDOMProps { /** * Describes the type of autocomplete functionality the input should provide if any. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete). */ @@ -56,7 +81,7 @@ export interface AriaSelectProps extends SelectProps, DOMProps, AriaLabeli form?: string } -export interface SpectrumPickerProps extends AriaSelectProps, AsyncLoadable, SpectrumLabelableProps, StyleProps { +export interface SpectrumPickerProps extends Omit, 'selectionMode' | 'selectedKey' | 'defaultSelectedKey' | 'onSelectionChange' | 'value' | 'defaultValue' | 'onChange'>, Omit, AsyncLoadable, SpectrumLabelableProps, StyleProps { /** Whether the textfield should be displayed with a quiet style. */ isQuiet?: boolean, /** Alignment of the menu relative to the input target. diff --git a/packages/react-aria-components/docs/Select.mdx b/packages/react-aria-components/docs/Select.mdx index 7a497d8b4a2..17b8e28e1b1 100644 --- a/packages/react-aria-components/docs/Select.mdx +++ b/packages/react-aria-components/docs/Select.mdx @@ -105,8 +105,11 @@ import {ChevronDown} from 'lucide-react'; } .react-aria-SelectValue { - flex: 1 0 auto; + flex: 1; text-align: start; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; &[data-placeholder] { font-style: italic; @@ -318,7 +321,7 @@ This example wraps `Select` and all of its children together into a single compo import type {SelectProps, ListBoxItemProps, ValidationResult} from 'react-aria-components'; import {Text, FieldError} from 'react-aria-components'; -interface MySelectProps extends Omit, 'children'> { +interface MySelectProps extends Omit, 'children'> { label?: string, description?: string, errorMessage?: string | ((validation: ValidationResult) => string), @@ -326,7 +329,9 @@ interface MySelectProps extends Omit, 'children children: React.ReactNode | ((item: T) => React.ReactNode) } -export function MySelect({label, description, errorMessage, children, items, ...props}: MySelectProps) { +export function MySelect( + {label, description, errorMessage, children, items, ...props}: MySelectProps +) { return ( + +export const SelectExample: SelectStory = (args) => ( + ); -export const SelectRenderProps: SelectStory = () => ( - {({isOpen}) => ( <> @@ -143,8 +151,8 @@ const usStateOptions = [ {id: 'WY', name: 'Wyoming'} ]; -export const SelectManyItems: SelectStory = () => ( - + {item => {item.name}} diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index 67c93fcd993..4328ecfc904 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -26,9 +26,9 @@ let TestSelect = (props) => ( Error - Cat - Dog - Kangaroo + Cat + Dog + Kangaroo @@ -554,4 +554,137 @@ describe('Select', () => { expect(onSubmit).toHaveBeenCalledTimes(1); expect(document.querySelector('[name=select]').value).toBe(''); }); + + it('should support multiple selection', async () => { + let onChange = jest.fn(); + let {getByTestId} = render( + + + + ); + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Select an item'); + + await selectTester.open(); + + let listbox = selectTester.listbox; + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + + let options = selectTester.options(); + expect(options).toHaveLength(3); + + await user.click(options[0]); + await user.click(options[1]); + expect(trigger).toHaveTextContent('Cat and Dog'); + await selectTester.close(); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith(['cat', 'dog']); + + let formData = new FormData(getByTestId('form')); + expect(formData.getAll('select')).toEqual(['cat', 'dog']); + }); + + it('should support multiple selection form integration with many items', async () => { + let items = []; + for (let i = 0; i < 320; i++) { + items.push({id: i, name: 'item' + i}); + } + + let {getByTestId} = render( +
e.preventDefault()}> + + +
+ ); + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Select an item'); + + let submit = getByTestId('submit'); + await user.click(submit); + + let fieldError = document.querySelector('.react-aria-FieldError'); + expect(fieldError).toHaveTextContent('Constraints not satisfied'); + + await selectTester.open(); + + let options = selectTester.options(); + await user.click(options[0]); + await user.click(options[1]); + await selectTester.close(); + expect(trigger).toHaveTextContent('item0 and item1'); + + let formData = new FormData(getByTestId('form')); + expect(formData.getAll('select')).toEqual(['0', '1']); + + await user.click(submit); + fieldError = document.querySelector('.react-aria-FieldError'); + expect(fieldError).toBe(null); + }); + + it('should support controlled multi-selection', async () => { + let {getByTestId} = render(); + + let wrapper = getByTestId('select'); + let selectTester = testUtilUser.createTester('Select', {root: wrapper}); + + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Dog and Kangaroo'); + + await selectTester.open(); + + let options = selectTester.options(); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + }); + + it('supports custom select value with multi-selection', async () => { + let items = [ + {id: 1, name: 'Cat'}, + {id: 2, name: 'Dog'} + ]; + + let {getByTestId} = render( + + ); + + let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')}); + let trigger = selectTester.trigger; + expect(trigger).toHaveTextContent('Cat'); + + await selectTester.selectOption({option: 'Dog'}); + expect(trigger).toHaveTextContent('2 selected items'); + }); }); diff --git a/packages/react-aria/src/index.ts b/packages/react-aria/src/index.ts index 5b35daebf44..aafbb87028a 100644 --- a/packages/react-aria/src/index.ts +++ b/packages/react-aria/src/index.ts @@ -21,7 +21,7 @@ export {useDialog} from '@react-aria/dialog'; export {useDisclosure} from '@react-aria/disclosure'; export {useDrag, useDrop, useDraggableCollection, useDroppableCollection, useDroppableItem, useDropIndicator, useDraggableItem, useClipboard, DragPreview, ListDropTargetDelegate, DIRECTORY_DRAG_TYPE, isDirectoryDropItem, isFileDropItem, isTextDropItem} from '@react-aria/dnd'; export {FocusRing, FocusScope, useFocusManager, useFocusRing} from '@react-aria/focus'; -export {I18nProvider, isRTL, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter} from '@react-aria/i18n'; +export {I18nProvider, isRTL, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter, useListFormatter} from '@react-aria/i18n'; export {useFocus, useFocusVisible, useFocusWithin, useHover, useInteractOutside, useKeyboard, useMove, usePress, useLongPress, useFocusable, Pressable, Focusable} from '@react-aria/interactions'; export {useField, useLabel} from '@react-aria/label'; export {useGridList, useGridListItem, useGridListSelectionCheckbox} from '@react-aria/gridlist'; diff --git a/yarn.lock b/yarn.lock index f812e28692e..8471c6e9e3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8049,7 +8049,6 @@ __metadata: "@react-stately/form": "npm:^3.2.0" "@react-stately/list": "npm:^3.12.4" "@react-stately/overlays": "npm:^3.6.18" - "@react-stately/select": "npm:^3.7.0" "@react-stately/utils": "npm:^3.10.8" "@react-types/combobox": "npm:^3.13.7" "@react-types/shared": "npm:^3.31.0" @@ -8247,6 +8246,7 @@ __metadata: "@react-stately/form": "npm:^3.2.0" "@react-stately/list": "npm:^3.12.4" "@react-stately/overlays": "npm:^3.6.18" + "@react-stately/utils": "npm:^3.10.8" "@react-types/select": "npm:^3.10.0" "@react-types/shared": "npm:^3.31.0" "@swc/helpers": "npm:^0.5.0" From 3b53af024e60d2736dec4afad7032aa56ac60acf Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 19 Aug 2025 13:13:29 -0400 Subject: [PATCH 2/2] Add example with TagGroup --- packages/react-aria-components/src/Select.tsx | 13 ++++---- .../react-aria-components/src/TagGroup.tsx | 8 +++-- .../stories/Select.stories.tsx | 30 +++++++++++++++++++ starters/docs/src/TagGroup.css | 3 ++ starters/docs/src/TagGroup.tsx | 2 +- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx index ebf547d6c70..30740522b99 100644 --- a/packages/react-aria-components/src/Select.tsx +++ b/packages/react-aria-components/src/Select.tsx @@ -13,7 +13,7 @@ import {AriaSelectProps, HiddenSelect, useFocusRing, useListFormatter, useLocalizedStringFormatter, useSelect} from 'react-aria'; import {ButtonContext} from './Button'; import {Collection, Node, SelectState, useSelectState} from 'react-stately'; -import {CollectionBuilder} from '@react-aria/collections'; +import {CollectionBuilder, createHideableComponent} from '@react-aria/collections'; import {ContextValue, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils'; import {FieldErrorContext} from './FieldError'; import {filterDOMProps, mergeProps, useResizeObserver} from '@react-aria/utils'; @@ -238,7 +238,9 @@ export interface SelectValueRenderProps { /** The object values of the currently selected items. */ selectedItems: (T | null)[], /** The textValue of the currently selected items. */ - selectedText: string + selectedText: string, + /** The state of the select. */ + state: SelectState } export interface SelectValueProps extends Omit, keyof RenderProps>, RenderProps> {} @@ -249,9 +251,9 @@ export const SelectValueContext = createContext(props: SelectValueProps, ref: ForwardedRef) { +export const SelectValue = /*#__PURE__*/ createHideableComponent(function SelectValue(props: SelectValueProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, SelectValueContext); - let state = useContext(SelectStateContext)!; + let state = useContext(SelectStateContext)! as SelectState; let {placeholder} = useSlottedContext(SelectContext)!; let rendered = state.selectedItems.map((item) => { let rendered = item.props?.children; @@ -312,7 +314,8 @@ export const SelectValue = /*#__PURE__*/ (forwardRef as forwardRefType)(function selectedItem: state.selectedItems[0]?.value as T ?? null, selectedItems: useMemo(() => state.selectedItems.map(item => item.value as T ?? null), [state.selectedItems]), selectedText, - isPlaceholder: state.selectedItems.length === 0 + isPlaceholder: state.selectedItems.length === 0, + state } }); diff --git a/packages/react-aria-components/src/TagGroup.tsx b/packages/react-aria-components/src/TagGroup.tsx index c23d711296b..bbc2826fbfb 100644 --- a/packages/react-aria-components/src/TagGroup.tsx +++ b/packages/react-aria-components/src/TagGroup.tsx @@ -61,9 +61,11 @@ export const TagListContext = createContext, HTML export const TagGroup = /*#__PURE__*/ (forwardRef as forwardRefType)(function TagGroup(props: TagGroupProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, TagGroupContext); return ( - - {collection => } - + + + {collection => } + + ); }); diff --git a/packages/react-aria-components/stories/Select.stories.tsx b/packages/react-aria-components/stories/Select.stories.tsx index 1201c2f5238..c71374de62e 100644 --- a/packages/react-aria-components/stories/Select.stories.tsx +++ b/packages/react-aria-components/stories/Select.stories.tsx @@ -16,6 +16,7 @@ import {LoadingSpinner, MyListBoxItem} from './utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import React, {JSX} from 'react'; import styles from '../example/index.css'; +import {Tag, TagGroup} from 'vanilla-starter/TagGroup'; import {useAsyncList} from 'react-stately'; import './styles.css'; @@ -83,6 +84,35 @@ export const SelectRenderProps: SelectStory = (args) => ( ); +export const SelectWithTagGroup: SelectStory = (args) => ( + +); + let makeItems = (length: number) => Array.from({length}, (_, i) => ({ id: i, name: `Item ${i}` diff --git a/starters/docs/src/TagGroup.css b/starters/docs/src/TagGroup.css index 6dff8c881a1..d72cd89433f 100644 --- a/starters/docs/src/TagGroup.css +++ b/starters/docs/src/TagGroup.css @@ -26,6 +26,9 @@ display: flex; align-items: center; transition: border-color 200ms; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &[data-hovered] { border-color: var(--border-color-hover); diff --git a/starters/docs/src/TagGroup.tsx b/starters/docs/src/TagGroup.tsx index a3ecd6eeec7..4ce35017cce 100644 --- a/starters/docs/src/TagGroup.tsx +++ b/starters/docs/src/TagGroup.tsx @@ -36,7 +36,7 @@ export function TagGroup( return ( ( - + {label && } {children}