@aircall/blocks 0.12.0 → 0.14.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.
@@ -29,32 +29,51 @@ Campaign Creation (`CampaignWizardPage`) and Add Contacts (`AddContactsPage`) ar
29
29
 
30
30
  ## 2. DashboardStandalonePage (full-page flows)
31
31
 
32
+ Four-part nesting: the outer `DashboardStandalonePage` is the `page-background` shell
33
+ (8px inset, flex row); `DashboardStandalonePageContent` is the rounded content **panel**
34
+ that holds the header + scroll area; the header carries a centered title (+ optional
35
+ description) with edge-pinned actions; `DashboardStandalonePageMain` is the scroll area.
36
+
32
37
  ```tsx
33
38
  import {
34
39
  DashboardStandalonePage,
40
+ DashboardStandalonePageContent,
35
41
  DashboardStandalonePageHeader,
36
42
  DashboardStandalonePageTitle,
43
+ DashboardStandalonePageDescription,
37
44
  DashboardStandalonePageActions,
38
45
  DashboardStandalonePageAction,
39
- DashboardStandalonePageContent
46
+ DashboardStandalonePageMain
40
47
  } from '@aircall/blocks';
41
48
 
42
49
  <DashboardStandalonePage>
43
- <DashboardStandalonePageHeader>
44
- <DashboardStandalonePageTitle>Create campaign</DashboardStandalonePageTitle>
45
- <DashboardStandalonePageActions>
46
- <DashboardStandalonePageAction onClick={onCancel}>Cancel</DashboardStandalonePageAction>
47
- </DashboardStandalonePageActions>
48
- </DashboardStandalonePageHeader>
49
50
  <DashboardStandalonePageContent>
50
- {/* a centered, width-capped Card — see below */}
51
+ <DashboardStandalonePageHeader>
52
+ <DashboardStandalonePageTitle>Create campaign</DashboardStandalonePageTitle>
53
+ {/* optional subtitle under the title */}
54
+ <DashboardStandalonePageDescription>Set up your outbound campaign</DashboardStandalonePageDescription>
55
+ {/* actions pin to an edge; side="start" pins left, default "end" pins right */}
56
+ <DashboardStandalonePageActions>
57
+ <DashboardStandalonePageAction onClick={onCancel}>Cancel</DashboardStandalonePageAction>
58
+ </DashboardStandalonePageActions>
59
+ </DashboardStandalonePageHeader>
60
+ <DashboardStandalonePageMain>
61
+ {/* a centered, width-capped Card — see below */}
62
+ </DashboardStandalonePageMain>
51
63
  </DashboardStandalonePageContent>
52
64
  </DashboardStandalonePage>
53
65
  ```
54
66
 
55
- ### What goes inside `DashboardStandalonePageContent` a centered, width-capped Card
67
+ - **Header** is a fixed 64px bar; the title (+ description) is optically centered and
68
+ actions are pinned to the left/right edges via `side` — declare them in any order.
69
+ - **Split layouts:** put a second panel (e.g. a `ChatbotPanel`) as a sibling of
70
+ `DashboardStandalonePageContent`; they share the shell's 8px gap. Hide a secondary
71
+ side panel below the `md` breakpoint (`className="hidden md:flex"`) so the main flow
72
+ gets the full width on narrow screens.
73
+
74
+ ### What goes inside `DashboardStandalonePageMain` — a centered, width-capped Card
56
75
 
57
- `DashboardStandalonePageContent` is a full-bleed scroll area; it does **not** constrain
76
+ `DashboardStandalonePageMain` is a full-bleed scroll area; it does **not** constrain
58
77
  width. The actual content is a centered container (`mx-auto` + a `max-w-*` cap) wrapping
59
78
  one or more `Card`s — the consistent pattern across the `DashboardStandalonePage` stories.
60
79
  Don't let content run full-width, and don't reach for `Paper`/raw page wrappers.
@@ -62,7 +81,7 @@ Don't let content run full-width, and don't reach for `Paper`/raw page wrappers.
62
81
  ```tsx
63
82
  import { Card, CardHeader, CardContent, CardFooter } from '@aircall/ds';
64
83
 
65
- <DashboardStandalonePageContent>
84
+ <DashboardStandalonePageMain>
66
85
  {/* single-column form / wizard */}
67
86
  <div className="mx-auto max-w-2xl pt-4">
68
87
  <Card>
@@ -71,13 +90,19 @@ import { Card, CardHeader, CardContent, CardFooter } from '@aircall/ds';
71
90
  <CardFooter className="justify-end gap-2">{/* Back / Next / Submit */}</CardFooter>
72
91
  </Card>
73
92
  </div>
74
- </DashboardStandalonePageContent>
93
+ </DashboardStandalonePageMain>
75
94
  ```
76
95
 
77
96
  Width cap by layout:
78
97
  - **Single-column form / wizard** → `mx-auto max-w-2xl pt-4` around one `Card` (CampaignWizard pattern).
