---
name: aircall-blocks/migrate-dashboard/combobox
description: >
  Migrate @dashboard/library search-selects to @aircall/ds Combobox — multi-select
  (MultiSearchSelect, MultiInlineSearchSelect, MultiSelectOption, multi MultiSelect) via
  the `multiple` prop, and single-select (SingleSearchSelect, SearchSelect) by omitting
  it. Load when a file imports any *SearchSelect / MultiSelect from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
  - aircall-blocks/setup
  - aircall-blocks/migrate-dashboard
sources:
  - "aircall/hydra:packages/ds/src/index.ts"
---

This skill builds on aircall-blocks/migrate-dashboard.

## 1. Component mapping

| @dashboard/library | @aircall/ds |
| --- | --- |
| `MultiSearchSelect` (root with search input + dropdown) | `Combobox` (root, `multiple` prop) + `ComboboxChips` + `ComboboxChipsInput` + `ComboboxContent` + `ComboboxList` + `ComboboxItem` |
| `MultiInlineSearchSelect` (root with inline chips + search box) | `Combobox` (root, `multiple` prop) + `ComboboxChips` + `ComboboxChipsInput` + `ComboboxContent` + `ComboboxList` + `ComboboxItem` |
| `MultiSelectOption` (option shape `{ value, label }`) | Consumer-defined plain object — DS has no equivalent type; define `{ value: string; label: string }` locally |
| `options` prop (array of options for the list) | `items` prop on `Combobox` |
| `onSelect` prop (receives `selectedKeys: string[]`) | `onValueChange` prop on `Combobox` (receives option objects, not raw keys) |
| `selectedKeys` / `defaultSelectedKeys` prop | `value` / `defaultValue` prop on `Combobox` (option objects, not strings) |
| `selectionMode="multiple"` | `multiple` boolean prop on `Combobox` |
| _single-select_ (`SingleSearchSelect`, `SearchSelect`, single-value `MultiSelect`) | **omit** `multiple` — `Combobox` is single by default. Use `ComboboxInput` instead of `ComboboxChips`/`ComboboxChipsInput`; `value`/`onValueChange` carry one option object (or `null` when cleared) |
| `renderTag` prop (custom chip renderer) | `ComboboxValue` render-prop inside `ComboboxChips`; render `ComboboxChip` per item |
| `renderItem` prop (custom list-row renderer) | `children` of `ComboboxItem` |
| `renderItemPrefix` / `renderItemSuffix` | Inline JSX children of `ComboboxItem` (before / after the label) |
| `renderItemDescription` | Inline `ItemDescription` children of `ComboboxItem` |
| `emptyLabel` prop | `ComboboxEmpty` children |
| `placeholder` prop | `placeholder` on `ComboboxChipsInput` |
| `loading` prop | Conditional render above `ComboboxList` (no built-in loading state) |
| `disabled` prop | `disabled` prop on `Combobox` |
| `onSearch` / `debounceDuration` | Caller-owned debounced state; filter `items` before passing to `Combobox` |
| `hideClearAll` / `onClearAll` | No direct equivalent — `ComboboxChip` ships individual removals; a clear-all button is custom UI beside `ComboboxChips` |
| `hideSelectedOptions` | Filter `items` in consumer state before passing to `Combobox` |
| `maxMenuHeight` / `maxSearchBoxHeight` | `className` on `ComboboxContent` / `ComboboxChips` (Tailwind `max-h-*`) |

## 2. Imports

```tsx
// All multi-select combobox primitives from @aircall/ds
import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
  useComboboxAnchor
} from '@aircall/ds';
```

Note: `ComboboxMultiSelect` is a recipe in `src/recipes/` and is intentionally **not** exported from `@aircall/ds`. Wire the primitives above directly.

## 3. Before / After

### 3a. MultiSearchSelect — dropdown multi-select with search

**Before (`@dashboard/library`):**
```tsx
import { MultiSearchSelect, MultiSelectOption } from '@dashboard/library';

interface TeamOption extends MultiSelectOption<string> {
  emoji: string;
}

const options: TeamOption[] = [
  { value: 'eng', label: 'Engineering', emoji: '🛠' },
  { value: 'sales', label: 'Sales', emoji: '📈' },
];

function TeamPicker() {
  const [selected, setSelected] = useState<string[]>(['eng']);
  const [loading, setLoading] = useState(false);

  const handleSearch = async (query: string) => {
    // fetch options remotely
  };

  return (
    <MultiSearchSelect<TeamOption>
      options={options}
      loading={loading}
      defaultSelectedKeys={['eng']}
      onSearch={handleSearch}
      onSelect={setSelected}
      renderItemPrefix={(o) => o.emoji}
      texts={{ placeholder: 'Select teams', item: 'team', items: 'teams' }}
      debounceDuration={300}
    />
  );
}
```

