Skip to content

Commit ce8c0fb

Browse files
committed
[combobox] Support primitive item values
Lets Combobox infer primitive values from supported `{ value, label }` item shapes, e.g. `items={[{ value: "banana", label: "Banana" }]}` with `defaultValue="banana"` or `<Combobox.Item value="banana" />`, while preserving the exact rendered value shape for `<Combobox.Item value={item}>`. Inferred values drive selection, highlight, scroll-into-view, typeahead, autofill, and labels. The public API is unchanged. Select is unchanged.
1 parent f8d9cbc commit ce8c0fb

12 files changed

Lines changed: 1509 additions & 100 deletions

File tree

docs/src/app/(docs)/react/components/combobox/page.mdx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ import { Combobox } from '@base-ui/react/combobox';
7474
## TypeScript
7575

7676
Combobox infers the item type from the `defaultValue` or `value` props passed to `<Combobox.Root>`.
77-
The type of items held in the `items` array must also match the `value` prop type passed to `<Combobox.Item>`.
77+
78+
When a non-virtualized `items` array uses the full `{ value, label }` shape, `<Combobox.Item>` can use either the whole item or its `value` field.
79+
Virtualized comboboxes do not support this primitive value inference.
80+
For other item shapes, the `items` array type should match the `value` prop type passed to `<Combobox.Item>`.
7881

7982
## Examples
8083

docs/src/app/(docs)/react/components/combobox/types.md

Lines changed: 45 additions & 45 deletions
Large diffs are not rendered by default.

packages/react/src/combobox/item/ComboboxItem.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,24 +95,28 @@ function ComboboxItemInner(props: ComboboxItemInnerProps) {
9595
}, [hasRegistered, virtualized, index, indexProp, store]);
9696

9797
useIsoLayoutEffect(() => {
98-
if (!hasRegistered || hasItems) {
98+
if (!hasRegistered || (hasItems && virtualized)) {
9999
return undefined;
100100
}
101101

102+
// Register the rendered item value so the visible registry preserves its exact
103+
// shape (a primitive `value`, or the whole item for `value={item}`) for selection,
104+
// highlight callbacks, autofill, and typeahead. Virtualized `items` lists are
105+
// owned by the root registry because it may directly reference the `items` array.
102106
const visibleMap = store.state.valuesRef.current;
103107
visibleMap[index] = itemValue;
104108

105109
// Stable registry that doesn't depend on filtering. Assume that no
106110
// filtering had occurred at this point; otherwise, an `items` prop is
107111
// required.
108-
if (selectionMode !== 'none') {
112+
if (!hasItems && selectionMode !== 'none') {
109113
store.state.allValuesRef.current.push(itemValue);
110114
}
111115

112116
return () => {
113117
delete visibleMap[index];
114118
};
115-
}, [hasRegistered, hasItems, index, itemValue, store, selectionMode]);
119+
}, [hasRegistered, hasItems, virtualized, index, itemValue, store, selectionMode]);
116120

