Skip to content

Commit fb444ed

Browse files
committed
[combobox] Support primitive item values
1 parent 5943dec commit fb444ed

22 files changed

Lines changed: 4749 additions & 143 deletions

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,27 @@ import { Combobox } from '@base-ui/react/combobox';
7373

7474
## TypeScript
7575

76-
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>`.
76+
Combobox infers the selected value type from `defaultValue`, `value`, or the return value of `itemToValue`.
77+
Source items and explicitly specified `<Combobox.Item value>` props can have different types. Use `itemToValue` to derive selection values automatically when rendering a collection: filtering and collection children receive source items, while change callbacks and equality checks receive mapped values.
78+
Mapped values must be defined and unique. An explicit item `value` takes precedence over `itemToValue`.
79+
80+
For example, the source items can be user objects while selection uses only their IDs. Their `label` fields are used automatically for display and filtering:
81+
82+
```tsx title="Mapping source items to selection values"
83+
type User = { id: string; label: string; email: string };
84+
85+
const users: User[] = [
86+
{ id: 'u1', label: 'Alice Johnson', email: 'alice@example.com' },
87+
{ id: 'u2', label: 'Bob Smith', email: 'bob@example.com' },
88+
];
89+
90+
<Combobox.Root items={users} itemToValue={(user) => user.id}>
91+
<Combobox.Input />
92+
<Combobox.List>
93+
{(user) => <Combobox.Item key={user.id}>{user.label}</Combobox.Item>}
94+
</Combobox.List>
95+
</Combobox.Root>;
96+
```
7897

7998
## Examples
8099

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

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

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ An input combined with a list of predefined items to select.
461461
- useFilteredItems
462462
- Exports:
463463
- Combobox - Root
464-
- Props: actionsRef, autoComplete, autoHighlight, children, defaultInputValue, defaultOpen, defaultValue, disabled, filter, filteredItems, form, grid, highlightItemOnHover, id, inline, inputRef, inputValue, isItemEqualToValue, itemToStringLabel, itemToStringValue, items, limit, locale, loopFocus, modal, multiple, name, onInputValueChange, onItemHighlighted, onOpenChange, onOpenChangeComplete, onValueChange, open, openOnInputClick, readOnly, required, value, virtualized
464+
- Props: actionsRef, autoComplete, autoHighlight, children, defaultInputValue, defaultOpen, defaultValue, disabled, filter, filteredItems, form, grid, highlightItemOnHover, id, inline, inputRef, inputValue, isItemEqualToValue, itemToStringLabel, itemToStringValue, itemToValue, items, limit, locale, loopFocus, modal, multiple, name, onInputValueChange, onItemHighlighted, onOpenChange, onOpenChangeComplete, onValueChange, open, openOnInputClick, readOnly, required, value, virtualized
465465
- Combobox - Trigger
466466
- Props: className, disabled, nativeButton, render, style
467467
- Data Attributes: data-dirty, data-disabled, data-filled, data-focused, data-invalid, data-list-empty, data-placeholder, data-popup-open, data-popup-side, data-pressed, data-readonly, data-required, data-touched, data-valid
@@ -527,7 +527,7 @@ An input combined with a list of predefined items to select.
527527
- Combobox - useFilter
528528
- Parameters: options
529529
- Combobox - useFilteredItems
530-
- Types: Combobox.Arrow.Props, Combobox.Arrow.State, Combobox.Backdrop.Props, Combobox.Backdrop.State, Combobox.Chip.Props, Combobox.Chip.State, Combobox.ChipRemove.Props, Combobox.ChipRemove.State, Combobox.Chips.Props, Combobox.Chips.State, Combobox.Clear.Props, Combobox.Clear.State, Combobox.Collection.Props, Combobox.Collection.State, Combobox.Empty.Props, Combobox.Empty.State, Combobox.Group.Props, Combobox.Group.State, Combobox.GroupLabel.Props, Combobox.GroupLabel.State, Combobox.Icon.Props, Combobox.Icon.State, Combobox.Input.Props, Combobox.Input.State, Combobox.InputGroup.Props, Combobox.InputGroup.State, Combobox.Item.Props, Combobox.Item.State, Combobox.ItemIndicator.Props, Combobox.ItemIndicator.State, Combobox.Label.Props, Combobox.Label.State, Combobox.List.Props, Combobox.List.State, Combobox.Popup.Props, Combobox.Popup.State, Combobox.Portal.Props, Combobox.Portal.State, Combobox.Positioner.Props, Combobox.Positioner.State, Combobox.Root.Actions, Combobox.Root.ChangeEventDetails, Combobox.Root.ChangeEventReason, Combobox.Root.HighlightEventDetails, Combobox.Root.HighlightEventReason, Combobox.Root.Props, Combobox.Root.State, Combobox.Row.Props, Combobox.Row.State, Combobox.Separator.Props, Combobox.Separator.State, Combobox.Status.Props, Combobox.Status.State, Combobox.Trigger.Props, Combobox.Trigger.State, Combobox.Value.Props, Combobox.Value.State
530+
- Types: Combobox.Arrow.Props, Combobox.Arrow.State, Combobox.Backdrop.Props, Combobox.Backdrop.State, Combobox.Chip.Props, Combobox.Chip.State, Combobox.ChipRemove.Props, Combobox.ChipRemove.State, Combobox.Chips.Props, Combobox.Chips.State, Combobox.Clear.Props, Combobox.Clear.State, Combobox.Collection.Props, Combobox.Collection.State, Combobox.Empty.Props, Combobox.Empty.State, Combobox.Group.Props, Combobox.Group.State, Combobox.GroupLabel.Props, Combobox.GroupLabel.State, Combobox.Icon.Props, Combobox.Icon.State, Combobox.Input.Props, Combobox.Input.State, Combobox.InputGroup.Props, Combobox.InputGroup.State, Combobox.Item.Props, Combobox.Item.State, Combobox.ItemIndicator.Props, Combobox.ItemIndicator.State, Combobox.Label.Props, Combobox.Label.State, Combobox.List.Props, Combobox.List.State, Combobox.Popup.Props, Combobox.Popup.State, Combobox.Portal.Props, Combobox.Portal.State, Combobox.Positioner.Props, Combobox.Positioner.State, Combobox.Root.Actions, Combobox.Root.ChangeEventDetails, Combobox.Root.ChangeEventReason, Combobox.Root.HighlightEventDetails, Combobox.Root.HighlightEventReason, Combobox.Root.MappedProps, Combobox.Root.Props, Combobox.Root.State, Combobox.Row.Props, Combobox.Row.State, Combobox.Separator.Props, Combobox.Separator.State, Combobox.Status.Props, Combobox.Status.State, Combobox.Trigger.Props, Combobox.Trigger.State, Combobox.Value.Props, Combobox.Value.State
531531

532532
</details>
533533

packages/react/src/autocomplete/root/AutocompleteRoot.spec.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ const groupItemsReadonly = [
3939
}}
4040
/>;
4141

42+
// @ts-expect-error - itemToValue is a Combobox-only prop
43+
<Autocomplete.Root items={objectItems} itemToValue={(item) => item.value} />;
44+
4245
<Autocomplete.Root
4346
items={groupItemsReadonly}
4447
itemToStringValue={(item) => {

packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ describe('<Autocomplete.Root />', () => {
1414

1515
const { render, renderToString } = createRenderer();
1616

17+
it('does not forward itemToValue supplied through an untyped spread', async () => {
18+
const itemToValue = vi.fn(() => 'mapped');
19+
const rootProps: any = { items: ['alpha'], itemToValue };
20+
21+
await render(
22+
<Autocomplete.Root {...rootProps}>
23+
<Autocomplete.Input />
24+
<Autocomplete.List>
25+
<Autocomplete.Item value="alpha">alpha</Autocomplete.Item>
26+
</Autocomplete.List>
27+
</Autocomplete.Root>,
28+
);
29+
30+
expect(itemToValue).not.toHaveBeenCalled();
31+
});
32+
1733
describe('keyboard interactions', () => {
1834
it('closes popup on Tab after selecting with Enter and typing again', async () => {
1935
const { user } = await render(

packages/react/src/autocomplete/root/AutocompleteRoot.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ export function AutocompleteRoot<ItemValue>(
3939
onValueChange,
4040
mode = 'list',
4141
itemToStringValue,
42+
itemToValue: ignoredItemToValue,
4243
...other
43-
} = props;
44+
} = props as AutocompleteRoot.Props<ItemValue> & { itemToValue?: unknown };
4445

4546
const enableInline = mode === 'inline' || mode === 'both';
4647
const staticItems = mode === 'inline' || mode === 'none';
@@ -155,6 +156,7 @@ export interface AutocompleteRootProps<ItemValue> extends Omit<
155156
| 'onSelectedValueChange'
156157
| 'fillInputOnItemPress'
157158
| 'itemToStringValue'
159+
| 'itemToValue'
158160
| 'isItemEqualToValue'
159161
// Different names
160162
| 'inputValue' // value

packages/react/src/combobox/collection/ComboboxCollection.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
'use client';
22
import * as React from 'react';
3-
import { useComboboxDerivedItemsContext } from '../root/ComboboxRootContext';
3+
import {
4+
ComboboxHasItemsContext,
5+
useComboboxDerivedItemsContext,
6+
} from '../root/ComboboxRootContext';
47
import { useGroupCollectionContext } from './GroupCollectionContext';
58

69
/**
@@ -14,7 +17,7 @@ import { useGroupCollectionContext } from './GroupCollectionContext';
1417
export function ComboboxCollection(props: ComboboxCollection.Props): React.JSX.Element | null {
1518
const { children } = props;
1619

17-
const { filteredItems } = useComboboxDerivedItemsContext();
20+
const { filteredItems, itemToValue } = useComboboxDerivedItemsContext();
1821
const groupContext = useGroupCollectionContext();
1922

2023
const itemsToRender = groupContext ? groupContext.items : filteredItems;
@@ -23,7 +26,28 @@ export function ComboboxCollection(props: ComboboxCollection.Props): React.JSX.E
2326
return null;
2427
}
2528

26-
return <React.Fragment>{itemsToRender.map(children)}</React.Fragment>;
29+
if (!itemToValue) {
30+
return <React.Fragment>{itemsToRender.map(children)}</React.Fragment>;
31+
}
32+
33+
return (
34+
<React.Fragment>
35+
{itemsToRender.map((item, index) => {
36+
const child = children(item, index);
37+
// A distinct context value intentionally associates each rendered child with its item.
38+
// eslint-disable-next-line react/jsx-no-constructed-context-values
39+
const contextValue = [itemToValue(item)] as const;
40+
return (
41+
<ComboboxHasItemsContext.Provider
42+
key={(child as React.ReactElement | null)?.key ?? index}
43+
value={contextValue}
44+
>
45+
{child}
46+
</ComboboxHasItemsContext.Provider>
47+
);
48+
})}
49+
</React.Fragment>
50+
);
2751
}
2852

2953
export interface ComboboxCollectionState {}

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

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,16 @@ interface ComboboxItemInnerProps {
3131
* composite list registration order.
3232
*/
3333
indexFromFilter: number | undefined;
34+
mappedValue?: any;
3435
}
3536

3637
function ComboboxItemInner(props: ComboboxItemInnerProps) {
37-
const { componentProps, forwardedRef, virtualized, indexFromFilter } = props;
38+
const { componentProps, forwardedRef, virtualized, indexFromFilter, mappedValue } = props;
3839
const {
3940
render,
4041
className,
4142
style,
42-
value: itemValue = null,
43+
value: itemValueProp = null,
4344
index: indexProp,
4445
disabled = false,
4546
nativeButton = false,
@@ -55,7 +56,8 @@ function ComboboxItemInner(props: ComboboxItemInnerProps) {
5556

5657
const store = useComboboxRootContext();
5758
const isRow = useComboboxRowContext();
58-
const hasItems = useComboboxHasItemsContext();
59+
const hasItemsContext = useComboboxHasItemsContext();
60+
const hasItems = hasItemsContext !== false;
5961

6062
const selectionMode = useStore(store, selectors.selectionMode);
6163
const readOnly = useStore(store, selectors.readOnly);
@@ -64,6 +66,16 @@ function ComboboxItemInner(props: ComboboxItemInnerProps) {
6466
const selectable = selectionMode !== 'none';
6567
const index = indexProp ?? (virtualized ? (indexFromFilter ?? -1) : listItem.index);
6668
const hasRegistered = listItem.index !== -1;
69+
const hasExplicitValue = componentProps.value !== undefined;
70+
71+
let itemValue = itemValueProp;
72+
if (!hasExplicitValue) {
73+
if (typeof hasItemsContext !== 'boolean') {
74+
[itemValue] = hasItemsContext;
75+
} else if (mappedValue !== undefined) {
76+
itemValue = mappedValue;
77+
}
78+
}
6779

6880
const rootId = useStore(store, selectors.id);
6981
const highlighted = useStore(store, selectors.isActive, index);
@@ -221,13 +233,26 @@ function ComboboxItemVirtualizedIndex(props: {
221233

222234
const store = useComboboxRootContext();
223235
const isItemEqualToValue = useStore(store, selectors.isItemEqualToValue);
224-
const { flatFilteredItems } = useComboboxDerivedItemsContext();
236+
const { flatFilteredItems, flatFilteredValues } = useComboboxDerivedItemsContext();
237+
238+
let indexFromFilter = componentProps.index;
239+
if (indexFromFilter == null) {
240+
indexFromFilter = findItemIndex(
241+
flatFilteredValues,
242+
componentProps.value ?? null,
243+
isItemEqualToValue,
244+
);
245+
if (indexFromFilter === -1) {
246+
indexFromFilter = findItemIndex(
247+
flatFilteredItems,
248+
componentProps.value ?? null,
249+
isItemEqualToValue,
250+
);
251+
}
252+
}
225253

226-
const indexFromFilter = findItemIndex(
227-
flatFilteredItems,
228-
componentProps.value ?? null,
229-
isItemEqualToValue,
230-
);
254+
const mappedValue =
255+
componentProps.value === undefined ? flatFilteredValues[indexFromFilter] : undefined;
231256

232257
// Only reached when `virtualized` is true (see the wrapper below).
233258
return (
@@ -236,6 +261,7 @@ function ComboboxItemVirtualizedIndex(props: {
236261
forwardedRef={forwardedRef}
237262
virtualized
238263
indexFromFilter={indexFromFilter}
264+
mappedValue={mappedValue}
239265
/>
240266
);
241267
}
@@ -257,7 +283,7 @@ export const ComboboxItem = React.memo(
257283
// `virtualized` (and whether an item provides an explicit `index`) must be stable for an
258284
// item's lifetime: the two branches return different component types, so flipping it at
259285
// runtime remounts the item and resets its refs and effects.
260-
if (virtualized && componentProps.index == null) {
286+
if (virtualized) {
261287
return (
262288
<ComboboxItemVirtualizedIndex componentProps={componentProps} forwardedRef={forwardedRef} />
263289
);

0 commit comments

Comments
 (0)