From ac768f53d37156b7417d7704d60d339ea2704fdc Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 17:33:37 -0500 Subject: [PATCH 1/2] feat: [combobox] add createItems for id-based values --- .../content/docs/components/combobox/demo.ts | 37 ++++++++++++ .../docs/components/combobox/index.mdx | 9 +++ .../content/docs/components/combobox/props.ts | 15 +++++ .../combobox/__tests__/combobox.test.tsx | 57 +++++++++++++++++++ .../components/combobox/combobox-content.tsx | 15 +++-- .../components/combobox/combobox-root.tsx | 30 +++++----- .../react/components/combobox/combobox.tsx | 3 +- 7 files changed, 145 insertions(+), 21 deletions(-) diff --git a/apps/www/src/content/docs/components/combobox/demo.ts b/apps/www/src/content/docs/components/combobox/demo.ts index 39c350ea7..f77374d33 100644 --- a/apps/www/src/content/docs/components/combobox/demo.ts +++ b/apps/www/src/content/docs/components/combobox/demo.ts @@ -149,3 +149,40 @@ export const controlledDemo = { ); }` }; + +export const createItemsDemo = { + type: 'code', + code: ` + function CreateItemsDemo() { + const users = [ + { id: "u1", name: "Jane Doe" }, + { id: "u2", name: "John Smith" }, + { id: "u3", name: "Ada Lovelace" } + ]; + const items = React.useMemo( + () => + 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..7d9effd36 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"; @@ -69,6 +70,14 @@ 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. + +Create the collection at module scope for static data. For data that changes, wrap it in `React.useMemo` keyed by the data. + + + ## 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..5b9631f06 100644 --- a/apps/www/src/content/docs/components/combobox/props.ts +++ b/apps/www/src/content/docs/components/combobox/props.ts @@ -49,6 +49,14 @@ 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[]; } export interface ComboboxInputProps { @@ -96,6 +104,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..ab4010613 100644 --- a/packages/react/components/combobox/__tests__/combobox.test.tsx +++ b/packages/react/components/combobox/__tests__/combobox.test.tsx @@ -484,3 +484,60 @@ 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: { + defaultValue?: string | null; + 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('shows the derived label for the selected value', () => { + render(); + expect(screen.getByRole('combobox')).toHaveValue('Ada Lovelace'); + }); + + 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-root.tsx b/packages/react/components/combobox/combobox-root.tsx index d031e0f40..537a3ff8c 100644 --- a/packages/react/components/combobox/combobox-root.tsx +++ b/packages/react/components/combobox/combobox-root.tsx @@ -37,35 +37,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, @@ -75,7 +75,7 @@ export const ComboboxRoot = ({ items, required, ...props -}: ComboboxRootProps) => { +}: ComboboxRootProps) => { const fieldContext = useFieldContext(); const resolvedRequired = required ?? fieldContext?.required; @@ -105,11 +105,11 @@ export const ComboboxRoot = ({ ) => { setInternalValue(value); if (multiple) { - (onValueChange as MultipleComboboxProps['onValueChange'])?.( - value as Value[] - ); + ( + onValueChange as MultipleComboboxProps['onValueChange'] + )?.(value as Value[]); } else { - (onValueChange as SingleComboboxProps['onValueChange'])?.( + (onValueChange as SingleComboboxProps['onValueChange'])?.( value as Value | null ); } 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 }); From 8c74adf0f10ee08affb5a88db75db4a38ad48747 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 18:57:14 -0500 Subject: [PATCH 2/2] fix: [combobox] show item labels on multi-select chips --- .../combobox/__tests__/combobox.test.tsx | 28 +++++++++++++++ .../components/combobox/combobox-input.tsx | 4 +-- .../components/combobox/combobox-items.ts | 35 +++++++++++++++++++ .../components/combobox/combobox-root.tsx | 5 ++- .../react/components/combobox/combobox.tsx | 3 +- 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 packages/react/components/combobox/combobox-items.ts diff --git a/packages/react/components/combobox/__tests__/combobox.test.tsx b/packages/react/components/combobox/__tests__/combobox.test.tsx index ab4010613..b60ac6e58 100644 --- a/packages/react/components/combobox/__tests__/combobox.test.tsx +++ b/packages/react/components/combobox/__tests__/combobox.test.tsx @@ -541,3 +541,31 @@ describe('Combobox.createItems', () => { }); }); }); + +describe('Combobox.createItems chips', () => { + const USERS = [ + { id: 'u1', name: 'Jane Doe' }, + { id: 'u2', name: 'John Smith' } + ]; + const USER_ITEMS = Combobox.createItems(USERS, { + getValue: user => user.id, + getLabel: user => user.name + }); + + it('labels chips with the derived label', () => { + render( + + + + {(user: (typeof USERS)[number]) => ( + + {user.name} + + )} + + + ); + expect(screen.getByText('John Smith')).toBeInTheDocument(); + expect(screen.queryByText('u2')).not.toBeInTheDocument(); + }); +}); 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-items.ts b/packages/react/components/combobox/combobox-items.ts new file mode 100644 index 000000000..d45f96123 --- /dev/null +++ b/packages/react/components/combobox/combobox-items.ts @@ -0,0 +1,35 @@ +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; + +type LabelLookup = (value: unknown) => string | undefined; + +const labelLookups = new WeakMap(); + +/** + * Wraps Base UI's `createItems` and records how to label each value, so chips + * can show labels instead of raw values. + */ +export const createItems: typeof ComboboxPrimitive.createItems = ( + data, + options +) => { + const collection = ComboboxPrimitive.createItems(data, options); + labelLookups.set(collection, value => { + for (const entry of data ?? []) { + const items = + typeof entry === 'object' && entry !== null && 'items' in entry + ? entry.items + : [entry]; + for (const item of items) { + if (options.getValue(item) === value) return options.getLabel(item); + } + } + return undefined; + }); + return collection; +}; + +/** Returns the label for `value` when `items` came from `createItems`. */ +export function getItemLabel(items: unknown, value: unknown) { + if (typeof items !== 'object' || items === null) return undefined; + return labelLookups.get(items)?.(value); +} diff --git a/packages/react/components/combobox/combobox-root.tsx b/packages/react/components/combobox/combobox-root.tsx index 537a3ff8c..bf252f7c5 100644 --- a/packages/react/components/combobox/combobox-root.tsx +++ b/packages/react/components/combobox/combobox-root.tsx @@ -11,6 +11,7 @@ import { useState } from 'react'; import { useFieldContext } from '../field'; +import { getItemLabel } from './combobox-items'; interface ComboboxContextValue { multiple: boolean; @@ -19,6 +20,7 @@ interface ComboboxContextValue { inputContainerRef: RefObject; value: Value | Value[] | null | undefined; onValueChange?: (value: Value | Value[] | null) => void; + getLabel: (value: Value) => string; } const ComboboxContext = createContext< @@ -124,7 +126,8 @@ export const ComboboxRoot = ({ hasItems: !!items, inputContainerRef, value: computedValue, - onValueChange: handleValueChange + onValueChange: handleValueChange, + getLabel: (value: Value) => getItemLabel(items, value) ?? String(value) }), [multiple, inputValue, items, computedValue, handleValueChange] ); diff --git a/packages/react/components/combobox/combobox.tsx b/packages/react/components/combobox/combobox.tsx index d557fc42f..6b6f66ecc 100644 --- a/packages/react/components/combobox/combobox.tsx +++ b/packages/react/components/combobox/combobox.tsx @@ -2,6 +2,7 @@ import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { ComboboxContent } from './combobox-content'; import { ComboboxInput } from './combobox-input'; import { ComboboxItem } from './combobox-item'; +import { createItems } from './combobox-items'; import { ComboboxGroup, ComboboxLabel, @@ -18,5 +19,5 @@ export const Combobox = Object.assign(ComboboxRoot, { Separator: ComboboxSeparator, useFilter: ComboboxPrimitive.useFilter, useFilteredItems: ComboboxPrimitive.useFilteredItems, - createItems: ComboboxPrimitive.createItems + createItems });