Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions docs/src/app/(docs)/react/components/combobox/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,67 @@ import { Combobox } from '@base-ui/react/combobox';
</Combobox.Root>;
```

## TypeScript
## Item values

Combobox infers the item type from the `defaultValue` or `value` props passed to `<Combobox.Root>`.
The type of items held in the `items` array must also match the `value` prop type passed to `<Combobox.Item>`.
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
<Combobox.Root defaultValue={users[0]} items={users}>
{/* @highlight-end */}
<Combobox.List>
{(user) => <Combobox.Item key={user.id}>{user.label}</Combobox.Item>}
</Combobox.List>
</Combobox.Root>;
```

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
<Combobox.Root defaultValue="some-id" items={users} itemToValue={(user) => user.id}>
{/* @highlight-end */}
<Combobox.List>
{(user) => <Combobox.Item key={user.id}>{user.label}</Combobox.Item>}
</Combobox.List>
</Combobox.Root>;
```

`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
<Combobox.Root items={users} itemToStringLabel={(user) => user.name}>
{/* @highlight-end */}
<Combobox.List>
{(user) => <Combobox.Item key={user.id}>{user.name}</Combobox.Item>}
</Combobox.List>
</Combobox.Root>;

// @highlight-start
<Combobox.Root
items={users}
itemToValue={(user) => user.id}
itemToStringLabel={(id, user) => user?.name ?? id}
>
{/* @highlight-end */}
<Combobox.List>
{(user) => <Combobox.Item key={user.id}>{user.name}</Combobox.Item>}
</Combobox.List>
</Combobox.Root>;
```

## Examples

Expand All @@ -91,6 +148,13 @@ export function MyCombobox<Value, Multiple extends boolean | undefined = false>(
): React.JSX.Element {
return <Combobox.Root {...props}>{/* ... */}</Combobox.Root>;
}

interface User {
id: string;
name: string;
}

type UserComboboxProps = Combobox.Root.MappedProps<string, false, User>;
```

### Multiple select
Expand Down
319 changes: 258 additions & 61 deletions docs/src/app/(docs)/react/components/combobox/types.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/src/app/(docs)/react/components/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ An input combined with a list of predefined items to select.
- Sections:
- Usage guidelines
- Anatomy
- TypeScript
- Item values
- Examples
- Typed wrapper component
- Multiple select
Expand Down Expand Up @@ -461,7 +461,7 @@ An input combined with a list of predefined items to select.
- useFilteredItems
- Exports:
- Combobox - Root
- 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
- 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
- Combobox - Trigger
- Props: className, disabled, nativeButton, render, style
- 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
Expand Down Expand Up @@ -527,7 +527,7 @@ An input combined with a list of predefined items to select.
- Combobox - useFilter
- Parameters: options
- Combobox - useFilteredItems
- 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
- 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

</details>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const groupItemsReadonly = [
}}
/>;

// @ts-expect-error - itemToValue is a Combobox-only prop
<Autocomplete.Root items={objectItems} itemToValue={(item) => item.value} />;

