# Combobox Field

A labeled multi-select combobox with title and description above a closed trigger, an overlay panel of searchable checkbox items, and helper or error text below.

## Installation

```tsx
import { ComboboxField } from "@makeplane/propel/components/combobox-field";
```
## Usage

```tsx
import { ComboboxField } from "@makeplane/propel/components/combobox-field";
import { Icon } from "@makeplane/propel/components/icon";
import { AddOutline } from "@makeplane/propel/icons";

const REGIONS = ["us-central-1", "us-east-1", "eu-central-1", "ap-west-1"];

export default function BasicDemo() {
  return (
    <ComboboxField
      name="region"
      label="Region"
      description="Filter the deployment region."
      hint="Helper text"
      required
      size="lg"
      items={REGIONS}
      placeholder="Select label"
      empty="No regions found"
      icon={<Icon icon={AddOutline} tint="placeholder" />}
    />
  );
}
```

### Options and selection

Pass primitive options directly, or records with **both** `getItemValue` and `getItemLabel`
(required for non-primitive items). `value` and `onValueChange` use unique string/number IDs;
`filter` receives the original options. Prefer stable accessor references for large lists.

The field picks one value by default: `value` is a single ID (or `null`), rows show a check, and the
popup closes on pick. Pass `multiple` to pick several — see [Multiple selection](#multiple-selection).

### Large lists and server results

Rows are virtualized by default: the popup stays trigger-width and long labels truncate (full text
as a tooltip). `virtualized={false}` mounts every option and sizes the popup to its content. For
huge catalogs, supply externally filtered results through `filteredItems`; virtualization bounds
rendering, not local search cost. Fetching and pagination remain application-owned. Use
`inputValue` and `onInputValueChange` to control the search query.

Keep known records in `items` and offered results in `filteredItems`. For off-page selection labels,
retain those records in `items` or supply `itemToStringLabel(id)`; otherwise the ID is displayed.
The field does not retain previous results. With `multiple`, Select all affects `filteredItems` when
provided (including an empty array), otherwise all `items`, and preserves selections outside that
list.

## Examples

### Multiple selection

`multiple` picks several values: `value` and `onValueChange` use an array of IDs, rows show a
checkbox, and a sticky Select all row selects or clears every option. The trigger previews three
labels plus a `+N more` count (or `N selected` when `maxVisible={0}`). Set `maxVisible` to change the
preview, `overflowLabel` to localize the count, and `selectAllLabel` to rename the row.

```tsx
import { ComboboxField } from "@makeplane/propel/components/combobox-field";

const REGIONS = ["us-central-1", "us-east-1", "eu-central-1", "ap-west-1", "ap-southeast-2"];

export default function MultipleDemo() {
  return (
    <ComboboxField
      multiple
      name="regions"
      label="Regions"
      size="lg"
      items={REGIONS}
      defaultValue={["us-east-1", "eu-central-1"]}
      placeholder="Select regions"
      empty="No regions found"
    />
  );
}
```

### Record options

Selection is ID-based. Supply `getItemValue` / `getItemLabel` so labels can repeat while IDs stay unique.

```tsx
import { ComboboxField } from "@makeplane/propel/components/combobox-field";
import { Icon } from "@makeplane/propel/components/icon";
import { AddOutline } from "@makeplane/propel/icons";
import * as React from "react";

const members = [
  { id: 7, name: "Alex", email: "engineering@example.com" },
  { id: 42, name: "Priya", email: "design@example.com" },
  { id: 99, name: "Marcus", email: "ops@example.com" },
];
type Member = (typeof members)[number];

// Module-level accessors stay referentially stable, which large lists rely on.
const memberId = (member: Member) => member.id;
const memberName = (member: Member) => member.name;

export default function RecordsDemo() {
  const [ids, setIds] = React.useState<number[]>([7]);
  return (
    <ComboboxField
      multiple
      name="members"
      label="Members"
      description="Selection uses member IDs; labels come from getItemLabel."
      hint="Both accessors are required for record options."
      size="lg"
      items={members}
      getItemValue={memberId}
      getItemLabel={memberName}
      value={ids}
      onValueChange={setIds}
      placeholder="Select members"
      empty="No members found"
      icon={<Icon icon={AddOutline} tint="placeholder" />}
    />
  );
}
```

### Rich options

`getItemProps` passes extra `ComboboxItem` props to each option's row: a leading `icon`, a
`trailing` count, `disabled`, and so on. The field still sets each row's value, label and checkbox
itself. `footer` pins content under the list.

```tsx
import { Badge } from "@makeplane/propel/components/badge";
import { ComboboxField } from "@makeplane/propel/components/combobox-field";
import { Icon } from "@makeplane/propel/components/icon";
import { GlobeOutline, ServerOutline } from "@makeplane/propel/icons";

const services = [
  { id: "api", name: "API gateway", instances: 12 },
  { id: "edge", name: "Edge cache", instances: 4 },
  { id: "legacy", name: "Legacy queue", instances: 0 },
];
type Service = (typeof services)[number];

// Module-level accessors stay referentially stable, which large lists rely on.
const serviceId = (service: Service) => service.id;
const serviceName = (service: Service) => service.name;
const serviceRow = (service: Service) => ({
  icon: <Icon icon={service.id === "edge" ? GlobeOutline : ServerOutline} tint="secondary" />,
  trailing: <Badge size="xs" variant="neutral" label={String(service.instances)} />,
  disabled: service.instances === 0,
});

export default function RichOptionsDemo() {
  return (
    <ComboboxField
      multiple
      name="services"
      label="Services"
      size="lg"
      items={services}
      getItemValue={serviceId}
      getItemLabel={serviceName}
      getItemProps={serviceRow}
      placeholder="Select services"
      empty="No services found"
      footer={<div className="px-3 py-2 text-caption-md-regular text-tertiary">3 services</div>}
    />
  );
}
```

### Sizes

`size` steps the label, description, and helper text together with the trigger's own scale.

```tsx
import { ComboboxField } from "@makeplane/propel/components/combobox-field";

const REGIONS = ["us-central-1", "us-east-1", "eu-central-1", "ap-west-1"];
const SIZES = ["lg", "xl", "2xl"] as const;

export default function SizesDemo() {
  return (
    <div className="flex flex-col gap-4">
      {SIZES.map((size) => (
        <ComboboxField
          key={size}
          name={`region-${size}`}
          label={size}
          description="Filter the deployment region."
          hint="Helper text"
          size={size}
          items={REGIONS}
          placeholder="Select label"
          empty="No regions found"
        />
      ))}
    </div>
  );
}
```

### Trigger width

`width="auto"` is the default and preserves the existing 288px minimum. Set `width="fit"` to make
a `secondary` or `ghost` trigger hug its rendered content. To keep the search panel usable,
`ComboboxField` gives a fit trigger content-based popup sizing by default: `auto-fixed` while the
list is virtualized and `auto` otherwise. An explicit `sizing` prop still takes precedence. Pill
remains intrinsically sized and does not accept `width`.

```tsx
import {
  ComboboxField,
  type ComboboxTriggerWidth,
} from "@makeplane/propel/components/combobox-field";

const REGIONS = ["us-east-1", "A considerably longer deployment region"];
const WIDTHS: ComboboxTriggerWidth[] = ["auto", "fit"];

export default function FitWidthDemo() {
  return (
    <div className="flex flex-wrap items-end gap-4">
      {WIDTHS.map((width) => (
        <ComboboxField
          key={width}
          name={`region-${width}`}
          label={width}
          size="lg"
          width={width}
          empty="No regions found"
          items={REGIONS}
          defaultValue="us-east-1"
        />
      ))}
    </div>
  );
}
```

### Pill

Passing `variant="pill"` makes the trigger hug its selection for compact filter pickers, like
`SelectField`'s pill. It follows Propel Pill anatomy and does not add a chevron. The popup gets
content-based sizing so the options stay readable. Pill accepts `xs`, `sm`, and `md`; the label and
helper text stay on the field's `md` typography rung.

```tsx
import { ComboboxField } from "@makeplane/propel/components/combobox-field";

const REGIONS = ["us-central-1", "us-east-1", "eu-central-1", "ap-west-1"];

export default function PillDemo() {
  return (
    <ComboboxField
      multiple
      name="regions"
      label="Regions"
      variant="pill"
      size="md"
      items={REGIONS}
      placeholder="Any region"
      empty="No regions found"
    />
  );
}
```

### Invalid

Setting `error` marks the field invalid, recoloring the trigger border. The error replaces helper text below the control; the description above the control stays.

```tsx
import { ComboboxField } from "@makeplane/propel/components/combobox-field";

const REGIONS = ["us-central-1", "us-east-1", "eu-central-1", "ap-west-1"];

export default function InvalidDemo() {
  return (
    <ComboboxField
      name="region"
      label="Region"
      error="Choose a deployment region."
      size="lg"
      items={REGIONS}
      placeholder="Select label"
      empty="No regions found"
    />
  );
}
```

### Disabled

```tsx
import { ComboboxField } from "@makeplane/propel/components/combobox-field";
import { Icon } from "@makeplane/propel/components/icon";
import { AddOutline } from "@makeplane/propel/icons";

const REGIONS = ["us-central-1", "us-east-1", "eu-central-1", "ap-west-1"];

export default function DisabledDemo() {
  return (
    <ComboboxField
      name="region"
      label="Region"
      description="Filter the deployment region."
      hint="Helper text"
      size="lg"
      disabled
      items={REGIONS}
      placeholder="Select label"
      empty="No regions found"
      icon={<Icon icon={AddOutline} tint="placeholder" />}
    />
  );
}
```
## API Reference

### ComboboxField

Ready-to-use combobox field: a closed trigger, overlay search, option rows, and helper/error
text. Picks one value by default; `multiple` picks several, with checkbox rows and Select all.

Record options require `getItemValue` and `getItemLabel`. Primitive string/number options use
themselves as IDs when those accessors are omitted. For large lists, keep accessors and
`itemToStringLabel` referentially stable; supply a new `items` array when what they return for an
item changes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` (required) | `"xs" \| "sm" \| "md" \| "lg" \| "xl" \| "2xl"` | — | Trigger size on Pill's scale: `xs`, `sm` or `md`. Trigger size, from `md` to `2xl`: height, text and glyph sizing. |
| `items` (required) | `readonly Item[] \| (readonly Item[] & readonly { items: readonly Item[]; }[])` | — | Known flat options: primitive IDs, or records with explicit value/label accessors. Selected values are always IDs. Keep off-page selected records here to resolve their labels, or supply `itemToStringLabel(id)` as a fallback. Records must not have an array-valued `items` property (reserved for Base UI groups). This field does not fetch or retain past records. |
| `empty` (required) | `ReactNode` | — | Message rendered when no item matches. |
| `getItemValue` (required) | `(item: Item) => Value` | — | Required for record items: derive a stable unique ID. Omit for string/number items, which are their own IDs. |
| `getItemLabel` (required) | `(item: Item) => string` | — | Required for record items: derive the display/filter label (labels may repeat). Omit for string/number items, which use `itemToStringLabel` or `String`. |
| `autoComplete` | `string` | — | Provides a hint to the browser for autofill. @see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete |
| `autoHighlight` | `boolean` | `false` | Whether the first matching item is highlighted automatically while filtering. |
| `highlightItemOnHover` | `boolean` | `true` | Whether moving the pointer over items should highlight them. Disabling this prop allows CSS `:hover` to be differentiated from the `:focus` (`data-highlighted`) state. |
| `itemToStringLabel` | `((itemValue: Value) => string)` | — | When the item values are objects (`<Combobox.Item value={object}>`), this function converts the object value to a string representation for display in the input. If the shape of the object is `{ value, label }`, the label will be used automatically without needing to specify this prop. With a `createItems()` collection, this receives the derived value, and the collection's `getLabel` takes precedence for values it can resolve. |
| `itemToStringValue` | `((itemValue: Value) => string)` | — | When the item values are objects (`<Combobox.Item value={object}>`), this function converts the object value to a string representation for form submission. If the shape of the object is `{ value, label }`, the value will be used automatically without needing to specify this prop. With a `createItems()` collection, this receives the derived value. |
| `actionsRef` | `RefObject<Actions \| null>` | — | A ref to imperative actions. - `unmount`: Manually unmounts the combobox. Call this after any externally controlled closing animation finishes. |
| `onOpenChange` | `((open: boolean, eventDetails: ChangeEventDetails) => void)` | — | Event handler called when the popup is opened or closed. |
| `onInputValueChange` | `((inputValue: string, eventDetails: ChangeEventDetails) => void)` | — | Event handler called when the input value changes. |
| `onItemHighlighted` | `((highlightedValue: Value, eventDetails: HighlightEventDetails) => void)` | — | Callback fired when an item is highlighted or unhighlighted. Receives the highlighted item value (or `undefined` if no item is highlighted) and event details with a `reason` property describing why the highlight changed. The `reason` can be: - `'keyboard'`: the highlight changed due to keyboard navigation. - `'pointer'`: the highlight changed due to pointer hovering. - `'none'`: the highlight changed programmatically. |
| `modal` | `boolean` | `true` | Whether the combobox enters a modal state when open, like `Select`: page scroll is locked and a press outside the popup only dismisses it. The input (or input group) is cut out of the backdrop, so filter-in-field compositions still take typing and clicks. Set `false` to leave the rest of the page interactive while the list is open. |
| `icon` | `ReactNode` | — | Decorative leading element at the inline-start, e.g. `<Icon icon={Plus} tint="placeholder" />`. |
| `trailing` | `ReactNode` | — | Decorative trailing glyph. Omit it to show a chevron (the Figma end node) — except on `pill`, which follows Pill anatomy and shows no glyph unless one is passed. This is not a nested popup-trigger control — the frame itself is the `Trigger`. |
| `placeholder` | `ReactNode` | — | Shown in the trigger while nothing is selected — Base UI's `Combobox.Value` placeholder. |
| `footer` | `ReactNode` | — | Sticky chrome pinned below the list. |
| `variant` | `"secondary" \| "ghost" \| "pill"` | `"secondary"` | `pill` hugs its value with outline Pill chrome and shows no automatic chevron. Frame look. `secondary` is the bordered field surface; `ghost` is borderless. Invalid on ghost has no trigger chrome — the danger cue is the helper text below. |
| `width` | `"auto" \| "fit"` | — | `auto` keeps the 288px minimum; `fit` hugs the rendered trigger content. |
| `filteredItems` | `readonly Item[]` | — | Externally filtered flat options offered in the popup. When supplied, bulk actions affect only these records, not all known `items`. Pass [] for an empty response. Omit for local filtering; local search does not change the bulk-action scope. Fetching and pagination are caller-owned. |
| `multiple` | `boolean` | `false` | Allow several selections: checkbox rows, a Select all row, and a trigger that previews the first `maxVisible` labels. Off (the default) picks one value, with check rows, and closes the popup on pick. Fixed for a mounted field: the value's shape follows it, so switching modes means remounting the field (e.g. with a new `key`). |
| `value` | `string \| number \| Value[] \| null` | — | The selected values (controlled). The selected value (controlled); `null` for none. |
| `defaultValue` | `string \| number \| Value[] \| null` | — | The initially selected values (uncontrolled). The initially selected value (uncontrolled). |
| `onValueChange` | `((value: Value[], details?: ChangeEventDetails) => void) \| ((value: Value \| null, details: ChangeEventDetails) => void)` | — | Called when the selection changes. Item presses pass Base UI's event `details`; Select all passes only the next value. Called when the selection changes, with Base UI's event `details`. |
| `maxVisible` | `number` | `3` | Maximum selected labels to preview. A nonnegative integer; zero shows only the count. Hidden labels are not formatted. Long previews truncate without hiding the overflow count. |
| `overflowLabel` | `((hiddenCount: number, details: { visibleCount: number; }) => ReactNode)` | `` (hiddenCount, { visibleCount }) => visibleCount > 0 ? `+${hiddenCount} more` : `${hiddenCount} selected` `` | Non-interactive summary of selections beyond `maxVisible`. Receives the hidden count and how many labels remain in the preview. Override to localize. |
| `selectAllLabel` | `string` | `Select all` | Label for the sticky row that selects or clears every item. Acts on the whole `items` list, NOT on whatever the panel search has filtered down to — so pressing it while a query is active also selects the values the query hid, and the row's checked / indeterminate state likewise reflects `items` rather than the visible subset. When `filteredItems` is supplied it defines the offered list instead. Other selections are preserved when selecting or clearing this list. This does not select unseen server results; use a label such as "Select loaded members" for remote pages. |
| `virtualized` | `boolean` | `true` | Mount only visible options and a small buffer. The popup stays at the anchor width and caps at 288px tall (`sizing="anchor-fixed"`) so a mid-open height settle cannot shrink under the virtualizer. Set false only when every option must remain mounted; rendering then grows with the list and the popup uses available height. |
| `sizing` | `"menu" \| "anchor" \| "sm" \| "md" \| "lg" \| "auto" \| "anchor-fixed" \| "auto-fixed"` | — | How the popup takes its width (see `ComboboxContent`). Defaults to `anchor-fixed` for a virtualized list and `anchor` otherwise. A `pill` trigger defaults to the content's width instead (`auto-fixed` virtualized, `auto` otherwise), so the options are not squeezed to its compact width. |
| `description` | `ReactNode` | — | Supporting text shown below the label. |
| `getItemProps` | `((item: Item) => ComboboxFieldItemProps)` | — | Extra `ComboboxItem` props for an option's row, e.g. `{ icon, trailing, disabled }`. The field sets each row's `value`, `label` and selection marker itself. Select all skips a `disabled` row: it neither selects nor clears it. That scope calls this for every offered option, so keep it referentially stable for large lists. |
| `error` | `ReactNode` | — | Error text shown below the control. |
| `hint` | `ReactNode` | — | Helper text shown below the control. Replaced by `error` when an error is set. |
| `searchLabel` | `string` | `Search` | Accessible name and placeholder for the panel search field. |
| `label` | `string` | — |  |
| `aria-label` | `string` | — |  |
