From ac768f53d37156b7417d7704d60d339ea2704fdc Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 17:33:37 -0500 Subject: [PATCH 1/3] 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/3] 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 }); From cfbe726281b88f204abca8fc14cdd0e540227564 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 19:46:57 -0500 Subject: [PATCH 3/3] feat: [combobox] use base ui chips, clear, empty, and status --- .../content/docs/components/combobox/demo.ts | 38 +++- .../docs/components/combobox/index.mdx | 76 ++++++- .../content/docs/components/combobox/props.ts | 62 ++++++ .../combobox/__tests__/combobox.test.tsx | 191 +++++++++++++++++- .../combobox/__tests__/data-slots.test.tsx | 36 +++- .../components/combobox/combobox-content.tsx | 22 +- .../components/combobox/combobox-input.tsx | 136 +++++++++++-- .../components/combobox/combobox-item.tsx | 17 +- .../components/combobox/combobox-misc.tsx | 107 +++++++++- .../components/combobox/combobox-root.tsx | 45 ++++- .../components/combobox/combobox.module.css | 44 +++- .../react/components/combobox/combobox.tsx | 12 +- packages/react/components/input/input.tsx | 143 +++++++++---- 13 files changed, 839 insertions(+), 90 deletions(-) diff --git a/apps/www/src/content/docs/components/combobox/demo.ts b/apps/www/src/content/docs/components/combobox/demo.ts index f77374d33..362b75aef 100644 --- a/apps/www/src/content/docs/components/combobox/demo.ts +++ b/apps/www/src/content/docs/components/combobox/demo.ts @@ -78,13 +78,13 @@ export const groupDemo = { - Fruits + Fruits Apple Banana - Vegetables + Vegetables Carrot Broccoli @@ -92,6 +92,40 @@ export const groupDemo = { ` }; +export const labelDemo = { + type: 'code', + code: ` + + + Fruits + + + + + + + Apple + Banana + Grape + Orange + + ` +}; + +export const emptyDemo = { + type: 'code', + code: ` + + + + No fruit matches your search + Apple + Banana + Grape + + ` +}; + export const iconDemo = { type: 'code', code: ` diff --git a/apps/www/src/content/docs/components/combobox/index.mdx b/apps/www/src/content/docs/components/combobox/index.mdx index 7d9effd36..4b6c7d84b 100644 --- a/apps/www/src/content/docs/components/combobox/index.mdx +++ b/apps/www/src/content/docs/components/combobox/index.mdx @@ -13,6 +13,8 @@ import { withFieldDemo, controlledDemo, createItemsDemo, + labelDemo, + emptyDemo, } from "./demo.ts"; @@ -25,8 +27,18 @@ Import and assemble the component: import { Combobox } from "@raystack/apsara"; + - + + + + + + + + + + ``` @@ -54,7 +66,7 @@ Wrap the Combobox with [Field](/docs/components/field) to add a label, descripti ### Groups and separators -Organize items into groups with labels and visual separators. Groups and labels are automatically hidden when the user is searching. +Organize items into groups with `Combobox.GroupLabel` and separators. Groups, group labels, and separators are hidden while the user is searching. @@ -62,8 +74,22 @@ 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. +The input shows the first two chips and a "+N" count for the rest. Press Backspace in an empty input to remove the last value. Press Left Arrow at the start of the input to move focus to the chips. On a focused chip, press Backspace or Delete to remove it. + +### Label and clear button + +`Combobox.Label` renders a visible label linked to the input. `Combobox.Clear` renders a button that clears the value. It shows only when there is a value or input text. Place both anywhere inside `Combobox`. + + + +### Empty state + +`Combobox.Content` shows "No results" when no item matches the input. To change the text, add `Combobox.Empty` as a direct child of `Combobox.Content`. + + + ### Controlled Use `value` and `onValueChange` for controlled behavior. @@ -110,17 +136,41 @@ When no `value` prop is provided, the text content of `children` is used as the +### Label Props + +A visible label for the input. It renders a `