UNPKG

@aircall/blocks

Version:

Aircall Blocks — higher-level UI compositions built on @aircall/ds

487 lines (415 loc) 17.9 kB
--- name: aircall-blocks/migrate-dashboard/combobox description: > Migrate @dashboard/library search-selects to @aircall/ds Comboboxmulti-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`