@aircall/blocks
Version:
Aircall Blocks — higher-level UI compositions built on @aircall/ds
487 lines (415 loc) • 17.9 kB
Markdown
---
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`