Skip to content

Conversation

devongovett
Copy link
Member

@devongovett devongovett commented Aug 18, 2025

Closes #8738
This adds support for selecting multiple items to RAC Select and S2 Picker. By default, the selected items are concatenated into a comma separated list. Using RAC SelectValue's render props, you can customize this to whatever string you want (e.g. "2 selected items"). Behavior is TBD for Spectrum.

The API is changing from using selectedKey to using value. When multi-select is enabled, value accepts an array instead of a single id. This matches the native React DOM <select> API. The old API is supported for backward compatibility, but only applies to single selection.

Behaviorally, it uses the existing ListBox component which already supports multi-select. Typeahead and arrow key on the button while the select is closed is disabled when using multi-select, and the popover stays open after selection to facilitate selecting multiple items.

/** The textValue of the currently selected item. */
selectedText: string | null
/** The object values of the currently selected items. */
selectedItems: (T | null)[],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want this to include null? The other option would be to filter out items that didn't set a value on the ListBoxItem. But then we'd need some other way of knowing the total selected item count at least.

@rspbot
Copy link

rspbot commented Aug 18, 2025

@rspbot
Copy link

rspbot commented Aug 18, 2025

@rspbot
Copy link

rspbot commented Aug 19, 2025

if (e.target.multiple) {
setValue(Array.from(
e.target.selectedOptions,
(option) => option.value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think currently we allow for Key which is string | number
but this would change it to string only right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already the case today with HiddenSelect since the DOM only accepts strings.

if (selectionMode === 'single') {
let key = keys.values().next().value ?? null;
setValue(key);
triggerState.close();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

combine work to keep open with #8733 ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah we will but we can merge them separately

# Conflicts:
#	packages/@react-spectrum/s2/stories/Picker.stories.tsx
#	packages/@react-stately/combobox/package.json
#	packages/@react-stately/select/package.json
#	yarn.lock
@rspbot
Copy link

rspbot commented Aug 26, 2025

@rspbot
Copy link

rspbot commented Aug 26, 2025

## API Changes

react-aria-components

/react-aria-components:Select

-Select <T extends {} = {
+Select <M extends SelectionMode = 'single', T extends {} = {
   
 }> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoFocus?: boolean
   children?: ChildrenOrFunction<SelectRenderProps>
   className?: ClassNameOrFunction<SelectRenderProps>
   defaultOpen?: boolean
-  defaultSelectedKey?: Key
+  defaultValue?: ValueType<SelectionMode>
   disabledKeys?: Iterable<Key>
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   isDisabled?: boolean
   isInvalid?: boolean
   isOpen?: boolean
   isRequired?: boolean
   name?: string
   onBlur?: (FocusEvent<Target>) => void
+  onChange?: (T) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onOpenChange?: (boolean) => void
-  onSelectionChange?: (Key | null) => void
   placeholder?: string = 'Select an item' (localized)
-  selectedKey?: Key | null
+  selectionMode?: SelectionMode = 'single'
   slot?: string | null
   style?: StyleOrFunction<SelectRenderProps>
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'native' | 'aria' = 'native'
+  value?: ValueType<SelectionMode>
 }

/react-aria-components:SelectProps

-SelectProps <T extends {} = {
+SelectProps <M extends SelectionMode = 'single', T extends {} = {
   
 }> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoFocus?: boolean
   children?: ChildrenOrFunction<SelectRenderProps>
   className?: ClassNameOrFunction<SelectRenderProps>
   defaultOpen?: boolean
-  defaultSelectedKey?: Key
+  defaultValue?: ValueType<SelectionMode>
   disabledKeys?: Iterable<Key>
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   isDisabled?: boolean
   isInvalid?: boolean
   isOpen?: boolean
   isRequired?: boolean
   name?: string
   onBlur?: (FocusEvent<Target>) => void
+  onChange?: (T) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onOpenChange?: (boolean) => void
-  onSelectionChange?: (Key | null) => void
   placeholder?: string = 'Select an item' (localized)
-  selectedKey?: Key | null
+  selectionMode?: SelectionMode = 'single'
   slot?: string | null
   style?: StyleOrFunction<SelectRenderProps>
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'native' | 'aria' = 'native'
+  value?: ValueType<SelectionMode>
 }

/react-aria-components:SelectValueRenderProps

 SelectValueRenderProps <T> {
   isPlaceholder: boolean
-  selectedItem: T | null
-  selectedText: string | null
+  selectedItems: Array<T | null>
+  selectedText: string
+  state: SelectState<T, 'single' | 'multiple'>
 }

/react-aria-components:SelectState

-SelectState <T> {
+SelectState <M extends SelectionMode = 'single', T> {
   close: () => void
   collection: Collection<Node<T>>
   commitValidation: () => void
-  defaultSelectedKey: Key | null
+  defaultValue: ValueType<SelectionMode>
   disabledKeys: Set<Key>
   displayValidation: ValidationResult
   focusStrategy: FocusStrategy | null
   isFocused: boolean
   isOpen: boolean
   open: (FocusStrategy | null) => void
   realtimeValidation: ValidationResult
   resetValidation: () => void
-  selectedItem: Node<T> | null
-  selectedKey: Key | null
+  selectedItems: Array<Node<T>>
   selectionManager: SelectionManager
   setFocused: (boolean) => void
   setOpen: (boolean) => void
-  setSelectedKey: (Key | null) => void
+  setValue: (Key | Array<Key> | null) => void
   toggle: (FocusStrategy | null) => void
   updateValidation: (ValidationResult) => void
+  value: ValueType<SelectionMode>
 }

@react-aria/select

/@react-aria/select:useSelect

-useSelect <T> {
+useSelect <M extends SelectionMode = 'single', T> {
-  props: AriaSelectOptions<T>
-  state: SelectState<T>
+  props: AriaSelectOptions<T, M>
+  state: SelectState<T, M>
   ref: RefObject<HTMLElement | null>
   returnVal: undefined
 }

/@react-aria/select:useHiddenSelect

-useHiddenSelect <T> {
+useHiddenSelect <M extends SelectionMode = 'single', T> {
   props: AriaHiddenSelectOptions
-  state: SelectState<T>
+  state: SelectState<T, M>
   triggerRef: RefObject<FocusableElement | null>
   returnVal: undefined
 }

/@react-aria/select:HiddenSelect

-HiddenSelect <T> {
+HiddenSelect <M extends SelectionMode = 'single', T> {
   autoComplete?: string
   form?: string
   isDisabled?: boolean
   label?: ReactNode
   name?: string
-  state: SelectState<T>
+  state: SelectState<T, SelectionMode>
   triggerRef: RefObject<FocusableElement | null>
 }

/@react-aria/select:AriaSelectOptions

-AriaSelectOptions <T> {
+AriaSelectOptions <M extends SelectionMode = 'single', T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoFocus?: boolean
   defaultOpen?: boolean
-  defaultSelectedKey?: Key
+  defaultValue?: ValueType<SelectionMode>
   description?: ReactNode
   disabledKeys?: Iterable<Key>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   isDisabled?: boolean
   isInvalid?: boolean
   isOpen?: boolean
   isRequired?: boolean
   items?: Iterable<T>
   keyboardDelegate?: KeyboardDelegate
   label?: ReactNode
   name?: string
   onBlur?: (FocusEvent<Target>) => void
+  onChange?: (T) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onOpenChange?: (boolean) => void
-  onSelectionChange?: (Key | null) => void
   placeholder?: string
-  selectedKey?: Key | null
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  selectionMode?: SelectionMode = 'single'
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'aria' | 'native' = 'aria'
+  value?: ValueType<SelectionMode>
 }

/@react-aria/select:SelectAria

-SelectAria <T> {
+SelectAria <M extends SelectionMode = 'single', T> {
   descriptionProps: DOMAttributes
   errorMessageProps: DOMAttributes
-  hiddenSelectProps: HiddenSelectProps<T>
+  hiddenSelectProps: HiddenSelectProps<T, SelectionMode>
   isInvalid: boolean
   labelProps: DOMAttributes
   menuProps: AriaListBoxOptions<T>
   triggerProps: AriaButtonProps
   validationErrors: Array<string>
   valueProps: DOMAttributes
 }

/@react-aria/select:HiddenSelectProps

-HiddenSelectProps <T> {
+HiddenSelectProps <M extends SelectionMode = 'single', T> {
   autoComplete?: string
   form?: string
   isDisabled?: boolean
   label?: ReactNode
   name?: string
-  state: SelectState<T>
+  state: SelectState<T, SelectionMode>
   triggerRef: RefObject<FocusableElement | null>
 }

/@react-aria/select:AriaSelectProps

-AriaSelectProps <T> {
+AriaSelectProps <M extends SelectionMode = 'single', T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoFocus?: boolean
   children: CollectionChildren<T>
   defaultOpen?: boolean
-  defaultSelectedKey?: Key
+  defaultValue?: ValueType<SelectionMode>
   description?: ReactNode
   disabledKeys?: Iterable<Key>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   isDisabled?: boolean
   isInvalid?: boolean
   isOpen?: boolean
   isRequired?: boolean
   items?: Iterable<T>
   label?: ReactNode
   name?: string
   onBlur?: (FocusEvent<Target>) => void
+  onChange?: (T) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onOpenChange?: (boolean) => void
-  onSelectionChange?: (Key | null) => void
   placeholder?: string
-  selectedKey?: Key | null
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  selectionMode?: SelectionMode = 'single'
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'aria' | 'native' = 'aria'
+  value?: ValueType<SelectionMode>
 }

@react-spectrum/picker

/@react-spectrum/picker:Picker

 Picker <T extends {}> {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   align?: Alignment = 'start'
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoFocus?: boolean
   bottom?: Responsive<DimensionValue>
   children: CollectionChildren<{}>
   contextualHelp?: ReactNode
   defaultOpen?: boolean
   defaultSelectedKey?: Key
   description?: ReactNode
   direction?: 'bottom' | 'top' = 'bottom'
   disabledKeys?: Iterable<Key>
   end?: Responsive<DimensionValue>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
   form?: string
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRow?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   height?: Responsive<DimensionValue>
   id?: string
   isDisabled?: boolean
   isHidden?: Responsive<boolean>
   isInvalid?: boolean
   isLoading?: boolean
   isOpen?: boolean
   isQuiet?: boolean
   isRequired?: boolean
   items?: Iterable<{}>
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   label?: ReactNode
   labelAlign?: Alignment = 'start'
   labelPosition?: LabelPosition = 'top'
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   menuWidth?: DimensionValue
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
   name?: string
   necessityIndicator?: NecessityIndicator = 'icon'
   onBlur?: (FocusEvent<Target>) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onLoadMore?: () => any
   onOpenChange?: (boolean) => void
   onSelectionChange?: (Key | null) => void
   order?: Responsive<number>
   placeholder?: string
   position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
   right?: Responsive<DimensionValue>
   selectedKey?: Key | null
   shouldFlip?: boolean = true
   start?: Responsive<DimensionValue>
   top?: Responsive<DimensionValue>
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'aria' | 'native' = 'aria'
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

/@react-spectrum/picker:SpectrumPickerProps

 SpectrumPickerProps <T> {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   align?: Alignment = 'start'
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoFocus?: boolean
   bottom?: Responsive<DimensionValue>
   children: CollectionChildren<T>
   contextualHelp?: ReactNode
   defaultOpen?: boolean
   defaultSelectedKey?: Key
   description?: ReactNode
   direction?: 'bottom' | 'top' = 'bottom'
   disabledKeys?: Iterable<Key>
   end?: Responsive<DimensionValue>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
   form?: string
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRow?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   height?: Responsive<DimensionValue>
   id?: string
   isDisabled?: boolean
   isHidden?: Responsive<boolean>
   isInvalid?: boolean
   isLoading?: boolean
   isOpen?: boolean
   isQuiet?: boolean
   isRequired?: boolean
   items?: Iterable<T>
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   label?: ReactNode
   labelAlign?: Alignment = 'start'
   labelPosition?: LabelPosition = 'top'
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   menuWidth?: DimensionValue
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
   name?: string
   necessityIndicator?: NecessityIndicator = 'icon'
   onBlur?: (FocusEvent<Target>) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onLoadMore?: () => any
   onOpenChange?: (boolean) => void
   onSelectionChange?: (Key | null) => void
   order?: Responsive<number>
   placeholder?: string
   position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
   right?: Responsive<DimensionValue>
   selectedKey?: Key | null
   shouldFlip?: boolean = true
   start?: Responsive<DimensionValue>
   top?: Responsive<DimensionValue>
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'aria' | 'native' = 'aria'
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

@react-spectrum/s2

/@react-spectrum/s2:Picker

-Picker <T extends {}> {
+Picker <M extends SelectionMode = 'single', T extends {}> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   align?: 'start' | 'end' = 'start'
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoFocus?: boolean
   children: ReactNode | ({}) => ReactNode
   contextualHelp?: ReactNode
   defaultOpen?: boolean
-  defaultSelectedKey?: Key
+  defaultValue?: ValueType<SelectionMode>
   dependencies?: ReadonlyArray<any>
   description?: ReactNode
   direction?: 'bottom' | 'top' = 'bottom'
   disabledKeys?: Iterable<Key>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   isDisabled?: boolean
   isInvalid?: boolean
   isOpen?: boolean
   isRequired?: boolean
   items?: Iterable<T>
   label?: ReactNode
   labelAlign?: Alignment = 'start'
   labelPosition?: LabelPosition = 'top'
   loadingState?: LoadingState
   menuWidth?: number
   name?: string
   necessityIndicator?: NecessityIndicator = 'icon'
   onBlur?: (FocusEvent<Target>) => void
+  onChange?: (T) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onLoadMore?: () => any
   onOpenChange?: (boolean) => void
-  onSelectionChange?: (Key | null) => void
   placeholder?: string = 'Select an item' (localized)
-  selectedKey?: Key | null
+  selectionMode?: SelectionMode = 'single'
   shouldFlip?: boolean = true
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   styles?: StylesProp
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'native' | 'aria' = 'native'
+  value?: ValueType<SelectionMode>
 }

/@react-spectrum/s2:PickerProps

-PickerProps <T extends {}> {
+PickerProps <M extends SelectionMode = 'single', T extends {}> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   align?: 'start' | 'end' = 'start'
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoComplete?: string
   autoFocus?: boolean
   children: ReactNode | ({}) => ReactNode
   contextualHelp?: ReactNode
   defaultOpen?: boolean
-  defaultSelectedKey?: Key
+  defaultValue?: ValueType<SelectionMode>
   dependencies?: ReadonlyArray<any>
   description?: ReactNode
   direction?: 'bottom' | 'top' = 'bottom'
   disabledKeys?: Iterable<Key>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   excludeFromTabOrder?: boolean
   form?: string
   id?: string
   isDisabled?: boolean
   isInvalid?: boolean
   isOpen?: boolean
   isRequired?: boolean
   items?: Iterable<T>
   label?: ReactNode
   labelAlign?: Alignment = 'start'
   labelPosition?: LabelPosition = 'top'
   loadingState?: LoadingState
   menuWidth?: number
   name?: string
   necessityIndicator?: NecessityIndicator = 'icon'
   onBlur?: (FocusEvent<Target>) => void
+  onChange?: (T) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onLoadMore?: () => any
   onOpenChange?: (boolean) => void
-  onSelectionChange?: (Key | null) => void
   placeholder?: string = 'Select an item' (localized)
-  selectedKey?: Key | null
+  selectionMode?: SelectionMode = 'single'
   shouldFlip?: boolean = true
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   slot?: string | null
   styles?: StylesProp
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'native' | 'aria' = 'native'
+  value?: ValueType<SelectionMode>
 }

@react-stately/select

/@react-stately/select:useSelectState

-useSelectState <T extends {}> {
+useSelectState <M extends SelectionMode = 'single', T extends {}> {
-  props: SelectStateOptions<T>
+  props: SelectStateOptions<T, M>
   returnVal: undefined
 }

/@react-stately/select:SelectProps

-SelectProps <T> {
+SelectProps <M extends SelectionMode = 'single', T> {
   autoFocus?: boolean
   children: CollectionChildren<T>
   defaultOpen?: boolean
-  defaultSelectedKey?: Key
+  defaultValue?: ValueType<SelectionMode>
   description?: ReactNode
   disabledKeys?: Iterable<Key>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   isDisabled?: boolean
   isInvalid?: boolean
   isOpen?: boolean
   isRequired?: boolean
   items?: Iterable<T>
   label?: ReactNode
   onBlur?: (FocusEvent<Target>) => void
+  onChange?: (T) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onOpenChange?: (boolean) => void
-  onSelectionChange?: (Key | null) => void
   placeholder?: string
-  selectedKey?: Key | null
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  selectionMode?: SelectionMode = 'single'
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'aria' | 'native' = 'aria'
+  value?: ValueType<SelectionMode>
 }

/@react-stately/select:SelectState

-SelectState <T> {
+SelectState <M extends SelectionMode = 'single', T> {
   close: () => void
   collection: Collection<Node<T>>
   commitValidation: () => void
-  defaultSelectedKey: Key | null
+  defaultValue: ValueType<SelectionMode>
   disabledKeys: Set<Key>
   displayValidation: ValidationResult
   focusStrategy: FocusStrategy | null
   isFocused: boolean
   isOpen: boolean
   open: (FocusStrategy | null) => void
   realtimeValidation: ValidationResult
   resetValidation: () => void
-  selectedItem: Node<T> | null
-  selectedKey: Key | null
+  selectedItems: Array<Node<T>>
   selectionManager: SelectionManager
   setFocused: (boolean) => void
   setOpen: (boolean) => void
-  setSelectedKey: (Key | null) => void
+  setValue: (Key | Array<Key> | null) => void
   toggle: (FocusStrategy | null) => void
   updateValidation: (ValidationResult) => void
+  value: ValueType<SelectionMode>
 }

/@react-stately/select:SelectStateOptions

-SelectStateOptions <T> {
+SelectStateOptions <M extends SelectionMode = 'single', T> {
   autoFocus?: boolean
   collection?: Collection<Node<T>>
   defaultOpen?: boolean
-  defaultSelectedKey?: Key
+  defaultValue?: ValueType<SelectionMode>
   description?: ReactNode
   disabledKeys?: Iterable<Key>
   errorMessage?: ReactNode | (ValidationResult) => ReactNode
   isDisabled?: boolean
   isInvalid?: boolean
   isOpen?: boolean
   isRequired?: boolean
   items?: Iterable<T>
   label?: ReactNode
   onBlur?: (FocusEvent<Target>) => void
+  onChange?: (T) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onKeyDown?: (KeyboardEvent) => void
   onKeyUp?: (KeyboardEvent) => void
   onOpenChange?: (boolean) => void
-  onSelectionChange?: (Key | null) => void
   placeholder?: string
-  selectedKey?: Key | null
-  validate?: (Key) => ValidationError | boolean | null | undefined
+  selectionMode?: SelectionMode = 'single'
+  validate?: (ValidationType<SelectionMode>) => ValidationError | boolean | null | undefined
   validationBehavior?: 'aria' | 'native' = 'aria'
+  value?: ValueType<SelectionMode>
 }

}
};

let listState = useListState({
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty exciting change! Question tho, Will ComboBox eventually have the same support for multiple selection? And if it were to be done, i'd imagine the changes needed for it would also be something like this? Changing from useSingleSelectListState to useListState + wiring up the other parts etc.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, possibly. There are some differences with ComboBox though. You'd need some way to display the selected items outside the text input, typically tags of some kind. It may end up being a separate TagField component in that case.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any update on when we might see this stuff released? Specifically the Combobox/TagField implementations?

Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only thing I found in testing is that the async select example in RAC storybook isn't hooked up correctly, it only does single select. I doubt it's a bug in the logic, more likely just needs to be passed through

@devongovett devongovett added this pull request to the merge queue Aug 29, 2025
Merged via the queue into main with commit 1e05403 Aug 29, 2025
32 checks passed
@devongovett devongovett deleted the multi-select branch August 29, 2025 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

react-aria-components: <SelectValue> breaks when not inside a <Button>
6 participants