@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.
Files changed (34) hide show
  1. package/README.md +31 -0
  2. package/dist/globals.css +1 -1
  3. package/dist/index.d.ts +28 -28
  4. package/dist/index.js +1 -1
  5. package/package.json +12 -2
  6. package/skills/aircall-ds/migrate-icons/SKILL.md +346 -0
  7. package/skills/aircall-ds/migrate-tractor/SKILL.md +314 -0
  8. package/skills/aircall-ds/migrate-tractor/accordion/SKILL.md +276 -0
  9. package/skills/aircall-ds/migrate-tractor/alert/SKILL.md +225 -0
  10. package/skills/aircall-ds/migrate-tractor/avatar/SKILL.md +272 -0
  11. package/skills/aircall-ds/migrate-tractor/badge/SKILL.md +274 -0
  12. package/skills/aircall-ds/migrate-tractor/button/SKILL.md +277 -0
  13. package/skills/aircall-ds/migrate-tractor/card/SKILL.md +278 -0
  14. package/skills/aircall-ds/migrate-tractor/combobox/SKILL.md +346 -0
  15. package/skills/aircall-ds/migrate-tractor/data-table/SKILL.md +333 -0
  16. package/skills/aircall-ds/migrate-tractor/dialog/SKILL.md +206 -0
  17. package/skills/aircall-ds/migrate-tractor/divider/SKILL.md +226 -0
  18. package/skills/aircall-ds/migrate-tractor/dropdown-menu/SKILL.md +266 -0
  19. package/skills/aircall-ds/migrate-tractor/dropzone/SKILL.md +338 -0
  20. package/skills/aircall-ds/migrate-tractor/form-and-field/SKILL.md +325 -0
  21. package/skills/aircall-ds/migrate-tractor/gauge/SKILL.md +248 -0
  22. package/skills/aircall-ds/migrate-tractor/input/SKILL.md +261 -0
  23. package/skills/aircall-ds/migrate-tractor/item/SKILL.md +298 -0
  24. package/skills/aircall-ds/migrate-tractor/link/SKILL.md +263 -0
  25. package/skills/aircall-ds/migrate-tractor/popover/SKILL.md +214 -0
  26. package/skills/aircall-ds/migrate-tractor/select/SKILL.md +245 -0
  27. package/skills/aircall-ds/migrate-tractor/sheet-vs-drawer/SKILL.md +272 -0
  28. package/skills/aircall-ds/migrate-tractor/skeleton/SKILL.md +190 -0
  29. package/skills/aircall-ds/migrate-tractor/styling/SKILL.md +421 -0
  30. package/skills/aircall-ds/migrate-tractor/tabs/SKILL.md +250 -0
  31. package/skills/aircall-ds/migrate-tractor/toast/SKILL.md +322 -0
  32. package/skills/aircall-ds/migrate-tractor/tooltip/SKILL.md +204 -0
  33. package/skills/aircall-ds/migrate-tractor/tree/SKILL.md +346 -0
  34. 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`.