<Autocomplete.Root
items={groupItemsReadonly}
itemToStringValue={(item) => {
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/autocomplete/root/AutocompleteRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ export function AutocompleteRoot<ItemValue>(
onValueChange,
mode = 'list',
itemToStringValue,
itemToValue: ignoredItemToValue,
...other
} = props;
} = props as AutocompleteRoot.Props<ItemValue> & { itemToValue?: unknown };

// Strip this Combobox-only prop for untyped callers. Forwarding it would enable mapped-value
// behavior that Autocomplete does not expose or support.
void ignoredItemToValue;

const enableInline = mode === 'inline' || mode === 'both';
const staticItems = mode === 'inline' || mode === 'none';
Expand Down Expand Up @@ -155,6 +160,7 @@ export interface AutocompleteRootProps<ItemValue> extends Omit<
| 'onSelectedValueChange'
| 'fillInputOnItemPress'
| 'itemToStringValue'
| 'itemToValue'
| 'isItemEqualToValue'
// Different names
| 'inputValue' // value
Expand Down
45 changes: 42 additions & 3 deletions packages/react/src/combobox/collection/ComboboxCollection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
'use client';
import * as React from 'react';
import { useComboboxDerivedItemsContext } from '../root/ComboboxRootContext';
import {
ComboboxHasItemsContext,
useComboboxDerivedItemsContext,
} from '../root/ComboboxRootContext';
import { isGroupedItems } from '../../internals/resolveValueLabel';
import { useGroupCollectionContext } from './GroupCollectionContext';

/**
Expand All @@ -14,12 +18,47 @@ import { useGroupCollectionContext } from './GroupCollectionContext';
export function ComboboxCollection(props: ComboboxCollection.Props): React.JSX.Element {
const { children } = props;

const { filteredItems } = useComboboxDerivedItemsContext();
const { filteredItems, itemToValue, mappedValues } = useComboboxDerivedItemsContext();
const groupContext = useGroupCollectionContext();
const tupleCache = React.useRef(new Map<any, { value: any; context: readonly [any] }>());

const itemsToRender = groupContext ? groupContext.items : filteredItems;

return <React.Fragment>{itemsToRender.map(children)}</React.Fragment>;
// The outer pass of a grouped collection renders group records. Only the nested collections
// render leaf items, which are the values accepted by `itemToValue`.
if (!itemToValue || (!groupContext && isGroupedItems(itemsToRender))) {
return <React.Fragment>{itemsToRender.map(children)}</React.Fragment>;
}

const renderedItems = new Set(itemsToRender);
for (const cachedItem of tupleCache.current.keys()) {
if (!renderedItems.has(cachedItem)) {
tupleCache.current.delete(cachedItem);
}
}

return (
<React.Fragment>
{itemsToRender.map((item, index) => {
const child = children(item, index);
const mappedValue = mappedValues?.has(item) ? mappedValues.get(item) : itemToValue(item);
const cached = tupleCache.current.get(item);
let contextValue = cached?.context;
if (!cached || !Object.is(cached.value, mappedValue)) {
contextValue = [mappedValue] as const;
tupleCache.current.set(item, { value: mappedValue, context: contextValue });
}
return (
<ComboboxHasItemsContext.Provider
key={(child as React.ReactElement | null)?.key ?? index}
value={contextValue!}
>
{child}
</ComboboxHasItemsContext.Provider>
);
})}
</React.Fragment>
);
}

export interface ComboboxCollectionState {}
Expand Down
59 changes: 40 additions & 19 deletions packages/react/src/combobox/item/ComboboxItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@ interface ComboboxItemInnerProps {
* composite list registration order.
*/
indexFromFilter: number | undefined;
mappedValue?: any;
}

