@aircall/ds 0.14.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 +28 -28
- package/dist/index.js +1 -1
- package/package.json +12 -2
- 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,338 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aircall-ds/migrate-tractor/dropzone
|
|
3
|
+
description: >
|
|
4
|
+
Migrate react-dropzone (useDropzone, DragAndDrop, ConversationDragAndDrop)
|
|
5
|
+
and project wrappers (Dropzone, DropzoneV2) to @aircall/ds Dropzone. Load
|
|
6
|
+
when a file imports from react-dropzone or uses DragAndDrop 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/dropzone.md"
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
This skill builds on aircall-ds/migrate-tractor.
|
|
18
|
+
|
|
19
|
+
## 1. Component mapping
|
|
20
|
+
|
|
21
|
+
| Tractor / react-dropzone | @aircall/ds |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `useDropzone()` hook | `<Dropzone>` (no hook needed) |
|
|
24
|
+
| `<DragAndDrop>` / `<ConversationDragAndDrop>` | `<Dropzone>` |
|
|
25
|
+
| Project wrapper `<Dropzone dropzoneOptions={…}>` | `<Dropzone>` (flatten options to direct props) |
|
|
26
|
+
| `onDrop` / `onDropAccepted` / `onAddFiles` | `onFiles: (files: File[]) => void` |
|
|
27
|
+
| `onDropRejected` | `onError: (rejections: FileRejection[]) => void` |
|
|
28
|
+
| `acceptMessage` | `hoverMessage` |
|
|
29
|
+
| `rejectMessage` | `dragRejectMessage` |
|
|
30
|
+
| `accept: Record<string, string[]>` / `acceptedMimeTypes` | `accept: string` (comma-separated HTML `<input accept>` form) |
|
|
31
|
+
| `multiple`, `disabled`, `maxFiles`, `maxSize`, `minSize` | same props, same semantics |
|
|
32
|
+
| `noClick` / `noKeyboard` / `noDrag` | omit `<DropzoneContent>` (headless mode = no click/keyboard) |
|
|
33
|
+
| `getRootProps()` / `getInputProps()` / `open()` | `<DropzoneTrigger>` |
|
|
34
|
+
| `text` / `subtext` (project wrapper) | `<DropzoneTitle>` / `<DropzoneDescription>` |
|
|
35
|
+
| `errorText` / `customError` (project wrapper) | consumer state + `<FieldError>` inside `<Field data-invalid>` |
|
|
36
|
+
| `variant="primary" \| "secondary"` (project wrapper) | removed — apply via `className` if needed |
|
|
37
|
+
| `validator` | inline guard in `onFiles` + `dragRejectMessage` |
|
|
38
|
+
|
|
39
|
+
### accept map helper
|
|
40
|
+
|
|
41
|
+
When old code passes `accept` as a `Record<string, string[]>`, convert to a string:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
function flattenAcceptMap(map: Record<string, string[]>): string {
|
|
45
|
+
return [...Object.keys(map), ...Object.values(map).flat()].join(',');
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### FileRejection shape (unchanged from react-dropzone)
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { type FileRejection, type FileError, type DropzoneErrorCode } from '@aircall/ds';
|
|
53
|
+
|
|
54
|
+
// FileRejection: { file: File; errors: FileError[] }
|
|
55
|
+
// FileError: { code: DropzoneErrorCode; message: string }
|
|
56
|
+
// DropzoneErrorCode: 'too-many-files' | 'file-too-large' | 'file-too-small' | 'file-invalid-type'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 2. Imports
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import {
|
|
63
|
+
Dropzone,
|
|
64
|
+
DropzoneContent,
|
|
65
|
+
DropzoneIcon,
|
|
66
|
+
DropzoneTitle,
|
|
67
|
+
DropzoneDescription,
|
|
68
|
+
DropzoneActions,
|
|
69
|
+
DropzoneTrigger,
|
|
70
|
+
Field,
|
|
71
|
+
FieldError,
|
|
72
|
+
Button,
|
|
73
|
+
type FileRejection,
|
|
74
|
+
type FileError,
|
|
75
|
+
type DropzoneErrorCode,
|
|
76
|
+
} from '@aircall/ds';
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 3. Pattern picker
|
|
80
|
+
|
|
81
|
+
| Pattern | Use `<DropzoneContent>`? | Use when |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| **Headless** | No | Drop area wraps existing UI; overlay appears on drag only. Old code used `noClick`, `noDrag`, or wrapped arbitrary children. |
|
|
84
|
+
| **Headless with trigger** | No | Headless drop area + an explicit button to open the picker (`useDropzone({ noClick: true })` + separate `open()` call). |
|
|
85
|
+
| **Card** | Yes | Standard dashed-border file-picker box with icon, title, description, and click-anywhere-to-open. |
|
|
86
|
+
|
|
87
|
+
## 4. Before / After
|
|
88
|
+
|
|
89
|
+
### Pattern 1 — Headless wrapping app content
|
|
90
|
+
|
|
91
|
+
Replaces `<DragAndDrop>` / `<ConversationDragAndDrop>` with child content.
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// Before
|
|
95
|
+
import { DragAndDrop } from '@aircall/tractor';
|
|
96
|
+
|
|
97
|
+
const validator = useCallback(
|
|
98
|
+
() => [{ message: rejectMessage, code: ErrorCode.FileInvalidType }],
|
|
99
|
+
[rejectMessage]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
<DragAndDrop
|
|
103
|
+
validator={cannotAttach ? validator : undefined}
|
|
104
|
+
multiple
|
|
105
|
+
acceptedMimeTypes={acceptedMimeTypes}
|
|
106
|
+
acceptMessage={t('FilePicker.DragAndDrop.AcceptMessage')}
|
|
107
|
+
rejectMessage={rejectMessage}
|
|
108
|
+
onAddFiles={handleOnAddFiles}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</DragAndDrop>
|
|
112
|
+
|
|
113
|
+
// After
|
|
114
|
+
import { Dropzone } from '@aircall/ds';
|
|
115
|
+
|
|
116
|
+
<Dropzone
|
|
117
|
+
multiple
|
|
118
|
+
accept={flattenAcceptMap(acceptedMimeTypes)}
|
|
119
|
+
onFiles={cannotAttach ? undefined : handleOnAddFiles}
|
|
120
|
+
onError={rejections => showErrorToast(rejections[0]?.errors[0]?.message ?? '')}
|
|
121
|
+
hoverMessage={t('FilePicker.DragAndDrop.AcceptMessage')}
|
|
122
|
+
dragRejectMessage={cannotAttach ? rejectMessage : t('FilePicker.DragAndDrop.UnsupportedType')}
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</Dropzone>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The `validator` collapses: gate acceptance in `onFiles` (pass `undefined` when disabled) and surface the rejection message via `dragRejectMessage`.
|
|
129
|
+
|
|
130
|
+
### Pattern 2 — Headless with explicit trigger button
|
|
131
|
+
|
|
132
|
+
Replaces a parallel `useDropzone({ noClick: true })` + `<DragAndDrop>` combo.
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
// Before
|
|
136
|
+
import { useDropzone } from 'react-dropzone';
|
|
137
|
+
import { DragAndDrop } from '@aircall/tractor';
|
|
138
|
+
import { Button } from '@aircall/ds';
|
|
139
|
+
import { ImportOutlined } from '@aircall/react-icons';
|
|
140
|
+
|
|
141
|
+
const { open, getInputProps } = useDropzone({
|
|
142
|
+
accept: POWERDIALER_ACCEPTED_MIME_TYPES,
|
|
143
|
+
multiple: false,
|
|
144
|
+
noClick: true,
|
|
145
|
+
noKeyboard: true,
|
|
146
|
+
onDropRejected,
|
|
147
|
+
onDropAccepted,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
<DragAndDrop
|
|
151
|
+
maxFiles={1}
|
|
152
|
+
acceptMessage={t('DragAndDropAcceptMessage')}
|
|
153
|
+
rejectMessage={t('DragAndDropRejectMessage')}
|
|
154
|
+
onAddFiles={files => files.length && onAddFile(files[0])}
|
|
155
|
+
acceptedMimeTypes={POWERDIALER_ACCEPTED_MIME_TYPES}
|
|
156
|
+
onDropRejected={onDropRejected}
|
|
157
|
+
>
|
|
158
|
+
<Button onClick={open}><ImportOutlined />{t('UploadAFile')}</Button>
|
|
159
|
+
<input {...getInputProps()} hidden />
|
|
160
|
+
</DragAndDrop>
|
|
161
|
+
|
|
162
|
+
// After
|
|
163
|
+
import { Dropzone, DropzoneTrigger, Button } from '@aircall/ds';
|
|
164
|
+
import { Import } from '@aircall/react-icons';
|
|
165
|
+
|
|
166
|
+
<Dropzone
|
|
167
|
+
accept="text/csv,application/vnd.ms-excel,.csv"
|
|
168
|
+
multiple={false}
|
|
169
|
+
maxFiles={1}
|
|
170
|
+
onFiles={([file]) => file && onAddFile(file)}
|
|
171
|
+
onError={rejections => {
|
|
172
|
+
if (rejections[0]?.errors[0]?.code === 'file-invalid-type') {
|
|
173
|
+
showErrorToast(t('Notifications.FailedFormatError'));
|
|
174
|
+
}
|
|
175
|
+
}}
|
|
176
|
+
hoverMessage={t('DragAndDropAcceptMessage')}
|
|
177
|
+
dragRejectMessage={t('DragAndDropRejectMessage')}
|
|
178
|
+
>
|
|
179
|
+
<DropzoneTrigger render={<Button />}>
|
|
180
|
+
<Import />{t('UploadAFile')}
|
|
181
|
+
</DropzoneTrigger>
|
|
182
|
+
</Dropzone>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The hidden `<input>` and `getInputProps()` spread are gone — `<Dropzone>` manages the file input internally. `<DropzoneTrigger>` calls `open()` on click via context.
|
|
186
|
+
|
|
187
|
+
### Pattern 3 — Card with click-to-open
|
|
188
|
+
|
|
189
|
+
Replaces a project wrapper `<Dropzone dropzoneOptions={…}>` with typography children.
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
// Before
|
|
193
|
+
import { Dropzone } from '@aircall/tractor';
|
|
194
|
+
|
|
195
|
+
<Dropzone
|
|
196
|
+
dropzoneOptions={{
|
|
197
|
+
onDrop: handleDrop,
|
|
198
|
+
onDropRejected: handleError,
|
|
199
|
+
maxFiles: 1,
|
|
200
|
+
accept: { 'audio/mpeg': ['.mp3'] },
|
|
201
|
+
maxSize: MAX_AUDIO_SIZE,
|
|
202
|
+
}}
|
|
203
|
+
errorText={capitalize(t('dropzone.secondary_text'))}
|
|
204
|
+
variant="primary"
|
|
205
|
+
>
|
|
206
|
+
<Typography variant="bodySemiboldM">{t('primary_text')}</Typography>
|
|
207
|
+
<Typography variant="bodyMediumS">{t('secondary_text')}</Typography>
|
|
208
|
+
</Dropzone>
|
|
209
|
+
|
|
210
|
+
// After
|
|
211
|
+
import { useState } from 'react';
|
|
212
|
+
import {
|
|
213
|
+
Dropzone,
|
|
214
|
+
DropzoneContent,
|
|
215
|
+
DropzoneTitle,
|
|
216
|
+
DropzoneDescription,
|
|
217
|
+
DropzoneActions,
|
|
218
|
+
DropzoneTrigger,
|
|
219
|
+
Field,
|
|
220
|
+
FieldError,
|
|
221
|
+
Button,
|
|
222
|
+
} from '@aircall/ds';
|
|
223
|
+
|
|
224
|
+
const [error, setError] = useState<string | null>(null);
|
|
225
|
+
|
|
226
|
+
<Field data-invalid={!!error}>
|
|
227
|
+
<Dropzone
|
|
228
|
+
accept="audio/mpeg,.mp3"
|
|
229
|
+
maxFiles={1}
|
|
230
|
+
maxSize={MAX_AUDIO_SIZE}
|
|
231
|
+
onFiles={([file]) => { setError(null); handleDrop([file]); }}
|
|
232
|
+
onError={() => setError(capitalize(t('dropzone.secondary_text')))}
|
|
233
|
+
>
|
|
234
|
+
<DropzoneContent error={!!error}>
|
|
235
|
+
<DropzoneTitle>{t('primary_text')}</DropzoneTitle>
|
|
236
|
+
<DropzoneDescription>{t('secondary_text')}</DropzoneDescription>
|
|
237
|
+
<DropzoneActions>
|
|
238
|
+
<DropzoneTrigger render={<Button size="sm" />}>Upload file</DropzoneTrigger>
|
|
239
|
+
</DropzoneActions>
|
|
240
|
+
</DropzoneContent>
|
|
241
|
+
</Dropzone>
|
|
242
|
+
{error && <FieldError>{error}</FieldError>}
|
|
243
|
+
</Field>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`errorText` / `customError` collapse into consumer state + `<FieldError>`. Pass `error={!!error}` to `<DropzoneContent>` for the destructive red border. `variant` is dropped — use `className` if a custom border color is needed.
|
|
247
|
+
|
|
248
|
+
## 5. Common mistakes
|
|
249
|
+
|
|
250
|
+
### Mistake 1 — Using `onDrop` / `onDropAccepted` instead of `onFiles`
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
// ❌ Wrong — react-dropzone callback names; DS ignores them silently
|
|
254
|
+
<Dropzone onDrop={handleDrop} onDropAccepted={handleAccepted}>…</Dropzone>
|
|
255
|
+
|
|
256
|
+
// ✅ Correct — DS fires accepted files via onFiles
|
|
257
|
+
<Dropzone onFiles={handleDrop}>…</Dropzone>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`<Dropzone>` does not forward unknown props to react-dropzone — it is a standalone implementation. `onDrop` / `onDropAccepted` are silently swallowed as generic DOM props and never called.
|
|
261
|
+
|
|
262
|
+
Source: `packages/ds/src/components/dropzone.tsx`
|
|
263
|
+
|
|
264
|
+
### Mistake 2 — Passing `accept` as an object instead of a string
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
// ❌ Wrong — react-dropzone Record shape; DS <input accept> expects a string
|
|
268
|
+
<Dropzone accept={{ 'audio/mpeg': ['.mp3'], 'audio/wav': ['.wav'] }}>…</Dropzone>
|
|
269
|
+
|
|
270
|
+
// ✅ Correct — comma-separated MIME types and/or extensions
|
|
271
|
+
<Dropzone accept="audio/mpeg,.mp3,audio/wav,.wav">…</Dropzone>
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
DS passes `accept` directly to the hidden `<input type="file" accept={…}>`. A plain object is coerced to `"[object Object]"` and the file picker accepts nothing.
|
|
275
|
+
|
|
276
|
+
Source: `packages/ds/src/components/dropzone.tsx`
|
|
277
|
+
|
|
278
|
+
### Mistake 3 — Adding `<DropzoneContent>` when headless behavior is wanted
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
// ❌ Wrong — DropzoneContent makes the entire area clickable and keyboard-focusable
|
|
282
|
+
<Dropzone onFiles={handleFiles}>
|
|
283
|
+
<DropzoneContent>
|
|
284
|
+
{children}
|
|
285
|
+
</DropzoneContent>
|
|
286
|
+
</Dropzone>
|
|
287
|
+
|
|
288
|
+
// ✅ Correct — omit DropzoneContent; Dropzone alone is headless (drag-only)
|
|
289
|
+
<Dropzone onFiles={handleFiles}>
|
|
290
|
+
{children}
|
|
291
|
+
</Dropzone>
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`<DropzoneContent>` renders a `role="button"` div that opens the picker on click and Space/Enter. Old code that ran with `noClick: true` should not include it — the drag overlay still appears via the parent `<Dropzone>`.
|
|
295
|
+
|
|
296
|
+
Source: `packages/ds/src/components/dropzone.tsx`
|
|
297
|
+
|
|
298
|
+
### Mistake 4 — Using `asChild` on `<DropzoneTrigger>` instead of the `render` prop
|
|
299
|
+
|
|
300
|
+
```tsx
|
|
301
|
+
// ❌ Wrong — asChild is a Radix convention; DS uses Base UI's render prop
|
|
302
|
+
<DropzoneTrigger asChild>
|
|
303
|
+
<Button>Upload</Button>
|
|
304
|
+
</DropzoneTrigger>
|
|
305
|
+
|
|
306
|
+
// ✅ Correct — pass the element to render via the render prop
|
|
307
|
+
<DropzoneTrigger render={<Button />}>Upload</DropzoneTrigger>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`<DropzoneTrigger>` follows the Base UI `render` prop convention, not Radix `asChild`. The label goes as children of `<DropzoneTrigger>`, not inside the `render` element. Passing `asChild` is ignored and the trigger falls back to a plain `<button>`.
|
|
311
|
+
|
|
312
|
+
Source: `packages/ds/src/components/dropzone.tsx`
|
|
313
|
+
|
|
314
|
+
### Mistake 5 — Placing error UI inside `<DropzoneContent>` instead of `<FieldError>`
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
// ❌ Wrong — DS has no built-in error banner inside DropzoneContent
|
|
318
|
+
<Dropzone onError={e => setError(e[0]?.errors[0]?.message)}>
|
|
319
|
+
<DropzoneContent>
|
|
320
|
+
<DropzoneTitle>Upload audio</DropzoneTitle>
|
|
321
|
+
{error && <p className="text-destructive">{error}</p>}
|
|
322
|
+
</DropzoneContent>
|
|
323
|
+
</Dropzone>
|
|
324
|
+
|
|
325
|
+
// ✅ Correct — wrap in Field + FieldError; pass error prop to DropzoneContent
|
|
326
|
+
<Field data-invalid={!!error}>
|
|
327
|
+
<Dropzone onError={() => setError(t('upload.error'))}>
|
|
328
|
+
<DropzoneContent error={!!error}>
|
|
329
|
+
<DropzoneTitle>Upload audio</DropzoneTitle>
|
|
330
|
+
</DropzoneContent>
|
|
331
|
+
</Dropzone>
|
|
332
|
+
{error && <FieldError>{error}</FieldError>}
|
|
333
|
+
</Field>
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
`<DropzoneContent error>` only toggles the destructive border — it does not render an error message. The accessible error text belongs in `<FieldError>` outside the dropzone, associated via `<Field data-invalid>`.
|
|
337
|
+
|
|
338
|
+
Source: `packages/ds/src/components/dropzone.tsx`
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aircall-ds/migrate-tractor/form-and-field
|
|
3
|
+
description: >
|
|
4
|
+
Migrate Tractor Form and FormItem to native <form> + @aircall/ds Field compound.
|
|
5
|
+
Load when a file imports Form, FormItem, or FormRow from @aircall/tractor. Covers
|
|
6
|
+
label/control/error layout, validation state, fieldsets, and help text.
|
|
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/form-and-field.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 form-specific steps below.
|
|
18
|
+
|
|
19
|
+
> **Principle — for a form that collects + submits data, drive it with `@aircall/blocks` `useForm` + the `Form*Field` wrappers, not local `useState`.** The ds `Field`/`FieldLabel`/`FieldError` primitives below are the field *shell* those wrappers render — reach for them bare only for non-form display. See `@aircall/blocks#aircall-blocks/migrate-dashboard/form-wizard`.
|
|
20
|
+
|
|
21
|
+
## 1. Component mapping
|
|
22
|
+
|
|
23
|
+
DS has no `Form` component. Use a native `<form>` element and wrap each field row in `<Field>`.
|
|
24
|
+
|
|
25
|
+
| Tractor | @aircall/ds | Notes |
|
|
26
|
+
| --- | --- | --- |
|
|
27
|
+
| `<Form>` | `<form>` | Native HTML element — no DS wrapper |
|
|
28
|
+
| `<FormItem label="…">` | `<Field>` + `<FieldLabel>` + control | Layout and label are now separate |
|
|
29
|
+
| `<FormItem validationStatus="error">` | `aria-invalid` on the control | No Tractor-style prop; use standard ARIA |
|
|
30
|
+
| `<FormItem hint="…">` | `<FieldDescription>` | Renders muted below the control |
|
|
31
|
+
| `<FormItem error="…">` | `<FieldError>` | Renders in `text-destructive`; conditionally mount it |
|
|
32
|
+
| — | `<FieldGroup>` | Stacks multiple `<Field>` rows with consistent spacing |
|
|
33
|
+
| — | `<FieldSet>` + `<FieldLegend>` | Wraps a group of related fields in a semantic `<fieldset>` |
|
|
34
|
+
| — | `<FieldContent>` | Groups the control + description + error in a column (used with `orientation="horizontal"`) |
|
|
35
|
+
|
|
36
|
+
## 2. Imports
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import {
|
|
40
|
+
Field,
|
|
41
|
+
FieldContent,
|
|
42
|
+
FieldDescription,
|
|
43
|
+
FieldError,
|
|
44
|
+
FieldGroup,
|
|
45
|
+
FieldLabel,
|
|
46
|
+
FieldLegend,
|
|
47
|
+
FieldSet,
|
|
48
|
+
Button,
|
|
49
|
+
Input
|
|
50
|
+
} from '@aircall/ds';
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 3. Before / After examples
|
|
54
|
+
|
|
55
|
+
### 3a. Single field with validation
|
|
56
|
+
|
|
57
|
+
**Before (Tractor):**
|
|
58
|
+
```tsx
|
|
59
|
+
import { FormItem, Input } from '@aircall/tractor';
|
|
60
|
+
|
|
61
|
+
<form onSubmit={onSubmit}>
|
|
62
|
+
<FormItem
|
|
63
|
+
label="Email"
|
|
64
|
+
validationStatus={emailError ? 'error' : undefined}
|
|
65
|
+
error={emailError}
|
|
66
|
+
>
|
|
67
|
+
<Input
|
|
68
|
+
type="email"
|
|
69
|
+
value={email}
|
|
70
|
+
onChange={e => setEmail(e.target.value)}
|
|
71
|
+
/>
|
|
72
|
+
</FormItem>
|
|
73
|
+
</form>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**After (DS):**
|
|
77
|
+
```tsx
|
|
78
|
+
import { Field, FieldLabel, FieldError, Input, Button } from '@aircall/ds';
|
|
79
|
+
|
|
80
|
+
<form onSubmit={onSubmit}>
|
|
81
|
+
<Field>
|
|
82
|
+
<FieldLabel htmlFor="email">Email</FieldLabel>
|
|
83
|
+
<Input
|
|
84
|
+
id="email"
|
|
85
|
+
type="email"
|
|
86
|
+
value={email}
|
|
87
|
+
onChange={e => setEmail(e.target.value)}
|
|
88
|
+
aria-invalid={!!emailError}
|
|
89
|
+
/>
|
|
90
|
+
{emailError && <FieldError>{emailError}</FieldError>}
|
|
91
|
+
</Field>
|
|
92
|
+
|
|
93
|
+
<Button type="submit" size="lg">Submit</Button>
|
|
94
|
+
</form>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Key changes:
|
|
98
|
+
- `<FormItem label="…">` → explicit `<FieldLabel htmlFor>` paired with `id` on the control; you own the id/htmlFor pairing.
|
|
99
|
+
- `validationStatus="error"` → removed; set `aria-invalid={true}` on the control instead.
|
|
100
|
+
- `error={…}` → `<FieldError>` rendered conditionally as a child of `<Field>`.
|
|
101
|
+
|
|
102
|
+
### 3b. Multiple rows with FieldGroup
|
|
103
|
+
|
|
104
|
+
**Before (Tractor):**
|
|
105
|
+
```tsx
|
|
106
|
+
import { FormItem, Input } from '@aircall/tractor';
|
|
107
|
+
|
|
108
|
+
<form>
|
|
109
|
+
<FormItem label="Name"><Input id="name" /></FormItem>
|
|
110
|
+
<FormItem label="Phone"><Input id="phone" type="tel" /></FormItem>
|
|
111
|
+
</form>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**After (DS):**
|
|
115
|
+
```tsx
|
|
116
|
+
import { Field, FieldGroup, FieldLabel, Input } from '@aircall/ds';
|
|
117
|
+
|
|
118
|
+
<form>
|
|
119
|
+
<FieldGroup>
|
|
120
|
+
<Field>
|
|
121
|
+
<FieldLabel htmlFor="name">Name</FieldLabel>
|
|
122
|
+
<Input id="name" />
|
|
123
|
+
</Field>
|
|
124
|
+
<Field>
|
|
125
|
+
<FieldLabel htmlFor="phone">Phone</FieldLabel>
|
|
126
|
+
<Input id="phone" type="tel" />
|
|
127
|
+
</Field>
|
|
128
|
+
</FieldGroup>
|
|
129
|
+
</form>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`FieldGroup` provides consistent vertical spacing between rows.
|
|
133
|
+
|
|
134
|
+
### 3c. Horizontal layout (inline control + label)
|
|
135
|
+
|
|
136
|
+
**After (DS):**
|
|
137
|
+
```tsx
|
|
138
|
+
import { Field, FieldLabel, Checkbox } from '@aircall/ds';
|
|
139
|
+
|
|
140
|
+
<Field orientation="horizontal">
|
|
141
|
+
<Checkbox id="remember" checked={remember} onCheckedChange={setRemember} />
|
|
142
|
+
<FieldLabel htmlFor="remember">Remember me</FieldLabel>
|
|
143
|
+
</Field>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Use `orientation="horizontal"` for Checkbox + label and Switch + label pairs. The `Field` component also accepts `orientation="responsive"`, which stacks vertically at narrow widths and switches to horizontal at `@md` breakpoint inside a `FieldGroup`.
|
|
147
|
+
|
|
148
|
+
### 3d. Grouped fields with fieldset / legend
|
|
149
|
+
|
|
150
|
+
**After (DS):**
|
|
151
|
+
```tsx
|
|
152
|
+
import { FieldSet, FieldLegend, FieldGroup, Field, FieldLabel, Input } from '@aircall/ds';
|
|
153
|
+
|
|
154
|
+
<FieldSet>
|
|
155
|
+
<FieldLegend>Contact preferences</FieldLegend>
|
|
156
|
+
<FieldGroup>
|
|
157
|
+
<Field>
|
|
158
|
+
<FieldLabel htmlFor="sms">SMS number</FieldLabel>
|
|
159
|
+
<Input id="sms" type="tel" />
|
|
160
|
+
</Field>
|
|
161
|
+
<Field>
|
|
162
|
+
<FieldLabel htmlFor="slack">Slack handle</FieldLabel>
|
|
163
|
+
<Input id="slack" />
|
|
164
|
+
</Field>
|
|
165
|
+
</FieldGroup>
|
|
166
|
+
</FieldSet>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`FieldLegend` renders a `<legend>` with `variant="legend"` (base text size) by default. Pass `variant="label"` for a smaller label-sized legend.
|
|
170
|
+
|
|
171
|
+
### 3e. Field with help text (hint)
|
|
172
|
+
|
|
173
|
+
**Before (Tractor):**
|
|
174
|
+
```tsx
|
|
175
|
+
import { FormItem, Input } from '@aircall/tractor';
|
|
176
|
+
|
|
177
|
+
<FormItem label="Alias" hint="Shown to teammates in your call queue.">
|
|
178
|
+
<Input id="alias" />
|
|
179
|
+
</FormItem>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**After (DS):**
|
|
183
|
+
```tsx
|
|
184
|
+
import { Field, FieldLabel, FieldDescription, Input } from '@aircall/ds';
|
|
185
|
+
|
|
186
|
+
<Field>
|
|
187
|
+
<FieldLabel htmlFor="alias">Alias</FieldLabel>
|
|
188
|
+
<Input id="alias" />
|
|
189
|
+
<FieldDescription>Shown to teammates in your call queue.</FieldDescription>
|
|
190
|
+
</Field>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 3f. Multiple validation errors via FieldError `errors` prop
|
|
194
|
+
|
|
195
|
+
`FieldError` accepts an `errors` array in addition to `children`. When multiple distinct messages are present it renders a `<ul>` automatically; duplicates are deduplicated.
|
|
196
|
+
|
|
197
|
+
**After (DS):**
|
|
198
|
+
```tsx
|
|
199
|
+
import { Field, FieldLabel, FieldError, Input } from '@aircall/ds';
|
|
200
|
+
|
|
201
|
+
<Field>
|
|
202
|
+
<FieldLabel htmlFor="pwd">Password</FieldLabel>
|
|
203
|
+
<Input id="pwd" type="password" aria-invalid={errors.length > 0} />
|
|
204
|
+
<FieldError errors={errors} />
|
|
205
|
+
</Field>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Where `errors` is `Array<{ message?: string }>`. When the array is empty `FieldError` renders nothing.
|
|
209
|
+
|
|
210
|
+
### 3g. Horizontal layout with FieldContent (label beside stacked controls)
|
|
211
|
+
|
|
212
|
+
When the label sits beside a column of controls (e.g. control + description + error), wrap the right-hand column in `FieldContent`:
|
|
213
|
+
|
|
214
|
+
**After (DS):**
|
|
215
|
+
```tsx
|
|
216
|
+
import { Field, FieldLabel, FieldContent, FieldDescription, FieldError, Input } from '@aircall/ds';
|
|
217
|
+
|
|
218
|
+
<Field orientation="horizontal">
|
|
219
|
+
<FieldLabel htmlFor="alias">Alias</FieldLabel>
|
|
220
|
+
<FieldContent>
|
|
221
|
+
<Input id="alias" aria-invalid={!!aliasError} />
|
|
222
|
+
<FieldDescription>Shown to teammates in your call queue.</FieldDescription>
|
|
223
|
+
{aliasError && <FieldError>{aliasError}</FieldError>}
|
|
224
|
+
</FieldContent>
|
|
225
|
+
</Field>
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## 4. Common mistakes
|
|
229
|
+
|
|
230
|
+
### Mistake 1 — Using `validationStatus` instead of `aria-invalid`
|
|
231
|
+
|
|
232
|
+
```tsx
|
|
233
|
+
// Wrong — Field and its children have no validationStatus prop; it is silently ignored
|
|
234
|
+
<Field validationStatus="error">
|
|
235
|
+
<FieldLabel htmlFor="email">Email</FieldLabel>
|
|
236
|
+
<Input id="email" />
|
|
237
|
+
</Field>
|
|
238
|
+
|
|
239
|
+
// Correct — set aria-invalid on the control; FieldError handles the error message
|
|
240
|
+
<Field>
|
|
241
|
+
<FieldLabel htmlFor="email">Email</FieldLabel>
|
|
242
|
+
<Input id="email" aria-invalid={!!emailError} />
|
|
243
|
+
{emailError && <FieldError>{emailError}</FieldError>}
|
|
244
|
+
</Field>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
DS uses standard ARIA for validation state rather than a custom prop. `aria-invalid` on the control signals the error to assistive technology; `FieldError` renders with `role="alert"` and applies the destructive colour automatically.
|
|
248
|
+
|
|
249
|
+
Source: `packages/ds/src/components/field.tsx` — `FieldError` sets `role="alert"` and `text-destructive`; `Field` has no `validationStatus` variant.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
### Mistake 2 — Omitting `htmlFor` / `id` pairing
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
// Wrong — FieldLabel without htmlFor is not associated with the control
|
|
257
|
+
<Field>
|
|
258
|
+
<FieldLabel>Email</FieldLabel>
|
|
259
|
+
<Input type="email" />
|
|
260
|
+
</Field>
|
|
261
|
+
|
|
262
|
+
// Correct — always supply matching htmlFor on FieldLabel and id on the control
|
|
263
|
+
<Field>
|
|
264
|
+
<FieldLabel htmlFor="email">Email</FieldLabel>
|
|
265
|
+
<Input id="email" type="email" />
|
|
266
|
+
</Field>
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Tractor's `<FormItem label="…">` wired the label to its child input automatically. DS `FieldLabel` is a styled `<label>` — it does not auto-discover its sibling control. Without `htmlFor` + `id` the label click does not focus the input and screen readers cannot announce the association.
|
|
270
|
+
|
|
271
|
+
Source: `packages/ds/src/components/field.tsx` — `FieldLabel` wraps `Label` (a plain `<label>`) with no auto-association logic.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
### Mistake 3 — Wrapping multiple fields without FieldGroup
|
|
276
|
+
|
|
277
|
+
```tsx
|
|
278
|
+
// Wrong — adjacent Fields have no spacing contract between them
|
|
279
|
+
<form>
|
|
280
|
+
<Field>
|
|
281
|
+
<FieldLabel htmlFor="first">First name</FieldLabel>
|
|
282
|
+
<Input id="first" />
|
|
283
|
+
</Field>
|
|
284
|
+
<Field>
|
|
285
|
+
<FieldLabel htmlFor="last">Last name</FieldLabel>
|
|
286
|
+
<Input id="last" />
|
|
287
|
+
</Field>
|
|
288
|
+
</form>
|
|
289
|
+
|
|
290
|
+
// Correct — use FieldGroup to get consistent gap-6 spacing
|
|
291
|
+
<form>
|
|
292
|
+
<FieldGroup>
|
|
293
|
+
<Field>
|
|
294
|
+
<FieldLabel htmlFor="first">First name</FieldLabel>
|
|
295
|
+
<Input id="first" />
|
|
296
|
+
</Field>
|
|
297
|
+
<Field>
|
|
298
|
+
<FieldLabel htmlFor="last">Last name</FieldLabel>
|
|
299
|
+
<Input id="last" />
|
|
300
|
+
</Field>
|
|
301
|
+
</FieldGroup>
|
|
302
|
+
</form>
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`Field` applies no external margin. `FieldGroup` is the intentional spacing container — it uses `gap-6` between children and adjusts automatically for nested `FieldGroup` elements (`*:data-[slot=field-group]:gap-4`).
|
|
306
|
+
|
|
307
|
+
Source: `packages/ds/src/components/field.tsx` — `FieldGroup` sets `flex flex-col gap-6`; `Field` sets no external gap.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
### Mistake 4 — Conditionally rendering FieldError without checking the message
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
// Wrong — FieldError always mounts, rendering an empty alert div
|
|
315
|
+
<FieldError>{emailError}</FieldError>
|
|
316
|
+
|
|
317
|
+
// Correct — either guard the mount or pass the errors array prop
|
|
318
|
+
{emailError && <FieldError>{emailError}</FieldError>}
|
|
319
|
+
// or
|
|
320
|
+
<FieldError errors={[{ message: emailError }]} />
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
When `children` is falsy and `errors` is empty or undefined, `FieldError` returns `null` on its own when you use the `errors` prop. But when using `children`, you must guard the mount yourself — an undefined/empty string child still causes a `<div role="alert">` to be inserted into the DOM, which screen readers may announce unexpectedly.
|
|
324
|
+
|
|
325
|
+
Source: `packages/ds/src/components/field.tsx` — `FieldError` only short-circuits to `null` via `useMemo` when `!children && !errors?.length`.
|