79
98
  - **Wide / two-column** (form + summary aside) → `mx-auto flex max-w-7xl flex-col gap-6 pt-8 lg:flex-row`, a `Card` per column.
80
99
 
100
+ > **API note (renamed):** `DashboardStandalonePageContent` is now the rounded **panel**
101
+ > wrapper (header + main), and the scroll area is `DashboardStandalonePageMain` — matching
102
+ > the `DashboardPage` family (`Page → Content → Main`). Earlier code where
103
+ > `DashboardStandalonePageContent` was the scroll area must move that content into
104
+ > `DashboardStandalonePageMain` and wrap header+main in `DashboardStandalonePageContent`.
105
+
81
106
  ## 3. DashboardPage (standard in-app page)
82
107
 
83
108
  ```tsx
@@ -154,8 +179,10 @@ Wrong:
154
179
  Correct:
155
180
  ```tsx
156
181
  <DashboardStandalonePage>
157
- <DashboardStandalonePageHeader>...</DashboardStandalonePageHeader>
158
- <DashboardStandalonePageContent>{/* wizard */}</DashboardStandalonePageContent>
182
+ <DashboardStandalonePageContent>
183
+ <DashboardStandalonePageHeader>...</DashboardStandalonePageHeader>
184
+ <DashboardStandalonePageMain>{/* wizard */}</DashboardStandalonePageMain>
185
+ </DashboardStandalonePageContent>
159
186
  </DashboardStandalonePage>