**After (`@aircall/ds`):**
```tsx
import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
  useComboboxAnchor
} from '@aircall/ds';
import { useMemo, useState, useRef } from 'react';

type TeamOption = { value: string; label: string; emoji: string };

const allOptions: TeamOption[] = [
  { value: 'eng', label: 'Engineering', emoji: '🛠' },
  { value: 'sales', label: 'Sales', emoji: '📈' },
];

function TeamPicker() {
  const [selected, setSelected] = useState<TeamOption[]>([allOptions[0]]);
  const [filteredOptions, setFilteredOptions] = useState(allOptions);
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const anchor = useComboboxAnchor();

  const handleSearch = (query: string) => {
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(async () => {
      // fetch or filter remotely, then update filteredOptions
      setFilteredOptions(
        allOptions.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
      );
    }, 300);
  };

  return (
    <Combobox
      multiple
      autoHighlight
      items={filteredOptions}
      itemToStringValue={(item: TeamOption) => item.value}
      value={selected}
      onValueChange={setSelected}
    >
      <ComboboxChips ref={anchor}>
        <ComboboxValue>
          {(values: TeamOption[]) => (
            <>
              {values.map((o) => (
                <ComboboxChip key={o.value}>{o.label}</ComboboxChip>
              ))}
              <ComboboxChipsInput
                placeholder={selected.length === 0 ? 'Select teams' : ''}
                onChange={(e) => handleSearch(e.target.value)}
              />
            </>
          )}
        </ComboboxValue>
      </ComboboxChips>
      <ComboboxContent anchor={anchor}>
        <ComboboxEmpty>No teams found.</ComboboxEmpty>
        <ComboboxList>
          {(item: TeamOption) => (
            <ComboboxItem key={item.value} value={item}>
              <span>{item.emoji}</span>
              {item.label}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}
```

Key changes:
- `options` → `items` on `Combobox`.
- `selectionMode="multiple"` → `multiple` boolean.
- `selectedKeys: string[]` → `value: TeamOption[]` (full option objects, not raw keys).
- `onSelect(keys)` → `onValueChange(options)` — caller must derive keys from `options.map(o => o.value)` if needed downstream.
- `onSearch` + `debounceDuration` → caller-owned debounced `onChange` on `ComboboxChipsInput`; filter `items` in state.
- `renderItemPrefix` → inline JSX sibling before the label inside `ComboboxItem`.
- `texts.placeholder` → `placeholder` on `ComboboxChipsInput`.
- `loading` → conditional render; DS has no built-in loading state on Combobox.

### 3b. MultiInlineSearchSelect — inline chips with search

**Before (`@dashboard/library`):**
```tsx
import { MultiInlineSearchSelect } from '@dashboard/library';

type UserOption = { value: string; label: string; email: string };

function UserPicker() {
  const [selected, setSelected] = useState<string[]>([]);
  const [options, setOptions] = useState<UserOption[]>(allUsers);

  const handleSearch = (text: string) => {
    setOptions(allUsers.filter((u) => u.label.includes(text)));
  };

  return (
    <MultiInlineSearchSelect<UserOption>
      placeholder="Search users..."
      options={options}
      selectedKeys={selected}
      loading={false}
      onSelect={setSelected}
      onSearch={handleSearch}
      renderItemDescription={(o) => o.email}
      emptyLabel="No users found"
    />
  );
}
```

