@aircall/ds 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/globals.css +1 -1
- package/dist/index.d.ts +94 -33
- package/dist/index.js +292 -42
- package/package.json +16 -3
- package/skills/aircall-ds/migrate-icons/SKILL.md +346 -0
- package/skills/aircall-ds/migrate-tractor/SKILL.md +314 -0
- package/skills/aircall-ds/migrate-tractor/accordion/SKILL.md +276 -0
- package/skills/aircall-ds/migrate-tractor/alert/SKILL.md +225 -0
- package/skills/aircall-ds/migrate-tractor/avatar/SKILL.md +272 -0
- package/skills/aircall-ds/migrate-tractor/badge/SKILL.md +274 -0
- package/skills/aircall-ds/migrate-tractor/button/SKILL.md +277 -0
- package/skills/aircall-ds/migrate-tractor/card/SKILL.md +278 -0
- package/skills/aircall-ds/migrate-tractor/combobox/SKILL.md +346 -0
- package/skills/aircall-ds/migrate-tractor/data-table/SKILL.md +333 -0
- package/skills/aircall-ds/migrate-tractor/dialog/SKILL.md +206 -0
- package/skills/aircall-ds/migrate-tractor/divider/SKILL.md +226 -0
- package/skills/aircall-ds/migrate-tractor/dropdown-menu/SKILL.md +266 -0
- package/skills/aircall-ds/migrate-tractor/dropzone/SKILL.md +338 -0
- package/skills/aircall-ds/migrate-tractor/form-and-field/SKILL.md +325 -0
- package/skills/aircall-ds/migrate-tractor/gauge/SKILL.md +248 -0
- package/skills/aircall-ds/migrate-tractor/input/SKILL.md +261 -0
- package/skills/aircall-ds/migrate-tractor/item/SKILL.md +298 -0
- package/skills/aircall-ds/migrate-tractor/link/SKILL.md +263 -0
- package/skills/aircall-ds/migrate-tractor/popover/SKILL.md +214 -0
- package/skills/aircall-ds/migrate-tractor/select/SKILL.md +245 -0
- package/skills/aircall-ds/migrate-tractor/sheet-vs-drawer/SKILL.md +272 -0
- package/skills/aircall-ds/migrate-tractor/skeleton/SKILL.md +190 -0
- package/skills/aircall-ds/migrate-tractor/styling/SKILL.md +421 -0
- package/skills/aircall-ds/migrate-tractor/tabs/SKILL.md +250 -0
- package/skills/aircall-ds/migrate-tractor/toast/SKILL.md +322 -0
- package/skills/aircall-ds/migrate-tractor/tooltip/SKILL.md +204 -0
- package/skills/aircall-ds/migrate-tractor/tree/SKILL.md +346 -0
- package/skills/aircall-ds/setup/SKILL.md +347 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aircall-ds/migrate-tractor/combobox
|
|
3
|
+
description: >
|
|
4
|
+
Migrate Tractor ComboBox (and the old Command + Popover search pattern) to the
|
|
5
|
+
@aircall/ds Combobox compound. Load when a file imports ComboBox, Command, or a
|
|
6
|
+
Command+Popover search combo from @aircall/tractor.
|
|
7
|
+
type: sub-skill
|
|
8
|
+
library: aircall-ds
|
|
9
|
+
library_version: "0.13.0"
|
|
10
|
+
requires:
|
|
11
|
+
- aircall-ds/setup
|
|
12
|
+
- aircall-ds/migrate-tractor
|
|
13
|
+
sources:
|
|
14
|
+
- "aircall/hydra:docs/migration-guides/tractor-to-ds/recipes/combobox.md"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
This skill builds on aircall-ds/migrate-tractor. Apply all cross-cutting rules from that skill (prop renames, `render` prop, data attributes) before the combobox-specific steps below.
|
|
18
|
+
|
|
19
|
+
## 1. When to use Combobox vs Select
|
|
20
|
+
|
|
21
|
+
Use `Combobox` when the user needs to **search or filter** options. For a short, fixed-enum list with no search, use `Select` instead (`aircall-ds/migrate-tractor/select`).
|
|
22
|
+
|
|
23
|
+
## 2. Component mapping
|
|
24
|
+
|
|
25
|
+
Tractor `ComboBox` was a single self-contained component. DS uses a compound structure where every visual slot is a named part. The old "Command + Popover" search recipe is fully replaced by this same compound.
|
|
26
|
+
|
|
27
|
+
| Tractor / old pattern | DS compound part | Role |
|
|
28
|
+
| --- | --- | --- |
|
|
29
|
+
| `<ComboBox>` | `<Combobox>` | State owner (`value`, `onValueChange`, `multiple`) |
|
|
30
|
+
| _(input built-in)_ | `<ComboboxInput>` | Visible text field (includes trigger chevron via `showTrigger`) |
|
|
31
|
+
| _(trigger built-in)_ | `<ComboboxTrigger>` | Standalone chevron button (used inside custom layouts) |
|
|
32
|
+
| _(no equivalent)_ | `<ComboboxContent>` | Popover container — handles portal + positioning |
|
|
33
|
+
| _(no equivalent)_ | `<ComboboxList>` | Scrollable list inside the popover |
|
|
34
|
+
| `<ComboBoxOption>` / `<CommandItem>` | `<ComboboxItem>` | Individual option |
|
|
35
|
+
| _(no equivalent)_ | `<ComboboxEmpty>` | Shown automatically when the filtered list is empty |
|
|
36
|
+
| _(no equivalent)_ | `<ComboboxGroup>` | Logical section wrapper |
|
|
37
|
+
| _(no equivalent)_ | `<ComboboxLabel>` | Section heading (must be inside a `ComboboxGroup`) |
|
|
38
|
+
| _(no equivalent)_ | `<ComboboxChips>` | Multi-select chip container (replaces custom tag inputs) |
|
|
39
|
+
| _(no equivalent)_ | `<ComboboxChip>` | Individual removable chip inside `ComboboxChips` |
|
|
40
|
+
| _(no equivalent)_ | `<ComboboxChipsInput>` | Inline input rendered inside `ComboboxChips` |
|
|
41
|
+
| _(no equivalent)_ | `useComboboxAnchor()` | Returns a ref used to anchor `ComboboxContent` to a chips container |
|
|
42
|
+
|
|
43
|
+
## 3. Verified DS exports (`packages/ds/src/index.ts`)
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput,
|
|
47
|
+
ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup,
|
|
48
|
+
ComboboxInput, ComboboxItem, ComboboxLabel, ComboboxList,
|
|
49
|
+
ComboboxSeparator, ComboboxTrigger, ComboboxValue,
|
|
50
|
+
useComboboxAnchor
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
All names used in the Before/After examples below exist in the published public API.
|
|
54
|
+
|
|
55
|
+
## 4. Key prop differences
|
|
56
|
+
|
|
57
|
+
| Tractor / old pattern | DS equivalent | Notes |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| `onChange` | `onValueChange` | Signature: `(value: string \| null) => void` (single); `(value: string[]) => void` (multiple) |
|
|
60
|
+
| `options={[…]}` | `<ComboboxItem>` children | Items are rendered declaratively, not via a data prop |
|
|
61
|
+
| `multi` / `isMulti` | `multiple` on `<Combobox>` | Boolean flag; `value` and `onValueChange` then use `string[]` |
|
|
62
|
+
| _(manual filter logic)_ | Built-in filtering | DS filters items automatically against typed input; opt out for async search by controlling `value` + `onChange` on `ComboboxInput` |
|
|
63
|
+
| `showClear` _(absent)_ | `showClear` on `<ComboboxInput>` | Renders an X button that calls `ComboboxPrimitive.Clear` |
|
|
64
|
+
|
|
65
|
+
## 5. Before / After
|
|
66
|
+
|
|
67
|
+
### 5a. Single-select
|
|
68
|
+
|
|
69
|
+
#### Before (Tractor)
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { ComboBox, ComboBoxOption } from '@aircall/tractor';
|
|
73
|
+
|
|
74
|
+
<ComboBox
|
|
75
|
+
value={country}
|
|
76
|
+
onChange={setCountry}
|
|
77
|
+
placeholder="Search countries"
|
|
78
|
+
>
|
|
79
|
+
{countries.map(c => (
|
|
80
|
+
<ComboBoxOption key={c.iso} value={c.iso}>
|
|
81
|
+
{c.name}
|
|
82
|
+
</ComboBoxOption>
|
|
83
|
+
))}
|
|
84
|
+
</ComboBox>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
#### After (DS)
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import {
|
|
91
|
+
Combobox,
|
|
92
|
+
ComboboxContent,
|
|
93
|
+
ComboboxEmpty,
|
|
94
|
+
ComboboxGroup,
|
|
95
|
+
ComboboxInput,
|
|
96
|
+
ComboboxItem,
|
|
97
|
+
ComboboxLabel,
|
|
98
|
+
ComboboxList,
|
|
99
|
+
} from '@aircall/ds';
|
|
100
|
+
|
|
101
|
+
<Combobox value={country} onValueChange={setCountry}>
|
|
102
|
+
<ComboboxInput placeholder="Search countries" showTrigger />
|
|
103
|
+
<ComboboxContent>
|
|
104
|
+
<ComboboxList>
|
|
105
|
+
<ComboboxEmpty>No results.</ComboboxEmpty>
|
|
106
|
+
<ComboboxGroup>
|
|
107
|
+
<ComboboxLabel>Suggestions</ComboboxLabel>
|
|
108
|
+
{countries.map(c => (
|
|
109
|
+
<ComboboxItem key={c.iso} value={c.iso}>
|
|
110
|
+
{c.name}
|
|
111
|
+
</ComboboxItem>
|
|
112
|
+
))}
|
|
113
|
+
</ComboboxGroup>
|
|
114
|
+
</ComboboxList>
|
|
115
|
+
</ComboboxContent>
|
|
116
|
+
</Combobox>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 5b. Multi-select with chips
|
|
120
|
+
|
|
121
|
+
#### Before (Tractor — custom tag input pattern)
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { ComboBox } from '@aircall/tractor';
|
|
125
|
+
|
|
126
|
+
<ComboBox
|
|
127
|
+
multi
|
|
128
|
+
value={selected}
|
|
129
|
+
onChange={setSelected}
|
|
130
|
+
placeholder="Add a tag"
|
|
131
|
+
options={items}
|
|
132
|
+
/>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### After (DS)
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
import {
|
|
139
|
+
Combobox,
|
|
140
|
+
ComboboxChip,
|
|
141
|
+
ComboboxChips,
|
|
142
|
+
ComboboxChipsInput,
|
|
143
|
+
ComboboxContent,
|
|
144
|
+
ComboboxEmpty,
|
|
145
|
+
ComboboxItem,
|
|
146
|
+
ComboboxList,
|
|
147
|
+
useComboboxAnchor,
|
|
148
|
+
} from '@aircall/ds';
|
|
149
|
+
|
|
150
|
+
function TagPicker() {
|
|
151
|
+
const [selected, setSelected] = React.useState<string[]>([]);
|
|
152
|
+
const anchorRef = useComboboxAnchor();
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<Combobox multiple value={selected} onValueChange={setSelected}>
|
|
156
|
+
<ComboboxChips ref={anchorRef}>
|
|
157
|
+
{selected.map(val => (
|
|
158
|
+
<ComboboxChip key={val} value={val}>
|
|
159
|
+
{items.find(i => i.value === val)?.label}
|
|
160
|
+
</ComboboxChip>
|
|
161
|
+
))}
|
|
162
|
+
<ComboboxChipsInput placeholder="Add a tag" />
|
|
163
|
+
</ComboboxChips>
|
|
164
|
+
<ComboboxContent anchor={anchorRef}>
|
|
165
|
+
<ComboboxList>
|
|
166
|
+
<ComboboxEmpty>No results.</ComboboxEmpty>
|
|
167
|
+
{items.map(item => (
|
|
168
|
+
<ComboboxItem key={item.value} value={item.value}>
|
|
169
|
+
{item.label}
|
|
170
|
+
</ComboboxItem>
|
|
171
|
+
))}
|
|
172
|
+
</ComboboxList>
|
|
173
|
+
</ComboboxContent>
|
|
174
|
+
</Combobox>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### 5c. Async / server-driven options
|
|
180
|
+
|
|
181
|
+
Control the input value yourself and disable the built-in filter by rendering only the results you fetch.
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
import {
|
|
185
|
+
Combobox,
|
|
186
|
+
ComboboxContent,
|
|
187
|
+
ComboboxEmpty,
|
|
188
|
+
ComboboxInput,
|
|
189
|
+
ComboboxItem,
|
|
190
|
+
ComboboxList,
|
|
191
|
+
} from '@aircall/ds';
|
|
192
|
+
|
|
193
|
+
function ContactPicker({ onPick }: { onPick: (id: string) => void }) {
|
|
194
|
+
const [query, setQuery] = React.useState('');
|
|
195
|
+
const { data: results, loading } = useSearch(query);
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<Combobox onValueChange={onPick}>
|
|
199
|
+
<ComboboxInput
|
|
200
|
+
placeholder="Search contacts"
|
|
201
|
+
value={query}
|
|
202
|
+
onChange={e => setQuery(e.target.value)}
|
|
203
|
+
/>
|
|
204
|
+
<ComboboxContent>
|
|
205
|
+
<ComboboxList>
|
|
206
|
+
{loading && <ComboboxEmpty>Searching…</ComboboxEmpty>}
|
|
207
|
+
{!loading && results.length === 0 && (
|
|
208
|
+
<ComboboxEmpty>No matches.</ComboboxEmpty>
|
|
209
|
+
)}
|
|
210
|
+
{results.map(r => (
|
|
211
|
+
<ComboboxItem key={r.id} value={r.id}>
|
|
212
|
+
{r.name}
|
|
213
|
+
</ComboboxItem>
|
|
214
|
+
))}
|
|
215
|
+
</ComboboxList>
|
|
216
|
+
</ComboboxContent>
|
|
217
|
+
</Combobox>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## 6. Common Mistakes
|
|
223
|
+
|
|
224
|
+
### Mistake 1 — Omitting `ComboboxList` and placing items directly in `ComboboxContent`
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
// Wrong
|
|
228
|
+
<ComboboxContent>
|
|
229
|
+
<ComboboxEmpty>No results.</ComboboxEmpty>
|
|
230
|
+
<ComboboxItem value="a">Alpha</ComboboxItem>
|
|
231
|
+
</ComboboxContent>
|
|
232
|
+
|
|
233
|
+
// Correct
|
|
234
|
+
<ComboboxContent>
|
|
235
|
+
<ComboboxList>
|
|
236
|
+
<ComboboxEmpty>No results.</ComboboxEmpty>
|
|
237
|
+
<ComboboxItem value="a">Alpha</ComboboxItem>
|
|
238
|
+
</ComboboxList>
|
|
239
|
+
</ComboboxContent>
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`ComboboxContent` is the popover shell (portal + positioner + popup). The scrollable, filterable list — including `ComboboxEmpty` visibility logic — lives in `ComboboxList`. Placing items directly in `ComboboxContent` bypasses the scroll container and breaks the empty-state `group-data-empty` CSS selector that shows `ComboboxEmpty`.
|
|
243
|
+
Source: `packages/ds/src/components/combobox.tsx`
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
### Mistake 2 — Forgetting `useComboboxAnchor` / not passing `anchor` for multi-select chips
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
// Wrong — popover anchors to the document body, not the chips container
|
|
251
|
+
<Combobox multiple value={selected} onValueChange={setSelected}>
|
|
252
|
+
<ComboboxChips>
|
|
253
|
+
{selected.map(val => <ComboboxChip key={val} value={val}>{val}</ComboboxChip>)}
|
|
254
|
+
<ComboboxChipsInput placeholder="Add a tag" />
|
|
255
|
+
</ComboboxChips>
|
|
256
|
+
<ComboboxContent>
|
|
257
|
+
<ComboboxList>
|
|
258
|
+
{items.map(i => <ComboboxItem key={i.value} value={i.value}>{i.label}</ComboboxItem>)}
|
|
259
|
+
</ComboboxList>
|
|
260
|
+
</ComboboxContent>
|
|
261
|
+
</Combobox>
|
|
262
|
+
|
|
263
|
+
// Correct
|
|
264
|
+
const anchorRef = useComboboxAnchor();
|
|
265
|
+
|
|
266
|
+
<Combobox multiple value={selected} onValueChange={setSelected}>
|
|
267
|
+
<ComboboxChips ref={anchorRef}>
|
|
268
|
+
{selected.map(val => <ComboboxChip key={val} value={val}>{val}</ComboboxChip>)}
|
|
269
|
+
<ComboboxChipsInput placeholder="Add a tag" />
|
|
270
|
+
</ComboboxChips>
|
|
271
|
+
<ComboboxContent anchor={anchorRef}>
|
|
272
|
+
<ComboboxList>
|
|
273
|
+
{items.map(i => <ComboboxItem key={i.value} value={i.value}>{i.label}</ComboboxItem>)}
|
|
274
|
+
</ComboboxList>
|
|
275
|
+
</ComboboxContent>
|
|
276
|
+
</Combobox>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Without `anchor={anchorRef}`, `ComboboxContent` uses the default Combobox root as its positional reference, so the dropdown opens at the wrong position (above or misaligned with the chip container). `useComboboxAnchor()` returns a stable `ref` that must be attached to `ComboboxChips` and forwarded to `ComboboxContent`.
|
|
280
|
+
Source: `packages/ds/src/components/combobox.tsx`
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
### Mistake 3 — Using a manual filter loop instead of the built-in filtering for static lists
|
|
285
|
+
|
|
286
|
+
```tsx
|
|
287
|
+
// Wrong — manual filter duplicates work the DS already does
|
|
288
|
+
const [query, setQuery] = React.useState('');
|
|
289
|
+
const visible = countries.filter(c =>
|
|
290
|
+
c.name.toLowerCase().includes(query.toLowerCase())
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
<Combobox value={country} onValueChange={setCountry}>
|
|
294
|
+
<ComboboxInput
|
|
295
|
+
placeholder="Search countries"
|
|
296
|
+
value={query}
|
|
297
|
+
onChange={e => setQuery(e.target.value)}
|
|
298
|
+
/>
|
|
299
|
+
<ComboboxContent>
|
|
300
|
+
<ComboboxList>
|
|
301
|
+
{visible.map(c => (
|
|
302
|
+
<ComboboxItem key={c.iso} value={c.iso}>{c.name}</ComboboxItem>
|
|
303
|
+
))}
|
|
304
|
+
</ComboboxList>
|
|
305
|
+
</ComboboxContent>
|
|
306
|
+
</Combobox>
|
|
307
|
+
|
|
308
|
+
// Correct — render all items; DS filters automatically
|
|
309
|
+
<Combobox value={country} onValueChange={setCountry}>
|
|
310
|
+
<ComboboxInput placeholder="Search countries" showTrigger />
|
|
311
|
+
<ComboboxContent>
|
|
312
|
+
<ComboboxList>
|
|
313
|
+
<ComboboxEmpty>No results.</ComboboxEmpty>
|
|
314
|
+
{countries.map(c => (
|
|
315
|
+
<ComboboxItem key={c.iso} value={c.iso}>{c.name}</ComboboxItem>
|
|
316
|
+
))}
|
|
317
|
+
</ComboboxList>
|
|
318
|
+
</ComboboxContent>
|
|
319
|
+
</Combobox>
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
For static option lists, the DS Combobox (via Base UI) filters rendered items automatically against what the user types — no state or `filter` callback needed. Manual filtering is only required for async/server-driven lists where you control the results yourself.
|
|
323
|
+
Source: `packages/ds/src/components/combobox.tsx`
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
### Mistake 4 — Placing `ComboboxLabel` directly in `ComboboxList` without a `ComboboxGroup`
|
|
328
|
+
|
|
329
|
+
```tsx
|
|
330
|
+
// Wrong
|
|
331
|
+
<ComboboxList>
|
|
332
|
+
<ComboboxLabel>Suggestions</ComboboxLabel>
|
|
333
|
+
<ComboboxItem value="a">Alpha</ComboboxItem>
|
|
334
|
+
</ComboboxList>
|
|
335
|
+
|
|
336
|
+
// Correct
|
|
337
|
+
<ComboboxList>
|
|
338
|
+
<ComboboxGroup>
|
|
339
|
+
<ComboboxLabel>Suggestions</ComboboxLabel>
|
|
340
|
+
<ComboboxItem value="a">Alpha</ComboboxItem>
|
|
341
|
+
</ComboboxGroup>
|
|
342
|
+
</ComboboxList>
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`ComboboxLabel` maps to Base UI's `ComboboxPrimitive.GroupLabel` — it is semantically scoped to a `ComboboxGroup`. Rendering it outside a group breaks the ARIA `group` association that screen readers rely on to announce the section heading.
|
|
346
|
+
Source: `packages/ds/src/components/combobox.tsx`
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aircall-ds/migrate-tractor/data-table
|
|
3
|
+
description: >
|
|
4
|
+
Migrate Tractor Table and TableColumn to the @aircall/ds DataTable (TanStack
|
|
5
|
+
Table-backed). Load when a file imports Table or TableColumn from @aircall/tractor.
|
|
6
|
+
Covers column definitions, sorting, row selection, loading states, and empty states.
|
|
7
|
+
type: sub-skill
|
|
8
|
+
library: aircall-ds
|
|
9
|
+
library_version: "0.13.0"
|
|
10
|
+
requires:
|
|
11
|
+
- aircall-ds/setup
|
|
12
|
+
- aircall-ds/migrate-tractor
|
|
13
|
+
sources:
|
|
14
|
+
- "aircall/hydra:packages/ds/src/components/data-table.tsx"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
This skill builds on aircall-ds/migrate-tractor. Apply all cross-cutting rules from that skill (prop renames, `render` prop, data attributes) before the data-table-specific steps below.
|
|
18
|
+
|
|
19
|
+
## 1. Architecture change
|
|
20
|
+
|
|
21
|
+
Tractor `Table` was a self-contained component: you passed `data`, `columns` (a `TableColumn[]` descriptor array), and optional callbacks — Tractor handled layout, sorting state, and selection state internally.
|
|
22
|
+
|
|
23
|
+
DS `DataTable` is built on **TanStack Table v8**. Columns are defined as `ColumnDef<TData>[]` (from `@tanstack/react-table`), sorting and selection state are controlled externally by the caller, and loading is expressed as a single `loadingState` string rather than a boolean `loading` flag.
|
|
24
|
+
|
|
25
|
+
There is no sub-component API (no `<TableColumn>`) — everything is declared in `columns`.
|
|
26
|
+
|
|
27
|
+
## 2. Component mapping
|
|
28
|
+
|
|
29
|
+
| Tractor | @aircall/ds |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `<Table data columns loading ...>` | `<DataTable data columns loadingState ...>` |
|
|
32
|
+
| `TableColumn<T>` (object descriptor) | `ColumnDef<TData, unknown>` from `@tanstack/react-table` |
|
|
33
|
+
| `columns[n].id` | `ColumnDef.id` (or accessor key) |
|
|
34
|
+
| `columns[n].label` | `ColumnDef.header` |
|
|
35
|
+
| `columns[n].renderer` | `ColumnDef.cell` (receives `{ row }`) |
|
|
36
|
+
| `columns[n].sortable` | `ColumnDef.enableSorting` |
|
|
37
|
+
| `loading={true}` | `loadingState="loading"` |
|
|
38
|
+
| `noDataMessage` | `emptyState` (accepts any `ReactNode`) |
|
|
39
|
+
| `onRowClick(record, event)` | `onRowClick(row)` (no `event` argument) |
|
|
40
|
+
| `bulkActions` + `verticalScrollingParent` | `enableRowSelection` + `actionBar` |
|
|
41
|
+
| `onOrderChange(columnId)` | `onSortingChange` (receives `SortingState`) + `sorting` |
|
|
42
|
+
| `order` (`OrderInput`) | `sorting` (`SortingState`) |
|
|
43
|
+
| `footer` | column-level `ColumnDef.footer` (auto-renders `<tfoot>`) |
|
|
44
|
+
| _(no equivalent)_ | `loadingState="sorting"` / `"filtering"` (overlay spinner) |
|
|
45
|
+
| _(no equivalent)_ | `loadingState="loadingMore"` (infinite scroll spinner row) |
|
|
46
|
+
| _(no equivalent)_ | `onFetchMore` + `hasMore` (IntersectionObserver sentinel) |
|
|
47
|
+
| _(no equivalent)_ | `fillHeight` (sticky header, internal scroll) |
|
|
48
|
+
|
|
49
|
+
## 3. Verified DS exports (`packages/ds/src/index.ts`)
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
DataTable, type DataTableLoadingState
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`@aircall/ds` **re-exports** the TanStack Table types you author — `ColumnDef`, `SortingState`, `RowSelectionState`, `OnChangeFn` — so **import them from `@aircall/ds`**, not `@tanstack/react-table`. The react-table runtime ships as a regular `@aircall/ds` dependency (auto-installed; `DataTable` owns the table instance internally), so there is nothing to install and no version to keep in sync.
|
|
56
|
+
|
|
57
|
+
## 4. Imports
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { DataTable, type DataTableLoadingState } from '@aircall/ds';
|
|
61
|
+
import type { ColumnDef, SortingState, RowSelectionState, OnChangeFn } from '@aircall/ds';
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 5. Column definitions
|
|
65
|
+
|
|
66
|
+
Tractor used a descriptor array (`TableColumn[]`) with a `renderer` function. DS uses `ColumnDef<TData>[]` from TanStack Table. The key changes:
|
|
67
|
+
|
|
68
|
+
| Tractor `TableColumn` field | TanStack `ColumnDef` equivalent |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `id` | `accessorKey` (auto-creates accessor) or `id` + `accessorFn` |
|
|
71
|
+
| `label` | `header` (`ReactNode` or render function) |
|
|
72
|
+
| `renderer(rowData, value, rowIndex)` | `cell: ({ row }) => row.original.<field>` |
|
|
73
|
+
| `sortable: false` | `enableSorting: false` |
|
|
74
|
+
| _(no equivalent)_ | `footer` (triggers `<tfoot>` rendering) |
|
|
75
|
+
| _(no equivalent)_ | `size`, `minSize`, `maxSize` (for `enableColumnResizing`) |
|
|
76
|
+
|
|
77
|
+
## 6. Before / After
|
|
78
|
+
|
|
79
|
+
### 6a. Basic read-only table
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
// Before
|
|
83
|
+
import { Table, TableColumn } from '@aircall/tractor';
|
|
84
|
+
|
|
85
|
+
type Person = { id: string; firstName: string; lastName: string; email: string };
|
|
86
|
+
|
|
87
|
+
const columns: TableColumn<Person>[] = [
|
|
88
|
+
{ id: 'firstName', label: 'First name' },
|
|
89
|
+
{ id: 'lastName', label: 'Last name' },
|
|
90
|
+
{
|
|
91
|
+
id: 'email',
|
|
92
|
+
label: 'Email',
|
|
93
|
+
renderer: ({ email }) => <a href={`mailto:${email}`}>{email}</a>
|
|
94
|
+
}
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
<Table data={people} columns={columns} loading={isLoading} noDataMessage="No people found." />
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
// After
|
|
102
|
+
import { DataTable } from '@aircall/ds';
|
|
103
|
+
import type { ColumnDef } from '@aircall/ds';
|
|
104
|
+
|
|
105
|
+
type Person = { id: string; firstName: string; lastName: string; email: string };
|
|
106
|
+
|
|
107
|
+
const columns: ColumnDef<Person>[] = [
|
|
108
|
+
{ accessorKey: 'firstName', header: 'First name' },
|
|
109
|
+
{ accessorKey: 'lastName', header: 'Last name' },
|
|
110
|
+
{
|
|
111
|
+
accessorKey: 'email',
|
|
112
|
+
header: 'Email',
|
|
113
|
+
cell: ({ row }) => <a href={`mailto:${row.original.email}`}>{row.original.email}</a>
|
|
114
|
+
}
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
<DataTable
|
|
118
|
+
data={people}
|
|
119
|
+
columns={columns}
|
|
120
|
+
loadingState={isLoading ? 'loading' : 'idle'}
|
|
121
|
+
emptyState={<p className="text-sm text-muted-foreground">No people found.</p>}
|
|
122
|
+
/>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 6b. Server-side sortable table
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
// Before
|
|
129
|
+
import { Table, TableColumn, OrderInput } from '@aircall/tractor';
|
|
130
|
+
import { OrderDirection } from '@aircall/tractor';
|
|
131
|
+
|
|
132
|
+
const [order, setOrder] = React.useState<OrderInput>({ field: 'firstName', direction: OrderDirection.Asc });
|
|
133
|
+
|
|
134
|
+
const columns: TableColumn<Person>[] = [
|
|
135
|
+
{ id: 'firstName', label: 'First name', sortable: true },
|
|
136
|
+
{ id: 'lastName', label: 'Last name', sortable: true },
|
|
137
|
+
{ id: 'email', label: 'Email', sortable: false }
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
<Table
|
|
141
|
+
data={people}
|
|
142
|
+
columns={columns}
|
|
143
|
+
order={order}
|
|
144
|
+
onOrderChange={(columnId) => {
|
|
145
|
+
setOrder(prev => ({
|
|
146
|
+
field: columnId,
|
|
147
|
+
direction: prev.field === columnId && prev.direction === OrderDirection.Asc
|
|
148
|
+
? OrderDirection.Desc
|
|
149
|
+
: OrderDirection.Asc
|
|
150
|
+
}));
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
// After
|
|
157
|
+
import { DataTable } from '@aircall/ds';
|
|
158
|
+
import type { ColumnDef, SortingState, OnChangeFn } from '@aircall/ds';
|
|
159
|
+
|
|
160
|
+
const [sorting, setSorting] = React.useState<SortingState>([{ id: 'firstName', desc: false }]);
|
|
161
|
+
|
|
162
|
+
const columns: ColumnDef<Person>[] = [
|
|
163
|
+
{ accessorKey: 'firstName', header: 'First name', enableSorting: true },
|
|
164
|
+
{ accessorKey: 'lastName', header: 'Last name', enableSorting: true },
|
|
165
|
+
{ accessorKey: 'email', header: 'Email', enableSorting: false }
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
<DataTable
|
|
169
|
+
data={people}
|
|
170
|
+
columns={columns}
|
|
171
|
+
sorting={sorting}
|
|
172
|
+
onSortingChange={setSorting}
|
|
173
|
+
loadingState={isSorting ? 'sorting' : 'idle'}
|
|
174
|
+
/>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Key changes:
|
|
178
|
+
- `order: OrderInput` → `sorting: SortingState` (array of `{ id, desc }` objects)
|
|
179
|
+
- `onOrderChange(columnId)` → `onSortingChange: OnChangeFn<SortingState>` (receives the new full state)
|
|
180
|
+
- A `loadingState="sorting"` dims the existing rows with an overlay spinner while the server re-queries — no skeleton flash
|
|
181
|
+
|
|
182
|
+
### 6c. Selectable table with bulk action bar
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
// Before
|
|
186
|
+
import { Table, TableColumn } from '@aircall/tractor';
|
|
187
|
+
|
|
188
|
+
const parentRef = React.useRef<HTMLDivElement | null>(null);
|
|
189
|
+
|
|
190
|
+
<div ref={parentRef} style={{ height: '500px', overflowY: 'auto' }}>
|
|
191
|
+
<Table
|
|
192
|
+
data={people}
|
|
193
|
+
columns={columns}
|
|
194
|
+
verticalScrollingParent={parentRef}
|
|
195
|
+
bulkActions={[
|
|
196
|
+
{ label: 'Delete', onExecute: (records) => deleteRecords(records) }
|
|
197
|
+
]}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
// After
|
|
204
|
+
import { DataTable } from '@aircall/ds';
|
|
205
|
+
import { ActionBar, ActionBarButton } from '@aircall/ds';
|
|
206
|
+
import type { RowSelectionState, OnChangeFn } from '@aircall/ds';
|
|
207
|
+
|
|
208
|
+
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
|
209
|
+
|
|
210
|
+
<DataTable
|
|
211
|
+
data={people}
|
|
212
|
+
columns={columns}
|
|
213
|
+
getRowId={(row) => row.id}
|
|
214
|
+
enableRowSelection
|
|
215
|
+
rowSelection={rowSelection}
|
|
216
|
+
onRowSelectionChange={setRowSelection}
|
|
217
|
+
actionBar={
|
|
218
|
+
<ActionBar>
|
|
219
|
+
<ActionBarButton variant="destructive" onClick={() => deleteSelected()}>
|
|
220
|
+
Delete
|
|
221
|
+
</ActionBarButton>
|
|
222
|
+
</ActionBar>
|
|
223
|
+
}
|
|
224
|
+
/>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Key changes:
|
|
228
|
+
- `verticalScrollingParent` is not needed — `DataTable` manages its own ActionBar positioning
|
|
229
|
+
- `bulkActions` array → pass an `<ActionBar>` node to the `actionBar` prop
|
|
230
|
+
- `onSelectionChange(selectedRows, count)` is replaced by controlled `rowSelection` state — derive selected records from `people.filter(p => rowSelection[p.id])`
|
|
231
|
+
- `getRowId` is required when selection must survive data refreshes (server pagination)
|
|
232
|
+
|
|
233
|
+
## 7. Common mistakes
|
|
234
|
+
|
|
235
|
+
### Mistake 1 — Using `renderer` from `TableColumn` on a `ColumnDef`
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
// Wrong — renderer is a Tractor TableColumn field; ColumnDef ignores it
|
|
239
|
+
const columns: ColumnDef<Person>[] = [
|
|
240
|
+
{
|
|
241
|
+
accessorKey: 'email',
|
|
242
|
+
header: 'Email',
|
|
243
|
+
renderer: ({ email }) => <a href={`mailto:${email}`}>{email}</a>
|
|
244
|
+
}
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
// Correct — ColumnDef uses cell; the cell function receives the TanStack cell context
|
|
248
|
+
const columns: ColumnDef<Person>[] = [
|
|
249
|
+
{
|
|
250
|
+
accessorKey: 'email',
|
|
251
|
+
header: 'Email',
|
|
252
|
+
cell: ({ row }) => <a href={`mailto:${row.original.email}`}>{row.original.email}</a>
|
|
253
|
+
}
|
|
254
|
+
];
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`ColumnDef` has no `renderer` field — passing it adds an unrecognised property that TypeScript will not catch (the field just becomes dead code). The cell always renders the raw accessor value. Use `cell: ({ row }) => …` and access typed row data via `row.original`.
|
|
258
|
+
|
|
259
|
+
Source: `packages/ds/src/components/data-table.tsx`
|
|
260
|
+
|
|
261
|
+
### Mistake 2 — Passing `loading={true}` instead of `loadingState="loading"`
|
|
262
|
+
|
|
263
|
+
```tsx
|
|
264
|
+
// Wrong — loading is a Tractor prop; DataTable has no loading prop
|
|
265
|
+
<DataTable data={[]} columns={columns} loading={isLoading} />
|
|
266
|
+
|
|
267
|
+
// Correct — use loadingState with one of the four async-state values
|
|
268
|
+
<DataTable
|
|
269
|
+
data={[]}
|
|
270
|
+
columns={columns}
|
|
271
|
+
loadingState={isLoading ? 'loading' : 'idle'}
|
|
272
|
+
/>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
DS replaced the boolean `loading` flag with a discriminated `loadingState` string so that different async phases (`'loading'`, `'sorting'`, `'filtering'`, `'loadingMore'`) can produce different UX (skeleton rows vs. overlay spinner vs. bottom spinner row). Passing `loading` as a prop is silently ignored — the table renders the empty state or no rows instead of skeleton rows.
|
|
276
|
+
|
|
277
|
+
Source: `packages/ds/src/components/data-table.tsx`
|
|
278
|
+
|
|
279
|
+
### Mistake 3 — Deriving selected records from `onSelectionChange` instead of `rowSelection` state
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
// Wrong — onSelectionChange does not exist on DataTable; selection is controlled via state
|
|
283
|
+
<DataTable
|
|
284
|
+
enableRowSelection
|
|
285
|
+
onSelectionChange={(selectedRows) => setSelectedPeople(selectedRows)}
|
|
286
|
+
columns={columns}
|
|
287
|
+
data={people}
|
|
288
|
+
/>
|
|
289
|
+
|
|
290
|
+
// Correct — hold RowSelectionState; derive selected records from data + state when needed
|
|
291
|
+
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
|
292
|
+
const selectedPeople = people.filter(p => rowSelection[p.id]);
|
|
293
|
+
|
|
294
|
+
<DataTable
|
|
295
|
+
enableRowSelection
|
|
296
|
+
rowSelection={rowSelection}
|
|
297
|
+
onRowSelectionChange={setRowSelection}
|
|
298
|
+
getRowId={(row) => row.id}
|
|
299
|
+
columns={columns}
|
|
300
|
+
data={people}
|
|
301
|
+
/>
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Tractor called `onSelectionChange(rows, count)` with the actual row objects. DS selection is fully controlled: `onRowSelectionChange` receives the new `RowSelectionState` (a record of `{ [rowId]: boolean }`). Derive selected rows by filtering your data array against the state object. Always provide `getRowId` so the IDs in `RowSelectionState` match your data's primary key.
|
|
305
|
+
|
|
306
|
+
Source: `packages/ds/src/components/data-table.tsx`
|
|
307
|
+
|
|
308
|
+
### Mistake 4 — Passing `noDataMessage` as a string instead of an `emptyState` node
|
|
309
|
+
|
|
310
|
+
```tsx
|
|
311
|
+
// Wrong — noDataMessage is a Tractor prop; DataTable renders nothing for it
|
|
312
|
+
<DataTable data={[]} columns={columns} noDataMessage="No records found." />
|
|
313
|
+
|
|
314
|
+
// Correct — emptyState accepts any ReactNode; render an Empty component for rich states
|
|
315
|
+
import { Empty, EmptyContent, EmptyTitle, EmptyDescription } from '@aircall/ds';
|
|
316
|
+
|
|
317
|
+
<DataTable
|
|
318
|
+
data={[]}
|
|
319
|
+
columns={columns}
|
|
320
|
+
emptyState={
|
|
321
|
+
<Empty>
|
|
322
|
+
<EmptyContent>
|
|
323
|
+
<EmptyTitle>No records found</EmptyTitle>
|
|
324
|
+
<EmptyDescription>Try adjusting your filters.</EmptyDescription>
|
|
325
|
+
</EmptyContent>
|
|
326
|
+
</Empty>
|
|
327
|
+
}
|
|
328
|
+
/>
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Tractor `noDataMessage` accepted a `ReactNode` but was declared as a simple prop. DS requires an `emptyState` prop; `noDataMessage` is silently ignored. For plain text, wrap it in a `<p>` with `text-sm text-muted-foreground`. For a full empty state with an icon and a call-to-action, use the `Empty` compound from `@aircall/ds`.
|
|
332
|
+
|
|
333
|
+
Source: `packages/ds/src/components/data-table.tsx`
|