diff --git a/apps/www/src/content/docs/components/combobox/demo.ts b/apps/www/src/content/docs/components/combobox/demo.ts
index 39c350ea7..71fb48d4b 100644
--- a/apps/www/src/content/docs/components/combobox/demo.ts
+++ b/apps/www/src/content/docs/components/combobox/demo.ts
@@ -149,3 +149,38 @@ export const controlledDemo = {
);
}`
};
+
+export const createItemsDemo = {
+ type: 'code',
+ code: `
+ function CreateItemsDemo() {
+ const items = React.useMemo(() => {
+ const users = [
+ { id: "u1", name: "Jane Doe" },
+ { id: "u2", name: "John Smith" },
+ { id: "u3", name: "Ada Lovelace" }
+ ];
+ return Combobox.createItems(users, {
+ getValue: user => user.id,
+ getLabel: user => user.name
+ });
+ }, []);
+ const [value, setValue] = React.useState(null);
+
+ return (
+
+ Selected ID: {value || "None"}
+
+
+
+ {user => (
+
+ {user.name}
+
+ )}
+
+
+
+ );
+ }`
+};
diff --git a/apps/www/src/content/docs/components/combobox/index.mdx b/apps/www/src/content/docs/components/combobox/index.mdx
index d30e80a97..ad9c2c0be 100644
--- a/apps/www/src/content/docs/components/combobox/index.mdx
+++ b/apps/www/src/content/docs/components/combobox/index.mdx
@@ -12,6 +12,7 @@ import {
iconDemo,
withFieldDemo,
controlledDemo,
+ createItemsDemo,
} from "./demo.ts";
@@ -61,6 +62,8 @@ Organize items into groups with labels and visual separators. Groups and labels
Pass the `multiple` prop to enable multi-select. Selected values appear as chips in the input. Items display checkboxes in multiple mode.
+Each chip shows its value as text. To show a label instead, pass `itemToStringLabel` to the root. Values in the `{ value, label }` shape show their `label` without it.
+
### Controlled
@@ -69,6 +72,16 @@ Use `value` and `onValueChange` for controlled behavior.
+### Using IDs as values
+
+When your app stores IDs instead of item objects, wrap the data with `Combobox.createItems`. `getValue` returns the value that `value` and `onValueChange` use. `getLabel` returns the text shown in the input and used for filtering. Pass a function as the child of `Combobox.Content` to render each item that matches the input. In TypeScript, set the item type on that function, for example `(user: User) => ...`.
+
+Create the collection at module scope for static data. For data that changes, wrap it in `React.useMemo` keyed by the data.
+
+With `multiple`, also pass `itemToStringLabel`, because chips do not read `getLabel`.
+
+
+
## API Reference
Root and input first, then the parts that make up the popup list.
diff --git a/apps/www/src/content/docs/components/combobox/props.ts b/apps/www/src/content/docs/components/combobox/props.ts
index 6b60a7236..6908fd723 100644
--- a/apps/www/src/content/docs/components/combobox/props.ts
+++ b/apps/www/src/content/docs/components/combobox/props.ts
@@ -49,6 +49,17 @@ export interface ComboboxRootProps {
* @default false
*/
modal?: boolean;
+
+ /**
+ * The items to filter. Accepts an array of items or a collection from `Combobox.createItems`.
+ * When set, the root filters the items and `Combobox.Content` can render them with a function child.
+ *
+ * @remarks `unknown[] | ReturnType`
+ */
+ items?: unknown[];
+
+ /** Converts a value to the text shown in the input and in multi-select chips. */
+ itemToStringLabel?: (value: string) => string;
}
export interface ComboboxInputProps {
@@ -96,6 +107,13 @@ export interface ComboboxContentProps {
* @defaultValue The theme's `radius`
*/
radius?: 'none' | 'small' | 'medium' | 'large' | 'full';
+
+ /**
+ * The list content. Pass a function to render each item that matches the input when `items` is set on the root.
+ */
+ children?:
+ | React.ReactNode
+ | ((item: unknown, index: number) => React.ReactNode);
}
export interface ComboboxItemProps {
diff --git a/packages/react/components/combobox/__tests__/combobox.test.tsx b/packages/react/components/combobox/__tests__/combobox.test.tsx
index 57617598e..19e28f859 100644
--- a/packages/react/components/combobox/__tests__/combobox.test.tsx
+++ b/packages/react/components/combobox/__tests__/combobox.test.tsx
@@ -230,6 +230,38 @@ describe('Combobox', () => {
expect(input).toHaveAttribute('placeholder', PLACEHOLDER_TEXT);
});
});
+
+ it('labels chips with itemToStringLabel', () => {
+ render(
+
+ FRUIT_OPTIONS.find(option => option.value === value)?.label ?? value
+ }
+ />
+ );
+ expect(screen.getByText('Banana')).toBeInTheDocument();
+ expect(screen.queryByText('banana')).not.toBeInTheDocument();
+ });
+
+ it('labels chips with the label of { value, label } values', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('Banana')).toBeInTheDocument();
+ });
+
+ it('labels chips with the value of { value } values', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('banana')).toBeInTheDocument();
+ });
});
describe('Keyboard Navigation', () => {
@@ -484,3 +516,54 @@ describe('Combobox', () => {
});
});
});
+
+describe('Combobox.createItems', () => {
+ const USERS = [
+ { id: 'u1', name: 'Jane Doe' },
+ { id: 'u2', name: 'John Smith' },
+ { id: 'u3', name: 'Ada Lovelace' }
+ ];
+ const USER_ITEMS = Combobox.createItems(USERS, {
+ getValue: user => user.id,
+ getLabel: user => user.name
+ });
+
+ const UserCombobox = (props: {
+ onValueChange?: (value: string | null) => void;
+ }) => (
+
+
+
+ {(user: (typeof USERS)[number]) => (
+
+ {user.name}
+
+ )}
+
+
+ );
+
+ it('calls onValueChange with the derived value', async () => {
+ const user = userEvent.setup();
+ const onValueChange = vi.fn();
+ render();
+
+ await user.click(screen.getByRole('combobox'));
+ await clickOption(await screen.findByText('John Smith'));
+
+ expect(onValueChange).toHaveBeenCalledWith('u2');
+ });
+
+ it('filters by the derived label', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByRole('combobox'), 'jo');
+
+ await waitFor(() => {
+ expect(screen.getByText('John Smith')).toBeInTheDocument();
+ expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument();
+ expect(screen.queryByText('Ada Lovelace')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/react/components/combobox/combobox-content.tsx b/packages/react/components/combobox/combobox-content.tsx
index ba86a4ae9..83cce3a7a 100644
--- a/packages/react/components/combobox/combobox-content.tsx
+++ b/packages/react/components/combobox/combobox-content.tsx
@@ -2,22 +2,27 @@
import { Combobox as ComboboxPrimitive } from '@base-ui/react';
import { cx } from 'class-variance-authority';
+import type { ReactNode } from 'react';
import { type Radius, radiusStyle } from '../../shared/radius';
import { useThemeInjection } from '../theme/portal';
import styles from './combobox.module.css';
import { useComboboxContext } from './combobox-root';
-export interface ComboboxContentProps
+export interface ComboboxContentProps-
extends Omit<
ComboboxPrimitive.Positioner.Props,
- 'render' | 'className' | 'style'
+ 'render' | 'className' | 'style' | 'children'
>,
- ComboboxPrimitive.Popup.Props {
+ Omit {
/** Corner radius for this popup only. Overrides the theme's `radius`. */
radius?: Radius;
+ /**
+ * The list content. Pass a function to render each item that matches the input when `items` is set on the root.
+ */
+ children?: ReactNode | ((item: Item, index: number) => ReactNode);
}
-export const ComboboxContent = ({
+export const ComboboxContent =
- ({
ref,
className,
children,
@@ -28,7 +33,7 @@ export const ComboboxContent = ({
sideOffset = 4,
radius,
...positionerProps
-}: ComboboxContentProps) => {
+}: ComboboxContentProps
- ) => {
const { inputContainerRef } = useComboboxContext();
const theme = useThemeInjection();
return (
diff --git a/packages/react/components/combobox/combobox-input.tsx b/packages/react/components/combobox/combobox-input.tsx
index 4bd7913c9..a8399ef4c 100644
--- a/packages/react/components/combobox/combobox-input.tsx
+++ b/packages/react/components/combobox/combobox-input.tsx
@@ -13,7 +13,7 @@ export interface ComboboxInputProps
> {}
export const ComboboxInput = ({ ref, ...props }: ComboboxInputProps) => {
- const { multiple, inputContainerRef, value, onValueChange } =
+ const { multiple, inputContainerRef, value, onValueChange, getLabel } =
useComboboxContext();
return (
{
chips={
multiple && Array.isArray(value)
? value.map(val => ({
- label: val,
+ label: getLabel(val),
onRemove: () =>
onValueChange?.((value as string[])?.filter(v => v !== val))
}))
diff --git a/packages/react/components/combobox/combobox-root.tsx b/packages/react/components/combobox/combobox-root.tsx
index d031e0f40..5f27a616d 100644
--- a/packages/react/components/combobox/combobox-root.tsx
+++ b/packages/react/components/combobox/combobox-root.tsx
@@ -19,6 +19,7 @@ interface ComboboxContextValue {
inputContainerRef: RefObject;
value: Value | Value[] | null | undefined;
onValueChange?: (value: Value | Value[] | null) => void;
+ getLabel: (value: Value) => string;
}
const ComboboxContext = createContext<
@@ -37,35 +38,35 @@ export const useComboboxContext = <
return context as ComboboxContextValue;
};
-export interface BaseComboboxRootProps
+export interface BaseComboboxRootProps
extends Omit<
- ComboboxPrimitive.Root.Props,
+ ComboboxPrimitive.Root.Props,
'onValueChange' | 'onInputValueChange' | 'multiple'
> {
onInputValueChange?: (inputValue: string) => void;
}
-export interface SingleComboboxProps
- extends BaseComboboxRootProps {
+export interface SingleComboboxProps
+ extends BaseComboboxRootProps {
multiple?: false;
value?: Value | null;
defaultValue?: Value | null;
onValueChange?: (value: Value | null) => void;
}
-export interface MultipleComboboxProps
- extends BaseComboboxRootProps {
+export interface MultipleComboboxProps
+ extends BaseComboboxRootProps {
multiple: true;
value?: Value[];
defaultValue?: Value[];
onValueChange?: (value: Value[]) => void;
}
-export type ComboboxRootProps =
- | SingleComboboxProps
- | MultipleComboboxProps;
+export type ComboboxRootProps =
+ | SingleComboboxProps
+ | MultipleComboboxProps;
-export const ComboboxRoot = ({
+export const ComboboxRoot = ({
multiple = false,
children,
onValueChange,
@@ -74,8 +75,9 @@ export const ComboboxRoot = ({
defaultValue,
items,
required,
+ itemToStringLabel,
...props
-}: ComboboxRootProps) => {
+}: ComboboxRootProps) => {
const fieldContext = useFieldContext();
const resolvedRequired = required ?? fieldContext?.required;
@@ -117,6 +119,19 @@ export const ComboboxRoot = ({
[onValueChange, multiple]
);
+ // Same fallback order that Base UI uses for the input text.
+ const getLabel = useCallback(
+ (value: Value) => {
+ if (itemToStringLabel) return itemToStringLabel(value);
+ if (typeof value !== 'object' || value === null)
+ return String(value ?? '');
+ if ('label' in value && value.label != null) return String(value.label);
+ if ('value' in value) return String(value.value);
+ return JSON.stringify(value);
+ },
+ [itemToStringLabel]
+ );
+
const contextValue = useMemo(
() => ({
multiple,
@@ -124,9 +139,10 @@ export const ComboboxRoot = ({
hasItems: !!items,
inputContainerRef,
value: computedValue,
- onValueChange: handleValueChange
+ onValueChange: handleValueChange,
+ getLabel
}),
- [multiple, inputValue, items, computedValue, handleValueChange]
+ [multiple, inputValue, items, computedValue, handleValueChange, getLabel]
);
return (
@@ -136,6 +152,7 @@ export const ComboboxRoot = ({
onValueChange={handleValueChange}
onInputValueChange={handleInputValueChange}
items={items}
+ itemToStringLabel={itemToStringLabel}
value={providedValue}
defaultValue={defaultValue}
required={resolvedRequired}
diff --git a/packages/react/components/combobox/combobox.tsx b/packages/react/components/combobox/combobox.tsx
index a79db22a0..d557fc42f 100644
--- a/packages/react/components/combobox/combobox.tsx
+++ b/packages/react/components/combobox/combobox.tsx
@@ -17,5 +17,6 @@ export const Combobox = Object.assign(ComboboxRoot, {
Label: ComboboxLabel,
Separator: ComboboxSeparator,
useFilter: ComboboxPrimitive.useFilter,
- useFilteredItems: ComboboxPrimitive.useFilteredItems
+ useFilteredItems: ComboboxPrimitive.useFilteredItems,
+ createItems: ComboboxPrimitive.createItems
});