**After (`@aircall/ds`):**
```tsx
import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
  useComboboxAnchor,
  ItemDescription
} from '@aircall/ds';

type UserOption = { value: string; label: string; email: string };

function UserPicker() {
  const [selected, setSelected] = useState<UserOption[]>([]);
  const anchor = useComboboxAnchor();

  // Base UI filters `items` internally as the user types — no manual filter needed
  // for local data. For remote data, see the async pattern in §3a.
  return (
    <Combobox
      multiple
      autoHighlight
      items={allUsers}
      itemToStringValue={(item: UserOption) => item.value}
      value={selected}
      onValueChange={setSelected}
    >
      <ComboboxChips ref={anchor}>
        <ComboboxValue>
          {(values: UserOption[]) => (
            <>
              {values.map((u) => (
                <ComboboxChip key={u.value}>{u.label}</ComboboxChip>
              ))}
              <ComboboxChipsInput
                placeholder={selected.length === 0 ? 'Search users...' : ''}
              />
            </>
          )}
        </ComboboxValue>
      </ComboboxChips>
      <ComboboxContent anchor={anchor}>
        <ComboboxEmpty>No users found</ComboboxEmpty>
        <ComboboxList>
          {(item: UserOption) => (
            <ComboboxItem key={item.value} value={item}>
              {item.label}
              <ItemDescription>{item.email}</ItemDescription>
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  );
}
```

Key changes:
- `selectedKeys: string[]` controlled prop → `value: UserOption[]` (objects).
- `onSelect(keys)` → `onValueChange(options)`.
- `onSearch` (local filtering) → omitted; Base UI filters `items` internally as the user types in `ComboboxChipsInput`. For remote async data, use the debounced fetch pattern from §3a.
- `renderItemDescription` → `ItemDescription` from `@aircall/ds` as a child of `ComboboxItem`.
- `emptyLabel` → `ComboboxEmpty` children.
- `hideSelectedOptions` logic → pre-filter `items` before passing to `Combobox`.

---

## 4. Common mistakes

### Mistake 1 — Using `selectedKeys: string[]` instead of `value: Option[]`

Wrong block:
```tsx
<Combobox multiple items={options} selectedKeys={['eng', 'sales']} onSelect={setKeys}>
```

Correct block:
```tsx
<Combobox
  multiple
  items={options}
  value={[{ value: 'eng', label: 'Engineering' }, { value: 'sales', label: 'Sales' }]}
  onValueChange={setSelected}
>
```

`Combobox` from `@aircall/ds` is a Base UI primitive. Its `value` / `onValueChange` carry full option objects, not raw string keys. Passing `selectedKeys` is a no-op (unknown prop silently ignored); the selection state will never be controlled.

Source: `packages/ds/src/components/combobox.tsx`

### Mistake 2 — Importing ComboboxMultiSelect from @aircall/ds

Wrong block:
```tsx
import { ComboboxMultiSelect } from '@aircall/ds';

<ComboboxMultiSelect options={options} placeholder="Select..." />
```

Correct block:
```tsx
import {
  Combobox,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxList,
  ComboboxItem,
  ComboboxValue,
  ComboboxChip,
  useComboboxAnchor
} from '@aircall/ds';

// Wire primitives directly (see §3 above)
```

`ComboboxMultiSelect` lives in `packages/ds/src/recipes/` and is intentionally excluded from `@aircall/ds`'s public `index.ts`. It is a copy-paste template, not a published export. The import resolves to `undefined` at runtime, crashing silently.

Source: `packages/ds/src/components/combobox.tsx`

### Mistake 3 — Rendering `<ComboboxClear>` as a JSX element

Wrong block:
```tsx
import { ComboboxClear } from '@aircall/ds';

// inside ComboboxChips:
<ComboboxClear onClick={onClearAll} />
```

Correct block:
```tsx
// ComboboxClear is type-only in @aircall/ds.
// Use a plain button for a custom clear-all action:
<button type="button" onClick={() => setSelected([])}>Clear all</button>
```

`ComboboxClear` is exported from `packages/ds/src/index.ts` with the `type` modifier (`type ComboboxClear`) — it is a TypeScript type alias, not a React component value. Rendering it as JSX produces a `React.createElement(undefined, …)` call and throws at runtime.

Source: `packages/ds/src/components/combobox.tsx`

### Mistake 4 — Passing `onSearch` / `debounceDuration` directly to Combobox

Wrong block:
```tsx
<Combobox
  multiple
  items={options}
  onSearch={handleSearch}
  debounceDuration={300}
>
```

Correct block:
```tsx
// Debounce in the caller; drive filtering through `items`
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
  if (debounceRef.current) clearTimeout(debounceRef.current);
  debounceRef.current = setTimeout(() => {
    fetchOrFilter(e.target.value).then(setOptions);
  }, 300);
};

<Combobox multiple items={options} ...>
  <ComboboxChipsInput onChange={handleSearch} />
```

