From 0d2f63cfa93386f3de12c67c2e2e1b1ff242b790 Mon Sep 17 00:00:00 2001 From: atomiks Date: Thu, 30 Apr 2026 15:36:57 +1000 Subject: [PATCH] [combobox] Support keyed values with object items --- .../(docs)/react/components/combobox/page.mdx | 92 ++++- .../(docs)/react/components/combobox/types.md | 8 +- .../react/src/combobox/item/ComboboxItem.tsx | 30 +- .../react/src/combobox/root/AriaCombobox.tsx | 141 ++++++- .../src/combobox/root/ComboboxRoot.spec.tsx | 36 +- .../src/combobox/root/ComboboxRoot.test.tsx | 384 ++++++++++++++++++ .../react/src/combobox/root/ComboboxRoot.tsx | 94 ++++- .../src/combobox/value/ComboboxValue.tsx | 5 +- packages/react/src/internals/itemEquality.ts | 10 +- .../src/internals/resolveValueLabel.test.ts | 42 +- .../react/src/internals/resolveValueLabel.tsx | 84 +++- 11 files changed, 859 insertions(+), 67 deletions(-) diff --git a/docs/src/app/(docs)/react/components/combobox/page.mdx b/docs/src/app/(docs)/react/components/combobox/page.mdx index 87a664f5681..abf6dba37bb 100644 --- a/docs/src/app/(docs)/react/components/combobox/page.mdx +++ b/docs/src/app/(docs)/react/components/combobox/page.mdx @@ -71,18 +71,98 @@ import { Combobox } from '@base-ui/react/combobox'; ; ``` -## TypeScript +## Value modes -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 supports two value modes: + +- **Item values**: `value` / `defaultValue` has the same shape as the entries in the `items` array. +- **Keyed values**: `value` / `defaultValue` is a primitive while `items` use the `{ value, label }` object shape. + +In the keyed mode, the combobox uses `item.label` for display and filtering, and `item.value` as the selected value. Other object shapes such as `{ id, name }` are not supported by the keyed mode; use item-shaped values instead. + +### Item values + +Use object-shaped values when the selected value should keep the full item data instead of a primitive key. + +- Use `itemToStringLabel` when the object does not already have a `label` field, or when you want to override the default `label` text shown in the input or trigger. +- Use `itemToStringValue` when form submission should serialize the object as a string, such as an `id`. +- Use `isItemEqualToValue` when items are re-created across renders or loaded asynchronously and object identity is not stable. + +```tsx title="Object-shaped values" {13-15} "value={user}" +interface User { + id: number; + name: string; +} + +const users: User[] = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, +]; + + user.name} + itemToStringValue={(user) => String(user.id)} + isItemEqualToValue={(item, value) => item.id === value.id} +> + + + + + + {(user: User) => ( + + {user.name} + + )} + + + + +; +``` + +### Keyed values + +Use keyed values when `items` already have a `{ value, label }` shape and the selected value should be the primitive `item.value`. +Closed-trigger typeahead is only available once a primitive keyed value has already been selected. + +```tsx title="Keyed values" "value"2,3,5 "defaultValue" +interface FruitItem { + value: string; + label: string; +} + +const items: FruitItem[] = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, +]; + + + + + + + + {(item) => ( + + {item.label} + + )} + + + + +; +``` ## Examples ### Typed wrapper component -The following example shows a typed wrapper around the Combobox component with correct type inference and type safety: +Use a two-generic wrapper when the selected value has the same shape as the `items` entries: -```tsx title="Specifying generic type parameters" +```tsx title="Wrapper for item-shaped values" import * as React from 'react'; import { Combobox } from '@base-ui/react/combobox'; @@ -93,6 +173,8 @@ export function MyCombobox( } ``` +If the wrapper should also support keyed `{ value, label }` items, forward the third `Items` generic as well. + ### Multiple select The combobox can allow multiple selections by adding the `multiple` prop to ``. diff --git a/docs/src/app/(docs)/react/components/combobox/types.md b/docs/src/app/(docs)/react/components/combobox/types.md index 3c73ebb8d19..9217649b999 100644 --- a/docs/src/app/(docs)/react/components/combobox/types.md +++ b/docs/src/app/(docs)/react/components/combobox/types.md @@ -14,8 +14,8 @@ Doesn't render its own HTML element. | 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. | +| defaultValue | `Value[] \| Value \| null` | - | The uncontrolled selected value of the combobox when it's initially rendered. Must match the item value shape, except when `items` use the `{ value, label }` shape, in which case a primitive `item.value` is also supported. To render a controlled combobox, use the `value` prop instead. | +| value | `Value[] \| Value \| null` | - | The selected value of the combobox. Use when controlled. Must match the item value shape, except when `items` use the `{ value, label }` shape, in which case a primitive `item.value` is also supported. | | 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. | @@ -33,9 +33,9 @@ Doesn't render its own HTML element. | 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 popup. | | 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. | +| 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 for display and filtering without needing to specify this prop. In that keyed mode, this prop is bypassed for the `{ value, label }` item array itself and only applies as a fallback when resolving other value shapes. | | 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. | +| 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. Primitive `value`/`defaultValue` is only supported implicitly when each item has the shape `{ value, label }`. | | 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. | diff --git a/packages/react/src/combobox/item/ComboboxItem.tsx b/packages/react/src/combobox/item/ComboboxItem.tsx index 61361abdddf..17636f00770 100644 --- a/packages/react/src/combobox/item/ComboboxItem.tsx +++ b/packages/react/src/combobox/item/ComboboxItem.tsx @@ -18,6 +18,7 @@ import { selectors } from '../store'; import { useButton } from '../../internals/use-button'; import { useComboboxRowContext } from '../row/ComboboxRowContext'; import { compareItemEquality, findItemIndex } from '../../internals/itemEquality'; +import { inferItemValue } from '../../internals/resolveValueLabel'; /** * An individual item in the list. @@ -60,11 +61,21 @@ export const ComboboxItem = React.memo( const isItemEqualToValue = useStore(store, selectors.isItemEqualToValue); const selectable = selectionMode !== 'none'; - const index = - indexProp ?? - (virtualized - ? findItemIndex(flatFilteredItems, itemValue, isItemEqualToValue) - : listItem.index); + const getVisibleItemValue = + hasItems && itemValue != null && typeof itemValue !== 'object' ? inferItemValue : undefined; + let index = indexProp; + if (index == null) { + if (virtualized) { + index = findItemIndex( + flatFilteredItems, + itemValue, + isItemEqualToValue, + getVisibleItemValue, + ); + } else { + index = listItem.index; + } + } const hasRegistered = listItem.index !== -1; const rootId = useStore(store, selectors.id); @@ -92,17 +103,16 @@ export const ComboboxItem = React.memo( }, [hasRegistered, virtualized, index, indexProp, store]); useIsoLayoutEffect(() => { - if (!hasRegistered || hasItems) { + if (!hasRegistered) { return undefined; } const visibleMap = store.state.valuesRef.current; visibleMap[index] = itemValue; - // Stable registry that doesn't depend on filtering. Assume that no - // filtering had occurred at this point; otherwise, an `items` prop is - // required. - if (selectionMode !== 'none') { + // Stable registry that doesn't depend on filtering. This is only needed + // when the combobox is driven by mounted items rather than the `items` prop. + if (selectionMode !== 'none' && !hasItems) { store.state.allValuesRef.current.push(itemValue); } diff --git a/packages/react/src/combobox/root/AriaCombobox.tsx b/packages/react/src/combobox/root/AriaCombobox.tsx index dd4dd5acdaa..b134b768e6e 100644 --- a/packages/react/src/combobox/root/AriaCombobox.tsx +++ b/packages/react/src/combobox/root/AriaCombobox.tsx @@ -46,6 +46,9 @@ import { HTMLProps } from '../../internals/types'; import { useValueChanged } from '../../internals/useValueChanged'; import { NOOP } from '../../internals/noop'; import { + inferItemValue, + hasValueAndLabelItemsInput, + resolveSelectedLabelString, stringifyAsLabel, stringifyAsValue, Group, @@ -60,6 +63,24 @@ import { } from '../../internals/itemEquality'; import { INITIAL_LAST_HIGHLIGHT, NO_ACTIVE_VALUE } from './utils/constants'; +function hasPrimitiveSelectionValue(value: any, multiple: boolean): boolean { + if (multiple) { + return ( + Array.isArray(value) && value.length > 0 && value.every((item) => typeof item !== 'object') + ); + } + + return value != null && typeof value !== 'object'; +} + +function hasEmptySelectionValue(value: any, multiple: boolean): boolean { + if (multiple) { + return Array.isArray(value) && value.length === 0; + } + + return value == null; +} + /** * @internal */ @@ -185,6 +206,19 @@ export function AriaCombobox( name: 'Combobox', state: 'selectedValue', }); + const hasPrimitiveSelectableItems = + hasItems && selectionMode !== 'none' && hasValueAndLabelItemsInput(items); + const hasEmptySelection = hasEmptySelectionValue(selectedValue, multiple); + const shouldUsePrimitiveValuesForItems = + hasPrimitiveSelectableItems && hasPrimitiveSelectionValue(selectedValue, multiple); + const shouldBypassItemToStringLabelForItems = + hasPrimitiveSelectableItems && (shouldUsePrimitiveValuesForItems || hasEmptySelection); + const shouldSkipPreMountItemRegistry = + hasPrimitiveSelectableItems && !shouldUsePrimitiveValuesForItems && hasEmptySelection; + const itemToStringLabelForItems = shouldBypassItemToStringLabelForItems + ? undefined + : itemToStringLabel; + const getVisibleItemValue = shouldUsePrimitiveValuesForItems ? inferItemValue : undefined; const filter = React.useMemo(() => { if (filterProp === null) { @@ -194,10 +228,21 @@ export function AriaCombobox( return filterProp; } if (single && !queryChangedAfterOpen) { - return createSingleSelectionCollatorFilter(collatorFilter, itemToStringLabel, selectedValue); + return createSingleSelectionCollatorFilter( + collatorFilter, + itemToStringLabelForItems, + selectedValue, + ); } - return createCollatorItemFilter(collatorFilter, itemToStringLabel); - }, [filterProp, single, selectedValue, queryChangedAfterOpen, collatorFilter, itemToStringLabel]); + return createCollatorItemFilter(collatorFilter, itemToStringLabelForItems); + }, [ + filterProp, + single, + selectedValue, + queryChangedAfterOpen, + collatorFilter, + itemToStringLabelForItems, + ]); // If neither inputValue nor defaultInputValue are provided, derive it from the // selected value for single mode so the input reflects the selection on mount. @@ -207,7 +252,12 @@ export function AriaCombobox( return defaultInputValueProp ?? ''; } if (single) { - return stringifyAsLabel(selectedValue, itemToStringLabel); + return resolveSelectedLabelString( + selectedValue, + items, + itemToStringLabel, + isItemEqualToValue, + ); } return ''; }, @@ -230,7 +280,9 @@ export function AriaCombobox( const isGrouped = isGroupedItems(items); const query = closeQuery ?? (inputValue === '' ? '' : String(inputValue).trim()); - const selectedLabelString = single ? stringifyAsLabel(selectedValue, itemToStringLabel) : ''; + const selectedLabelString = single + ? resolveSelectedLabelString(selectedValue, items, itemToStringLabel, isItemEqualToValue) + : ''; const shouldBypassFiltering = single && @@ -277,7 +329,7 @@ export function AriaCombobox( const candidateItems = filterQuery === '' ? group.items - : group.items.filter((item) => filter(item, filterQuery, itemToStringLabel)); + : group.items.filter((item) => filter(item, filterQuery, itemToStringLabelForItems)); if (candidateItems.length === 0) { continue; @@ -313,7 +365,7 @@ export function AriaCombobox( if (limit > -1 && limitedItems.length >= limit) { break; } - if (filter(item, filterQuery, itemToStringLabel)) { + if (filter(item, filterQuery, itemToStringLabelForItems)) { limitedItems.push(item); } } @@ -327,7 +379,7 @@ export function AriaCombobox( filterQuery, limit, filter, - itemToStringLabel, + itemToStringLabelForItems, flatItems, ]); @@ -339,6 +391,12 @@ export function AriaCombobox( return filteredItems as Value[]; }, [filteredItems, isGrouped]); + const visibleItemValues = React.useMemo(() => { + return flatFilteredItems.map((item) => + getVisibleItemValue ? getVisibleItemValue(item) : item, + ); + }, [flatFilteredItems, getVisibleItemValue]); + const store = useRefWithInit( () => new Store({ @@ -449,10 +507,17 @@ export function AriaCombobox( const forceMount = useStableCallback(() => { if (items) { + if (shouldSkipPreMountItemRegistry) { + labelsRef.current = []; + valuesRef.current = []; + return; + } + // Ensure typeahead works on a closed list. labelsRef.current = flatFilteredItems.map((item) => - stringifyAsLabel(item, itemToStringLabel), + stringifyAsLabel(item, itemToStringLabelForItems), ); + valuesRef.current = visibleItemValues; } else { store.set('forceMounted', true); } @@ -616,7 +681,7 @@ export function AriaCombobox( if (shouldFillInput) { setInputValue( - stringifyAsLabel(nextValue, itemToStringLabel), + resolveSelectedLabelString(nextValue, items, itemToStringLabel, isItemEqualToValue), createChangeEventDetails(eventDetails.reason, eventDetails.event), ); } @@ -731,7 +796,12 @@ export function AriaCombobox( setInputValue('', createChangeEventDetails(REASONS.inputClear)); } } else { - const stringVal = stringifyAsLabel(selectedValue, itemToStringLabel); + const stringVal = resolveSelectedLabelString( + selectedValue, + items, + itemToStringLabel, + isItemEqualToValue, + ); if (inputRef.current && inputRef.current.value !== stringVal) { // If no selection was made, treat this as clearing the typed filter. const reason = stringVal === '' ? REASONS.inputClear : REASONS.none; @@ -771,15 +841,21 @@ export function AriaCombobox( return; } - const registry = items ? flatItems : allValuesRef.current; + const findSelectedIndex = (value: any) => { + if (!items) { + return findItemIndex(allValuesRef.current, value, isItemEqualToValue); + } + + return findItemIndex(flatItems, value, isItemEqualToValue, getVisibleItemValue); + }; if (multiple) { const currentValue = Array.isArray(selectedValue) ? selectedValue : []; const lastValue = currentValue[currentValue.length - 1]; - const lastIndex = findItemIndex(registry, lastValue, isItemEqualToValue); + const lastIndex = findSelectedIndex(lastValue); setIndices({ selectedIndex: lastIndex === -1 ? null : lastIndex }); } else { - const index = findItemIndex(registry, selectedValue, isItemEqualToValue); + const index = findSelectedIndex(selectedValue); setIndices({ selectedIndex: index === -1 ? null : index }); } }, @@ -791,16 +867,30 @@ export function AriaCombobox( flatItems, multiple, isItemEqualToValue, + getVisibleItemValue, setIndices, ], ); useIsoLayoutEffect(() => { if (items) { - valuesRef.current = flatFilteredItems; + if (!open && !inlineProp) { + if (shouldSkipPreMountItemRegistry) { + valuesRef.current = []; + } else { + valuesRef.current = visibleItemValues; + } + } listRef.current.length = flatFilteredItems.length; } - }, [items, flatFilteredItems]); + }, [ + items, + flatFilteredItems, + visibleItemValues, + inlineProp, + open, + shouldSkipPreMountItemRegistry, + ]); useIsoLayoutEffect(() => { const pendingHighlight = pendingQueryHighlightRef.current; @@ -850,7 +940,10 @@ export function AriaCombobox( return; } - const itemValue = candidateItems[storeActiveIndex]; + const itemValue = + hasItems && Object.hasOwn(valuesRef.current, storeActiveIndex) + ? valuesRef.current[storeActiveIndex] + : candidateItems[storeActiveIndex]; const previouslyHighlightedItemValue = lastHighlightRef.current.value; const isSameItem = previouslyHighlightedItemValue !== NO_ACTIVE_VALUE && @@ -918,7 +1011,12 @@ export function AriaCombobox( } if (single && !hasInputValue && !inputInsidePopup) { - const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel); + const nextInputValue = resolveSelectedLabelString( + selectedValue, + items, + itemToStringLabel, + isItemEqualToValue, + ); if (inputValue !== nextInputValue) { setInputValue(nextInputValue, createChangeEventDetails(REASONS.none)); @@ -946,7 +1044,12 @@ export function AriaCombobox( return; } - const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel); + const nextInputValue = resolveSelectedLabelString( + selectedValue, + items, + itemToStringLabel, + isItemEqualToValue, + ); if (inputValue !== nextInputValue) { setInputValue(nextInputValue, createChangeEventDetails(REASONS.none)); diff --git a/packages/react/src/combobox/root/ComboboxRoot.spec.tsx b/packages/react/src/combobox/root/ComboboxRoot.spec.tsx index 3557dc11602..065c4e8e167 100644 --- a/packages/react/src/combobox/root/ComboboxRoot.spec.tsx +++ b/packages/react/src/combobox/root/ComboboxRoot.spec.tsx @@ -14,6 +14,18 @@ const objectItemsReadonly = [ { value: 'c', label: 'cherry' }, ] as const; +const arbitraryObjectItems = [ + { id: 'a', label: 'apple' }, + { id: 'b', label: 'banana' }, + { id: 'c', label: 'cherry' }, +] as const; + +const valueOnlyObjectItems = [ + { value: 'a', name: 'apple' }, + { value: 'b', name: 'banana' }, + { value: 'c', name: 'cherry' }, +] as const; + const groupItemsReadonly = [ { value: 'fruits', @@ -95,6 +107,12 @@ const groupItemsReadonly = [ }} />; +// @ts-expect-error - primitive selected values require primitive items or object items with a `value` +; + +// @ts-expect-error - implicit primitive selected values require object items with both `value` and `label` +; + >( export function Wrapper( props: Combobox.Root.Props, -) { +): React.JSX.Element; +export function Wrapper(props: Combobox.Root.Props): React.JSX.Element { return ; } + +; + +type KeyedItems = readonly Value[] | readonly { value: Value; label: string }[] | undefined; + +export function KeyedWrapper< + Value, + Multiple extends boolean | undefined = false, + Items extends KeyedItems = readonly Value[] | undefined, +>(props: Combobox.Root.Props): React.JSX.Element; +export function KeyedWrapper(props: Combobox.Root.Props): React.JSX.Element { + return ; +} + +; diff --git a/packages/react/src/combobox/root/ComboboxRoot.test.tsx b/packages/react/src/combobox/root/ComboboxRoot.test.tsx index 518c270622e..e725204fc8e 100644 --- a/packages/react/src/combobox/root/ComboboxRoot.test.tsx +++ b/packages/react/src/combobox/root/ComboboxRoot.test.tsx @@ -6097,6 +6097,59 @@ describe('', () => { expect(screen.getByRole('option', { name: 'Bob' })).toHaveAttribute('aria-selected', 'true'); }); + it('keeps object-shaped comparator arguments during label resolution when items include a value field', async () => { + const users = [ + { id: 1, name: 'Alice', value: 'alice' }, + { id: 2, name: 'Bob', value: 'bob' }, + ]; + + const compare = vi.fn((item: (typeof users)[number], value: (typeof users)[number]) => { + if (typeof item !== 'object' || typeof value !== 'object') { + throw new Error('Comparator received primitive values'); + } + + return item.id === value.id; + }); + + await render( + item.name} + itemToStringValue={(item) => String(item.id)} + isItemEqualToValue={compare} + defaultOpen + > + + + + + + + + + {(item) => ( + + {item.name} + + )} + + + + + , + ); + + expect(screen.getByTestId('value')).toHaveTextContent('Bob'); + expect(screen.getByRole('combobox')).toHaveValue('Bob'); + expect(compare.mock.calls.length).toBeGreaterThan(0); + expect( + compare.mock.calls.every( + ([item, value]) => typeof item === 'object' && typeof value === 'object', + ), + ).toBe(true); + }); + it('properly deselects object values using the provided comparator', async () => { const users = [ { id: 1, name: 'Alice' }, @@ -6461,6 +6514,337 @@ describe('', () => { }); }); + describe('primitive selected values with object items', () => { + interface FruitItem { + value: string; + label: string; + } + + const items: FruitItem[] = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + + it('derives the input value from the matching item label', async () => { + await render( + + + + + + + {(item: FruitItem) => ( + + {item.label} + + )} + + + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + }); + + it('syncs the selected index while closed', async () => { + await render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('1'); + }); + }); + + it('derives the input value from the matching item label when equality is customized', async () => { + await render( + itemValue.toLowerCase() === value.toLowerCase()} + > + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + }); + + it('uses mounted item values for keyboard selection', async () => { + const onValueChange = vi.fn(); + const renderedItems = [...items].reverse(); + const { user } = await render( + + + + + + + {renderedItems.map((item) => ( + + {item.label} + + ))} + + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.keyboard('{ArrowDown}{Enter}'); + + await waitFor(() => { + expect(onValueChange).toHaveBeenCalled(); + }); + + expect(onValueChange.mock.calls[0][0]).toBe('cherry'); + }); + + it('uses inferred primitive values for closed trigger typeahead when a primitive is selected', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + + + + + + + + + {(item: FruitItem) => ( + + {item.label} + + )} + + + + + , + ); + + await act(async () => { + screen.getByTestId('trigger').focus(); + }); + await user.keyboard('b'); + + await waitFor(() => { + expect(onValueChange).toHaveBeenCalled(); + }); + + expect(onValueChange.mock.calls[0][0]).toBe('banana'); + }); + + it('does not resolve a closed trigger match before the first primitive selection', async () => { + const itemToStringLabel = vi.fn((value: string) => value.toUpperCase()); + const onValueChange = vi.fn(); + const { user } = await render( + + + + + + + + + {(item: FruitItem) => ( + + {item.label} + + )} + + + + + , + ); + + await act(async () => { + screen.getByTestId('trigger').focus(); + }); + await user.keyboard('b'); + await flushMicrotasks(); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(itemToStringLabel).not.toHaveBeenCalled(); + }); + + it('shows the matched label for trigger-only keyed mode after a selection exists', async () => { + await render( + + + + + , + ); + + expect(screen.getByRole('combobox')).toHaveTextContent('Banana'); + }); + + it('does not call itemToStringLabel with object items before the first primitive selection', async () => { + const itemToStringLabel = vi.fn((value: string) => value.toUpperCase()); + const { user } = await render( + + + + + + + {(item: FruitItem) => ( + + {item.label} + + )} + + + + + , + ); + + await user.click(screen.getByRole('combobox')); + await user.type(screen.getByRole('combobox'), 'b'); + + expect(screen.getByRole('option', { name: 'Banana' })).toBeVisible(); + expect(itemToStringLabel).not.toHaveBeenCalled(); + }); + + it('keeps isItemEqualToValue primitive-shaped in implicit primitive mode', async () => { + const isItemEqualToValue = vi.fn((itemValue: string, value: string) => { + return itemValue.toLowerCase() === value.toLowerCase(); + }); + + await render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('1'); + }); + + expect(isItemEqualToValue).toHaveBeenCalled(); + expect( + isItemEqualToValue.mock.calls.every( + ([itemValue, value]) => typeof itemValue === 'string' && typeof value === 'string', + ), + ).toBe(true); + }); + + it('keeps itemToStringLabel primitive-shaped during closed trigger typeahead', async () => { + const itemToStringLabel = vi.fn((value: string) => value.toUpperCase()); + const onValueChange = vi.fn(); + + const { user } = await render( + + + + + + + + + {(item: FruitItem) => ( + + {item.label} + + )} + + + + + , + ); + + await act(async () => { + screen.getByTestId('trigger').focus(); + }); + await user.keyboard('b'); + + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith( + 'banana', + expect.objectContaining({ reason: REASONS.none }), + ); + }); + + expect(itemToStringLabel.mock.calls.every(([value]) => typeof value === 'string')).toBe(true); + }); + + it('keeps itemToStringValue primitive-shaped during browser autofill matching', async () => { + const itemToStringValue = vi.fn((value: string) => value.toUpperCase()); + const { user } = await render( + + + + + + + {(item: FruitItem) => ( + + {item.label} + + )} + + + + + , + ); + + fireEvent.change( + screen.getAllByDisplayValue('APPLE').find((el) => el.getAttribute('name') === 'fruit')!, + { target: { value: 'BANANA' } }, + ); + await flushMicrotasks(); + + await user.click(screen.getByRole('combobox')); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + expect(itemToStringValue.mock.calls.every(([value]) => typeof value === 'string')).toBe(true); + }); + + it('syncs the last selected index for primitive arrays with object items', async () => { + await render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('2'); + }); + }); + }); + describe('prop: loopFocus', () => { it('loops focus from last to first item with ArrowDown by default', async () => { const { user } = await render( diff --git a/packages/react/src/combobox/root/ComboboxRoot.tsx b/packages/react/src/combobox/root/ComboboxRoot.tsx index 6567c5e37ec..ba972c9fb1b 100644 --- a/packages/react/src/combobox/root/ComboboxRoot.tsx +++ b/packages/react/src/combobox/root/ComboboxRoot.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import { AriaCombobox, type AriaComboboxState } from './AriaCombobox'; +import type { Group } from '../../internals/resolveValueLabel'; /** * Groups all parts of the combobox. @@ -8,9 +9,14 @@ import { AriaCombobox, type AriaComboboxState } from './AriaCombobox'; * * Documentation: [Base UI Combobox](https://base-ui.com/react/components/combobox) */ -export function ComboboxRoot( - props: ComboboxRoot.Props, -): React.JSX.Element { +export function ComboboxRoot< + Value, + Multiple extends boolean | undefined = false, + Items extends readonly any[] | readonly Group[] | undefined = + | readonly Value[] + | readonly Group[] + | undefined, +>(props: ComboboxRoot.Props): React.JSX.Element { const { multiple = false as Multiple, defaultValue, @@ -40,7 +46,66 @@ type ComboboxValueType = Multiple e ? Value[] : Value; -export type ComboboxRootProps = Omit< +type ItemsInput = readonly any[] | readonly Group[] | undefined; + +type ItemFromItems[]> = + Items extends readonly (infer Item)[] + ? Item extends { items: readonly (infer GroupItem)[] } + ? GroupItem + : Item + : never; + +type IsAny = 0 extends 1 & T ? true : false; + +type IsUnknown = + IsAny extends true + ? false + : unknown extends T + ? [T] extends [unknown] + ? true + : false + : false; + +type PrimitiveSelectableItemShape = { + value: Value; + label: unknown; +}; + +type CompatibleItems = + IsAny extends true + ? Items + : IsUnknown extends true + ? Items + : [NonNullable] extends [never] + ? Items + : [ItemFromItems>] extends [never] + ? Items + : [ItemFromItems>] extends [NonNullable] + ? Items + : [NonNullable] extends [ItemFromItems>] + ? Items + : ItemFromItems> extends PrimitiveSelectableItemShape< + infer ItemValue + > + ? [ItemValue] extends [NonNullable] + ? Items + : [NonNullable] extends [ItemValue] + ? Items + : never + : never; + +type CompatibleItemsConstraint = + CompatibleItems extends never + ? { + __invalidItemsShapeForValue: never; + } + : {}; + +export type ComboboxRootProps< + Value, + Multiple extends boolean | undefined = false, + Items extends ItemsInput = readonly Value[] | readonly Group[] | undefined, +> = Omit< AriaCombobox.Props>, | 'fillInputOnItemPress' | 'autoComplete' @@ -51,6 +116,7 @@ export type ComboboxRootProps`), 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. + * If the shape of the object is `{ value, label }`, the label will be used automatically for display and filtering without needing to specify this prop. + * In that keyed mode, this prop is bypassed for the `{ value, label }` item array itself and only applies as a fallback when resolving other value shapes. */ itemToStringLabel?: ((itemValue: Value) => string) | undefined; /** @@ -101,6 +174,7 @@ export type ComboboxRootProps boolean) | undefined; /** * The uncontrolled selected value of the combobox when it's initially rendered. + * Must match the item value shape, except when `items` use the `{ value, label }` shape, in which case a primitive `item.value` is also supported. * * To render a controlled combobox, use the `value` prop instead. */ @@ -140,6 +214,7 @@ export type ComboboxRootProps | null | undefined; /** @@ -151,7 +226,7 @@ export type ComboboxRootProps void) | undefined; -}; +} & CompatibleItemsConstraint; export interface ComboboxRootState extends AriaComboboxState {} @@ -164,10 +239,11 @@ export type ComboboxRootHighlightEventReason = AriaCombobox.HighlightEventReason export type ComboboxRootHighlightEventDetails = AriaCombobox.HighlightEventDetails; export namespace ComboboxRoot { - export type Props = ComboboxRootProps< + export type Props< Value, - Multiple - >; + Multiple extends boolean | undefined = false, + Items extends ItemsInput = readonly Value[] | readonly Group[] | undefined, + > = ComboboxRootProps; export type State = ComboboxRootState; export type Actions = ComboboxRootActions; export type ChangeEventReason = ComboboxRootChangeEventReason; diff --git a/packages/react/src/combobox/value/ComboboxValue.tsx b/packages/react/src/combobox/value/ComboboxValue.tsx index 0a257f335e0..37084ae8207 100644 --- a/packages/react/src/combobox/value/ComboboxValue.tsx +++ b/packages/react/src/combobox/value/ComboboxValue.tsx @@ -17,6 +17,7 @@ export function ComboboxValue(props: ComboboxValue.Props): React.ReactElement { const store = useComboboxRootContext(); const itemToStringLabel = useStore(store, selectors.itemToStringLabel); + const isItemEqualToValue = useStore(store, selectors.isItemEqualToValue); const selectedValue = useStore(store, selectors.selectedValue); const items = useStore(store, selectors.items); const multiple = useStore(store, selectors.selectionMode) === 'multiple'; @@ -33,9 +34,9 @@ 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, isItemEqualToValue); } else { - children = resolveSelectedLabel(selectedValue, items, itemToStringLabel); + children = resolveSelectedLabel(selectedValue, items, itemToStringLabel, isItemEqualToValue); } return {children}; diff --git a/packages/react/src/internals/itemEquality.ts b/packages/react/src/internals/itemEquality.ts index ee26e1fbc65..094d08b4438 100644 --- a/packages/react/src/internals/itemEquality.ts +++ b/packages/react/src/internals/itemEquality.ts @@ -33,10 +33,11 @@ export function selectedValueIncludes( }); } -export function findItemIndex( +export function findItemIndex( itemValues: readonly Item[] | undefined | null, selectedValue: Value, - comparer: ItemEqualityComparer, + comparer: ItemEqualityComparer, + getItemValue?: ((itemValue: Item) => Candidate) | undefined, ): number { if (!itemValues || itemValues.length === 0) { return -1; @@ -45,7 +46,10 @@ export function findItemIndex( if (itemValue === undefined) { return false; } - return compareItemEquality(itemValue, selectedValue, comparer); + const candidateValue = getItemValue + ? getItemValue(itemValue) + : (itemValue as unknown as Candidate); + return compareItemEquality(candidateValue, selectedValue, comparer); }); } diff --git a/packages/react/src/internals/resolveValueLabel.test.ts b/packages/react/src/internals/resolveValueLabel.test.ts index 91dc4901df8..aeb2d48496f 100644 --- a/packages/react/src/internals/resolveValueLabel.test.ts +++ b/packages/react/src/internals/resolveValueLabel.test.ts @@ -1,7 +1,45 @@ -import { expect } from 'vitest'; -import { hasNullItemLabel } from './resolveValueLabel'; +import { expect, vi } from 'vitest'; +import { compareItemEquality } from './itemEquality'; +import { hasNullItemLabel, resolveSelectedLabelString } from './resolveValueLabel'; describe('resolveValueLabel', () => { + describe('resolveSelectedLabelString', () => { + it('prefers a matching items label over itemToStringLabel for primitive values', () => { + const itemToStringLabel = vi.fn(() => 'WRONG'); + + expect( + resolveSelectedLabelString( + 'b', + [ + { value: 'a', label: 'Apple' }, + { value: 'b', label: 'Banana' }, + ], + itemToStringLabel, + ), + ).toBe('Banana'); + expect(itemToStringLabel).not.toHaveBeenCalled(); + }); + + it('uses custom equality when matching primitive values to item labels', () => { + expect( + resolveSelectedLabelString( + 'B', + [ + { value: 'a', label: 'Apple' }, + { value: 'b', label: 'Banana' }, + ], + undefined, + (itemValue, value) => + compareItemEquality( + String(itemValue).toLowerCase(), + String(value).toLowerCase(), + Object.is, + ), + ), + ).toBe('Banana'); + }); + }); + 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..f4fd74e1ff6 100644 --- a/packages/react/src/internals/resolveValueLabel.tsx +++ b/packages/react/src/internals/resolveValueLabel.tsx @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import { compareItemEquality, type ItemEqualityComparer } from './itemEquality'; import { serializeValue } from './serializeValue'; type ItemRecord = Record; @@ -27,6 +28,27 @@ export function isGroupedItems( ); } +function hasValueAndLabelItems(items: readonly any[]): boolean { + return ( + items.length > 0 && + items.every((item) => item && typeof item === 'object' && 'value' in item && 'label' in item) + ); +} + +export function hasValueAndLabelItemsInput( + items: ReadonlyArray> | undefined, +): boolean { + if (!items || items.length === 0) { + return false; + } + + if (isGroupedItems(items)) { + return items.every((group) => hasValueAndLabelItems(group.items)); + } + + return hasValueAndLabelItems(items); +} + /** * Checks if the items array contains an item with a null value that has a non-null label. */ @@ -82,19 +104,41 @@ export function stringifyAsValue(item: any, itemToStringValue?: (item: any) => s return serializeValue(item); } +export function inferItemValue(item: any) { + if (item && typeof item === 'object' && 'value' in item) { + return item.value; + } + + return item; +} + +function findMatchingItem( + items: ReadonlyArray, + value: any, + isItemEqualToValue?: ItemEqualityComparer, +) { + return items.find((item) => { + const itemValue = inferItemValue(item); + return isItemEqualToValue + ? compareItemEquality(itemValue, value, isItemEqualToValue) + : itemValue === value; + }); +} + +function findItemByValueProperty(items: ReadonlyArray, value: any) { + return items.find((item) => item && item.value === value); +} + export function resolveSelectedLabel( value: any, items: ItemsInput, itemToStringLabel?: (item: any) => string, + isItemEqualToValue?: ItemEqualityComparer, ): React.ReactNode { function fallback() { return stringifyAsLabel(value, itemToStringLabel); } - if (itemToStringLabel && value != null) { - return itemToStringLabel(value); - } - // Custom object with explicit label takes precedence if (value && typeof value === 'object' && 'label' in value && value.label != null) { return value.label; @@ -113,16 +157,12 @@ export function resolveSelectedLabel( : arrayItems; if (value == null || typeof value !== 'object') { - const match = flatItems.find((item) => item.value === value); + const match = findMatchingItem(flatItems, value, isItemEqualToValue); if (match && match.label != null) { return match.label; } - return fallback(); - } - - // Object without explicit label: try matching by its `value` property - if ('value' in value) { - const match = flatItems.find((item) => item && item.value === value.value); + } else if ('value' in value) { + const match = findItemByValueProperty(flatItems, value.value); if (match && match.label != null) { return match.label; } @@ -132,10 +172,30 @@ export function resolveSelectedLabel( return fallback(); } +export function resolveSelectedLabelString( + value: any, + items: ItemsInput, + itemToStringLabel?: (item: any) => string, + isItemEqualToValue?: ItemEqualityComparer, +): string { + const label = resolveSelectedLabel(value, items, itemToStringLabel, isItemEqualToValue); + + if (label == null || typeof label === 'boolean') { + return ''; + } + + if (typeof label === 'string' || typeof label === 'number') { + return String(label); + } + + return stringifyAsLabel(value); +} + export function resolveMultipleLabels( values: any[], items: ItemsInput, itemToStringLabel?: (item: any) => string, + isItemEqualToValue?: ItemEqualityComparer, ): React.ReactNode { return values.reduce((acc, value, index) => { if (index > 0) { @@ -143,7 +203,7 @@ export function resolveMultipleLabels( } acc.push( - {resolveSelectedLabel(value, items, itemToStringLabel)} + {resolveSelectedLabel(value, items, itemToStringLabel, isItemEqualToValue)} , ); return acc;