117121
useIsoLayoutEffect(() => {
118122
if (!open) {

packages/react/src/combobox/root/AriaCombobox.tsx

Lines changed: 96 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ import { NOOP } from '../../internals/noop';
4949
import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups';
5050
import { mergeProps } from '../../merge-props';
5151
import {
52+
findItemIndexByValue,
53+
inferItemValue,
54+
resolveLabelString,
55+
resolveRenderedValue,
5256
stringifyAsLabel,
5357
stringifyAsValue,
5458
Group,
@@ -175,6 +179,12 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
175179
const single = selectionMode === 'single';
176180
const hasInputValue = inputValueProp !== undefined || defaultInputValueProp !== undefined;
177181
const hasItems = items !== undefined;
182+
183+
// Resolving a primitive selected value to its `{ value, label }` item's label is only
184+
// supported for non-virtualized lists (virtualized selection sync matches whole items),
185+
// so virtualized lists resolve labels from the value's own shape to keep the displayed
186+
// label consistent with what can actually be highlighted.
187+
const labelItems = virtualized ? undefined : items;
178188
const hasFilteredItemsProp = filteredItemsProp !== undefined;
179189

180190
let autoHighlightMode: false | 'input-change' | 'always';
@@ -212,7 +222,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
212222
return defaultInputValueProp ?? '';
213223
}
214224
if (single) {
215-
return stringifyAsLabel(selectedValue, itemToStringLabel);
225+
return resolveLabelString(selectedValue, labelItems, itemToStringLabel, isItemEqualToValue);
216226
}
217227
return '';
218228
},
@@ -235,7 +245,15 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
235245
const isGrouped = isGroupedItems(items);
236246
const query = closeQuery ?? (inputValue === '' ? '' : String(inputValue).trim());
237247

238-
const selectedLabelString = single ? stringifyAsLabel(selectedValue, itemToStringLabel) : '';
248+
// Resolving the label can scan `items`, so memoize to avoid repeating it on every
249+
// render (e.g. each keystroke) when the selection and items are unchanged.
250+
const selectedLabelString = React.useMemo(
251+
() =>
252+
single
253+
? resolveLabelString(selectedValue, labelItems, itemToStringLabel, isItemEqualToValue)
254+
: '',
255+
[single, selectedValue, labelItems, itemToStringLabel, isItemEqualToValue],
256+
);
239257

240258
const shouldBypassFiltering =
241259
single &&
@@ -306,12 +324,11 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
306324
if (filterQuery === '') {
307325
return limit > -1
308326
? flatItems.slice(0, limit)
309-
: // The cast here is done as `flatItems` is readonly.
310-
// valuesRef.current, a mutable ref, can be set to `flatFilteredItems`, which may
311-
// reference this exact readonly value, creating a mutation risk.
312-
// However, <Combobox.Item> can never mutate this value as the mutating effect
313-
// bails early when `items` is provided, and this is only ever returned
314-
// when `items` is provided due to the early return at the top of this hook.
327+
: // The cast here is done as `flatItems` is readonly. Reusing the prop's array is
328+
// safe: in the non-virtualized path `valuesRef` is a distinct array (mounted
329+
// items write their own slots and the root effect fills the rest), and in the
330+
// virtualized path `valuesRef` aliases this array but is only ever read by index,
331+
// never mutated.
315332
(flatItems as Value[]);
316333
}
317334

@@ -461,12 +478,15 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
461478
nameProp,
462479
);
463480

464-
const forceMount = useStableCallback(() => {
481+
const forceMount = useStableCallback((renderItems = false) => {
465482
if (items) {
466483
// Ensure typeahead works on a closed list.
467484
labelsRef.current = flatFilteredItems.map((item) =>
468485
stringifyAsLabel(item, itemToStringLabel),
469486
);
487+
if (renderItems && !virtualized) {
488+
store.set('forceMounted', true);
489+
}
470490
} else {
471491
store.set('forceMounted', true);
472492
}
@@ -647,7 +667,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
647667

648668
if (shouldFillInput) {
649669
setInputValue(
650-
stringifyAsLabel(nextValue, itemToStringLabel),
670+
resolveLabelString(nextValue, labelItems, itemToStringLabel, isItemEqualToValue),
651671
createChangeEventDetails(eventDetails.reason, eventDetails.event),
652672
);
653673
}
@@ -771,7 +791,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
771791
setInputValue('', createChangeEventDetails(REASONS.inputClear));
772792
}
773793
} else {
774-
const stringVal = stringifyAsLabel(selectedValue, itemToStringLabel);
794+
const stringVal = selectedLabelString;
775795
if (inputRef.current && inputRef.current.value !== stringVal) {
776796
// If no selection was made, treat this as clearing the typed filter.
777797
const reason = stringVal === '' ? REASONS.inputClear : REASONS.none;
@@ -805,42 +825,74 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
805825

806826
React.useImperativeHandle(props.actionsRef, () => ({ unmount: handleUnmount }), [handleUnmount]);
807827

828+
const navigationValue =
829+
multiple && Array.isArray(selectedValue)
830+
? selectedValue[selectedValue.length - 1]
831+
: selectedValue;
832+
833+
// Derive the selected value's index in the (unfiltered) `items`. Non-virtualized
834+
// lists can match the full item (object `value={item}`) or its inferred value
835+
// (primitive `value` against a `{ value, label }` entry). Only the closed state is
836+
// consumed (the syncing effect bails while open), so skip the scan while open to
837+
// avoid rescanning large lists on every selection change during open interactions.
838+
const itemsSelectedIndex = React.useMemo(() => {
839+
if (open || selectionMode === 'none' || !items) {
840+
return -1;
841+
}
842+
if (virtualized) {
843+
return findItemIndex(flatItems, navigationValue, isItemEqualToValue);
844+
}
845+
return findItemIndexByValue(flatItems, navigationValue, isItemEqualToValue);
846+
}, [open, selectionMode, items, virtualized, flatItems, navigationValue, isItemEqualToValue]);
847+
808848
useIsoLayoutEffect(
809849
function syncSelectedIndex() {
850+
// Snapshot the selected index only while the popup is closed, so selecting,
851+
// deselecting, clearing, or filtering while open doesn't move the highlight off
852+
// the active item. Drives initial highlight and scroll-into-view on open.
810853
if (open || selectionMode === 'none') {
811854
return;
812855
}
813856

814-
const registry = items ? flatItems : allValuesRef.current;
815-
816-
if (multiple) {
817-
const currentValue = Array.isArray(selectedValue) ? selectedValue : [];
818-
const lastValue = currentValue[currentValue.length - 1];
819-
const lastIndex = findItemIndex(registry, lastValue, isItemEqualToValue);
820-
setIndices({ selectedIndex: lastIndex === -1 ? null : lastIndex });
821-
} else {
822-
const index = findItemIndex(registry, selectedValue, isItemEqualToValue);
823-
setIndices({ selectedIndex: index === -1 ? null : index });
824-
}
857+
// With `items` the index is the derived value above; without `items`, match
858+
// against the values registered by mounted items.
859+
const index = items
860+
? itemsSelectedIndex
861+
: findItemIndex(allValuesRef.current, navigationValue, isItemEqualToValue);
862+
setIndices({ selectedIndex: index === -1 ? null : index });
825863
},
826864
[
827865
open,
828-
selectedValue,
829-
items,
830866
selectionMode,
831-
flatItems,
832-
multiple,
867+
items,
868+
itemsSelectedIndex,
869+
navigationValue,
833870
isItemEqualToValue,
834871
setIndices,
835872
],
836873
);
837874

838875
useIsoLayoutEffect(() => {
839876
if (items) {
840-
valuesRef.current = flatFilteredItems;
877+
if (virtualized) {
878+
valuesRef.current = flatFilteredItems;
879+
listRef.current.length = flatFilteredItems.length;
880+
return;
881+
}
882+
883+
// Mounted items register their own rendered value (preserving its exact shape).
884+
// Fill any remaining slots in place with an inferred value so the registry stays
885+
// complete without clobbering those writes.
886+
const visibleMap = valuesRef.current;
887+
visibleMap.length = flatFilteredItems.length;
888+
for (let index = 0; index < flatFilteredItems.length; index += 1) {
889+
if (visibleMap[index] === undefined) {
890+
visibleMap[index] = inferItemValue(flatFilteredItems[index]);
891+
}
892+
}
841893
listRef.current.length = flatFilteredItems.length;
842894
}
843-
}, [items, flatFilteredItems]);
895+
}, [items, virtualized, flatFilteredItems]);
844896

845897
useIsoLayoutEffect(() => {
846898
const pendingHighlight = pendingQueryHighlightRef.current;
@@ -890,7 +942,13 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
890942
return;
891943
}
892944

893-
const itemValue = candidateItems[storeActiveIndex];
945+
const candidateItem = candidateItems[storeActiveIndex];
946+
// Surface the rendered item value so highlight callbacks report the same shape used
947+
// for selection. Non-virtualized lists can fall back to the inferred value for any
948+
// slot not registered by a mounted item; virtualized lists keep the item shape.
949+
const itemValue = hasItems
950+
? resolveRenderedValue(valuesRef.current[storeActiveIndex], candidateItem, virtualized)
951+
: candidateItem;
894952
const previouslyHighlightedItemValue = lastHighlightRef.current.value;
895953
const isSameItem =
896954
previouslyHighlightedItemValue !== NO_ACTIVE_VALUE &&
@@ -916,6 +974,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
916974
inline,
917975
open,
918976
store,
977+
virtualized,
919978
]);
920979

921980
useIsoLayoutEffect(() => {
@@ -966,7 +1025,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
9661025
validation.change(selectedValue);
9671026

9681027
if (single && !hasInputValue && !inputInsidePopup) {
969-
const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel);
1028+
const nextInputValue = selectedLabelString;
9701029

9711030
if (inputValue !== nextInputValue) {
9721031
setInputValue(nextInputValue, createChangeEventDetails(REASONS.none));
@@ -990,7 +1049,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
9901049
return;
9911050
}
9921051

993-
const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel);
1052+
const nextInputValue = selectedLabelString;
9941053

9951054
if (inputValue !== nextInputValue) {
9961055
setInputValue(nextInputValue, createChangeEventDetails(REASONS.none));
@@ -1529,6 +1588,12 @@ interface ComboboxRootProps<ItemValue> {
15291588
/**
15301589
* The items to be displayed in the list.
15311590
* Can be either a flat array of items or an array of groups with items.
1591+
* In non-virtualized lists, when items use the `{ value, label }` shape, a primitive
1592+
* `<Combobox.Item value>` resolves its text label from the matching item. In single
1593+
* selection mode `<Combobox.Value>` and the input render that label instead of the raw
1594+
* value; in multiple selection mode the input stays the query field, so only
1595+
* `<Combobox.Value>` renders the selected labels.
1596+
* Virtualized lists do not support this primitive value inference.
15321597
*/
15331598
items?: readonly any[] | readonly Group<any>[] | undefined;
15341599
/**

0 commit comments

Comments
 (0)