diff --git a/packages/@react-aria/combobox/src/useComboBox.ts b/packages/@react-aria/combobox/src/useComboBox.ts index c8db04c19cc..2bac2e28722 100644 --- a/packages/@react-aria/combobox/src/useComboBox.ts +++ b/packages/@react-aria/combobox/src/useComboBox.ts @@ -12,14 +12,14 @@ 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'; -import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; +import {chain, getActiveElement, getOwnerDocument, isAppleDevice, mergeProps, nodeContains, useEvent, useFormReset, useId, useLabels, useRouter, useUpdateEffect} from '@react-aria/utils'; import {ComboBoxState} from '@react-stately/combobox'; import {dispatchVirtualFocus} from '@react-aria/focus'; -import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef} from 'react'; +import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react'; import {getChildNodes, getItemCount} from '@react-stately/collections'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -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. */ @@ -57,6 +57,8 @@ export interface ComboBoxAria extends ValidationResult { listBoxProps: AriaListBoxOptions, /** Props for the optional trigger button, to be passed to `useButton`. */ buttonProps: AriaButtonProps, + /** Props for the element representing the selected value. */ + valueProps: DOMAttributes, /** Props for the combo box description element, if any. */ descriptionProps: DOMAttributes, /** Props for the combo box error message element, if any. */ @@ -69,7 +71,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, @@ -158,7 +160,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta break; case 'Escape': if ( - state.selectedKey !== null || + !state.selectionManager.isEmpty || state.inputValue === '' || props.allowsCustomValue ) { @@ -206,6 +208,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta state.setFocused(true); }; + let valueId = useValueId([state.selectedItems, state.selectionManager.selectionMode]); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; let {labelProps, inputProps, descriptionProps, errorMessageProps} = useTextField({ ...props, @@ -217,10 +220,11 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta onFocus, autoComplete: 'off', validate: undefined, - [privateValidationStateProp]: state + [privateValidationStateProp]: state, + 'aria-describedby': [valueId, props['aria-describedby']].filter(Boolean).join(' ') || undefined }, 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 +336,7 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta }); // 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) { @@ -392,6 +397,9 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta linkBehavior: 'selection' as const, ['UNSTABLE_itemBehavior']: 'action' }), + valueProps: { + id: valueId + }, descriptionProps, errorMessageProps, isInvalid, @@ -399,3 +407,29 @@ export function useComboBox(props: AriaComboBoxOptions, state: ComboBoxSta validationDetails }; } + +// This is a modified version of useSlotId that uses useEffect instead of useLayoutEffect. +// Triggering re-renders from useLayoutEffect breaks useComboBoxState's useEffect logic in React 18. +// These re-renders preempt async state updates in the useEffect, which ends up running multiple times +// prior to the state being updated. This results in onSelectionChange being called multiple times. +// TODO: refactor useComboBoxState to avoid this. +function useValueId(depArray: ReadonlyArray = []): string | undefined { + let id = useId(); + let [exists, setExists] = useState(true); + let [lastDeps, setLastDeps] = useState(depArray); + + // If the deps changed, set exists to true so we can test whether the element exists. + if (lastDeps.some((v, i) => !Object.is(v, depArray[i]))) { + setExists(true); + setLastDeps(depArray); + } + + useEffect(() => { + if (exists && !document.getElementById(id)) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setExists(false); + } + }, [id, exists, lastDeps]); + + return exists ? id : undefined; +} diff --git a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js index 0ef2af2f80e..eb02f61880d 100644 --- a/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js +++ b/packages/@react-spectrum/autocomplete/test/SearchAutocomplete.test.js @@ -2479,7 +2479,8 @@ describe('SearchAutocomplete', function () { expect(input).toHaveValue('test'); let button = getByTestId('submit'); - await act(async () => await user.click(button)); + // For some reason, user.click() causes act warnings related to suspense... + await act(() => button.click()); expect(input).toHaveValue('hi'); }); } diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 167e59e665f..245127dd922 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -5286,11 +5286,12 @@ describe('ComboBox', function () { expect(input).toHaveValue('One'); let button = getByTestId('submit'); - await act(async () => await user.click(button)); + // For some reason, user.click() causes act warnings related to suspense... + await act(() => button.click()); expect(input).toHaveValue('Two'); rerender(); - await act(async () => await user.click(button)); + await user.click(button); expect(document.querySelector('input[name=combobox]')).toHaveValue('2'); }); } diff --git a/packages/@react-spectrum/s2/src/ComboBox.tsx b/packages/@react-spectrum/s2/src/ComboBox.tsx index e6178185891..51247090172 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' | keyof GlobalDOMAttributes>, + Omit, 'children' | 'style' | 'className' | 'render' | 'defaultFilter' | 'allowsEmptyCollection' | 'selectionMode' | 'selectedKey' | 'defaultSelectedKey' | 'onSelectionChange' | 'value' | 'defaultValue' | 'onChange' | keyof GlobalDOMAttributes>, + Omit, ComboboxStyleProps, StyleProps, SpectrumLabelableProps, diff --git a/packages/@react-stately/combobox/src/useComboBoxState.ts b/packages/@react-stately/combobox/src/useComboBoxState.ts index e2cce7de048..308c22e0fa7 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. */ @@ -55,56 +82,102 @@ export interface ComboBoxStateOptions extends Omit, 'childre shouldCloseOnBlur?: boolean } +const EMPTY_VALUE: Key[] = []; + /** * Provides state management for a combo box component. Handles building a collection * 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 +269,7 @@ export function useComboBoxState(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,15 +363,17 @@ export function useComboBoxState(props: ComboBoxStateOptions { - lastSelectedKey.current = null; - setSelectedKey(null); + let value = selectionMode === 'multiple' ? EMPTY_VALUE : 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); + props.onChange?.(displayValue); // Stop menu from reopening from useEffect let itemText = selectedKey != null ? collection.getItem(selectedKey)?.textValue ?? '' : ''; @@ -324,10 +400,10 @@ 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..3eefa51123e 100644 --- a/packages/@react-types/combobox/src/index.d.ts +++ b/packages/@react-types/combobox/src/index.d.ts @@ -29,26 +29,53 @@ import { 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, @@ -57,7 +84,7 @@ export interface ComboBoxProps extends CollectionBase, Omit 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' @@ -70,12 +97,12 @@ 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 } -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' diff --git a/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx b/packages/dev/s2-docs/pages/react-aria/ComboBox.mdx index 93afe017da9..cc102cadfcb 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'; @@ -107,9 +107,111 @@ function Example() { } ``` -## Selection +### TagGroup -Use the `defaultSelectedKey` or `selectedKey` prop to set the selected item. The selected key corresponds to the `id` prop of an item. Items can be disabled with the `isDisabled` prop. See the [selection guide](selection?component=ComboBox#single-selection) for more details. +Use the `ComboBoxValue` render prop function to display the selected items as a [TagGroup](TagGroup). + +```tsx render +"use client"; +import {ComboBox, ComboBoxValue, 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 -*/ + + + +
+ + +
+ {/*- begin highlight -*/} + > + {({selectedItems, state}) => ( + item != null)} + 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}} + + )} + + {/*- end highlight -*/} + + + {state => {state.name}} + + +
+``` + +## Value + +Use the `defaultValue` or `value` prop to set the selected item. The value corresponds to the `id` prop of an item. Items can be disabled with the `isDisabled` prop. ```tsx render "use client"; @@ -126,8 +228,8 @@ function Example() { label="Favorite Animal" placeholder="Select an animal" /*- begin highlight -*/ - selectedKey={animal} - onSelectionChange={setAnimal}> + value={animal} + onChange={setAnimal}> {/*- end highlight -*/} Koala Kangaroo @@ -180,7 +282,7 @@ function Example(props) { ### Fully controlled -Both `inputValue` and `selectedKey` can be controlled simultaneously. However, each interaction will only trigger either `onInputChange` or `onSelectionChange`, not both. When controlling both props, you must update both values accordingly. +Both `inputValue` and `value` can be controlled simultaneously. However, each interaction will only trigger either `onInputChange` or `onChange`, not both. When controlling both props, you must update both values accordingly. ```tsx render "use client"; @@ -203,24 +305,24 @@ function ControlledComboBox() { ]; /*- end collapse -*/ - let [fieldState, setFieldState] = useState<{selectedKey: Key | null, inputValue: string}>({ - selectedKey: null, + let [fieldState, setFieldState] = useState<{value: Key | null, inputValue: string}>({ + value: null, inputValue: '' }); - let onSelectionChange = (id: Key | null) => { - // Update inputValue when selectedKey changes. + let onChange = (id: Key | null) => { + // Update inputValue when value changes. setFieldState({ inputValue: id == null ? '' : options.find(o => o.id === id)?.name ?? '', - selectedKey: id + value: id }); }; let onInputChange = (value: string) => { - // Reset selectedKey to null if the input is cleared. + // Reset value to null if the input is cleared. setFieldState(prevState => ({ inputValue: value, - selectedKey: value === '' ? null : prevState.selectedKey + value: value === '' ? null : prevState.value })); }; @@ -231,15 +333,15 @@ function ControlledComboBox() { placeholder="Select a major" /*- begin highlight -*/ defaultItems={options} - selectedKey={fieldState.selectedKey} + value={fieldState.value} inputValue={fieldState.inputValue} - onSelectionChange={onSelectionChange} + onChange={onChange} onInputChange={onInputChange}> {/*- end highlight -*/} {item => {item.name}}
-        Current selected major id: {fieldState.selectedKey}{'\n'}
+        Current selected major id: {fieldState.value}{'\n'}
         Current input text: {fieldState.inputValue}
       
@@ -342,11 +444,12 @@ import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; -```tsx links={{ComboBox: '#combobox', Button: 'Button', Popover: 'Popover', ListBox: 'ListBox'}} +```tsx links={{ComboBox: '#combobox', ComboBoxValue: '#comboboxvalue', Button: 'Button', Popover: 'Popover', ListBox: 'ListBox'}} +); + +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'} +]; diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js index bb17da5e894..9aee03b0cdd 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, ComboBoxValue, 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'; @@ -28,6 +28,7 @@ let TestComboBox = (props) => (