160
187
  ```
161
188
 
@@ -155,6 +155,31 @@ Why one form: `useFormWizard().data` was a single accumulator across pages — a
155
155
  submits the complete object. Per-step `useForm`s would fragment that state and lose
156
156
  cross-step validation.
157
157
 
158
+ ## Validation UX — validate on click, don't disable
159
+
160
+ The `goNext` above is the **validate-on-click** pattern, and it's the one to reach for:
161
+ the Next button stays enabled, and clicking it validates the step and reveals what's
162
+ wrong. This is usually the product requirement ("never disable the button — tell me on
163
+ click"). It works because a **`'submit'`-cause validation also runs the field's
164
+ `onChange` validator** — so `form.validateField(name, 'submit')` populates errors even
165
+ for fields the user never touched, and each `Form*Field` renders them through its own
166
+ `FieldError`. (Verify against `form-core`'s `defaultValidationLogic`: the `submit` case
167
+ runs `[onChange, onBlur, onSubmit, …]`.)
168
+
169
+ - **Cross-field rules** ("at least one of A or B") can't be a single-field validator.
170
+ Put them on the parent `useForm`'s `validators`, or `safeParse` a step schema inside
171
+ `goNext`, and render the message where it belongs (e.g. a section-level `FieldError`).
172
+ - **i18n:** function validators can `return t(key)` directly; if you use a schema
173
+ library, build the schema in a factory that receives `t` (module-scope schemas can't
174
+ call `t`).
175
+ - If the design genuinely wants the button **disabled** until valid, subscribe
176
+ reactively instead — but this is the exception, not the default:
177
+ ```tsx
178
+ <form.Subscribe selector={s => isStepValid(s.values)}>
179
+ {ok => <Button disabled={!ok} onClick={() => setStep(next.id)}>Next</Button>}
180
+ </form.Subscribe>
181
+ ```
182
+
158
183
  ## Prop mapping
159
184
 
160
185
  | `FormWizardProps` / `useFormWizard` | After |
@@ -192,6 +217,22 @@ one field.
192
217
  **`necessityIndicator="required" | "optional"`** — mark only the exceptions (the few optional fields
193
218
  in a mostly-required form, or vice versa); never mix both markers in one form.
194
219
 
220
+ **Layout — `orientation` / `controlPosition`** — every wrapper defaults to a **vertical** field
221
+ (label above the control). Two shared props flip to a side-by-side layout, reusing the DS `Field`
222
+ orientation:
223
+ - `orientation="horizontal"` puts the label/description beside the control. `orientation="responsive"`
224
+ is vertical on narrow screens and horizontal once wide — it needs a `FieldGroup` ancestor (which
225
+ provides the `@container/field-group` the responsive variant reads).
226
+ - `controlPosition="start" | "end"` picks the control's side. Defaults per control:
227
+ `FormSwitchField` → `'start'` (switch left of its label), text/select/etc. → `'end'`.
228
+ - `FormSwitchField` **defaults to the inline (horizontal) layout** — a switch stacked under its label
229
+ is rarely wanted. A settings toggle: `<FormSwitchField label description>` (switch on the left);
230
+ pin the switch to the right with `controlPosition="end"`. Force the old stacked look with
231
+ `orientation="vertical"`.
232
+ - A responsive text field (label + description on the left, input + error on the right on desktop,
233
+ stacked on mobile): `<FormInputField orientation="responsive" label description … />` inside a
234
+ `FieldGroup`.
235
+
195
236
  ## Fields reference
196
237
 
197
238
  `@aircall/blocks` ships typed wrappers for all common DS controls; each takes `form`,
@@ -204,9 +245,45 @@ spread the control bundle onto the matching DS primitive.
204
245
  | text/email/password | `FormInputField` | radio group | `FormRadioGroupField` |
205
246
  | select | `FormSelectField` | OTP | `FormOTPField` |
206
247
  | combobox (single) | `FormComboboxField` | switch | `FormSwitchField` |
207
- | combobox (multi) | `FormMultiComboboxField` | number | `FormNumberField` |
248
+ | combobox (multi) | `FormMultiComboboxField` | number | `FormNumericField` |
208
249
  | textarea | `FormTextareaField` | slider | `FormSliderField` |
209
250
 
251
+ ## Multi-step gotchas
252
+
253
+ The `Stepper` **unmounts inactive `StepperContent`** (there's no `forceMount`). That's
254
+ usually fine — but two consequences bite:
255
+
256
+ - Field **values** persist in the form store across unmount, and so does field **meta**
257
+ (`isDirty` / `isTouched`) — a field's `mount()` cleanup is a no-op. So you can read
258
+ `form.state.fieldMeta['step1.field'].isDirty` while on step 3, and edits made on an
259
+ earlier step are still there when you submit. Add `forceMount` only if a step must
260
+ stay in the DOM.
261
+ - **Re-basing a saved draft: do NOT use `form.reset(values)`.** `reset` clears
262
+ `isTouched`, and when an unmounted step later remounts its field re-seeds to the
263
+ (empty) default — silently wiping the user's value. To mark the current values as
264
+ "synced" (e.g. after an autosave / draft save) while keeping them editable, clear
265
+ `isDirty` per field instead:
266
+ ```tsx
267
+ for (const path of Object.keys(form.state.fieldMeta))
268
+ form.setFieldMeta(path, m => ({ ...m, isDirty: false }));
269
+ ```
270
+ Now `isDirty` marks exactly what changed since the last sync — ideal for sending a
271
+ minimal update patch on the next save.
272
+
273
+ ## Field groups for large steps (optional)
274
+
275
+ For steps with many fields you can extract each into a `withFieldGroup` bound to a
276
+ subtree of the form, instead of listing everything in one flat `useForm`. Two things
277
+ to know:
278
+
279
+ - `fields` accepts a **subtree key** (`fields="general"`) **or** a **`FieldsMap`**
280
+ (`fields={{ groupField: 'form.path' }}`) — the map lets a group bind onto a flat
281
+ model without restructuring it.
282
+ - A field group has **no group-level `validators`**. Put cross-field rules on a
283
+ field-level validator that reads its sibling via `fieldApi.form.getFieldValue(...)`,
284
+ or on the parent `useForm`. Use `group.Subscribe` for reactive reads inside the group
285
+ (its `state.values` is the group's subset).
286
+
210
287
  ## Common Mistakes
211
288
 
212
289
  ### 1. Fabricating a `Stepper` instance API (`useStepper({steps})`, `nextStep`, `stepper=` prop)
@@ -288,3 +365,38 @@ Correct:
288
365
  `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`.
289
366
 
290
367
  Source: `packages/blocks/src/form/use-form.ts`
368
+
369
+ ### 5. Re-basing a multi-step draft with `form.reset`
370
+
371
+ Wrong:
372
+ ```tsx
373
+ await saveDraft(form.state.values);
374
+ form.reset(form.state.values); // clears isTouched → remounted steps re-seed to empty
375
+ ```
376
+
377
+ Correct:
378
+ ```tsx
379
+ await saveDraft(form.state.values);
380
+ for (const path of Object.keys(form.state.fieldMeta))
381
+ form.setFieldMeta(path, m => ({ ...m, isDirty: false })); // keeps values + isTouched
382
+ ```
383
+
384
+ `form.reset` is right for a single-screen form, but inside a `Stepper` (which unmounts inactive steps) it makes fields re-seed to their defaults on the next remount, wiping user input. Clearing `isDirty` re-bases the "changed since last sync" baseline without touching values.
385
+
386
+ Source: `packages/blocks/src/form/use-form.ts`
387
+
388
+ ## Testing (jsdom)
389
+
390
+ - **Drive navigation one step at a time.** `Next` handlers are async (validation runs
391
+ before advancing), so fire the click, **wait for the next step to render**, then
392
+ continue. Two back-to-back clicks race the async nav and both land on the same step.
393
+ ```tsx
394
+ fireEvent.click(getByTestId('next'));
395
+ await screen.findByText('Role'); // step 2 is on screen
396
+ fireEvent.click(getByTestId('next'));
397
+ ```
398
+ - **Assert errors without filling every field.** Because a `'submit'`-cause validation
399
+ runs the `onChange` validators (see "Validation UX"), clicking Next on an empty step
400
+ surfaces each field's `FieldError` — assert on those directly.
401
+ - **Field meta survives step changes**, so a test can edit a field on step 1, navigate
402
+ to a later step, submit, and still see the step-1 value in the payload.