`Combobox` has no `onSearch` or `debounceDuration` prop. Search and debounce are caller-owned: attach `onChange` to `ComboboxChipsInput`, debounce the callback, and update `items` via state.

Source: `packages/ds/src/components/combobox.tsx`

---

## 5. Inside a form — use `FormMultiComboboxField`, not a raw `Combobox`

When the multi-select is a **field of an `@aircall/blocks` `useForm`** (the common case
when migrating a `MultiInlineSearchSelect` that lived in a `FormWizard` / `FormField`),
don't wire a raw `Combobox` + `useState`. Use `FormMultiComboboxField` from
`@aircall/blocks` — it owns the value, validation, and error wiring (see
`aircall-blocks/migrate-dashboard/form-wizard`). It changes three things vs §3:

- **The bound field is `string[]` (ids), not option objects.** `FormMultiComboboxField`
  binds `name: DeepKeysOfType<values, string[]>` and its render-prop control bundle gives
  `comboboxProps = { value: string[]; onValueChange: (string[]) => void }`. Spread it onto
  `Combobox` and pass `items={ids}` (string ids) — the inverse of §3's option-object model.
- **Keep an id→label cache** (`useRef(new Map())`) populated from each server-search page,
  because a selected id may fall outside the current page; chips/options read
  `cache.get(id)?.label ?? id`.
- **Reshape rich domain types to `string[]` and reconstruct at the submit boundary.** A
  field like `assignees: {id,type}[]` or `callerIdNumbers: {id,phoneNumber}[]` can't bind.
  Store `xIds: string[]` and rebuild the payload in your create/update mapper — either
  collapse to a constant (`assignedUserIds: ids.map(Number)`, all teammates) or reconstruct
  a schema-required richer shape from a lookup map (`ids → {ID, phoneNumber}` from the
  eligibility query, read cache-first at the page level). An already-`string[]` field (e.g.
  `outcomeIds`) needs no reshape.

```tsx
import { FormMultiComboboxField } from '@aircall/blocks';
import {
  Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput,
  ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList,
  ComboboxValue, useComboboxAnchor,
} from '@aircall/ds';

const [search, setSearch] = useState('');
const anchor = useComboboxAnchor();
const { data } = useTeamSearch(search);                 // server search
const ids = useMemo(() => (data?.items ?? []).map(t => t.ID), [data]);
const labels = useRef(new Map<string, string>());
for (const t of data?.items ?? []) labels.current.set(t.ID, t.name);

<FormMultiComboboxField form={form} name="assignedTeamIds" label="Teams">
  {(_field, { comboboxProps, comboboxInputProps }) => (
    <Combobox multiple items={ids} filter={null}
      inputValue={search} onInputValueChange={setSearch} {...comboboxProps}>
      <ComboboxChips ref={anchor}>
        <ComboboxValue>
          {(selected: string[]) => (
            <>
              {selected.map(id => (
                <ComboboxChip key={id}>{labels.current.get(id) ?? id}</ComboboxChip>
              ))}
              <ComboboxChipsInput {...comboboxInputProps} placeholder={selected.length ? '' : 'Search teams'} />
            </>
          )}
        </ComboboxValue>
      </ComboboxChips>
      <ComboboxContent anchor={anchor}>
        <ComboboxEmpty>No teams found</ComboboxEmpty>
        <ComboboxList>
          {(id: string) => (
            <ComboboxItem key={id} value={id}>{labels.current.get(id) ?? id}</ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )}
</FormMultiComboboxField>
```

`filter={null}` = manual/server search (else Base UI double-filters the server-filtered list).

**More form-bound specifics:**
- **Coloured chips** (e.g. outcomes): cache `{name, color}`, pass the hex to `ComboboxChip`'s
  `legacyColor?: string` (guard null) — `legacyColor={cache.get(id)?.color ?? undefined}`.
- **Cap at N:** hide options at the limit (`items={isAtLimit ? [] : ids}`, `isAtLimit` from
  `field.state.value.length` in the render-prop) **and** slice any other write path (inline create-and-select).
- **Switch-gated:** render under `<form.Subscribe selector={s => s.values.flag}>`; clear on
  disable by wrapping `switchProps.onCheckedChange` → `form.setFieldValue('xIds', [])`.

> Testing: opening these comboboxes (and toggling the switch) in jsdom needs the selector guard +
> hidden-checkbox toggle from `aircall-ds/setup`. Without them the popup crashes on open.

Source: `packages/blocks/src/form/form-field.tsx`
