diff --git a/docs/src/app/(docs)/react/components/combobox/page.mdx b/docs/src/app/(docs)/react/components/combobox/page.mdx index 48bd610f74b..24b415b670d 100644 --- a/docs/src/app/(docs)/react/components/combobox/page.mdx +++ b/docs/src/app/(docs)/react/components/combobox/page.mdx @@ -71,10 +71,67 @@ import { Combobox } from '@base-ui/react/combobox'; ; ``` -## TypeScript +## Item values -Combobox infers the item type from the `defaultValue` or `value` props passed to ``. -The type of items held in the `items` array must also match the `value` prop type passed to ``. +Combobox infers the selected value type from `defaultValue`, `value`, or the return value of `itemToValue`. + +By default, each item in the `items` array is also its selection value. Use object selection when selection state and change handlers need the full object. The selected value should reference an object from the `items` array unless `isItemEqualToValue` provides custom equality. + +```tsx title="Selecting an object" +const users = [ + { id: 'some-id', label: 'Alice Johnson', email: 'alice@example.com' }, + { id: 'other-id', label: 'Bob Smith', email: 'bob@example.com' }, +]; + +// @highlight-start + + {/* @highlight-end */} + + {(user) => {user.label}} + +; +``` + +Use `itemToValue` when selection should instead contain a stable primitive key, such as for form submission, serialization, or controlled state that does not depend on object identity. The source objects remain available for rendering, display, and filtering. + +```tsx title="Selecting a primitive key" +// @highlight-start + user.id}> + {/* @highlight-end */} + + {(user) => {user.label}} + +; +``` + +`itemToStringLabel` is useful with either selection shape when items do not have a `label` field. With object selection, it receives the item object. With `itemToValue`, it receives the mapped value and the matching source item: + +```tsx title="Deriving labels" +const users = [ + { id: 'some-id', name: 'Alice Johnson' }, + { id: 'other-id', name: 'Bob Smith' }, +]; + +// @highlight-start + user.name}> + {/* @highlight-end */} + + {(user) => {user.name}} + +; + +// @highlight-start + user.id} + itemToStringLabel={(id, user) => user?.name ?? id} +> + {/* @highlight-end */} + + {(user) => {user.name}} + +; +``` ## Examples @@ -91,6 +148,13 @@ export function MyCombobox( ): React.JSX.Element { return {/* ... */}; } + +interface User { + id: string; + name: string; +} + +type UserComboboxProps = Combobox.Root.MappedProps; ``` ### Multiple select diff --git a/docs/src/app/(docs)/react/components/combobox/types.md b/docs/src/app/(docs)/react/components/combobox/types.md index 098d0aeff8e..d2bc67dc0a7 100644 --- a/docs/src/app/(docs)/react/components/combobox/types.md +++ b/docs/src/app/(docs)/react/components/combobox/types.md @@ -11,50 +11,47 @@ Doesn't render its own HTML element. **Root Props:** -| Prop | Type | Default | Description | -| :------------------- | :------------------------------------------------------------------------------------------------------ | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| name | `string` | - | Identifies the field when a form is submitted. | -| defaultValue | `Value[] \| Value \| null` | - | The uncontrolled selected value of the combobox when it's initially rendered. To render a controlled combobox, use the `value` prop instead. | -| value | `Value[] \| Value \| null` | - | The selected value of the combobox. Use when controlled. | -| onValueChange | `((value: Value[] \| Value \| null, eventDetails: Combobox.Root.ChangeEventDetails) => void)` | - | Event handler called when the selected value of the combobox changes. | -| defaultInputValue | `string \| number \| string[]` | - | The uncontrolled input value when initially rendered. To render a controlled input, use the `inputValue` prop instead. | -| inputValue | `string \| string[] \| number` | - | The input value of the combobox. Use when controlled. | -| onInputValueChange | `((inputValue: string, eventDetails: Combobox.Root.ChangeEventDetails) => void)` | - | Event handler called when the input value changes. | -| defaultOpen | `boolean` | `false` | Whether the popup is initially open. To render a controlled popup, use the `open` prop instead. | -| open | `boolean` | - | Whether the popup is currently open. Use when controlled. | -| onOpenChange | `((open: boolean, eventDetails: Combobox.Root.ChangeEventDetails) => void)` | - | Event handler called when the popup is opened or closed. | -| autoHighlight | `boolean` | `false` | Whether the first matching item is highlighted automatically while filtering. | -| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | -| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: Manually unmounts the combobox. Call this after any externally controlled closing animation finishes. | -| autoComplete | `string` | - | Provides a hint to the browser for autofill. | -| filter | `((itemValue: Value, query: string, itemToString?: ((itemValue: Value) => string)) => boolean) \| null` | - | ComboboxFilter function used to match items vs input query. | -| filteredItems | `any[] \| Group[]` | - | Filtered items to display in the list. When provided, the list will use these items instead of filtering the `items` prop internally. Use when you want to control filtering logic externally with the `useFilter()` hook. | -| form | `string` | - | Identifies the form that owns the internal input. Useful when the combobox is rendered outside the form. | -| grid | `boolean` | `false` | Whether list items are presented in a grid layout. When enabled, arrow keys navigate across rows and columns inferred from DOM rows. | -| inline | `boolean` | `false` | Whether the list is rendered inline without using the component's own popup. Specify `open` unconditionally in conjunction with this prop so the list is considered visible: `` In a `Combobox.Root` > `Dialog.Root` composition, bind the Combobox's `open` and `onOpenChange` props to the `Dialog`'s `open` and `onOpenChange` state instead so the component resets its transient state (filter query, highlighted item, and input value) when the dialog closes. | -| isItemEqualToValue | `((itemValue: Value, value: Value) => boolean)` | - | Custom comparison logic used to determine if a combobox item value matches the current selected value. Useful when item values are objects without matching referentially. Defaults to `Object.is` comparison. | -| itemToStringLabel | `((itemValue: Value) => string)` | - | When the item values are objects (``), this function converts the object value to a string representation for display in the input. If the shape of the object is `{ value, label }`, the label will be used automatically without needing to specify this prop. | -| itemToStringValue | `((itemValue: Value) => string)` | - | When the item values are objects (``), this function converts the object value to a string representation for form submission. If the shape of the object is `{ value, label }`, the value will be used automatically without needing to specify this prop. | -| items | `any[] \| Group[]` | - | The items to be displayed in the list. Can be either a flat array of items or an array of groups with items. | -| limit | `number` | `-1` | The maximum number of items to display in the list. | -| locale | `Intl.LocalesArgument` | - | The locale to use for string comparison. Defaults to the user's runtime locale. | -| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the input when the end of the list is reached while using the arrow keys. The first item can then be reached by pressing ArrowDown again from the input, or the last item can be reached by pressing ArrowUp from the input. The input is always included in the focus loop per [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). When disabled, focus does not move when on the last element and the user presses ArrowDown, or when on the first element and the user presses ArrowUp. | -| modal | `boolean` | `false` | Determines if the popup enters a modal state when open. `true`: user interaction is limited to the popup: document page scroll is locked and pointer interactions on outside elements are disabled.`false`: user interaction with the rest of the document is allowed. On touch devices, a `true` modal blocks outside taps but leaves the page scrollable unless the popup spans nearly the full viewport width, matching native iOS behavior. | -| multiple | `boolean` | `false` | Whether multiple items can be selected. | -| onItemHighlighted | `((highlightedValue: Value \| undefined, eventDetails: Combobox.Root.HighlightEventDetails) => void)` | - | Callback fired when an item is highlighted or unhighlighted. Receives the highlighted item value (or `undefined` if no item is highlighted) and event details with a `reason` property describing why the highlight changed. The `reason` can be: `'keyboard'`: the highlight changed due to keyboard navigation.`'pointer'`: the highlight changed due to pointer hovering.`'none'`: the highlight changed programmatically. | -| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the popup is opened or closed. | -| openOnInputClick | `boolean` | `true` | Whether the popup opens when clicking the input. | -| virtualized | `boolean` | `false` | Whether the items are being externally virtualized. | -| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | -| readOnly | `boolean` | `false` | Whether the user should be unable to choose a different option from the popup. | -| required | `boolean` | `false` | Whether the user must choose a value before submitting a form. | -| inputRef | `React.Ref` | - | A ref to the hidden input element. | -| id | `string` | - | The id of the component. | -| children | `React.ReactNode` | - | - | - -**`autoComplete` Prop References:** - -- See [developer.mozilla.org](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete) +| Prop | Type | Default | Description | +| :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| name | `string` | - | Identifies the field when a form is submitted. | +| defaultValue | `NormalizedMappedValue[] \| NormalizedMappedValue \| Value[] \| Value \| null` | - | The uncontrolled selected value of the combobox when it's initially rendered. To render a controlled combobox, use the `value` prop instead. | +| value | `NormalizedMappedValue[] \| NormalizedMappedValue \| Value[] \| Value \| null` | - | The selected value of the combobox. Use when controlled. | +| onValueChange | `((value: any[], eventDetails: Combobox.Root.ChangeEventDetails) => void) \| ((value: any \| null, eventDetails: Combobox.Root.ChangeEventDetails) => void) \| ((value: Value[] \| Value \| null, eventDetails: Combobox.Root.ChangeEventDetails) => void)` | - | Event handler called when the selected value of the combobox changes. | +| defaultInputValue | `string \| number \| string[]` | - | The uncontrolled input value when initially rendered. To render a controlled input, use the `inputValue` prop instead. | +| inputValue | `string \| string[] \| number` | - | The input value of the combobox. Use when controlled. | +| onInputValueChange | `((inputValue: string, eventDetails: Combobox.Root.ChangeEventDetails) => void)` | - | Event handler called when the input value changes. | +| defaultOpen | `boolean` | `false` | Whether the popup is initially open. To render a controlled popup, use the `open` prop instead. | +| open | `boolean` | - | Whether the popup is currently open. Use when controlled. | +| onOpenChange | `((open: boolean, eventDetails: Combobox.Root.ChangeEventDetails) => void)` | - | Event handler called when the popup is opened or closed. | +| autoHighlight | `boolean` | `false` | Whether the first matching item is highlighted automatically while filtering. | +| highlightItemOnHover | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. | +| actionsRef | `React.RefObject` | - | A ref to imperative actions. `unmount`: Manually unmounts the combobox. Call this after any externally controlled closing animation finishes. | +| autoComplete | `string` | - | Provides a hint to the browser for autofill. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete). | +| filter | `((item: unknown \| GroupItem \| SourceItem, query: string, itemToString?: ((item: unknown \| GroupItem \| SourceItem) => string)) => boolean) \| ((item: Item, query: string, itemToString?: ((item: Item) => string)) => boolean) \| ((itemValue: Value, query: string, itemToString?: ((itemValue: Value) => string)) => boolean) \| null` | - | ComboboxFilter function used to match items vs input query. | +| filteredItems | `SourceItem[] \| Item[] \| Group[] \| any[] \| Group[]` | - | - | +| form | `string` | - | Identifies the form that owns the internal input. Useful when the combobox is rendered outside the form. | +| grid | `boolean` | `false` | Whether list items are presented in a grid layout. When enabled, arrow keys navigate across rows and columns inferred from DOM rows. | +| inline | `boolean` | `false` | Whether the list is rendered inline without using the component's own popup. Specify `open` unconditionally in conjunction with this prop so the list is considered visible: `` In a `Combobox.Root` > `Dialog.Root` composition, bind the Combobox's `open` and `onOpenChange` props to the `Dialog`'s `open` and `onOpenChange` state instead so the component resets its transient state (filter query, highlighted item, and input value) when the dialog closes. | +| isItemEqualToValue | `((itemValue: NormalizedMappedValue, value: NormalizedMappedValue) => boolean) \| ((itemValue: Value, value: Value) => boolean)` | - | Custom comparison logic used to determine if a combobox item value matches the current selected value. Useful when item values are objects without matching referentially. Defaults to `Object.is` comparison. | +| itemToStringLabel | `((itemValue: NormalizedMappedValue, sourceItem?: unknown \| GroupItem \| SourceItem) => string) \| ((itemValue: NormalizedMappedValue, sourceItem?: Item) => string) \| ((itemValue: Value) => string)` | - | Converts a mapped value to a string representation for display in the input. The corresponding source item is provided as the second argument when it is available. If the source item shape is `{ value, label }`, its label is used automatically without needing to specify this prop. | +| itemToStringValue | `((itemValue: NormalizedMappedValue) => string) \| ((itemValue: Value) => string)` | - | Converts an item value to a string representation for form submission. When `itemToValue` is specified, this receives the mapped value. If the shape of the object is `{ value, label }`, the value will be used automatically without needing to specify this prop. | +| itemToValue | `((item: unknown \| GroupItem \| SourceItem) => Value) \| ((item: Item) => Value)` | - | Maps each source item to the value used for selection, change callbacks, and equality checks. Filtering and function children receive the source item. Collection items use the mapped value; manually rendered items must specify a value of the same type. The returned value should be defined and uniquely identify the item. `undefined` is normalized to `null`, reflected in callback types, and logs a development warning. | +| items | `SourceItem[] \| Item[] \| Group[] \| any[] \| Group[]` | - | - | +| limit | `number` | `-1` | The maximum number of items to display in the list. | +| locale | `Intl.LocalesArgument` | - | The locale to use for string comparison. Defaults to the user's runtime locale. | +| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the input when the end of the list is reached while using the arrow keys. The first item can then be reached by pressing ArrowDown again from the input, or the last item can be reached by pressing ArrowUp from the input. The input is always included in the focus loop per [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). When disabled, focus does not move when on the last element and the user presses ArrowDown, or when on the first element and the user presses ArrowUp. | +| modal | `boolean` | `false` | Determines if the popup enters a modal state when open. `true`: user interaction is limited to the popup: document page scroll is locked and pointer interactions on outside elements are disabled.`false`: user interaction with the rest of the document is allowed. On touch devices, a `true` modal blocks outside taps but leaves the page scrollable unless the popup spans nearly the full viewport width, matching native iOS behavior. | +| multiple | `boolean \| false` | `false` | Whether multiple items can be selected. | +| onItemHighlighted | `((highlightedValue: Value \| undefined, eventDetails: Combobox.Root.HighlightEventDetails) => void)` | - | Callback fired when an item is highlighted or unhighlighted. | +| onOpenChangeComplete | `((open: boolean) => void)` | - | Event handler called after any animations complete when the popup is opened or closed. | +| openOnInputClick | `boolean` | `true` | Whether the popup opens when clicking the input. | +| virtualized | `boolean` | `false` | Whether the items are being externally virtualized. | +| disabled | `boolean` | `false` | Whether the component should ignore user interaction. | +| readOnly | `boolean` | `false` | Whether the user should be unable to choose a different option from the popup. | +| required | `boolean` | `false` | Whether the user must choose a value before submitting a form. | +| inputRef | `React.Ref` | - | A ref to the hidden input element. | +| id | `string` | - | The id of the component. | +| children | `React.ReactNode` | - | - | ### Root.Props @@ -135,6 +132,205 @@ type ComboboxRootHighlightEventDetails = | { reason: 'pointer'; event: PointerEvent; index: number }; ``` +### Root.MappedProps + +```typescript +type ComboboxRootMappedProps = { + /** + * Provides a hint to the browser for autofill. + * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete). + */ + autoComplete?: string; + /** + * Whether the first matching item is highlighted automatically while filtering. + * @default false + */ + autoHighlight?: boolean; + /** + * Whether moving the pointer over items should highlight them. + * Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. + * @default true + */ + highlightItemOnHover?: boolean; + /** + * Converts an item value to a string representation for form submission. + * When `itemToValue` is specified, this receives the mapped value. + * If the shape of the object is `{ value, label }`, the value will be used automatically without needing to specify this prop. + */ + itemToStringValue?: (itemValue: NormalizedMappedValue) => string; + /** + * Custom comparison logic used to determine if a combobox item value matches the current selected value. Useful when item values are objects without matching referentially. + * Defaults to `Object.is` comparison. + */ + isItemEqualToValue?: ( + itemValue: NormalizedMappedValue, + value: NormalizedMappedValue, + ) => boolean; + /** + * A ref to imperative actions. + * - `unmount`: Manually unmounts the combobox. + * Call this after any externally controlled closing animation finishes. + */ + actionsRef?: React.RefObject; + /** Event handler called when the popup is opened or closed. */ + onOpenChange?: (open: boolean, eventDetails: Combobox.Root.ChangeEventDetails) => void; + /** Event handler called when the input value changes. */ + onInputValueChange?: (inputValue: string, eventDetails: Combobox.Root.ChangeEventDetails) => void; + children?: React.ReactNode; + /** Identifies the field when a form is submitted. */ + name?: string; + /** + * Identifies the form that owns the internal input. + * Useful when the combobox is rendered outside the form. + */ + form?: string; + /** The id of the component. */ + id?: string; + /** + * Whether the user must choose a value before submitting a form. + * @default false + */ + required?: boolean; + /** + * Whether the user should be unable to choose a different option from the popup. + * @default false + */ + readOnly?: boolean; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean; + /** + * Whether the popup is initially open. + * + * To render a controlled popup, use the `open` prop instead. + * @default false + */ + defaultOpen?: boolean; + /** Whether the popup is currently open. Use when controlled. */ + open?: boolean; + /** Event handler called after any animations complete when the popup is opened or closed. */ + onOpenChangeComplete?: (open: boolean) => void; + /** + * Whether the popup opens when clicking the input. + * @default true + */ + openOnInputClick?: boolean; + /** + * Whether to loop keyboard focus back to the input when the end of the list is reached while using the arrow keys. The first item can then be reached by pressing ArrowDown again from the input, or the last item can be reached by pressing ArrowUp from the input. + * The input is always included in the focus loop per [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). + * When disabled, focus does not move when on the last element and the user presses ArrowDown, or when on the first element and the user presses ArrowUp. + * @default true + */ + loopFocus?: boolean; + /** The input value of the combobox. Use when controlled. */ + inputValue?: string | string[] | number; + /** + * The uncontrolled input value when initially rendered. + * + * To render a controlled input, use the `inputValue` prop instead. + */ + defaultInputValue?: string | number | string[]; + /** A ref to the hidden input element. */ + inputRef?: React.Ref; + /** + * Whether list items are presented in a grid layout. + * When enabled, arrow keys navigate across rows and columns inferred from DOM rows. + * @default false + */ + grid?: boolean; + /** + * Whether the items are being externally virtualized. + * @default false + */ + virtualized?: boolean; + /** + * Whether the list is rendered inline without using the component's own popup. + * + * Specify `open` unconditionally in conjunction with this prop so the list is considered + * visible: `` + * + * In a `Combobox.Root` > `Dialog.Root` composition, bind the Combobox's `open` and + * `onOpenChange` props to the `Dialog`'s `open` and `onOpenChange` state instead so the + * component resets its transient state (filter query, highlighted item, and input value) when + * the dialog closes. + * @default false + */ + inline?: boolean; + /** + * Determines if the popup enters a modal state when open. + * - `true`: user interaction is limited to the popup: document page scroll is locked and pointer interactions on outside elements are disabled. + * - `false`: user interaction with the rest of the document is allowed. + * + * On touch devices, a `true` modal blocks outside taps but leaves the page scrollable unless the popup spans nearly the full viewport width, matching native iOS behavior. + * @default false + */ + modal?: boolean; + /** + * The maximum number of items to display in the list. + * @default -1 + */ + limit?: number; + /** + * The locale to use for string comparison. + * Defaults to the user's runtime locale. + */ + locale?: Intl.LocalesArgument; + /** + * Whether multiple items can be selected. + * @default false + */ + multiple?: boolean | undefined; + /** The selected value of the combobox. Use when controlled. */ + value?: NormalizedMappedValue[] | NormalizedMappedValue | null; + /** + * The uncontrolled selected value of the combobox when it's initially rendered. + * + * To render a controlled combobox, use the `value` prop instead. + */ + defaultValue?: NormalizedMappedValue[] | NormalizedMappedValue | null; + /** Event handler called when the selected value of the combobox changes. */ + onValueChange?: ( + value: any[] | any | null, + eventDetails: Combobox.Root.ChangeEventDetails, + ) => void; + /** Callback fired when an item is highlighted or unhighlighted. */ + onItemHighlighted?: ( + highlightedValue: any | undefined, + eventDetails: Combobox.Root.HighlightEventDetails, + ) => void; + /** + * The items to be displayed in the list. + * Can be either a flat array of items or an array of groups with items. + */ + items?: Item[] | Group[]; + /** + * Filtered items to display in the list. + * When provided, the list will use these items instead of filtering the `items` prop internally. + * Use when you want to control filtering logic externally with the `useFilter()` hook. + */ + filteredItems?: Item[] | Group[]; + /** Filter function used to match items vs input query. */ + filter?: ((item: Item, query: string, itemToString?: (item: Item) => string) => boolean) | null; + /** + * Converts a mapped value to a string representation for display in the input. + * The corresponding source item is provided as the second argument when it is available. + * If the source item shape is `{ value, label }`, its label is used automatically without + * needing to specify this prop. + */ + itemToStringLabel?: (itemValue: NormalizedMappedValue, sourceItem?: Item) => string; + /** + * Maps each source item to the value used for selection, change callbacks, and equality checks. + * Filtering and function children receive the source item. Collection items use the mapped + * value; manually rendered items must specify a value of the same type. + * The returned value should be defined and uniquely identify the item. `undefined` is + * normalized to `null`, reflected in callback types, and logs a development warning. + */ + itemToValue: (item: Item) => Value; +}; +``` + ### Trigger A button that opens the popup. @@ -207,10 +403,10 @@ Doesn't render its own HTML element. **Value Props:** -| Prop | Type | Default | Description | -| :---------- | :------------------------------------------------------------- | :------ | :--------------------------------------------------------------------------------------------------------------------------------------------------- | -| placeholder | `React.ReactNode` | - | The placeholder value to display when no value is selected. This is overridden by `children` if specified, or by a null item's label in `items`. | -| children | `React.ReactNode \| ((selectedValue: any) => React.ReactNode)` | - | - | +| Prop | Type | Default | Description | +| :---------- | :------------------------------------------------------------- | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| placeholder | `React.ReactNode` | - | The placeholder value to display when no value is selected. This is overridden by `children` if specified, or in single-selection mode by a null item's label in `items`. | +| children | `React.ReactNode \| ((selectedValue: any) => React.ReactNode)` | - | - | ### Value.Props @@ -642,17 +838,17 @@ Renders a `
` element. **Item Props:** -| Prop | Type | Default | Description | -| :----------- | :------------------------------------------------------------------------------------------ | :------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| value | `any` | `null` | A unique value that identifies this item. | -| onClick | `((event: BaseUIEvent>) => void)` | - | An optional click handler for the item when selected. It fires when clicking the item with the pointer, as well as when pressing `Enter` with the keyboard if the item is highlighted when the `Input` or `List` element has focus. | -| index | `number` | - | The index of the item in the list. Improves performance when specified by avoiding the need to calculate the index automatically from the DOM. | -| nativeButton | `boolean` | `false` | Whether the component renders a native ` + country.code} + defaultValue="CA" + > + + + + + + + ); + } + + const { user } = await render(); + await user.click(screen.getByRole('button', { name: 'Filter selection out' })); + + expect(screen.getByTestId('input')).toHaveValue('Canada'); + expect(screen.getByTestId('value')).toHaveTextContent('Canada'); + }); + + it('maps separately created filteredItems to the emitted value', async () => { + const onValueChange = vi.fn(); + const filteredItems = countries.map((country) => ({ ...country })); + const { user } = await render( + country.code} + onValueChange={onValueChange} + > + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + , + ); + + await user.click(screen.getByTestId('input')); + await user.click(screen.getByRole('option', { name: 'Canada' })); + + expect(onValueChange.mock.lastCall?.[0]).toBe('CA'); + await waitFor(() => { + expect(screen.getByTestId('input')).toHaveValue('Canada'); + }); + }); + + it('uses itemToStringLabel before the item label for a mapped value', async () => { + await render( + item.value} + defaultValue={2} + itemToStringLabel={(value: number) => `Custom ${value.toFixed(0)}`} + > + + , + ); + + expect(screen.getByTestId('input')).toHaveValue('Custom 2'); + }); + + it('falls back to the raw value when a mapped value has no matching item', async () => { + await render( + item.value} defaultValue={99}> + + + + + , + ); + + expect(screen.getByTestId('input')).toHaveValue('99'); + expect(screen.getByTestId('value')).toHaveTextContent('99'); + }); + + it('does not resolve an unmatched mapped value through a source value field', async () => { + const items = [{ id: 'mapped-a', value: 'external', label: 'Wrong' }]; + + await render( + item.id} defaultValue="external"> + + + + + , + ); + + expect(screen.getByTestId('input')).toHaveValue('external'); + expect(screen.getByTestId('value')).toHaveTextContent('external'); + }); + + it('infers labels and selects mapped values from grouped items', async () => { + const groupedItems = [ + { groupLabel: 'fruits', items: [{ id: 'apple', label: 'Apple' }] }, + { groupLabel: 'vegetables', items: [{ id: 'carrot', label: 'Carrot' }] }, + ]; + const onValueChange = vi.fn(); + + const { user } = await render( + item.id.toUpperCase()} + defaultValue="CARROT" + defaultOpen + onValueChange={onValueChange} + > + + + + + + {(group) => ( + + {group.groupLabel} + + {(item) => {item.label}} + + + )} + + , + ); + + expect(screen.getByTestId('input')).toHaveValue('Carrot'); + expect(screen.getByTestId('value')).toHaveTextContent('Carrot'); + + await user.click(screen.getByRole('option', { name: 'Apple' })); + expect(onValueChange.mock.lastCall?.[0]).toBe('APPLE'); + }); + + it('infers labels and selects mapped values from grouped filteredItems', async () => { + const groupedItems = [ + { groupLabel: 'fruits', items: [{ id: 'apple', label: 'Apple' }] }, + { groupLabel: 'vegetables', items: [{ id: 'carrot', label: 'Carrot' }] }, + ]; + const onValueChange = vi.fn(); + + const { user } = await render( + item.id.toUpperCase()} + defaultValue="CARROT" + defaultOpen + onValueChange={onValueChange} + > + + + + + + {(group) => ( + + {group.groupLabel} + + {(item) => {item.label}} + + + )} + + , + ); + + expect(screen.getByTestId('input')).toHaveValue('Carrot'); + expect(screen.getByTestId('value')).toHaveTextContent('Carrot'); + + await user.click(screen.getByRole('option', { name: 'Apple' })); + expect(onValueChange.mock.lastCall?.[0]).toBe('APPLE'); + }); + + it('resolves the source label for a null mapped value', async () => { + const items = [ + { value: null, label: 'None' }, + { value: 'apple', label: 'Apple' }, + ]; + + await render( + item.value} defaultValue={null}> + + + + + , + ); + + expect(screen.getByTestId('input')).toHaveValue('None'); + expect(screen.getByTestId('value')).toHaveTextContent('None'); + }); + + it('renders the placeholder when no mapped null item has a label', async () => { + await render( + item.value} + > + + + + , + ); + + expect(screen.getByTestId('value')).toHaveTextContent('Pick'); + }); + + it('renders mapped labels in Combobox.Value for multiple selection', async () => { + const items = [ + { value: 1, label: 'Apple' }, + { value: 2, label: 'Banana' }, + { value: 3, label: 'Cherry' }, + ]; + + await render( + item.value} + multiple + defaultValue={[1, 3]} + > + + + + , + ); + + expect(screen.getByTestId('value')).toHaveTextContent('Apple, Cherry'); + }); + }); + + describe('filtering', () => { + it('filters on the source item, not the mapped value', async () => { + const labels = Object.fromEntries( + countries.map((country) => [country.code, country.label]), + ); + const filter = vi.fn( + ( + item: (typeof countries)[number], + query: string, + itemToString?: (item: (typeof countries)[number]) => string, + ) => itemToString!(item).toLowerCase().includes(query.toLowerCase()), + ); + const { user } = await render( + labels[value]} />, + ); + + await user.click(screen.getByTestId('input')); + await user.type(screen.getByTestId('input'), 'Can'); + + await waitFor(() => { + expect(screen.queryByRole('option', { name: 'United States' })).toBe(null); + }); + expect(screen.getByRole('option', { name: 'Canada' })).not.toBe(null); + expect(filter).toHaveBeenCalled(); + expect(filter.mock.calls.every(([item]) => typeof item === 'object')).toBe(true); + }); + + it('passes the mapped value, not the source item, to itemToStringLabel', async () => { + const items = [ + { value: 'US', label: 'United States' }, + { value: 'CA', label: 'Canada' }, + ]; + + const { user } = await render( + item.value} + // Written for the mapped primitive value; throws if handed the outer item. + itemToStringLabel={(value: string) => + value.toLowerCase() === 'ca' ? 'canada' : 'united states' + } + > + + + + + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + + + + , + ); + + const input = screen.getByTestId('input'); + await user.click(input); + await user.type(input, 'can'); + + expect(await screen.findByRole('option', { name: 'Canada' })).not.toBe(null); + expect(screen.queryByRole('option', { name: 'United States' })).toBe(null); + }); + + it('provides the source item to itemToStringLabel for custom item shapes', async () => { + const users = [ + { id: 'u1', name: 'Alice Johnson' }, + { id: 'u2', name: 'Bob Smith' }, + ]; + const { user } = await render( + item.id} + itemToStringLabel={(value, item) => item?.name ?? value} + > + + + {(item) => {item.name}} + + , + ); + + await user.type(screen.getByTestId('input'), 'Bob'); + await user.click(screen.getByRole('option', { name: 'Bob Smith' })); + + expect(screen.getByTestId('input')).toHaveValue('Bob Smith'); + }); + + it('uses the mapped value for closed-trigger typeahead labels', async () => { + const items = [ + { value: 'US', label: 'United States' }, + { value: 'CA', label: 'Canada' }, + ]; + const onValueChange = vi.fn(); + + const { user } = await render( + item.value} + itemToStringLabel={(value: string) => + value.toLowerCase() === 'ca' ? 'canada' : 'united states' + } + onValueChange={onValueChange} + > + Open + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + , + ); + + await act(async () => { + screen.getByTestId('trigger').focus(); + }); + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + await user.keyboard('c'); + + await waitFor(() => { + expect(onValueChange.mock.lastCall?.[0]).toBe('CA'); + }); + }); + + it('starts keyboard navigation from the filtered items and emits the mapped value', async () => { + const items = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + const onValueChange = vi.fn(); + + const { user } = await render( + item.value} + multiple + defaultValue={['cherry']} + onValueChange={onValueChange} + > + + + + + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + + + + , + ); + + const input = screen.getByTestId('input'); + await user.click(input); + await user.type(input, 'ba'); + await screen.findByRole('option', { name: 'Banana' }); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(onValueChange).toHaveBeenLastCalledWith( + ['cherry', 'banana'], + expect.objectContaining({ reason: REASONS.itemPress }), + ); + }); + + it('reports the mapped value to onItemHighlighted after autoHighlight filtering', async () => { + const items = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + ]; + const onItemHighlighted = vi.fn(); + + const { user } = await render( + item.value} + autoHighlight + onItemHighlighted={onItemHighlighted} + > + + + + + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + + + + , + ); + + await user.type(screen.getByRole('combobox'), 'b'); + + await waitFor(() => { + expect(onItemHighlighted.mock.calls.length).toBeGreaterThan(0); + }); + expect(onItemHighlighted.mock.lastCall?.[0]).toBe('banana'); + }); + + it('resolves an initial non-empty query before any item mounts', async () => { + await render(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Canada' })).not.toBe(null); + }); + expect(screen.queryByRole('option', { name: 'United States' })).toBe(null); + }); + }); + + describe('autofill', () => { + it('matches on the label and emits the mapped value while closed', async () => { + const onValueChange = vi.fn(); + await render( + country.code} + onValueChange={onValueChange} + > + + + + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + + + + , + ); + + const hiddenInput = screen + .getAllByDisplayValue('') + .find((el) => el.getAttribute('name') === 'country') as HTMLInputElement; + + fireEvent.change(hiddenInput, { target: { value: 'Canada' } }); + await flushMicrotasks(); + + expect(onValueChange.mock.lastCall?.[0]).toBe('CA'); + }); + + it('matches mapped filtered items while the popup is closed', async () => { + const onValueChange = vi.fn(); + await render( + country.code} + onValueChange={onValueChange} + > + + + + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + + + + , + ); + + const hiddenInput = screen + .getAllByDisplayValue('') + .find((element) => element.getAttribute('name') === 'country')!; + + fireEvent.change(hiddenInput, { target: { value: 'Canada' } }); + await flushMicrotasks(); + + expect(onValueChange.mock.lastCall?.[0]).toBe('CA'); + }); + + it('calls itemToStringValue with the mapped value', async () => { + const items = [ + { value: 'US', label: 'United States' }, + { value: 'CA', label: 'Canada' }, + ]; + const onValueChange = vi.fn(); + // Written for the mapped primitive value; throws if handed the outer item. + const itemToStringValue = (value: string) => value.toLowerCase(); + + await render( + item.value} + name="country" + onValueChange={onValueChange} + itemToStringValue={itemToStringValue} + > + + + + + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + + + + , + ); + + fireEvent.change( + screen.getAllByDisplayValue('').find((el) => el.getAttribute('name') === 'country')!, + { target: { value: 'us' } }, + ); + await flushMicrotasks(); + + expect(onValueChange).toHaveBeenCalledWith( + 'US', + expect.objectContaining({ reason: 'none' }), + ); + }); + + it('ignores autofill matches for null mapped values', async () => { + const items = [ + { value: null, label: 'None' }, + { value: 'apple', label: 'Apple' }, + ]; + const onValueChange = vi.fn(); + + await render( + item.value} + name="fruit" + onValueChange={onValueChange} + > + + + + + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + + + + , + ); + + fireEvent.change( + screen.getAllByDisplayValue('').find((el) => el.getAttribute('name') === 'fruit')!, + { target: { value: 'None' } }, + ); + await flushMicrotasks(); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe('object values', () => { + it('highlights the selected item when the mapped value is matched by isItemEqualToValue', async () => { + const items = [ + { value: { code: 'us' }, label: 'United States' }, + { value: { code: 'ca' }, label: 'Canada' }, + ]; + const { user } = await render( + item.value} + defaultValue={{ code: 'ca' }} + isItemEqualToValue={(a: { code: string }, b: { code: string }) => a.code === b.code} + > + + + + + + + + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + + + + , + ); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('Canada'); + expect(screen.getByTestId('value')).toHaveTextContent('Canada'); + await user.click(screen.getByTestId('input')); + + const canada = await screen.findByRole('option', { name: 'Canada' }); + await waitFor(() => { + expect(canada).toHaveAttribute('data-highlighted'); + }); + await waitFor(() => { + expect(input).toHaveAttribute('aria-activedescendant', canada.id); + }); + }); + + it('does not misclassify a mapped value that is itself a labeled-item shape', async () => { + const items = [ + { value: { value: 'ca', label: 'Canada' }, label: 'CA' }, + { value: { value: 'us', label: 'United States' }, label: 'US' }, + ]; + + const { user } = await render( + item.value} + defaultValue={items[0].value} + > + + + + + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + + + + , + ); + + const input = screen.getByRole('combobox'); + await user.click(screen.getByTestId('input')); + + const ca = await screen.findByRole('option', { name: 'CA' }); + await waitFor(() => { + expect(ca).toHaveAttribute('data-highlighted'); + }); + await waitFor(() => { + expect(input).toHaveAttribute('aria-activedescendant', ca.id); + }); + }); + + it('preserves allocating mapped objects across controlled rerenders with a comparator', async () => { + const items = [ + { id: 1, label: 'One' }, + { id: 2, label: 'Two' }, + ]; + + function App() { + const [value, setValue] = React.useState<{ id: number } | null>(null); + return ( + ({ id: item.id })} + itemToStringLabel={(itemValue) => String(itemValue.id)} + isItemEqualToValue={(itemValue, selectedValue) => itemValue.id === selectedValue.id} + value={value} + onValueChange={setValue} + > + + + {(item) => {item.label}} + + + ); + } + + const { user } = await render(); + await user.click(screen.getByTestId('input')); + await user.click(screen.getByRole('option', { name: 'Two' })); + await user.click(screen.getByTestId('input')); + + expect(screen.getByRole('option', { name: 'Two' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + }); + + it('warns and normalizes an undefined mapped value', async () => { + const items: Array<{ id?: string; label: string }> = [{ label: 'Missing ID' }]; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const onValueChange = vi.fn(); + + try { + const { user } = await render( + item.id as string} + defaultOpen + onValueChange={onValueChange} + > + + {(item) => {item.label}} + + , + ); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('`itemToValue` returned `undefined`'), + ); + await user.click(screen.getByRole('option', { name: 'Missing ID' })); + expect(onValueChange.mock.lastCall?.[0]).toBe(null); + } finally { + warnSpy.mockRestore(); + } + }); + + it('warns once for each root that returns an undefined mapped value', async () => { + const items: Array<{ id?: string; label: string }> = [{ label: 'Missing ID' }]; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + await render( + + item.id} /> + item.id} /> + , + ); + + expect(warnSpy).toHaveBeenCalledTimes(2); + } finally { + warnSpy.mockRestore(); + } + }); + + it('syncs the selected index while closed using the mapped value', async () => { + const { user } = await render( + country.code} defaultValue="US"> + + + + + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('0'); + }); + + await user.click(screen.getByTestId('input')); + expect(await screen.findByRole('listbox')).not.toBe(null); + await waitFor(() => { + expect(screen.getByRole('option', { name: 'United States' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + }); + + it('emits the mapped value after the source objects are recreated while open', async () => { + const onItemHighlighted = vi.fn(); + + function App() { + const [, force] = React.useReducer((x) => x + 1, 0); + // Recreate the source object identities on each render (same codes). + const items = countries.map((country) => ({ ...country })); + return ( +
+ country.code} + onItemHighlighted={onItemHighlighted} + > + + + + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + + + + + +
+ ); + } + + const { user } = await render(); + + await user.click(screen.getByTestId('input')); + await screen.findByRole('listbox'); + // Recreate the source objects, then highlight. + fireEvent.click(screen.getByTestId('force')); + await flushMicrotasks(); + onItemHighlighted.mockClear(); + await user.keyboard('{ArrowDown}'); + + await waitFor(() => { + expect(onItemHighlighted.mock.calls.some(([highlighted]) => highlighted === 'US')).toBe( + true, + ); + }); + // Never emits the recreated source object. + expect( + onItemHighlighted.mock.calls.every( + ([highlighted]) => highlighted === undefined || typeof highlighted === 'string', + ), + ).toBe(true); + }); + + it('provides the mapped value through a custom item component', async () => { + const onValueChange = vi.fn(); + + function CountryItem({ country }: { country: (typeof countries)[number] }) { + return {country.label}; + } + + const { user } = await render( + country.code} + onValueChange={onValueChange} + > + + + + + + {(country: (typeof countries)[number]) => ( + + )} + + + + + , + ); + + await user.click(screen.getByTestId('input')); + await user.click(await screen.findByRole('option', { name: 'Australia' })); + + expect(onValueChange.mock.calls[0][0]).toBe('AU'); + }); + + it('does not rerender mapped collection items when their projected values are unchanged', async () => { + const renderSpy = vi.fn(); + function renderItem(props: React.ComponentProps<'div'>) { + renderSpy(); + return
; + } + + const { user } = await render( + country.code} + filter={null} + defaultOpen + > + + + {(country: (typeof countries)[number]) => ( + + {country.label} + + )} + + , + ); + + const initialRenderCount = renderSpy.mock.calls.length; + await user.type(screen.getByTestId('input'), 'x'); + + expect(renderSpy).toHaveBeenCalledTimes(initialRenderCount); + }); + + it('keeps static JSX items keyboard-accessible when itemToValue is passed without items', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + + itemToValue={(item: { id: string }) => item.id} + defaultOpen + onValueChange={onValueChange} + > + + + Create item + + , + ); + + await user.click(screen.getByTestId('input')); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(onValueChange.mock.lastCall?.[0]).toBe('create'); + }); + + it('includes static items in the keyboard order of a mapped filteredItems list', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + country.code} + defaultOpen + onValueChange={onValueChange} + > + + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + Create item + + , + ); + + await user.click(screen.getByTestId('input')); + await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + + expect(onValueChange.mock.lastCall?.[0]).toBe('create'); + }); + + it('does not associate static items with Collection source indexes', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + country.code} + defaultValue="US" + onValueChange={onValueChange} + > + + + All countries + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + + , + ); + + expect(screen.getByRole('option', { name: 'All countries' })).toHaveAttribute( + 'aria-selected', + 'false', + ); + expect(screen.getByRole('option', { name: 'United States' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + + await user.click(screen.getByRole('option', { name: 'All countries' })); + expect(onValueChange.mock.lastCall?.[0]).toBe('all'); + + await user.click(screen.getByRole('combobox')); + await user.click(await screen.findByRole('option', { name: 'Canada' })); + expect(onValueChange.mock.lastCall?.[0]).toBe('CA'); + }); + + it('uses the mapped value for collection items with an incompatible explicit value', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + country.code} + onValueChange={onValueChange} + > + + + + + + {(country: (typeof countries)[number]) => ( + + {country.label} + + )} + + + + + , + ); + + await user.click(screen.getByTestId('input')); + await user.click(await screen.findByRole('option', { name: 'Australia' })); + + expect(onValueChange.mock.calls[0][0]).toBe('AU'); + }); + + it('lets an explicit virtualized Item value take precedence over itemToValue', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + country.code} + virtualized + defaultOpen + onValueChange={onValueChange} + > + + + {countries.map((country, index) => ( + + {country.label} + + ))} + + , + ); + + await user.click(screen.getByRole('option', { name: 'Canada' })); + + expect(onValueChange.mock.lastCall?.[0]).toBe(countries[1]); + }); + + it('uses an explicit mapped value to find a virtualized item index', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + country.code} + virtualized + defaultOpen + onValueChange={onValueChange} + > + + + {countries.map((country) => ( + + {country.label} + + ))} + + , + ); + + await user.click(screen.getByRole('option', { name: 'Canada' })); + + expect(onValueChange.mock.lastCall?.[0]).toBe('CA'); + expect(screen.getByRole('option', { name: 'Canada' }).id).not.toContain('--1'); + }); + + it('accepts an explicit virtualized mapped object value matched by the custom comparator', async () => { + const items = [ + { id: 1, label: 'One' }, + { id: 2, label: 'Two' }, + ]; + const onValueChange = vi.fn(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const { user } = await render( + ({ id: item.id })} + isItemEqualToValue={(itemValue, value) => itemValue.id === value.id} + virtualized + defaultOpen + onValueChange={onValueChange} + > + + + {items.map((item, index) => ( + + {item.label} + + ))} + + , + ); + + await user.click(screen.getByRole('option', { name: 'Two' })); + + expect(onValueChange.mock.lastCall?.[0]).toEqual({ id: 2 }); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it('does not warn when explicit mapped collection items reorder after keyboard selection', async () => { + const onValueChange = vi.fn(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const { user } = await render( + country.code} + defaultInputValue="filtering" + defaultOpen + onValueChange={onValueChange} + > + + + {(country: (typeof countries)[number]) => ( + + {country.label} + + )} + + , + ); + + await act(async () => { + screen.getByTestId('input').focus(); + }); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(onValueChange.mock.lastCall?.[0]).toBe('AU'); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + + it('treats explicit virtualized Item values as mapped when source and value domains overlap', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + item + 1} + virtualized + defaultOpen + onValueChange={onValueChange} + > + + + First + Second + + , + ); + + const options = screen.getAllByRole('option'); + expect(options[0].id).not.toBe(options[1].id); + + await user.click(screen.getByRole('option', { name: 'First' })); + + expect(onValueChange.mock.lastCall?.[0]).toBe(2); + }); + + it('derives the value for a manually rendered virtualized item from its index', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + country.code} + virtualized + defaultOpen + onValueChange={onValueChange} + > + + + {countries.map((country, index) => ( + + {country.label} + + ))} + + , + ); + + await user.click(screen.getByRole('option', { name: 'Canada' })); + + expect(onValueChange.mock.lastCall?.[0]).toBe('CA'); + }); + + it('updates mounted item values when itemToValue changes', async () => { + const items = [ + { id: '1', code: 'US', label: 'United States' }, + { id: '2', code: 'CA', label: 'Canada' }, + ]; + const itemToId = (item: (typeof items)[number]) => item.id; + const itemToCode = (item: (typeof items)[number]) => item.code; + const onValueChange = vi.fn(); + const comboboxChildren = ( + + + + {(item: (typeof items)[number]) => ( + {item.label} + )} + + + ); + + function App() { + const [itemToValue, setItemToValue] = React.useState(() => itemToId); + return ( + + + + {comboboxChildren} + + + ); + } + + const { user } = await render(); + await user.click(screen.getByRole('button', { name: 'Use codes' })); + await user.click(screen.getByRole('option', { name: 'Canada' })); + + expect(onValueChange.mock.lastCall?.[0]).toBe('CA'); + }); + + it('updates the derived input label when itemToValue changes', async () => { + const items = [ + { id: '1', code: 'A', label: 'One' }, + { id: '2', code: '1', label: 'Two' }, + ]; + const itemToId = (item: (typeof items)[number]) => item.id; + const itemToCode = (item: (typeof items)[number]) => item.code; + + function App() { + const [itemToValue, setItemToValue] = React.useState(() => itemToId); + return ( + + + + + + + + + + ); + } + + const { user } = await render(); + expect(screen.getByTestId('input')).toHaveValue('One'); + expect(screen.getByTestId('value')).toHaveTextContent('One'); + + await user.click(screen.getByRole('button', { name: 'Use codes' })); + + expect(screen.getByTestId('input')).toHaveValue('Two'); + expect(screen.getByTestId('value')).toHaveTextContent('Two'); + }); + + describe('multiple', () => { + it('emits and removes mapped values with chips', async () => { + const onValueChange = vi.fn(); + + function App() { + const [value, setValue] = React.useState([]); + return ( + country.code} + multiple + value={value} + onValueChange={(next) => { + setValue(next); + onValueChange(next); + }} + > + + {value.map((code) => ( + + {code} + + + ))} + + + + + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + + + + + ); + } + + const { user } = await render(); + + await user.click(screen.getByTestId('input')); + await user.click(await screen.findByRole('option', { name: 'United States' })); + expect(onValueChange.mock.lastCall?.[0]).toEqual(['US']); + + await user.click(await screen.findByRole('option', { name: 'Canada' })); + expect(onValueChange.mock.lastCall?.[0]).toEqual(['US', 'CA']); + + await user.click(screen.getByTestId('remove-US')); + expect(onValueChange.mock.lastCall?.[0]).toEqual(['CA']); + }); + }); + }); + + it('does not retain or mutate a filteredItems array after the prop is removed', async () => { + const filteredItems = ['one', 'two']; + + function App() { + const [useFilteredItems, setUseFilteredItems] = React.useState(true); + return ( + + + + + + + {(item: string) => ( + + {item} + + )} + + static + + + + ); + } + + const { user } = await render(); + await user.click(screen.getByRole('button', { name: 'Remove filtered items' })); + + expect(filteredItems).toEqual(['one', 'two']); + expect(screen.getAllByRole('option')).toHaveLength(1); + }); + + it('cleans a mapped filteredItems registry when the prop is removed', async () => { + const filteredItems = [ + { id: 'one', label: 'One' }, + { id: 'two', label: 'Two' }, + ]; + const onValueChange = vi.fn(); + + function App() { + const [useFilteredItems, setUseFilteredItems] = React.useState(true); + return ( + + + item.id} + defaultOpen + onValueChange={onValueChange} + > + + + + {(item: (typeof filteredItems)[number]) => ( + {item.label} + )} + + Static + + + + ); + } + + const { user } = await render(); + await user.click(screen.getByRole('button', { name: 'Remove filtered items' })); + await user.click(screen.getByTestId('input')); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(onValueChange.mock.lastCall?.[0]).toBe('static'); + }); + + describe('initial input value derivation', () => { + it('derives input from defaultValue on first mount when unspecified', async () => { + await render( + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('apple'); + }); + + it('derives input from defaultValue on first mount with items prop', async () => { + const items = [{ value: 'apple', label: 'Apple' }]; + await render( + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('Apple'); + }); + + it('does not derive a primitive input label from items without itemToValue', async () => { + const items = [{ value: 'apple', label: Apple }]; + const { setProps } = await render( + + + , + ); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('apple'); + + await setProps({ items: [{ value: 'apple', label: Apricot }] }); + expect(input).toHaveValue('apple'); }); it('derives input from controlled value on first mount when unspecified', async () => { diff --git a/packages/react/src/combobox/root/ComboboxRoot.tsx b/packages/react/src/combobox/root/ComboboxRoot.tsx index 015c785d188..4619384b05d 100644 --- a/packages/react/src/combobox/root/ComboboxRoot.tsx +++ b/packages/react/src/combobox/root/ComboboxRoot.tsx @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import type { Group } from '../../internals/resolveValueLabel'; import { AriaCombobox, type AriaComboboxState } from './AriaCombobox'; /** @@ -8,17 +9,62 @@ import { AriaCombobox, type AriaComboboxState } from './AriaCombobox'; * * Documentation: [Base UI Combobox](https://base-ui.com/react/components/combobox) */ +export function ComboboxRoot( + props: ComboboxRootMappedOverloadProps & { multiple: true }, +): React.JSX.Element; +export function ComboboxRoot( + props: ComboboxRootMappedOverloadProps & { + multiple?: false | undefined; + }, +): React.JSX.Element; +export function ComboboxRoot( + props: ComboboxRootMappedOverloadProps, +): React.JSX.Element; +export function ComboboxRoot( + props: ComboboxRootMappedProps, +): React.JSX.Element; export function ComboboxRoot( props: ComboboxRoot.Props, +): React.JSX.Element; +export function ComboboxRoot( + props: ComboboxRoot.Props | ComboboxRootMappedProps, ): React.JSX.Element { const { - multiple = false as Multiple, + multiple = false, defaultValue, value, onValueChange, autoComplete, + itemToValue, ...other - } = props; + } = props as ComboboxRootMappedProps; + + const warnedUndefinedItemValueRef = React.useRef(false); + + // Memoized so the projected values aren't recomputed on every render when the getter is stable. + const mapItemToValue = React.useMemo(() => { + if (!itemToValue) { + return undefined; + } + + return (item: any) => { + const itemValue = itemToValue(item); + if (itemValue === undefined) { + if (process.env.NODE_ENV !== 'production') { + if (!warnedUndefinedItemValueRef.current) { + warnedUndefinedItemValueRef.current = true; + console.warn( + 'Base UI: Combobox.Root `itemToValue` returned `undefined`. ' + + 'This cannot identify an item, so the value was replaced with `null`. ' + + 'Return a defined value instead.', + ); + } + } + return null; + } + return itemValue; + }; + }, [itemToValue]); return ( ); } @@ -40,6 +87,17 @@ type ComboboxValueType = Multiple e ? Value[] : Value; +type NormalizedMappedValue = + | Exclude + | (undefined extends Value ? null : never); + +/** Unwraps grouped source items so callbacks receive leaf items. */ +type ComboboxSourceItem = [Item] extends [never] + ? unknown + : Item extends Group + ? GroupItem + : Item; + export type ComboboxRootProps = Omit< AriaCombobox.Props>, | 'fillInputOnItemPress' @@ -51,6 +109,7 @@ export type ComboboxRootProps`), this function converts the object value to a string representation for display in the input. + * Converts an item value to a string representation for display in the input. + * When `itemToValue` is specified, this receives the mapped value. * If the shape of the object is `{ value, label }`, the label will be used automatically without needing to specify this prop. */ itemToStringLabel?: ((itemValue: Value) => string) | undefined; /** - * When the item values are objects (``), this function converts the object value to a string representation for form submission. + * Converts an item value to a string representation for form submission. + * When `itemToValue` is specified, this receives the mapped value. * If the shape of the object is `{ value, label }`, the value will be used automatically without needing to specify this prop. */ itemToStringValue?: ((itemValue: Value) => string) | undefined; @@ -152,6 +213,91 @@ export type ComboboxRootProps = Omit< + ComboboxRootProps, Multiple>, + 'items' | 'filteredItems' | 'filter' | 'itemToStringLabel' | 'onValueChange' | 'onItemHighlighted' +> & { + /** + * Event handler called when the selected value of the combobox changes. + */ + onValueChange?: + | (( + value: + | ComboboxValueType>, Multiple> + | (Multiple extends true ? never : null), + eventDetails: ComboboxRoot.ChangeEventDetails, + ) => void) + | undefined; + /** + * Callback fired when an item is highlighted or unhighlighted. + */ + onItemHighlighted?: + | (( + highlightedValue: NoInfer> | undefined, + eventDetails: ComboboxRoot.HighlightEventDetails, + ) => void) + | undefined; + /** + * The items to be displayed in the list. + * Can be either a flat array of items or an array of groups with items. + */ + items?: readonly Item[] | readonly Group[] | undefined; + /** + * Filtered items to display in the list. + * When provided, the list will use these items instead of filtering the `items` prop internally. + * Use when you want to control filtering logic externally with the `useFilter()` hook. + */ + filteredItems?: readonly Item[] | readonly Group[] | undefined; + /** + * Filter function used to match items vs input query. + */ + filter?: + | null + | ((item: Item, query: string, itemToString?: (item: Item) => string) => boolean) + | undefined; + /** + * Converts a mapped value to a string representation for display in the input. + * The corresponding source item is provided as the second argument when it is available. + * If the source item shape is `{ value, label }`, its label is used automatically without + * needing to specify this prop. + */ + itemToStringLabel?: + | ((itemValue: NormalizedMappedValue, sourceItem?: Item | undefined) => string) + | undefined; + /** + * Maps each source item to the value used for selection, change callbacks, and equality checks. + * Filtering and function children receive the source item. Collection items use the mapped + * value; manually rendered items must specify a value of the same type. + * The returned value should be defined and uniquely identify the item. `undefined` is + * normalized to `null`, reflected in callback types, and logs a development warning. + */ + itemToValue: (item: Item) => Value; +}; + +type ComboboxRootMappedOverloadProps< + Value, + Multiple extends boolean | undefined, + SourceItem, +> = Omit< + ComboboxRootMappedProps>, + 'items' | 'filteredItems' +> & { + items?: readonly SourceItem[] | undefined; + filteredItems?: readonly SourceItem[] | undefined; +}; + export interface ComboboxRootState extends AriaComboboxState {} export type ComboboxRootActions = AriaCombobox.Actions; @@ -167,6 +313,11 @@ export namespace ComboboxRoot { Value, Multiple >; + export type MappedProps< + Value, + Multiple extends boolean | undefined = false, + Item = any, + > = ComboboxRootMappedProps; export type State = ComboboxRootState; export type Actions = ComboboxRootActions; export type ChangeEventReason = ComboboxRootChangeEventReason; diff --git a/packages/react/src/combobox/root/ComboboxRootContext.tsx b/packages/react/src/combobox/root/ComboboxRootContext.tsx index 70bcde8eaca..31f5d2270ec 100644 --- a/packages/react/src/combobox/root/ComboboxRootContext.tsx +++ b/packages/react/src/combobox/root/ComboboxRootContext.tsx @@ -7,7 +7,9 @@ export interface ComboboxDerivedItemsContext { query: string; hasItems: boolean; filteredItems: any[]; - flatFilteredItems: any[]; + flatFilteredValues: any[]; + itemToValue: ((item: any) => any) | undefined; + mappedValues: Map | undefined; } export const ComboboxRootContext = React.createContext(undefined); @@ -17,7 +19,9 @@ export const ComboboxFloatingContext = React.createContext(undefined); -export const ComboboxHasItemsContext = React.createContext(false); +// Collection uses a tuple so Item can distinguish any mapped value, including booleans, +// from the root's has-items flag without creating another context. +export const ComboboxHasItemsContext = React.createContext(false); // `inputValue` can't be placed in the store. // https://github.com/mui/base-ui/issues/2703 export const ComboboxInputValueContext = diff --git a/packages/react/src/combobox/store.ts b/packages/react/src/combobox/store.ts index cdb134aeff5..e833d87ccaa 100644 --- a/packages/react/src/combobox/store.ts +++ b/packages/react/src/combobox/store.ts @@ -12,6 +12,7 @@ export type State = { labelId: string | undefined; items: readonly any[] | undefined; + itemValues: readonly any[] | undefined; selectedValue: any; @@ -95,6 +96,7 @@ export const selectors = { labelId: (state: State) => state.labelId, items: (state: State) => state.items, + itemValues: (state: State) => state.itemValues, selectedValue: (state: State) => state.selectedValue, hasSelectionChips: (state: State) => { @@ -114,7 +116,7 @@ export const selectors = { }, hasNullItemLabel: (state: State, enabled: boolean) => { - return enabled ? hasNullItemLabel(state.items) : false; + return enabled ? hasNullItemLabel(state.items, state.itemValues) : false; }, open: (state: State) => state.open, diff --git a/packages/react/src/combobox/trigger/ComboboxTrigger.test.tsx b/packages/react/src/combobox/trigger/ComboboxTrigger.test.tsx index 512b6f50e71..5d998a343f0 100644 --- a/packages/react/src/combobox/trigger/ComboboxTrigger.test.tsx +++ b/packages/react/src/combobox/trigger/ComboboxTrigger.test.tsx @@ -911,6 +911,119 @@ describe('', () => { }); }); + it('selects the mapped value on closed-trigger typeahead (itemToValue)', async () => { + const countries = [ + { code: 'US', label: 'United States' }, + { code: 'CA', label: 'Canada' }, + { code: 'AU', label: 'Australia' }, + ]; + const onValueChange = vi.fn(); + + const { user } = await render( + country.code} + onValueChange={onValueChange} + > + + + + + + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + await act(async () => { + trigger.focus(); + }); + + // Typeahead matches the item's label ("Canada") while the list is closed and + // selects the mapped value. + await user.keyboard('Can'); + + await waitFor(() => { + expect(onValueChange.mock.calls.some(([value]) => value === 'CA')).toBe(true); + }); + expect(trigger).toHaveTextContent('Canada'); + }); + + it('uses mounted registrations for filteredItems-only closed-trigger typeahead', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + + + + + + + + + {(item: string) => ( + + {item} + + )} + + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + await act(async () => { + trigger.focus(); + }); + await user.keyboard('b'); + + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith('banana', expect.any(Object)); + }); + }); + + it('selects mapped filteredItems on closed-trigger typeahead', async () => { + const countries = [ + { code: 'US', label: 'United States' }, + { code: 'CA', label: 'Canada' }, + ]; + const onValueChange = vi.fn(); + const { user } = await render( + country.code} + onValueChange={onValueChange} + > + + + + + {(country: (typeof countries)[number]) => ( + {country.label} + )} + + , + ); + + await act(async () => { + screen.getByTestId('trigger').focus(); + }); + await user.keyboard('c'); + + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith('CA', expect.any(Object)); + }); + }); + it.each([false, true])( 'cycles to the next matching item when typing after open/close (no items prop, keepMounted %s)', async (keepMounted) => { diff --git a/packages/react/src/combobox/value/ComboboxValue.test.tsx b/packages/react/src/combobox/value/ComboboxValue.test.tsx index 32317e2ad93..578ac14a941 100644 --- a/packages/react/src/combobox/value/ComboboxValue.test.tsx +++ b/packages/react/src/combobox/value/ComboboxValue.test.tsx @@ -828,6 +828,18 @@ describe('', () => { expect(screen.getByTestId('value')).toHaveTextContent('None'); }); + it('does not resolve a null label from filteredItems without itemToValue', async () => { + await render( + + + + + , + ); + + expect(screen.getByTestId('value')).toHaveTextContent('Select an option'); + }); + it('uses placeholder when items have null value without label', async () => { const items = [ { value: null, label: null }, @@ -896,5 +908,34 @@ describe('', () => { expect(screen.getByTestId('value')).toHaveTextContent('Select options'); }); + + it('displays placeholder for an empty array when items contain a labeled null value', async () => { + const items = [ + { value: null, label: 'None' }, + { value: 'option1', label: 'Option 1' }, + ]; + + await render( + item.value} multiple defaultValue={[]}> + + + + , + ); + + expect(screen.getByTestId('value')).toHaveTextContent('Select options'); + }); + + it('displays the multiple placeholder with an identity-mapped labeled null item', async () => { + await render( + + + + + , + ); + + expect(screen.getByTestId('value')).toHaveTextContent('Select options'); + }); }); }); diff --git a/packages/react/src/combobox/value/ComboboxValue.tsx b/packages/react/src/combobox/value/ComboboxValue.tsx index 0a257f335e0..d71d7c17a8f 100644 --- a/packages/react/src/combobox/value/ComboboxValue.tsx +++ b/packages/react/src/combobox/value/ComboboxValue.tsx @@ -2,8 +2,8 @@ import * as React from 'react'; import { useStore } from '@base-ui/utils/store'; import { useComboboxRootContext } from '../root/ComboboxRootContext'; -import { resolveMultipleLabels, resolveSelectedLabel } from '../../internals/resolveValueLabel'; import { selectors } from '../store'; +import { resolveMultipleLabels, resolveSelectedLabel } from '../../internals/resolveValueLabel'; /** * The current value of the combobox. @@ -19,10 +19,15 @@ export function ComboboxValue(props: ComboboxValue.Props): React.ReactElement { const itemToStringLabel = useStore(store, selectors.itemToStringLabel); const selectedValue = useStore(store, selectors.selectedValue); const items = useStore(store, selectors.items); + const itemValues = useStore(store, selectors.itemValues); + const isItemEqualToValue = useStore(store, selectors.isItemEqualToValue); const multiple = useStore(store, selectors.selectionMode) === 'multiple'; const hasSelectedValue = useStore(store, selectors.hasSelectedValue); - const shouldCheckNullItemLabel = !hasSelectedValue && placeholder != null && childrenProp == null; + // A labeled `null` item only suppresses the placeholder in single mode, where `null` is itself + // a selectable value. In multiple mode an empty selection always shows the placeholder. + const shouldCheckNullItemLabel = + !multiple && !hasSelectedValue && placeholder != null && childrenProp == null; const hasNullLabel = useStore(store, selectors.hasNullItemLabel, shouldCheckNullItemLabel); let children = null; @@ -33,9 +38,21 @@ export function ComboboxValue(props: ComboboxValue.Props): React.ReactElement { } else if (!hasSelectedValue && placeholder != null && !hasNullLabel) { children = placeholder; } else if (multiple && Array.isArray(selectedValue)) { - children = resolveMultipleLabels(selectedValue, items, itemToStringLabel); + children = resolveMultipleLabels( + selectedValue, + items, + itemToStringLabel, + itemValues, + isItemEqualToValue, + ); } else { - children = resolveSelectedLabel(selectedValue, items, itemToStringLabel); + children = resolveSelectedLabel( + selectedValue, + items, + itemToStringLabel, + itemValues, + isItemEqualToValue, + ); } return {children}; @@ -47,7 +64,8 @@ export interface ComboboxValueProps { children?: React.ReactNode | ((selectedValue: any) => React.ReactNode); /** * The placeholder value to display when no value is selected. - * This is overridden by `children` if specified, or by a null item's label in `items`. + * This is overridden by `children` if specified, or in single-selection mode by a null item's + * label in `items`. */ placeholder?: React.ReactNode; } diff --git a/packages/react/src/internals/resolveValueLabel.test.ts b/packages/react/src/internals/resolveValueLabel.test.ts index 91dc4901df8..a2c5e2f5f07 100644 --- a/packages/react/src/internals/resolveValueLabel.test.ts +++ b/packages/react/src/internals/resolveValueLabel.test.ts @@ -1,7 +1,38 @@ import { expect } from 'vitest'; -import { hasNullItemLabel } from './resolveValueLabel'; +import { hasNullItemLabel, resolveSelectedLabel } from './resolveValueLabel'; describe('resolveValueLabel', () => { + describe('resolveSelectedLabel', () => { + it('preserves an explicit selected label for ordinary items', () => { + const selected = { value: 'a', label: 'Selected label' }; + const items = [{ value: 'a', label: 'Item label' }]; + + expect(resolveSelectedLabel(selected, items)).toBe('Selected label'); + }); + + it('lets a mapped label formatter override labels on mapped object values', () => { + const selected = { value: 'a', label: 'Mapped label' }; + const sourceItem = { id: 'a', name: 'Source label' }; + + expect( + resolveSelectedLabel( + selected, + [sourceItem], + (value, item) => `${item.name}: ${value.value}`, + [selected], + ), + ).toBe('Source label: a'); + }); + + it('provides the matching source item for a mapped null value', () => { + const sourceItem = { id: 'none', name: 'No selection' }; + + expect(resolveSelectedLabel(null, [sourceItem], (_value, item) => item.name, [null])).toBe( + 'No selection', + ); + }); + }); + describe('hasNullItemLabel', () => { it('returns true when grouped items contain a null-valued item with a label', () => { const items = [ diff --git a/packages/react/src/internals/resolveValueLabel.tsx b/packages/react/src/internals/resolveValueLabel.tsx index 751a0d228ea..3788a4fc2f0 100644 --- a/packages/react/src/internals/resolveValueLabel.tsx +++ b/packages/react/src/internals/resolveValueLabel.tsx @@ -1,9 +1,10 @@ 'use client'; import * as React from 'react'; import { serializeValue } from './serializeValue'; +import { defaultItemEquality, findItemIndex, type ItemEqualityComparer } from './itemEquality'; type ItemRecord = Record; -type ItemsInput = ItemRecord | ReadonlyArray | ReadonlyArray> | undefined; +type ItemsInput = ItemRecord | ReadonlyArray | undefined; interface LabeledItem { value: any; @@ -29,8 +30,13 @@ export function isGroupedItems( /** * Checks if the items array contains an item with a null value that has a non-null label. + * When `itemValues` is provided, the value comes from the projection rather than `item.value`. */ -export function hasNullItemLabel(items: ItemsInput): boolean { +export function hasNullItemLabel(items: ItemsInput, itemValues?: readonly any[]): boolean { + if (itemValues) { + return (items as ReadonlyArray)?.[itemValues.indexOf(null)]?.label != null; + } + if (!Array.isArray(items)) { return items != null && 'null' in items; } @@ -85,14 +91,20 @@ export function stringifyAsValue(item: any, itemToStringValue?: (item: any) => s export function resolveSelectedLabel( value: any, items: ItemsInput, - itemToStringLabel?: (item: any) => string, + itemToStringLabel?: (item: any, sourceItem?: any) => string, + itemValues?: readonly any[], + isItemEqualToValue: ItemEqualityComparer = defaultItemEquality, ): React.ReactNode { function fallback() { return stringifyAsLabel(value, itemToStringLabel); } - if (itemToStringLabel && value != null) { - return itemToStringLabel(value); + const projectedIndex = itemValues ? findItemIndex(itemValues, value, isItemEqualToValue) : -1; + const projectedItem = + projectedIndex === -1 ? undefined : (items as ReadonlyArray)?.[projectedIndex]; + + if (itemToStringLabel && itemValues && (value != null || projectedIndex !== -1)) { + return itemToStringLabel(value, projectedItem); } // Custom object with explicit label takes precedence @@ -100,6 +112,16 @@ export function resolveSelectedLabel( return value.label; } + // Values projected from source items via `itemToValue`: the label lives on the source item at + // the same index, so the value is matched positionally rather than against `item.value`. + if (itemValues) { + return projectedItem?.label != null ? projectedItem.label : fallback(); + } + + if (itemToStringLabel && value != null) { + return itemToStringLabel(value); + } + // Items provided as plain record map if (items && !Array.isArray(items)) { return (items as any)[value] ?? fallback(); @@ -135,7 +157,9 @@ export function resolveSelectedLabel( export function resolveMultipleLabels( values: any[], items: ItemsInput, - itemToStringLabel?: (item: any) => string, + itemToStringLabel?: (item: any, sourceItem?: any) => string, + itemValues?: readonly any[], + isItemEqualToValue?: ItemEqualityComparer, ): React.ReactNode { return values.reduce((acc, value, index) => { if (index > 0) { @@ -143,7 +167,7 @@ export function resolveMultipleLabels( } acc.push( - {resolveSelectedLabel(value, items, itemToStringLabel)} + {resolveSelectedLabel(value, items, itemToStringLabel, itemValues, isItemEqualToValue)} , ); return acc; diff --git a/packages/react/src/select/value/SelectValue.test.tsx b/packages/react/src/select/value/SelectValue.test.tsx index fde4d317408..3976a3f0701 100644 --- a/packages/react/src/select/value/SelectValue.test.tsx +++ b/packages/react/src/select/value/SelectValue.test.tsx @@ -359,6 +359,34 @@ describe('', () => { expect(screen.getByTestId('value')).toHaveTextContent('Canada'); }); + it('uses itemToStringLabel before item labels for primitive selected values', async () => { + const items = [ + { value: 'US', label: 'United States' }, + { value: 'CA', label: 'Canada' }, + ]; + + await render( + 'Custom label'}> + + + + + + + {items.map((item) => ( + + {item.label} + + ))} + + + + , + ); + + expect(screen.getByTestId('value')).toHaveTextContent('Custom label'); + }); + it('falls back to label/value properties when functions are not provided', async () => { const items = [ { label: 'United States', value: 'US' },