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 });