function ComboboxItemInner(props: ComboboxItemInnerProps) {
const { componentProps, forwardedRef, virtualized, indexFromFilter } = props;
const { componentProps, forwardedRef, virtualized, indexFromFilter, mappedValue } = props;
const {
render,
className,
style,
value: itemValue = null,
value: itemValueProp = null,
index: indexProp,
disabled = false,
nativeButton = false,
Expand All @@ -55,7 +56,7 @@ function ComboboxItemInner(props: ComboboxItemInnerProps) {

const store = useComboboxRootContext();
const isRow = useComboboxRowContext();
const hasItems = useComboboxHasItemsContext();
const hasItemsContext = useComboboxHasItemsContext();

const selectionMode = useStore(store, selectors.selectionMode);
const readOnly = useStore(store, selectors.readOnly);
Expand All @@ -64,6 +65,16 @@ function ComboboxItemInner(props: ComboboxItemInnerProps) {
const selectable = selectionMode !== 'none';
const index = indexProp ?? indexFromFilter ?? listItem.index;
const hasRegistered = index !== -1;
const hasExplicitValue = componentProps.value !== undefined;

let itemValue = itemValueProp;
if (typeof hasItemsContext !== 'boolean') {
// Function children belong to their source item, so mapping wins over a mismatched explicit
// value and keeps pointer and keyboard selection in the same value domain.
[itemValue] = hasItemsContext;
} else if (!hasExplicitValue && mappedValue !== undefined) {
itemValue = mappedValue;
}

const rootId = useStore(store, selectors.id);
const highlighted = useStore(store, selectors.isActive, index);
Expand All @@ -90,7 +101,7 @@ function ComboboxItemInner(props: ComboboxItemInnerProps) {
}, [hasRegistered, virtualized, index, indexProp, store]);

useIsoLayoutEffect(() => {
if (!hasRegistered || hasItems) {
if (!hasRegistered) {
return undefined;
}

Expand All @@ -100,10 +111,10 @@ function ComboboxItemInner(props: ComboboxItemInnerProps) {
return () => {
delete visibleMap[index];
};
}, [hasRegistered, hasItems, index, itemValue, store]);
}, [hasRegistered, index, itemValue, store]);

useIsoLayoutEffect(() => {
if (!hasRegistered || hasItems) {
if (!hasRegistered || hasItemsContext === true) {
return;
}

Expand All @@ -118,7 +129,7 @@ function ComboboxItemInner(props: ComboboxItemInnerProps) {
if (compareItemEquality(itemValue, lastSelectedValue, isItemEqualToValue)) {
store.set('selectedIndex', index);
}
}, [hasRegistered, hasItems, store, index, itemValue, isItemEqualToValue]);
}, [hasRegistered, hasItemsContext, store, index, itemValue, isItemEqualToValue]);

const { getButtonProps, buttonRef } = useButton({
disabled,
Expand Down Expand Up @@ -221,13 +232,18 @@ function ComboboxItemVirtualizedIndex(props: {

const store = useComboboxRootContext();
const isItemEqualToValue = useStore(store, selectors.isItemEqualToValue);
const { flatFilteredItems } = useComboboxDerivedItemsContext();
const { flatFilteredValues, itemToValue } = useComboboxDerivedItemsContext();

const indexFromFilter = findItemIndex(
flatFilteredItems,
componentProps.value ?? null,
isItemEqualToValue,
);
// An explicit `value` is always a selection value, so it is looked up against the projected
// values. Without `itemToValue` those are the source items themselves.
const indexFromFilter =
componentProps.index ??
findItemIndex(flatFilteredValues, componentProps.value ?? null, isItemEqualToValue);

const mappedValue =
itemToValue && componentProps.value === undefined
? flatFilteredValues[indexFromFilter]
: undefined;

// Only reached when `virtualized` is true (see the wrapper below).
return (
Expand All @@ -236,6 +252,7 @@ function ComboboxItemVirtualizedIndex(props: {
forwardedRef={forwardedRef}
virtualized
indexFromFilter={indexFromFilter}
mappedValue={mappedValue}
/>
);
}
Expand All @@ -254,10 +271,13 @@ export const ComboboxItem = React.memo(
const store = useComboboxRootContext();
const virtualized = useStore(store, selectors.virtualized);

// `virtualized` (and whether an item provides an explicit `index`) must be stable for an
// item's lifetime: the two branches return different component types, so flipping it at
// runtime remounts the item and resets its refs and effects.
if (virtualized && componentProps.index == null) {
// `virtualized` and whether an item provides `index` and `value` must be stable for the item's
// lifetime: the two branches return different component types, so changing any of them at
// runtime can remount the item and reset its refs and effects.
// An item that supplies both `index` and `value` needs nothing from the filtered set, so it
// stays off that context. Omitting `value` means the value is projected from the source at
// this index, which does require the subscription.
if (virtualized && (componentProps.index == null || componentProps.value === undefined)) {
return (
<ComboboxItemVirtualizedIndex componentProps={componentProps} forwardedRef={forwardedRef} />
);
Expand Down Expand Up @@ -302,8 +322,9 @@ export interface ComboboxItemProps
*/
index?: number | undefined;
/**
* A unique value that identifies this item.
* @default null
* A unique value that identifies this item. Items rendered by collection function children use
* the value returned by the root's `itemToValue` prop when this is omitted.
* Manually rendered items must specify a value with the same type as the root's selected value.
*/
value?: any;
/**
Expand Down
Loading
Loading