@aircall/blocks 0.6.0 → 0.9.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.
@@ -0,0 +1,347 @@
1
+ ---
2
+ name: aircall-blocks/migrate-dashboard/empty-states
3
+ description: >
4
+ Migrate @dashboard/library MessageScreen family (ComingSoonScreen,
5
+ RestrictedAccessScreen, NotFoundScreen, NoDataScreen, NoSupportScreen,
6
+ UnknownErrorScreen) to @aircall/blocks composable empty-state blocks.
7
+ Load when a file imports any of those from @dashboard/library.
8
+ type: sub-skill
9
+ library: aircall-blocks
10
+ library_version: "0.5.1"
11
+ requires:
12
+ - aircall-blocks/setup
13
+ - aircall-blocks/migrate-dashboard
14
+ sources:
15
+ - "aircall/hydra:docs/migration-guides/tractor-to-ds/recipes/empty-states.md"
16
+ ---
17
+
18
+ This skill builds on aircall-blocks/migrate-dashboard.
19
+
20
+ ## 1. Variant mapping
21
+
22
+ | `@dashboard/library` | `@aircall/blocks` root | Old asset | New icon |
23
+ | --- | --- | --- | --- |
24
+ | `ComingSoonScreen` | `ComingSoonEmptyState` | `aircall-placeholder.svg` | `Sprout` |
25
+ | `RestrictedAccessScreen` | `RestrictedAccessEmptyState` | `lock.svg` | `LockKeyhole` |
26
+ | `NotFoundScreen` | `NotFoundEmptyState` | `not-found.svg` | `Search` |
27
+ | `NoDataScreen` | `NoDataEmptyState` | `no-data.svg` | `Search` |
28
+ | `NoSupportScreen` | `NoSupportEmptyState` | `settings.svg` | `Settings` |
29
+ | `UnknownErrorScreen` | `UnknownErrorEmptyState` | `warning.svg` | `TriangleAlert` |
30
+
31
+ Each preset exports four individually importable parts — `*EmptyState` (root), `*Media`, `*Title`, `*Description` — all from `@aircall/blocks`.
32
+
33
+ ## 2. Prop mapping
34
+
35
+ | `MessageScreen` prop | New equivalent |
36
+ | --- | --- |
37
+ | `title` | children of `*Title` — omit to use the baked-in design copy |
38
+ | `subtitle` (string or element) | children of `*Description` |
39
+ | `buttonText` + `onClick` | `EmptyStateButton` inside `EmptyStateActions` |
40
+ | `image` | fixed per variant — drop it |
41
+ | `data-test` | pass to the root: `<NotFoundEmptyState data-test="…">` |
42
+ | `w` / `h` / `backgroundColor` | drop — constrain the parent instead |
43
+
44
+ ## 3. Before / After examples
45
+
46
+ ### 3a. NotFoundScreen → NotFoundEmptyState
47
+
48
+ **Before (`@dashboard/library`):**
49
+ ```tsx
50
+ import { NotFoundScreen } from '@dashboard/library';
51
+
52
+ <NotFoundScreen
53
+ title="We couldn't find that page"
54
+ subtitle="It may have been moved, deleted, or the link may be incorrect."
55
+ buttonText="Go to inbox"
56
+ onClick={goToInbox}
57
+ />
58
+ ```
59
+
60
+ **After (`@aircall/blocks`):**
61
+ ```tsx
62
+ import {
63
+ NotFoundEmptyState,
64
+ NotFoundMedia,
65
+ NotFoundTitle,
66
+ NotFoundDescription,
67
+ EmptyStateHeader,
68
+ EmptyStateActions,
69
+ EmptyStateButton
70
+ } from '@aircall/blocks';
71
+
72
+ <NotFoundEmptyState>
73
+ <EmptyStateHeader>
74
+ <NotFoundMedia />
75
+ <NotFoundTitle />
76
+ <NotFoundDescription />
77
+ </EmptyStateHeader>
78
+ <EmptyStateActions>
79
+ <EmptyStateButton variant="outline" onClick={goToInbox}>
80
+ Go to inbox
81
+ </EmptyStateButton>
82
+ </EmptyStateActions>
83
+ </NotFoundEmptyState>
84
+ ```
85
+
86
+ The `*Title` and `*Description` ship the Figma copy as defaults — drop `title`/`subtitle` props to adopt them, or override by passing children.
87
+
88
+ ### 3b. ComingSoonScreen → ComingSoonEmptyState (two buttons)
89
+
90
+ **Before (`@dashboard/library`):**
91
+ ```tsx
92
+ import { ComingSoonScreen } from '@dashboard/library';
93
+
94
+ <ComingSoonScreen
95
+ title="Coming soon!"
96
+ subtitle="You'll be able to access this feature in the near future."
97
+ buttonText="Go back"
98
+ onClick={goBack}
99
+ />
100
+ ```
101
+
102
+ **After (`@aircall/blocks`):**
103
+ ```tsx
104
+ import {
105
+ ComingSoonEmptyState,
106
+ ComingSoonMedia,
107
+ ComingSoonTitle,
108
+ ComingSoonDescription,
109
+ EmptyStateHeader,
110
+ EmptyStateActions,
111
+ EmptyStateButton,
112
+ EmptyStateExternalLink
113
+ } from '@aircall/blocks';
114
+
115
+ <ComingSoonEmptyState>
116
+ <EmptyStateHeader>
117
+ <ComingSoonMedia />
118
+ <ComingSoonTitle />
119
+ <ComingSoonDescription />
120
+ </EmptyStateHeader>
121
+ <EmptyStateActions>
122
+ <EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
123
+ </EmptyStateActions>
124
+ <EmptyStateExternalLink href={docsUrl} />
125
+ </ComingSoonEmptyState>
126
+ ```
127
+
128
+ `EmptyStateExternalLink` is a sibling of `EmptyStateActions` (not inside it) so it sits on its own line below the buttons.
129
+
130
+ ### 3c. UnknownErrorScreen → UnknownErrorEmptyState (error state)
131
+
132
+ **Before (`@dashboard/library`):**
133
+ ```tsx
134
+ import { UnknownErrorScreen } from '@dashboard/library';
135
+
136
+ <UnknownErrorScreen
137
+ title="Something went wrong"
138
+ subtitle="Please try again."
139
+ buttonText="Try again"
140
+ onClick={reload}
141
+ />
142
+ ```
143
+
144
+ **After (`@aircall/blocks`):**
145
+ ```tsx
146
+ import {
147
+ UnknownErrorEmptyState,
148
+ UnknownErrorMedia,
149
+ UnknownErrorTitle,
150
+ UnknownErrorDescription,
151
+ EmptyStateHeader,
152
+ EmptyStateActions,
153
+ EmptyStateButton
154
+ } from '@aircall/blocks';
155
+
156
+ <UnknownErrorEmptyState>
157
+ <EmptyStateHeader>
158
+ <UnknownErrorMedia />
159
+ <UnknownErrorTitle />
160
+ <UnknownErrorDescription />
161
+ </EmptyStateHeader>
162
+ <EmptyStateActions>
163
+ <EmptyStateButton variant="default" onClick={reload}>Try again</EmptyStateButton>
164
+ </EmptyStateActions>
165
+ </UnknownErrorEmptyState>
166
+ ```
167
+
168
+ `UnknownErrorEmptyState` carries `role="alert"` — it announces assertively to screen readers when it mounts. `NoDataEmptyState` carries `role="status"` (polite). The others are static page content with no live region.
169
+
170
+ ### 3d. RestrictedAccessScreen → RestrictedAccessEmptyState (learn more link)
171
+
172
+ **Before (`@dashboard/library`):**
173
+ ```tsx
174
+ import { RestrictedAccessScreen } from '@dashboard/library';
175
+
176
+ <RestrictedAccessScreen
177
+ title="You don't have access to this page."
178
+ subtitle="Contact an admin on your team to get access."
179
+ buttonText="Learn more"
180
+ onClick={() => window.open(docsUrl)}
181
+ />
182
+ ```
183
+
184
+ **After (`@aircall/blocks`):**
185
+ ```tsx
186
+ import {
187
+ RestrictedAccessEmptyState,
188
+ RestrictedAccessMedia,
189
+ RestrictedAccessTitle,
190
+ RestrictedAccessDescription,
191
+ EmptyStateHeader,
192
+ EmptyStateExternalLink
193
+ } from '@aircall/blocks';
194
+
195
+ <RestrictedAccessEmptyState>
196
+ <EmptyStateHeader>
197
+ <RestrictedAccessMedia />
198
+ <RestrictedAccessTitle />
199
+ <RestrictedAccessDescription />
200
+ </EmptyStateHeader>
201
+ <EmptyStateExternalLink href={docsUrl} />
202
+ </RestrictedAccessEmptyState>
203
+ ```
204
+
205
+ Use `EmptyStateExternalLink` (renders a real `<a target="_blank">`) instead of an `onClick` that calls `window.open`.
206
+
207
+ ### 3e. Custom / one-off empty state (no variant)
208
+
209
+ When migrating a bespoke `MessageScreen` usage (custom `image` prop), use the generic family directly:
210
+
211
+ ```tsx
212
+ import {
213
+ EmptyState,
214
+ EmptyStateHeader,
215
+ EmptyStateMedia,
216
+ EmptyStateTitle,
217
+ EmptyStateDescription,
218
+ EmptyStateActions,
219
+ EmptyStateButton
220
+ } from '@aircall/blocks';
221
+ import { Sparkles } from '@aircall/react-icons';
222
+
223
+ <EmptyState>
224
+ <EmptyStateHeader>
225
+ <EmptyStateMedia>
226
+ <Sparkles />
227
+ </EmptyStateMedia>
228
+ <EmptyStateTitle>No integrations yet</EmptyStateTitle>
229
+ <EmptyStateDescription>Connect your first integration to get started.</EmptyStateDescription>
230
+ </EmptyStateHeader>
231
+ <EmptyStateActions>
232
+ <EmptyStateButton variant="default" onClick={openCatalog}>Browse catalog</EmptyStateButton>
233
+ </EmptyStateActions>
234
+ </EmptyState>
235
+ ```
236
+
237
+ Icons must come from `@aircall/react-icons`, never from `lucide-react` directly.
238
+
239
+ ## 4. Common Mistakes
240
+
241
+ ### Mistake 1: Passing `title`/`subtitle` as props to the root
242
+
243
+ ```tsx
244
+ // WRONG — the root block has no title/subtitle props; they are silently ignored
245
+ <NotFoundEmptyState title="Page not found" subtitle="Check the URL." />
246
+
247
+ // CORRECT — pass custom copy as children of the *Title / *Description parts
248
+ <NotFoundEmptyState>
249
+ <EmptyStateHeader>
250
+ <NotFoundMedia />
251
+ <NotFoundTitle>Page not found</NotFoundTitle>
252
+ <NotFoundDescription>Check the URL.</NotFoundDescription>
253
+ </EmptyStateHeader>
254
+ </NotFoundEmptyState>
255
+ ```
256
+
257
+ Mechanism: the old `MessageScreen` was a single component that accepted flat string props. The new blocks are a composable family; the root only accepts layout-level props (`data-test`, `role`, `className`, …). Title and description are separate sub-components.
258
+
259
+ Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyState.Props` extends `React.ComponentProps<typeof Empty>`, which has no `title` or `subtitle`.
260
+
261
+ ---
262
+
263
+ ### Mistake 2: Placing `EmptyStateExternalLink` inside `EmptyStateActions`
264
+
265
+ ```tsx
266
+ // WRONG — the link ends up in the same flex row as the buttons
267
+ <EmptyStateActions>
268
+ <EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
269
+ <EmptyStateExternalLink href={docsUrl} />
270
+ </EmptyStateActions>
271
+
272
+ // CORRECT — place it as a sibling after EmptyStateActions
273
+ <EmptyStateActions>
274
+ <EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
275
+ </EmptyStateActions>
276
+ <EmptyStateExternalLink href={docsUrl} />
277
+ ```
278
+
279
+ Mechanism: `EmptyStateActions` is a flex row (`flex-row flex-wrap justify-center gap-3`). Placing the link inside it makes it appear inline with the buttons at the same level, breaking the stacked design where the link sits on its own line below.
280
+
281
+ Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateActions` renders `EmptyContent` with `flex-row` class; `EmptyStateExternalLink` is meant to be a sibling in the `EmptyState` root's column flow.
282
+
283
+ ---
284
+
285
+ ### Mistake 3: Setting `size` on `EmptyStateButton`
286
+
287
+ ```tsx
288
+ // WRONG — size is locked; passing it is a type error and overrides the design spec
289
+ <EmptyStateButton variant="outline" size="sm" onClick={goBack}>Go back</EmptyStateButton>
290
+
291
+ // CORRECT — omit size; only variant is yours to choose
292
+ <EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
293
+ ```
294
+
295
+ Mechanism: `EmptyStateButtonProps` is `Omit<React.ComponentProps<typeof Button>, 'size'>` — `size` is explicitly removed from the type. The button always renders at `size="default"` to keep all empty states visually consistent regardless of the surrounding UI.
296
+
297
+ Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateButton` wraps `Button` with `size="default"` hardcoded; the `size` key is stripped from the exported props type.
298
+
299
+ ---
300
+
301
+ ### Mistake 4: Carrying over `w`, `h`, or `backgroundColor` from `Gap` props
302
+
303
+ ```tsx
304
+ // WRONG — these props do not exist on the new blocks root
305
+ <NotFoundEmptyState w="100%" h="100%" backgroundColor="neutral-100" />
306
+
307
+ // CORRECT — the block fills its container and centers automatically; constrain the parent
308
+ <div className="h-full w-full">
309
+ <NotFoundEmptyState />
310
+ </div>
311
+ ```
312
+
313
+ Mechanism: the old `MessageScreen` extended `GapProps` (Tractor) which exposed `w`/`h`/`backgroundColor` to fill the viewport. The new `*EmptyState` roots extend `React.ComponentProps<typeof Empty>` (DS), which is a plain `<div>` with Tailwind layout — these props do not exist.
314
+
315
+ Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateProps` extends `React.ComponentProps<typeof Empty>`; `packages/blocks/src/components/coming-soon.tsx` — `ComingSoonEmptyState` is `ComingSoon.Root` with the same props shape.
316
+
317
+ ---
318
+
319
+ ### Mistake 5: Importing icons from `lucide-react` directly in generic empty states
320
+
321
+ ```tsx
322
+ // WRONG — imports lucide-react directly, bypassing Aircall's icon layer
323
+ import { Sparkles } from 'lucide-react';
324
+ import { EmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@aircall/blocks';
325
+
326
+ <EmptyState>
327
+ <EmptyStateHeader>
328
+ <EmptyStateMedia><Sparkles /></EmptyStateMedia>
329
+ <EmptyStateTitle>No integrations yet</EmptyStateTitle>
330
+ </EmptyStateHeader>
331
+ </EmptyState>
332
+
333
+ // CORRECT — always import from @aircall/react-icons
334
+ import { Sparkles } from '@aircall/react-icons';
335
+ import { EmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@aircall/blocks';
336
+
337
+ <EmptyState>
338
+ <EmptyStateHeader>
339
+ <EmptyStateMedia><Sparkles /></EmptyStateMedia>
340
+ <EmptyStateTitle>No integrations yet</EmptyStateTitle>
341
+ </EmptyStateHeader>
342
+ </EmptyState>
343
+ ```
344
+
345
+ Mechanism: `@aircall/react-icons` re-exports all lucide icons plus Aircall custom icons; importing from `lucide-react` directly bypasses any Aircall overrides and breaks the single icon source-of-truth. All six built-in presets source their icons through `@aircall/react-icons` — custom media slots should follow the same convention.
346
+
347
+ Source: `packages/blocks/src/components/coming-soon.tsx` — imports `Sprout` from `@aircall/react-icons`, not from `lucide-react`.
@@ -0,0 +1,271 @@
1
+ ---
2
+ name: aircall-blocks/migrate-dashboard/form-wizard
3
+ description: >
4
+ Migrate @dashboard/library FormWizard, useFormWizard, and FormField to a fully
5
+ integrated TanStack Form: one @aircall/blocks useForm + the Form*Field wrappers,
6
+ driven through ds Stepper for the multi-step UI. Load when a file imports
7
+ FormWizard, useFormWizard, or FormField from @dashboard/library.
8
+ type: sub-skill
9
+ library: aircall-blocks
10
+ library_version: "0.5.1"
11
+ requires:
12
+ - aircall-blocks/setup
13
+ - aircall-blocks/migrate-dashboard
14
+ sources:
15
+ - "aircall/hydra:packages/ds/src/components/stepper.tsx"
16
+ - "aircall/hydra:packages/blocks/src/form/use-form.ts"
17
+ ---
18
+
19
+ This skill builds on aircall-blocks/migrate-dashboard.
20
+
21
+ > **Principle — the form owns state, never `useState`.** All field values, validation,
22
+ > dirty/`canSubmit`/`isSubmitting`, and errors live in ONE `useForm`; render every field
23
+ > via a `Form*Field` wrapper. Do not carry over `useFormWizard().data`/`editData` or raw
24
+ > `useState` + `<Input value/onChange>` — rewrite them to the form. (`useState` is still
25
+ > fine for non-field UI like the active step.)
26
+
27
+ ## Concept split
28
+
29
+ `FormWizard` bundled the overlay, the stepper, and the form state. The DS stack
30
+ separates them — and the form state becomes **one** TanStack Form spanning every
31
+ step (the canonical TanStack [multi-step wizard](https://tanstack.com/form/latest/docs/framework/react/examples/multi-step-wizard) pattern):
32
+
33
+ | @dashboard/library | Target |
34
+ |---|---|
35
+ | `FormWizard` overlay shell | `Dialog` from `@aircall/ds` (or render inline) |
36
+ | `FormWizard.Stepper` / `pageIndex` / step titles | `Stepper` + `StepperProgress` + `StepperPanel` + `StepperContent` from `@aircall/ds` |
37
+ | `useFormWizard().data` / `editData` | **one** `useForm` from `@aircall/blocks` with all steps' `defaultValues` — values accumulate across steps automatically |
38
+ | `FormField` | a `Form*Field` from `@aircall/blocks` (`FormInputField`, `FormSelectField`, …) |
39
+ | `navigateToNextPage` | validate the current step's fields, then advance the `Stepper` `value` |
40
+ | `onSuccess(data)` | the single `useForm`'s `onSubmit` (fires from the last step) |
41
+ | `onClose` / `onCancel` | `DialogClose` + `onOpenChange` |
42
+
43
+ ## Import mapping
44
+
45
+ ```tsx
46
+ // Before
47
+ import { FormWizard, useFormWizard, FormField } from '@dashboard/library';
48
+
49
+ // After
50
+ import { useState } from 'react';
51
+ import {
52
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose,
53
+ Stepper, StepperProgress, StepperPanel, StepperContent,
54
+ Button, Input
55
+ } from '@aircall/ds';
56
+ import { useForm, FormInputField, FormSelectField } from '@aircall/blocks';
57
+ ```
58
+
59
+ ## Stepper API (read this — it is value-controlled)
60
+
61
+ `Stepper` takes a `steps: { id; title }[]` array and is controlled by `value` /
62
+ `onValueChange` (the active step **id**). Panels render with `StepperContent value={id}`
63
+ inside a `StepperPanel`. `useStepper()` (no args, only inside a `Stepper`) returns
64
+ `{ activeId, goTo, getIndex, steps }`. There is no `nextStep`/`prevStep`/`currentStep` —
65
+ advance by setting `value` (or `goTo(id)`).
66
+
67
+ ## After — one form, ds Stepper, validate-per-step
68
+
69
+ ```tsx
70
+ const STEPS = [
71
+ { id: 'details', title: 'Details' },
72
+ { id: 'role', title: 'Role' }
73
+ ] as const;
74
+
75
+ // which fields belong to which step — used to validate before advancing
76
+ const STEP_FIELDS: Record<string, string[]> = {
77
+ details: ['name'],
78
+ role: ['role']
79
+ };
80
+
81
+ export function CreateUserWizard({ open, onClose }: { open: boolean; onClose: () => void }) {
82
+ const [step, setStep] = useState<string>(STEPS[0].id);
83
+
84
+ // ONE form for the whole wizard — values from every step accumulate here
85
+ const form = useForm({
86
+ defaultValues: { name: '', role: '' },
87
+ onSubmit: async ({ value }) => {
88
+ await saveUser(value);
89
+ onClose();
90
+ }
91
+ });
92
+
93
+ const stepIndex = STEPS.findIndex(s => s.id === step);
94
+ const isLast = stepIndex === STEPS.length - 1;
95
+
96
+ // validate only the CURRENT step's fields before moving on
97
+ const goNext = async () => {
98
+ const results = await Promise.all(
99
+ STEP_FIELDS[step].map(name => form.validateField(name, 'submit'))
100
+ );
101
+ if (results.every(errors => errors.length === 0)) {
102
+ setStep(STEPS[stepIndex + 1].id);
103
+ }
104
+ };
105
+
106
+ return (
107
+ <Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
108
+ <DialogContent className="max-w-lg p-0">
109
+ <DialogHeader className="border-b px-6 py-4">
110
+ <DialogTitle>Create user</DialogTitle>
111
+ <DialogClose />
112
+ </DialogHeader>
113
+
114
+ <form onSubmit={e => { e.preventDefault(); void form.handleSubmit(); }}>
115
+ <Stepper steps={STEPS} value={step} onValueChange={setStep} className="px-6 pt-4">
116
+ <StepperProgress />
117
+ <StepperPanel className="p-6">
118
+ <StepperContent value="details" className="space-y-4">
119
+ <FormInputField form={form} name="name" label="Full name">
120
+ {(_field, { inputProps }) => <Input {...inputProps} />}
121
+ </FormInputField>
122
+ </StepperContent>
123
+
124
+ <StepperContent value="role" className="space-y-4">
125
+ <FormSelectField form={form} name="role" label="Role">
126
+ {(_field, control) => <Input {...control.inputProps} />}
127
+ </FormSelectField>
128
+ </StepperContent>
129
+ </StepperPanel>
130
+ </Stepper>
131
+
132
+ <div className="flex justify-end gap-2 border-t px-6 py-3">
133
+ {stepIndex > 0 && (
134
+ <Button type="button" variant="ghost" onClick={() => setStep(STEPS[stepIndex - 1].id)}>
135
+ Back
136
+ </Button>
137
+ )}
138
+ {isLast ? (
139
+ <form.AppForm>
140
+ <form.SubmitButton>Create user</form.SubmitButton>
141
+ </form.AppForm>
142
+ ) : (
143
+ <Button type="button" onClick={goNext}>Next</Button>
144
+ )}
145
+ </div>
146
+ </form>
147
+ </DialogContent>
148
+ </Dialog>
149
+ );
150
+ }
151
+ ```
152
+
153
+ Why one form: `useFormWizard().data` was a single accumulator across pages — a single
154
+ `useForm` reproduces that exactly (every field lives in one `value`), so the final step
155
+ submits the complete object. Per-step `useForm`s would fragment that state and lose
156
+ cross-step validation.
157
+
158
+ ## Prop mapping
159
+
160
+ | `FormWizardProps` / `useFormWizard` | After |
161
+ |---|---|
162
+ | `initialData` | the single `useForm({ defaultValues })` |
163
+ | `onSuccess(data)` | `onSubmit` on that `useForm` (runs from the last step) |
164
+ | `onClose` / `onCancel` | `DialogClose` + `onOpenChange` on `Dialog` |
165
+ | `pageIndex` / current page | `value` (active step id) on `Stepper` |
166
+ | `navigateToNextPage` | validate step fields, then `setStep(next.id)` |
167
+ | `navigateToPreviousPage` | `setStep(prev.id)` |
168
+ | `FormWizard.Page stepTitle` | a `{ id, title }` entry in the `steps` array |
169
+
170
+ | `FormFieldProps` | After |
171
+ |---|---|
172
+ | `name` / `label` | `name` / `label` on the `Form*Field` |
173
+ | `validate` | `validators.onChange` / `onSubmit` on the `Form*Field` |
174
+ | `defaultValue` | `defaultValues[name]` in `useForm` |
175
+ | `getErrorMessage(e)` | return the translated string from the validator |
176
+
177
+ ## Fields reference
178
+
179
+ `@aircall/blocks` ships typed wrappers for all common DS controls; each takes `form`,
180
+ `name`, `label`, optional `validators`, and a render-prop `(field, control) => …` —
181
+ spread the control bundle onto the matching DS primitive.
182
+
183
+ | Input | Block | Input | Block |
184
+ |---|---|---|---|
185
+ | text/email/password | `FormInputField` | radio group | `FormRadioGroupField` |
186
+ | select | `FormSelectField` | OTP | `FormOTPField` |
187
+ | combobox (single) | `FormComboboxField` | switch | `FormSwitchField` |
188
+ | combobox (multi) | `FormMultiComboboxField` | number | `FormNumberField` |
189
+ | textarea | `FormTextareaField` | slider | `FormSliderField` |
190
+
191
+ ## Common Mistakes
192
+
193
+ ### 1. Fabricating a `Stepper` instance API (`useStepper({steps})`, `nextStep`, `stepper=` prop)
194
+
195
+ Wrong:
196
+ ```tsx
197
+ const stepper = useStepper({ steps }); // useStepper takes no args
198
+ <Stepper stepper={stepper}> // no `stepper` prop
199
+ <StepperContent step={steps[0]}>... // no `step` prop
200
+ <Button onClick={stepper.nextStep}>Next</Button> // no nextStep
201
+ ```
202
+
203
+ Correct:
204
+ ```tsx
205
+ const [step, setStep] = useState(STEPS[0].id);
206
+ <Stepper steps={STEPS} value={step} onValueChange={setStep}>
207
+ <StepperPanel>
208
+ <StepperContent value="details">...</StepperContent>
209
+ </StepperPanel>
210
+ </Stepper>
211
+ <Button type="button" onClick={() => setStep(next.id)}>Next</Button>
212
+ ```
213
+
214
+ `Stepper` is value-controlled by step **id**; `useStepper()` (no args, inside the tree) exposes `{ activeId, goTo, getIndex, steps }`. The invented instance API does not exist and won't compile.
215
+
216
+ Source: `packages/ds/src/components/stepper.tsx`
217
+
218
+ ### 2. One `useForm` per step instead of one for the wizard
219
+
220
+ Wrong:
221
+ ```tsx
222
+ function DetailsStep() { const form = useForm({ defaultValues: { name: '' }, ... }); }
223
+ function RoleStep() { const form = useForm({ defaultValues: { role: '' }, ... }); }
224
+ ```
225
+
226
+ Correct:
227
+ ```tsx
228
+ // one form at the wizard level holds every step's fields
229
+ const form = useForm({ defaultValues: { name: '', role: '' }, onSubmit });
230
+ ```
231
+
232
+ `useFormWizard().data` was one accumulator. Per-step forms fragment the value, drop earlier steps on submit, and prevent cross-step validation.
233
+
234
+ Source: `packages/blocks/src/form/use-form.ts`
235
+
236
+ ### 3. Advancing without validating the current step
237
+
238
+ Wrong:
239
+ ```tsx
240
+ <Button onClick={() => setStep(next.id)}>Next</Button> // skips validation
241
+ ```
242
+
243
+ Correct:
244
+ ```tsx
245
+ const goNext = async () => {
246
+ const r = await Promise.all(STEP_FIELDS[step].map(n => form.validateField(n, 'submit')));
247
+ if (r.every(e => e.length === 0)) setStep(next.id);
248
+ };
249
+ ```
250
+
251
+ `form.validateField(name, cause)` returns the field's errors; gating `setStep` on them reproduces `FormWizard`'s per-page validation. Without it the user advances past empty/invalid required fields.
252
+
253
+ Source: `packages/blocks/src/form/use-form.ts`
254
+
255
+ ### 4. `CardSaveBar`/`SubmitButton` passed `form` as a prop or placed outside `form.AppForm`
256
+
257
+ Wrong:
258
+ ```tsx
259
+ <CardSaveBar form={form} />
260
+ ```
261
+
262
+ Correct:
263
+ ```tsx
264
+ <form.AppForm>
265
+ <form.SubmitButton>Create user</form.SubmitButton>
266
+ </form.AppForm>
267
+ ```
268
+
269
+ `SubmitButton`/`CardSaveBar` are registered form components — they read `canSubmit`/`isSubmitting` from form context and must be rendered via `form.AppForm`, not handed a `form` prop. They must also sit inside the `<form>` so their `type="submit"` triggers `onSubmit`.
270
+
271
+ Source: `packages/blocks/src/form/use-form.ts`