From 488ab857be29394b825f2c3020fd27a697b6814c Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 26 Jan 2026 11:28:06 -0800 Subject: [PATCH 01/11] feat: Add support for multi-select ComboBox --- .../@react-aria/combobox/src/useComboBox.ts | 6 +- .../combobox/src/useComboBoxState.ts | 177 +++++++++++++----- .../@react-types/autocomplete/src/index.d.ts | 4 +- packages/@react-types/combobox/src/index.d.ts | 40 +++- .../react-aria-components/src/ComboBox.tsx | 12 +- .../stories/ComboBox.stories.tsx | 101 +++++++++- 6 files changed, 277 insertions(+), 63 deletions(-) diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index c8db04c19cc..946cef824db 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -12,7 +12,7 @@ import {announce} from '@react-aria/live-announcer'; import {AriaButtonProps} from '@react-types/button'; -import {AriaComboBoxProps} from '@react-types/combobox'; +import {AriaComboBoxProps, SelectionMode} from '@react-types/combobox'; import {ariaHideOutside} from '@react-aria/overlays'; import {AriaListBoxOptions, getItemId, listData} from '@react-aria/listbox'; import {BaseEvent, DOMAttributes, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; @@ -29,7 +29,7 @@ import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useMenuTrigger} from '@react-aria/menu'; import {useTextField} from '@react-aria/textfield'; -export interface AriaComboBoxOptions extends Omit, 'children'> { +export interface AriaComboBoxOptions extends Omit, 'children'> { /** The ref for the input element. */ inputRef: RefObject, /** The ref for the list box popover. */ @@ -69,7 +69,7 @@ export interface ComboBoxAria extends ValidationResult { * @param props - Props for the combo box. * @param state - State for the select, as returned by `useComboBoxState`. */ -export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxState): ComboBoxAria { +export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxState): ComboBoxAria { let { buttonRef, popoverRef, diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index e2cce7de048..6e8a5b08016 100644 --- a/packages/@react-stately/combobox/src/useComboBoxState.ts +++ b/packages/@react-stately/combobox/src/useComboBoxState.ts @@ -10,18 +10,45 @@ * governing permissions and limitations under the License. */ -import {Collection, CollectionStateBase, FocusStrategy, Key, Node} from '@react-types/shared'; -import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox'; +import {Collection, CollectionStateBase, FocusStrategy, Key, Node, Selection} from '@react-types/shared'; +import {ComboBoxProps, MenuTriggerAction, SelectionMode, ValueType} from '@react-types/combobox'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {getChildNodes} from '@react-stately/collections'; -import {ListCollection, SingleSelectListState, useSingleSelectListState} from '@react-stately/list'; +import {ListCollection, ListState, useListState} 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'; -export interface ComboBoxState extends SingleSelectListState, OverlayTriggerState, FormValidationState { - /** The default selected key. */ +export interface ComboBoxState 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 combobox value. */ + readonly value: ValueType, + /** The default combobox value. */ + readonly defaultValue: ValueType, + /** Sets the combobox value. */ + setValue(value: Key | readonly Key[] | null): void, + /** + * The value of the first selected item. + * @deprecated + */ + readonly selectedItem: Node | null, + /** The value of the selected items. */ + readonly selectedItems: Node[], /** The current value of the combo box input. */ inputValue: string, /** The default value of the combo box input. */ @@ -46,7 +73,7 @@ export interface ComboBoxState extends SingleSelectListState, OverlayTrigg type FilterFn = (textValue: string, inputValue: string) => boolean; -export interface ComboBoxStateOptions extends Omit, 'children'>, CollectionStateBase { +export interface ComboBoxStateOptions extends Omit, 'children'>, CollectionStateBase { /** The filter function used to determine if a option should be included in the combo box list. */ defaultFilter?: FilterFn, /** Whether the combo box allows the menu to be open when the collection is empty. */ @@ -60,51 +87,95 @@ export interface ComboBoxStateOptions extends Omit, 'childre * of items from props and manages the option selection state of the combo box. In addition, it tracks the input value, * focus state, and other properties of the combo box. */ -export function useComboBoxState(props: ComboBoxStateOptions): ComboBoxState { +export function useComboBoxState(props: ComboBoxStateOptions): ComboBoxState { let { defaultFilter, menuTrigger = 'input', allowsEmptyCollection = false, allowsCustomValue, - shouldCloseOnBlur = true + shouldCloseOnBlur = true, + selectionMode = 'single' as SelectionMode } = props; let [showAllItems, setShowAllItems] = useState(false); let [isFocused, setFocusedState] = useState(false); let [focusStrategy, setFocusStrategy] = useState(null); - let onSelectionChange = (key) => { - if (props.onSelectionChange) { - props.onSelectionChange(key); - } + let defaultValue = useMemo(() => { + return props.defaultValue !== undefined ? props.defaultValue : (selectionMode === 'single' ? props.defaultSelectedKey ?? null : []) as ValueType; + }, [props.defaultValue, props.defaultSelectedKey, selectionMode]); + let value = useMemo(() => { + return props.value !== undefined ? props.value : (selectionMode === 'single' ? props.selectedKey : undefined) as ValueType; + }, [props.value, props.selectedKey, selectionMode]); + let [controlledValue, setControlledValue] = useControlledState(value, defaultValue, props.onChange as any); + // Only display the first selected item if in single selection mode but the value is an array. + let displayValue: ValueType = selectionMode === 'single' && Array.isArray(controlledValue) ? controlledValue[0] : controlledValue; + + let setValue = (value: Key | Key[] | null) => { + if (selectionMode === 'single') { + let key = Array.isArray(value) ? value[0] ?? null : value; + setControlledValue(key); + if (key !== displayValue) { + props.onSelectionChange?.(key); + } + } else { + let keys: Key[] = []; + if (Array.isArray(value)) { + keys = value; + } else if (value != null) { + keys = [value]; + } - // If key is the same, reset the inputValue and close the menu - // (scenario: user clicks on already selected option) - if (key === selectedKey) { - resetInputValue(); - closeMenu(); + setControlledValue(keys); } }; - let {collection, + let { + collection, selectionManager, - selectedKey, - setSelectedKey, - selectedItem, disabledKeys - } = useSingleSelectListState({ + } = useListState({ ...props, - onSelectionChange, - items: props.items ?? props.defaultItems + items: props.items ?? props.defaultItems, + selectionMode, + disallowEmptySelection: selectionMode === 'single', + allowDuplicateSelectionEvents: true, + selectedKeys: useMemo(() => convertValue(displayValue), [displayValue]), + onSelectionChange: (keys: Selection) => { + // impossible, but TS doesn't know that + if (keys === 'all') { + return; + } + + if (selectionMode === 'single') { + let key = keys.values().next().value ?? null; + if (key === displayValue) { + props.onSelectionChange?.(key); + // If key is the same, reset the inputValue and close the menu + // (scenario: user clicks on already selected option) + resetInputValue(); + closeMenu(); + } else { + setValue(key); + } + } else { + setValue([...keys]); + } + } }); + let selectedKey = selectionMode === 'single' ? selectionManager.firstSelectedKey : null; + let selectedItems = useMemo(() => { + return [...selectionManager.selectedKeys].map(key => collection.getItem(key)).filter(item => item != null); + }, [selectionManager.selectedKeys, collection]); + let [inputValue, setInputValue] = useControlledState( props.inputValue, getDefaultInputValue(props.defaultInputValue, selectedKey, collection) || '', props.onInputChange ); - let [initialSelectedKey] = useState(selectedKey); - let [initialValue] = useState(inputValue); + let [initialValue] = useState(displayValue); + let [initialInputValue] = useState(inputValue); // Preserve original collection so we can show all items on demand let originalCollection = collection; @@ -196,7 +267,7 @@ export function useComboBoxState(props: ComboBoxStateOptions(props: ComboBoxStateOptions(props: ComboBoxStateOptions(props: ComboBoxStateOptions(props: ComboBoxStateOptions ({inputValue, selectedKey}), [inputValue, selectedKey]) + value: useMemo(() => Array.isArray(displayValue) && displayValue.length === 0 ? null : ({inputValue, value: displayValue as any, selectedKey}), [inputValue, selectedKey, displayValue]) }); // Revert input value and close menu @@ -289,14 +361,15 @@ export function useComboBoxState(props: ComboBoxStateOptions { - lastSelectedKey.current = null; - setSelectedKey(null); + let value = selectionMode === 'multiple' ? [] : null; + lastValueRef.current = value as any; + setValue(value); closeMenu(); }; let commitSelection = () => { // If multiple things are controlled, call onSelectionChange - if (props.selectedKey !== undefined && props.inputValue !== undefined) { + if (value !== undefined && props.inputValue !== undefined) { props.onSelectionChange?.(selectedKey); // Stop menu from reopening from useEffect @@ -327,7 +400,7 @@ export function useComboBoxState(props: ComboBoxStateOptions(props: ComboBoxStateOptions(props: ComboBoxStateOptions extends CollectionBase, Omit { +export interface SearchAutocompleteProps extends CollectionBase, Omit { /** The list of SearchAutocomplete items (uncontrolled). */ defaultItems?: Iterable, /** The list of SearchAutocomplete items (controlled). */ @@ -44,7 +44,7 @@ export interface SearchAutocompleteProps extends CollectionBase, Omit void } -export interface AriaSearchAutocompleteProps extends SearchAutocompleteProps, Omit, DOMProps, AriaLabelingProps {} +export interface AriaSearchAutocompleteProps extends SearchAutocompleteProps, Omit, DOMProps, AriaLabelingProps {} export interface SpectrumSearchAutocompleteProps extends SpectrumTextInputBase, Omit, 'menuTrigger' | 'isInvalid' | 'validationState' | 'validate'>, SpectrumFieldValidation, SpectrumLabelableProps, StyleProps, Omit { /** diff --git a/packages/@react-types/combobox/src/index.d.ts b/packages/@react-types/combobox/src/index.d.ts index 3558c274dc8..295b010d260 100644 --- a/packages/@react-types/combobox/src/index.d.ts +++ b/packages/@react-types/combobox/src/index.d.ts @@ -23,32 +23,58 @@ import { Key, LabelableProps, LoadingState, - SingleSelection, SpectrumFieldValidation, SpectrumLabelableProps, SpectrumTextInputBase, StyleProps, TextInputBase, - Validation + Validation, + ValueBase } from '@react-types/shared'; export type MenuTriggerAction = 'focus' | 'input' | 'manual'; +export type SelectionMode = 'single' | 'multiple'; +export type ValueType = M extends 'single' ? Key | null : Key[]; +type ValidationType = M extends 'single' ? Key : Key[]; -export interface ComboBoxValidationValue { - /** The selected key in the ComboBox. */ +export interface ComboBoxValidationValue { + /** + * The selected key in the ComboBox. + * @deprecated + */ selectedKey: Key | null, + /** The keys of the currently selected items. */ + value: ValidationType, /** The value of the ComboBox input. */ inputValue: string } -export interface ComboBoxProps extends CollectionBase, Omit, InputBase, TextInputBase, Validation, FocusableProps, LabelableProps, HelpTextProps { +export interface ComboBoxProps extends CollectionBase, InputBase, ValueBase>, TextInputBase, Validation, FocusableProps, LabelableProps, HelpTextProps { /** The list of ComboBox items (uncontrolled). */ defaultItems?: Iterable, /** The list of ComboBox items (controlled). */ items?: Iterable, /** Method that is called when the open state of the menu changes. Returns the new open state and the action that caused the opening of the menu. */ onOpenChange?: (isOpen: boolean, menuTrigger?: MenuTriggerAction) => void, - /** Handler that is called when the selection changes. */ + /** + * 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, /** The value of the ComboBox input (controlled). */ inputValue?: string, @@ -70,7 +96,7 @@ export interface ComboBoxProps extends CollectionBase, Omit extends ComboBoxProps, DOMProps, InputDOMProps, AriaLabelingProps { +export interface AriaComboBoxProps extends ComboBoxProps, DOMProps, InputDOMProps, AriaLabelingProps { /** Whether keyboard navigation is circular. */ shouldFocusWrap?: boolean } diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 5efbdd26945..53f3da93dca 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -39,6 +39,8 @@ import {PopoverContext} from './Popover'; import React, {createContext, ForwardedRef, forwardRef, useCallback, useMemo, useRef, useState} from 'react'; import {TextContext} from './Text'; +type SelectionMode = 'single' | 'multiple'; + export interface ComboBoxRenderProps { /** * Whether the combobox is currently open. @@ -62,7 +64,7 @@ export interface ComboBoxRenderProps { isRequired: boolean } -export interface ComboBoxProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps, GlobalDOMAttributes { +export interface ComboBoxProps extends Omit, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps, GlobalDOMAttributes { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. A function may be provided to compute the class based on component state. * @default 'react-aria-ComboBox' @@ -82,13 +84,13 @@ export interface ComboBoxProps extends Omit, HTMLDivElement>>(null); -export const ComboBoxStateContext = createContext | null>(null); +export const ComboBoxContext = createContext, HTMLDivElement>>(null); +export const ComboBoxStateContext = createContext | null>(null); /** * A combo box combines a text input with a listbox, allowing users to filter a list of options to items matching a query. */ -export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function ComboBox(props: ComboBoxProps, ref: ForwardedRef) { +export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function ComboBox(props: ComboBoxProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, ComboBoxContext); let {children, isDisabled = false, isInvalid = false, isRequired = false} = props; let content = useMemo(() => ( @@ -116,7 +118,7 @@ export const ComboBox = /*#__PURE__*/ (forwardRef as forwardRefType)(function Co const CLEAR_CONTEXTS = [LabelContext, ButtonContext, InputContext, GroupContext, TextContext]; interface ComboBoxInnerProps { - props: ComboBoxProps, + props: ComboBoxProps, collection: Collection>, comboBoxRef: RefObject } diff --git a/packages/react-aria-components/stories/ComboBox.stories.tsx b/packages/react-aria-components/stories/ComboBox.stories.tsx index 4aa528a18bb..9c256300c77 100644 --- a/packages/react-aria-components/stories/ComboBox.stories.tsx +++ b/packages/react-aria-components/stories/ComboBox.stories.tsx @@ -10,12 +10,13 @@ * governing permissions and limitations under the License. */ -import {Button, Collection, ComboBox, ComboBoxProps, Input, Label, ListBox, ListLayout, Popover, useFilter, Virtualizer} from 'react-aria-components'; +import {Button, Collection, ComboBox, ComboBoxProps, ComboBoxStateContext, Input, Label, ListBox, ListLayout, Popover, useFilter, Virtualizer} from 'react-aria-components'; import {ListBoxLoadMoreItem} from '../src/ListBox'; import {LoadingSpinner, MyListBoxItem} from './utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import React, {JSX, useMemo, useState} from 'react'; import styles from '../example/index.css'; +import {Tag, TagGroup} from 'vanilla-starter/TagGroup'; import {useAsyncList} from 'react-stately'; import './styles.css'; @@ -389,3 +390,101 @@ export const ComboBoxListBoxItemWithAriaLabel: ComboBoxStory = () => ( ); + +export const MultiSelectComboBox: ComboBoxStory = () => ( + + +
+ + +
+ + {state => state && ( + item.value)} + renderEmptyState={() => 'No selected items'} + onRemove={(keys) => { + // Remove keys from ComboBox state. + if (Array.isArray(state.value)) { + state.setValue(state.value.filter(k => !keys.has(k))); + } + }}> + {item => {item.name}} + + )} + + + + renderEmptyState={renderEmptyState} + data-testid="combo-box-list-box" + className={styles.menu}> + {item => {item.name}} + + +
+); + +const usStateOptions = [ + {id: 'AL', name: 'Alabama'}, + {id: 'AK', name: 'Alaska'}, + {id: 'AS', name: 'American Samoa'}, + {id: 'AZ', name: 'Arizona'}, + {id: 'AR', name: 'Arkansas'}, + {id: 'CA', name: 'California'}, + {id: 'CO', name: 'Colorado'}, + {id: 'CT', name: 'Connecticut'}, + {id: 'DE', name: 'Delaware'}, + {id: 'DC', name: 'District Of Columbia'}, + {id: 'FM', name: 'Federated States Of Micronesia'}, + {id: 'FL', name: 'Florida'}, + {id: 'GA', name: 'Georgia'}, + {id: 'GU', name: 'Guam'}, + {id: 'HI', name: 'Hawaii'}, + {id: 'ID', name: 'Idaho'}, + {id: 'IL', name: 'Illinois'}, + {id: 'IN', name: 'Indiana'}, + {id: 'IA', name: 'Iowa'}, + {id: 'KS', name: 'Kansas'}, + {id: 'KY', name: 'Kentucky'}, + {id: 'LA', name: 'Louisiana'}, + {id: 'ME', name: 'Maine'}, + {id: 'MH', name: 'Marshall Islands'}, + {id: 'MD', name: 'Maryland'}, + {id: 'MA', name: 'Massachusetts'}, + {id: 'MI', name: 'Michigan'}, + {id: 'MN', name: 'Minnesota'}, + {id: 'MS', name: 'Mississippi'}, + {id: 'MO', name: 'Missouri'}, + {id: 'MT', name: 'Montana'}, + {id: 'NE', name: 'Nebraska'}, + {id: 'NV', name: 'Nevada'}, + {id: 'NH', name: 'New Hampshire'}, + {id: 'NJ', name: 'New Jersey'}, + {id: 'NM', name: 'New Mexico'}, + {id: 'NY', name: 'New York'}, + {id: 'NC', name: 'North Carolina'}, + {id: 'ND', name: 'North Dakota'}, + {id: 'MP', name: 'Northern Mariana Islands'}, + {id: 'OH', name: 'Ohio'}, + {id: 'OK', name: 'Oklahoma'}, + {id: 'OR', name: 'Oregon'}, + {id: 'PW', name: 'Palau'}, + {id: 'PA', name: 'Pennsylvania'}, + {id: 'PR', name: 'Puerto Rico'}, + {id: 'RI', name: 'Rhode Island'}, + {id: 'SC', name: 'South Carolina'}, + {id: 'SD', name: 'South Dakota'}, + {id: 'TN', name: 'Tennessee'}, + {id: 'TX', name: 'Texas'}, + {id: 'UT', name: 'Utah'}, + {id: 'VT', name: 'Vermont'}, + {id: 'VI', name: 'Virgin Islands'}, + {id: 'VA', name: 'Virginia'}, + {id: 'WA', name: 'Washington'}, + {id: 'WV', name: 'West Virginia'}, + {id: 'WI', name: 'Wisconsin'}, + {id: 'WY', name: 'Wyoming'} +]; From 92ccc6d3f270295ab79dddac7c3e3188e2112b39 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 27 Jan 2026 09:31:05 -0800 Subject: [PATCH 02/11] Add docs example --- .../dev/s2-docs/pages/react-aria/ComboBox.mdx | 104 +++++++++++++++++- 1 file changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx b/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx index 93afe017da9..f039e21264e 100644 --- a/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx +++ b/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx @@ -20,7 +20,7 @@ export const description = 'Combines a text input with a listbox, allowing users {docs.exports.ComboBox.description} - ```tsx render docs={vanillaDocs.exports.ComboBox} links={vanillaDocs.links} props={['label', 'isDisabled']} initialProps={{label: 'Favorite Animal', placeholder: 'Select an animal'}} type="vanilla" files={["starters/docs/src/ComboBox.tsx", "starters/docs/src/ComboBox.css"]} + ```tsx render docs={vanillaDocs.exports.ComboBox} links={vanillaDocs.links} props={['label', 'selectionMode', 'isDisabled']} initialProps={{label: 'Favorite Animal', placeholder: 'Select an animal'}} type="vanilla" files={["starters/docs/src/ComboBox.tsx", "starters/docs/src/ComboBox.css"]} "use client"; import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; @@ -34,7 +34,7 @@ export const description = 'Combines a text input with a listbox, allowing users ``` - ```tsx render docs={vanillaDocs.exports.ComboBox} links={vanillaDocs.links} props={['label', 'isDisabled']} initialProps={{label: 'Favorite Animal', placeholder: 'Select an animal'}} type="tailwind" files={["starters/tailwind/src/ComboBox.tsx"]} + ```tsx render docs={vanillaDocs.exports.ComboBox} links={vanillaDocs.links} props={['label', 'selectionMode', 'isDisabled']} initialProps={{label: 'Favorite Animal', placeholder: 'Select an animal'}} type="tailwind" files={["starters/tailwind/src/ComboBox.tsx"]} "use client"; import {ComboBox, ComboBoxItem} from 'tailwind-starter/ComboBox'; @@ -142,6 +142,106 @@ function Example() { } ``` +### Multiple selection + +Use `ComboBoxStateContext` to render the selected items as a [TagGroup](TagGroup). + +```tsx render +"use client"; +import {ComboBox, ComboBoxStateContext, Input} from 'react-aria-components'; +import {ComboBoxListBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; +import {Label, FieldButton} from 'vanilla-starter/Form'; +import {Popover} from 'vanilla-starter/Popover'; +import {Tag, TagGroup} from 'vanilla-starter/TagGroup'; +import {ChevronDown} from 'lucide-react'; + +/*- begin collapse -*/ +const states = [ + {id: 'AL', name: 'Alabama'}, + {id: 'AK', name: 'Alaska'}, + {id: 'AZ', name: 'Arizona'}, + {id: 'AR', name: 'Arkansas'}, + {id: 'CA', name: 'California'}, + {id: 'CO', name: 'Colorado'}, + {id: 'CT', name: 'Connecticut'}, + {id: 'DE', name: 'Delaware'}, + {id: 'DC', name: 'District of Columbia'}, + {id: 'FL', name: 'Florida'}, + {id: 'GA', name: 'Georgia'}, + {id: 'HI', name: 'Hawaii'}, + {id: 'ID', name: 'Idaho'}, + {id: 'IL', name: 'Illinois'}, + {id: 'IN', name: 'Indiana'}, + {id: 'IA', name: 'Iowa'}, + {id: 'KS', name: 'Kansas'}, + {id: 'KY', name: 'Kentucky'}, + {id: 'LA', name: 'Louisiana'}, + {id: 'ME', name: 'Maine'}, + {id: 'MD', name: 'Maryland'}, + {id: 'MA', name: 'Massachusetts'}, + {id: 'MI', name: 'Michigan'}, + {id: 'MN', name: 'Minnesota'}, + {id: 'MS', name: 'Mississippi'}, + {id: 'MO', name: 'Missouri'}, + {id: 'MT', name: 'Montana'}, + {id: 'NE', name: 'Nebraska'}, + {id: 'NV', name: 'Nevada'}, + {id: 'NH', name: 'New Hampshire'}, + {id: 'NJ', name: 'New Jersey'}, + {id: 'NM', name: 'New Mexico'}, + {id: 'NY', name: 'New York'}, + {id: 'NC', name: 'North Carolina'}, + {id: 'ND', name: 'North Dakota'}, + {id: 'OH', name: 'Ohio'}, + {id: 'OK', name: 'Oklahoma'}, + {id: 'OR', name: 'Oregon'}, + {id: 'PA', name: 'Pennsylvania'}, + {id: 'RI', name: 'Rhode Island'}, + {id: 'SC', name: 'South Carolina'}, + {id: 'SD', name: 'South Dakota'}, + {id: 'TN', name: 'Tennessee'}, + {id: 'TX', name: 'Texas'}, + {id: 'UT', name: 'Utah'}, + {id: 'VT', name: 'Vermont'}, + {id: 'VA', name: 'Virginia'}, + {id: 'WA', name: 'Washington'}, + {id: 'WV', name: 'West Virginia'}, + {id: 'WI', name: 'Wisconsin'}, + {id: 'WY', name: 'Wyoming'} +]; +/*- end collapse -*/ + + + +
+ + +
+ + {state => state && ( + 'No selected items'} + onRemove={(keys) => { + // Remove keys from Select state. + if (Array.isArray(state.value)) { + state.setValue(state.value.filter(k => !keys.has(k))); + } + }}> + {item => {item.value.name}} + + )} + + + + {state => {state.name}} + + +
+``` + ### Input value Use the `inputValue` or `defaultInputValue` prop to set the value of the input field. By default, the value will be reverted to the selected item on blur. Set the `allowsCustomValue` prop to enable entering values that are not in the list. From c247e8fff6c6d1f3e96c2520b2b277b20541c968 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 27 Jan 2026 09:31:21 -0800 Subject: [PATCH 03/11] Render multiple hidden inputs for multi-select --- .../react-aria-components/src/ComboBox.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 53f3da93dca..5c4afe161e8 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -29,14 +29,14 @@ import {CollectionBuilder} from '@react-aria/collections'; import {FieldErrorContext} from './FieldError'; import {filterDOMProps, useResizeObserver} from '@react-aria/utils'; import {FormContext} from './Form'; -import {forwardRefType, GlobalDOMAttributes, RefObject} from '@react-types/shared'; +import {forwardRefType, GlobalDOMAttributes, Key, RefObject} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; import {ListBoxContext, ListStateContext} from './ListBox'; import {OverlayTriggerStateContext} from './Dialog'; import {PopoverContext} from './Popover'; -import React, {createContext, ForwardedRef, forwardRef, useCallback, useMemo, useRef, useState} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, ReactElement, useCallback, useMemo, useRef, useState} from 'react'; import {TextContext} from './Text'; type SelectionMode = 'single' | 'multiple'; @@ -206,6 +206,18 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: let DOMProps = filterDOMProps(props, {global: true}); delete DOMProps.id; + let inputs: ReactElement[] = []; + if (name && formValue === 'key') { + let values: (Key | null)[] = Array.isArray(state.value) ? state.value : [state.value]; + if (values.length === 0) { + values = [null]; + } + + inputs = values.map((value, i) => ( + + )); + } + return ( ({props, collection, comboBoxRef: ref}: data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} data-required={props.isRequired || undefined} /> - {name && formValue === 'key' && } + {inputs} ); } From 9b6ecfa3ee694ef7d861edb02ab09ee66e81d14a Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 27 Jan 2026 09:36:59 -0800 Subject: [PATCH 04/11] Add a couple tests --- .../test/ComboBox.test.js | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index 4e581397905..8b21e43f01f 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -11,7 +11,7 @@ */ import {act} from '@testing-library/react'; -import {Button, ComboBox, ComboBoxContext, FieldError, Header, Input, Label, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, ListLayout, Popover, Text, Virtualizer} from '../'; +import {Button, ComboBox, ComboBoxContext, FieldError, Form, Header, Input, Label, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, ListLayout, Popover, Text, Virtualizer} from '../'; import {fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import React, {useState} from 'react'; import {User} from '@react-aria/test-utils'; @@ -630,4 +630,52 @@ describe('ComboBox', () => { expect(tree.queryByRole('listbox')).toBeNull(); expect(comboboxTester.combobox).toHaveValue('Apple'); }); + + it('should support multiple selection', async () => { + let onChange = jest.fn(); + let {container, getByTestId} = render( +
+ + + ); + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + + expect(comboboxTester.combobox).toHaveValue(''); + await comboboxTester.open(); + + let listbox = comboboxTester.listbox; + expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + + let options = comboboxTester.options(); + expect(options).toHaveLength(3); + + await user.click(options[0]); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(comboboxTester.combobox).toHaveValue(''); + expect(comboboxTester.listbox).toBeInTheDocument(); + await user.click(options[1]); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(comboboxTester.combobox).toHaveValue(''); + expect(comboboxTester.listbox).toBeInTheDocument(); + await comboboxTester.close(); + + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith(['1', '2']); + + let formData = new FormData(getByTestId('form')); + expect(formData.getAll('select')).toEqual(['1', '2']); + }); + + it('should support controlled multi-selection', async () => { + let {container} = render(); + + let comboboxTester = testUtilUser.createTester('ComboBox', {root: container}); + expect(comboboxTester.combobox).toHaveValue(''); + await comboboxTester.open(); + + let options = comboboxTester.options(); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + expect(options[2]).toHaveAttribute('aria-selected', 'true'); + }); }); From b83c5dfe8c67b407ad779527e9a000569b1c9eb3 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 28 Jan 2026 21:31:23 -0800 Subject: [PATCH 05/11] Omit from RSP --- packages/@react-spectrum/s2/src/ComboBox.tsx | 5 +++-- packages/@react-types/combobox/src/index.d.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index d3cd25f882f..36e607cd2a7 100644 --- a/packages/@react-spectrum/s2/src/ComboBox.tsx +++ b/packages/@react-spectrum/s2/src/ComboBox.tsx @@ -33,7 +33,7 @@ import { SectionProps, Virtualizer } from 'react-aria-components'; -import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SpectrumLabelableProps} from '@react-types/shared'; +import {AsyncLoadable, GlobalDOMAttributes, HelpTextProps, LoadingState, SingleSelection, SpectrumLabelableProps} from '@react-types/shared'; import {AvatarContext} from './Avatar'; import {BaseCollection, CollectionNode, createLeafComponent} from '@react-aria/collections'; import {baseColor, focusRing, space, style} from '../style' with {type: 'macro'}; @@ -79,7 +79,8 @@ export interface ComboboxStyleProps { size?: 'S' | 'M' | 'L' | 'XL' } export interface ComboBoxProps extends - Omit, 'children' | 'style' | 'className' | 'render' | 'defaultFilter' | 'allowsEmptyCollection' | 'isTriggerUpWhenOpen' | keyof GlobalDOMAttributes>, + Omit, 'children' | 'style' | 'className' | 'render' | 'defaultFilter' | 'allowsEmptyCollection' | 'isTriggerUpWhenOpen' | 'selectionMode' | 'selectedKey' | 'defaultSelectedKey' | 'onSelectionChange' | 'value' | 'defaultValue' | 'onChange' | keyof GlobalDOMAttributes>, + Omit, ComboboxStyleProps, StyleProps, SpectrumLabelableProps, diff --git a/packages/@react-types/combobox/src/index.d.ts b/packages/@react-types/combobox/src/index.d.ts index 295b010d260..3eefa51123e 100644 --- a/packages/@react-types/combobox/src/index.d.ts +++ b/packages/@react-types/combobox/src/index.d.ts @@ -23,6 +23,7 @@ import { Key, LabelableProps, LoadingState, + SingleSelection, SpectrumFieldValidation, SpectrumLabelableProps, SpectrumTextInputBase, @@ -83,7 +84,7 @@ export interface ComboBoxProps extends Co /** Handler that is called when the ComboBox input value changes. */ onInputChange?: (value: string) => void, /** Whether the ComboBox allows a non-item matching input value to be set. */ - allowsCustomValue?: boolean, +allowsCustomValue?: boolean, // /** // * Whether the Combobox should only suggest matching options or autocomplete the field with the nearest matching option. // * @default 'suggest' @@ -101,7 +102,7 @@ export interface AriaComboBoxProps extend shouldFocusWrap?: boolean } -export interface SpectrumComboBoxProps extends SpectrumTextInputBase, Omit, 'menuTrigger' | 'isInvalid' | 'validationState'>, SpectrumFieldValidation, SpectrumLabelableProps, StyleProps, Omit { +export interface SpectrumComboBoxProps extends SpectrumTextInputBase, Omit, 'menuTrigger' | 'isInvalid' | 'validationState' | 'selectionMode' | 'selectedKey' | 'defaultSelectedKey' | 'onSelectionChange' | 'value' | 'defaultValue' | 'onChange'>, Omit, SpectrumFieldValidation, SpectrumLabelableProps, StyleProps, Omit { /** * The interaction required to display the ComboBox menu. Note that this prop has no effect on the mobile ComboBox experience. * @default 'input' From fa9a6315ece75dc7502cc63c0db6bfed08009090 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 29 Jan 2026 09:24:32 -0800 Subject: [PATCH 06/11] Add more tests --- .../@react-aria/combobox/src/useComboBox.ts | 5 +- .../combobox/src/useComboBoxState.ts | 11 ++-- .../dev/s2-docs/pages/react-aria/ComboBox.mdx | 4 +- .../test/ComboBox.test.js | 56 +++++++++++++++++-- 4 files changed, 63 insertions(+), 13 deletions(-) diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index 946cef824db..0dbb685e2b4 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -158,7 +158,7 @@ export function useComboBox(props: AriaCo break; case 'Escape': if ( - state.selectedKey !== null || + !state.selectionManager.isEmpty || state.inputValue === '' || props.allowsCustomValue ) { @@ -220,7 +220,7 @@ export function useComboBox(props: AriaCo [privateValidationStateProp]: state }, inputRef); - useFormReset(inputRef, state.defaultSelectedKey, state.setSelectedKey); + useFormReset(inputRef, state.defaultValue, state.setValue); // Press handlers for the ComboBox button let onPress = (e: PressEvent) => { @@ -332,6 +332,7 @@ export function useComboBox(props: AriaCo }); // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically. + // TODO: do we need to do this for multi-select? let lastSelectedKey = useRef(state.selectedKey); useEffect(() => { if (isAppleDevice() && state.isFocused && state.selectedItem && state.selectedKey !== lastSelectedKey.current) { diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index 6e8a5b08016..c0485953aee 100644 --- a/packages/@react-stately/combobox/src/useComboBoxState.ts +++ b/packages/@react-stately/combobox/src/useComboBoxState.ts @@ -313,13 +313,13 @@ export function useComboBoxState + {/*- begin highlight -*/} {state => state && ( 'No selected items'} onRemove={(keys) => { - // Remove keys from Select state. + // Remove keys from ComboBox state. if (Array.isArray(state.value)) { state.setValue(state.value.filter(k => !keys.has(k))); } @@ -234,6 +235,7 @@ const states = [ )} + {/*- end highlight -*/} {state => {state.name}} diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index ccb9df941ec..57d50ee32c5 100644 --- a/packages/react-aria-components/test/ComboBox.test.js +++ b/packages/react-aria-components/test/ComboBox.test.js @@ -258,7 +258,7 @@ describe('ComboBox', () => { }); it('should support formValue', () => { - let {getByRole, rerender} = render(); + let {getByRole, rerender} = render(); let input = getByRole('combobox'); expect(input).not.toHaveAttribute('name'); expect(input).toHaveValue('Dog'); @@ -266,7 +266,7 @@ describe('ComboBox', () => { expect(hiddenInput).toHaveAttribute('name', 'test'); expect(hiddenInput).toHaveValue('2'); - rerender(); + rerender(); expect(input).toHaveAttribute('name', 'test'); expect(document.querySelector('input[type=hidden]')).toBeNull(); }); @@ -274,7 +274,7 @@ describe('ComboBox', () => { it('should support form reset', async () => { const tree = render(
- +