@spark-web/design-system 5.1.4 → 5.1.6

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,504 @@
1
+ # Vendor admin — form / settings page pattern
2
+
3
+ ## Before using this pattern
4
+
5
+ Read `node_modules/@spark-web/design-system/patterns/vendor-admin/CLAUDE.md`
6
+ fully before implementing this pattern. The layout-shell rule, the page-title
7
+ rule (`Heading level="2"`), the spacing/density rule, and the role/feature-flag
8
+ gating rule defined there all apply to this pattern and take precedence over
9
+ component-level rules.
10
+
11
+ ## What this pattern is
12
+
13
+ A full page layout for a vendor-admin **form or settings screen** — one or more
14
+ sections of inputs the vendor edits and saves. Unlike the list page, a form page
15
+ is a centered, bounded, standalone flow: it is **not** opened in a side panel
16
+ (see the surface "Detail interaction rule" — settings is a full page, not a
17
+ panel).
18
+
19
+ ## When to use this pattern
20
+
21
+ Use this pattern when the PRD describes any of the following:
22
+
23
+ - A settings, configuration, or preferences screen the vendor edits
24
+ - A "create" or "edit a record" flow with input fields and a save action
25
+ - The words "settings", "configure", "preferences", "form", "edit", or "manage
26
+ your …"
27
+
28
+ If the screen instead displays a list of records, use the list-page pattern:
29
+
30
+ `node_modules/@spark-web/design-system/patterns/vendor-admin/list-page.md`
31
+
32
+ ---
33
+
34
+ ## Component docs to read
35
+
36
+ Read these before implementing — they own the component-level rules. Only read
37
+ the ones the screen actually uses:
38
+
39
+ - `node_modules/@spark-web/container/CLAUDE.md` — Container `size` keys (page
40
+ centering)
41
+ - `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking, gap, align
42
+ - `node_modules/@spark-web/heading/CLAUDE.md` — page title (`level="2"`) and
43
+ section titles (`level="3"`)
44
+ - `node_modules/@spark-web/divider/CLAUDE.md` — Divider, section separation
45
+ - `node_modules/@spark-web/field/CLAUDE.md` — Field API (label, message, tone),
46
+ and the Field-context disabled mechanism that wraps every input
47
+ - `node_modules/@spark-web/text-input/CLAUDE.md` — TextInput API
48
+ - `node_modules/@spark-web/text-area/CLAUDE.md` — TextArea API
49
+ - `node_modules/@spark-web/select/CLAUDE.md` — Select API (single-select)
50
+ - `node_modules/@spark-web/multi-select/CLAUDE.md` — MultiSelect API (grouped
51
+ options)
52
+ - `node_modules/@spark-web/checkbox/CLAUDE.md` — Checkbox API (tone, size)
53
+ - `node_modules/@spark-web/currency-input/CLAUDE.md` — CurrencyInput API (money
54
+ fields)
55
+ - `node_modules/@spark-web/date-picker/CLAUDE.md` — DatePicker API (date fields)
56
+ - `node_modules/@spark-web/alert/CLAUDE.md` — inline success/error feedback
57
+ - `node_modules/@spark-web/button/CLAUDE.md` — submit Button, `loading` prop
58
+ - `node_modules/@spark-web/a11y/CLAUDE.md` — VisuallyHidden (accessible labels)
59
+
60
+ ---
61
+
62
+ ## Page structure
63
+
64
+ ```
65
+ Page
66
+ Container size="medium" — centers + bounds the page (940px)
67
+ Outer Stack — gap="xxlarge" marginY="xxlarge" marginX="large"
68
+ Heading level="2" — page title (surface rule — NOT PageHeader, NOT level 1)
69
+ Branch / context guard (conditional) — render the form only once a branch/context is selected
70
+ Section (per form section) — separated by Divider
71
+ Divider — between sections
72
+ Section Stack gap="xlarge"
73
+ Heading level="3" — section title
74
+ Alert (conditional) — section-level success/error feedback
75
+ Field-wrapped inputs — checkbox / select / multi-select / text-input /
76
+ text-area / currency-input / date-picker
77
+ Stack align="right" — submit row
78
+ Button loading={saving} — section-level save (when sections save independently)
79
+ Stack align="right" (conditional) — page-level save (when the whole form saves at once)
80
+ Button loading={saving}
81
+ ```
82
+
83
+ ---
84
+
85
+ ## Section 1 — Page shell
86
+
87
+ The page is centered and width-bounded by `Container`, then given vertical
88
+ rhythm by an outer `Stack`.
89
+
90
+ ```tsx
91
+ import { Container } from '@spark-web/container';
92
+ import { Heading } from '@spark-web/heading';
93
+ import { Stack } from '@spark-web/stack';
94
+
95
+ <Container size="medium">
96
+ <Stack gap="xxlarge" marginY="xxlarge" marginX="large">
97
+ <Heading level="2">{pageTitle}</Heading>
98
+ {/* sections */}
99
+ </Stack>
100
+ </Container>;
101
+ ```
102
+
103
+ Rules:
104
+
105
+ - The outer wrapper is **`Container size="medium"`** (940px) — this is the
106
+ form-page centering rule. Do not use `Stack height="full"` here (that is the
107
+ list-page wrapper) and do not omit the Container.
108
+ - The page body is a single `Stack` with `gap="xxlarge"` (between major
109
+ sections), `marginY="xxlarge"`, and `marginX="large"`. These spacing tokens
110
+ come straight from the surface spacing/density rule — never raw pixel gaps.
111
+ - The page title is **`Heading level="2"`** per the surface page-title rule —
112
+ never `PageHeader`, never `level="1"`. The fixed shell Header already carries
113
+ product identity; the in-page title is a section heading within the shell.
114
+
115
+ `Container`'s `size` keys are `xsmall | small | medium | large | xlarge`
116
+ (`medium` = 940px). Use `size="medium"` for a standard settings/form page; use a
117
+ narrower size only when the PRD calls for a tight single-column form.
118
+
119
+ ---
120
+
121
+ ## Section 2 — Sectioned layout
122
+
123
+ A form page is a vertical sequence of titled sections separated by a `Divider`.
124
+ Each section groups a coherent set of fields plus (when each section saves
125
+ independently) its own save button.
126
+
127
+ ```tsx
128
+ import { Divider } from '@spark-web/divider';
129
+ import { Heading } from '@spark-web/heading';
130
+ import { Stack } from '@spark-web/stack';
131
+
132
+ <>
133
+ <Divider />
134
+ <Stack gap="xlarge">
135
+ <Heading level="3">{sectionTitle}</Heading>
136
+ {/* fields */}
137
+ </Stack>
138
+ </>;
139
+ ```
140
+
141
+ Rules:
142
+
143
+ - Separate major sections with `<Divider />` from `@spark-web/divider`. A
144
+ `Divider` precedes each section so sections read as distinct blocks within the
145
+ `gap="xxlarge"` outer rhythm.
146
+ - `Divider`'s `color` defaults to `'standard'` — do not pass a color unless the
147
+ PRD requires a specific divider tone; the default is correct for section
148
+ separation.
149
+ - Each section is a `Stack gap="xlarge"` titled with **`Heading level="3"`** (a
150
+ sub-section step-down from the page's `level="2"` title, per the surface
151
+ page-title rule). Never use `level="2"` for a section title (reserved for the
152
+ page) and never jump to `level="4"+`.
153
+ - Render `null` for a section that is conditionally hidden — never render an
154
+ empty titled section or a dangling `Divider`.
155
+
156
+ ---
157
+
158
+ ## Section 3 — Form fields (every input wrapped in Field)
159
+
160
+ Every input on a vendor-admin form page is wrapped in `Field` from
161
+ `@spark-web/field`. `Field` owns the visible label, the help/error `message`,
162
+ and the `tone`, and it propagates `disabled` to its child input via React
163
+ context.
164
+
165
+ ```tsx
166
+ import { Field } from '@spark-web/field';
167
+ import { TextInput } from '@spark-web/text-input';
168
+
169
+ <Field
170
+ label="Trading name"
171
+ message={error?.message}
172
+ tone={error ? 'critical' : 'neutral'}
173
+ >
174
+ <TextInput value={name} onChange={e => setName(e.target.value)} />
175
+ </Field>;
176
+ ```
177
+
178
+ `Field` rules:
179
+
180
+ - `Field`'s `tone` union is **`critical | neutral | positive`** — there is no
181
+ `info` or `caution` tone on a field. Use `critical` for validation errors,
182
+ `positive` for confirmed-valid state, `neutral` (default) otherwise.
183
+ - A `message` only renders the error styling when paired with `tone="critical"`
184
+ (`invalid = message && tone === 'critical'`). Always set `tone="critical"`
185
+ alongside an error `message`.
186
+ - `Field` disables its child input by passing `disabled` down through context —
187
+ set `disabled` on the `Field`, not on the input, so the input, label, and
188
+ message all reflect the disabled state together.
189
+
190
+ ### CRITICAL — DatePicker and CurrencyInput MUST be inside a Field
191
+
192
+ `DatePicker` (`@spark-web/date-picker`) and `CurrencyInput`
193
+ (`@spark-web/currency-input`) read their `disabled` state from the **Field
194
+ context** (`useFieldContext()`), not from a prop. Rendered outside a `Field`,
195
+ they have no context to read and the disabled/placeholder behaviour breaks.
196
+ **Always wrap them in a `Field`.**
197
+
198
+ ```tsx
199
+ import { Field } from '@spark-web/field';
200
+ import { CurrencyInput } from '@spark-web/currency-input';
201
+ import { DatePicker } from '@spark-web/date-picker';
202
+
203
+ <Field label="Amount" disabled={isLocked}>
204
+ <CurrencyInput value={amount} onChange={setAmount} />
205
+ </Field>
206
+
207
+ <Field label="Start date" disabled={isLocked}>
208
+ <DatePicker value={startDate} onChange={setStartDate} />
209
+ </Field>;
210
+ ```
211
+
212
+ `DatePicker.onChange` is `(day: Date | undefined) => void`; `CurrencyInput`
213
+ takes a controlled `value` / `onChange` pair.
214
+
215
+ ### Input components
216
+
217
+ - **Text** — `TextInput` from `@spark-web/text-input` (single line), `TextArea`
218
+ from `@spark-web/text-area` (multi line).
219
+ - **Single-select** — `Select` from `@spark-web/select`: pass `options`
220
+ (`Array<Option | Group>`), `value`, `onChange={e => …e.target.value}`, and a
221
+ `placeholder`. Always inside a `Field`.
222
+ - **Multi-select** — `MultiSelect` from `@spark-web/multi-select`: pass grouped
223
+ `options` (`Array<{ label, options: Option[] }>`), `onChange` (receives a
224
+ `SelectedOptions` object keyed by group label), `placeholder`, and optional
225
+ `defaultOptions`. `MultiSelect` renders no visible label, so either wrap it in
226
+ a `Field` or label it with `aria-labelledby` pointing at a `VisuallyHidden`
227
+ element.
228
+ - **Checkbox** — `Checkbox` from `@spark-web/checkbox`: controlled via `checked`
229
+ / `onChange`. Its `tone` union is **`critical | neutral | positive`** (no
230
+ `info`/`caution`) and `size` is **`small | medium`** (`small` is the default).
231
+ Provide an accessible label (its `children`, or a `VisuallyHidden` child when
232
+ the visible label lives elsewhere, e.g. in a settings-row layout).
233
+ - **Currency** — `CurrencyInput` (see the CRITICAL Field-wrapping rule above).
234
+ - **Date** — `DatePicker` (see the CRITICAL Field-wrapping rule above).
235
+
236
+ ### React-hook-form binding convention
237
+
238
+ Wire forms with `react-hook-form`, validating with a Zod schema via
239
+ `@hookform/resolvers` (`zodResolver`). Bind each input through `register` (or a
240
+ `Controller` for non-native inputs like `Select`/`MultiSelect`/`DatePicker`/
241
+ `CurrencyInput`) and drive each `Field`'s error presentation from the form's
242
+ `errors[name]`:
243
+
244
+ - when `errors[name]` is present → `tone="critical"` and
245
+ `message={errors[name].message}`;
246
+ - otherwise → `tone="neutral"` (and any static help `message`).
247
+
248
+ In the **vendor-portal** repo this `errors[name] → tone/message` mapping is
249
+ already packaged in a local `FormField` wrapper (exported as `Field`) — use it
250
+ as the consumer substitution for `@spark-web/field`. See the overlay (below); do
251
+ not re-derive the mapping by hand on vendor-portal pages.
252
+
253
+ ---
254
+
255
+ ## Section 4 — Feedback
256
+
257
+ Surface save success/error with an `Alert` from `@spark-web/alert`, rendered
258
+ inside the section it relates to (above the submit row), or at page level for a
259
+ page-wide save.
260
+
261
+ ```tsx
262
+ import { Alert } from '@spark-web/alert';
263
+
264
+ {
265
+ updateState && (
266
+ <Alert
267
+ tone={updateState.success ? 'positive' : 'critical'}
268
+ onClose={() => setUpdateState(null)}
269
+ closeLabel="Dismiss"
270
+ >
271
+ {updateState.message}
272
+ </Alert>
273
+ );
274
+ }
275
+ ```
276
+
277
+ Rules:
278
+
279
+ - `Alert`'s `tone` union is `caution | critical | info | positive`. For save
280
+ feedback use `positive` (success) or `critical` (failure).
281
+ - `onClose` and `closeLabel` must be provided together — `Alert` only renders
282
+ the close button when both are set. Reset the feedback state on close and when
283
+ the form context changes.
284
+ - Working in vendor-portal? Its `FlashMessage` is the local success/error
285
+ binding over `Alert` — apply the overlay substitution.
286
+
287
+ ---
288
+
289
+ ## Section 5 — Submit
290
+
291
+ The save action is a single `Button` from `@spark-web/button`, right-aligned in
292
+ a `Stack align="right"`, with its `loading` prop bound to the in-flight save
293
+ state.
294
+
295
+ ```tsx
296
+ import { Button } from '@spark-web/button';
297
+ import { Stack } from '@spark-web/stack';
298
+
299
+ <Stack align="right">
300
+ <Button type="submit" onClick={handleSave} loading={saving}>
301
+ Save changes
302
+ </Button>
303
+ </Stack>;
304
+ ```
305
+
306
+ Rules:
307
+
308
+ - Right-align the submit with `Stack align="right"` — do not center or
309
+ left-align the primary save.
310
+ - Bind `loading={saving}` so the button shows a spinner and is non-interactive
311
+ during the mutation — never render a separate external spinner.
312
+ - One primary save per save-scope (see conditional rules) — do not stack
313
+ multiple primary buttons in one row.
314
+
315
+ ---
316
+
317
+ ## Conditional rules
318
+
319
+ ### Branch / context-selection guard
320
+
321
+ A vendor-admin form often depends on a selected branch/vendor context. Guard the
322
+ form: when no context is selected, render a short selection prompt instead of
323
+ the form, and render the sections only once the context exists.
324
+
325
+ ```tsx
326
+ if (!selectedContextId) {
327
+ return (
328
+ <Container size="small">
329
+ <Stack padding="large" margin="large" align="center">
330
+ <Text size="large">Please select a branch first.</Text>
331
+ </Stack>
332
+ </Container>
333
+ );
334
+ }
335
+ ```
336
+
337
+ - Use a narrower `Container size="small"` for the guard prompt; the form itself
338
+ uses `size="medium"`.
339
+ - Also guard each section's data: when the underlying record is absent, fall
340
+ back to the same prompt rather than rendering empty sections. Adding a `key`
341
+ per section tied to the selected context forces section state to reset when
342
+ the context changes.
343
+
344
+ ### Section-level save vs page-level save
345
+
346
+ - **Section-level save** — when each section persists independently (e.g. a
347
+ settings page where "Product settings" and "Notification settings" each have
348
+ their own Save). Put a `Stack align="right"` + `Button loading` **inside each
349
+ section** and give each section its own in-flight + feedback state.
350
+ - **Page-level save** — when the whole form submits as one unit (e.g. a create
351
+ flow). Put a single `Stack align="right"` + `Button loading` **after the last
352
+ section**, driven by one form-submit handler and one in-flight state.
353
+
354
+ Choose one per section/page; do not mix a page-level save with per-section saves
355
+ for the same fields.
356
+
357
+ ---
358
+
359
+ ## Structural skeleton
360
+
361
+ Use this skeleton as the starting point for new builds and uplifts. Do not use
362
+ existing page implementations as a structural reference. (This example shows
363
+ section-level saves; collapse to a single trailing save row for a page-level
364
+ save.)
365
+
366
+ ```tsx
367
+ import { Container } from '@spark-web/container';
368
+ import { Divider } from '@spark-web/divider';
369
+ import { Heading } from '@spark-web/heading';
370
+ import { Stack } from '@spark-web/stack';
371
+ import { Text } from '@spark-web/text';
372
+
373
+ // Branch / context guard — see Conditional rules. When a section's underlying
374
+ // record is absent, fall back to this same prompt instead of rendering empty
375
+ // sections.
376
+ if (!selectedContextId) {
377
+ return (
378
+ <Container size="small">
379
+ <Stack padding="large" margin="large" align="center">
380
+ <Text size="large">Please select a branch first.</Text>
381
+ </Stack>
382
+ </Container>
383
+ );
384
+ }
385
+
386
+ <Container size="medium">
387
+ <Stack gap="xxlarge" marginY="xxlarge" marginX="large">
388
+ <Heading level="2">{pageTitle}</Heading>
389
+
390
+ {/* One block per form section — Section 2 (see above) */}
391
+ <Divider />
392
+ <Stack gap="xlarge" key={`${record.id}-details`}>
393
+ <Heading level="3">Details</Heading>
394
+
395
+ {/* Section 4 — feedback Alert (conditional) — see above */}
396
+
397
+ {/* Section 3 — Field-wrapped inputs; DatePicker / CurrencyInput per the
398
+ CRITICAL Field-wrapping rule — see above */}
399
+
400
+ {/* Section 5 — submit row (Stack align="right" + Button loading) — see
401
+ above; section-level save shown here, or collapse to a single
402
+ trailing save row for a page-level save */}
403
+ </Stack>
404
+ </Stack>
405
+ </Container>;
406
+ ```
407
+
408
+ ---
409
+
410
+ ## Working in vendor-portal?
411
+
412
+ Apply the substitutions in
413
+ `node_modules/@spark-web/design-system/patterns/vendor-admin/vendor-portal.md`.
414
+ The relevant ones for a form page: the `FormField` wrapper (exported as `Field`)
415
+ in place of hand-wiring `@spark-web/field` error props; `FlashMessage` in place
416
+ of a hand-built `Alert` success/error toggle; `MultiselectCheckbox` is being
417
+ superseded by `@spark-web/multi-select` (the shapes match — prefer `MultiSelect`
418
+ in new code); and the `SettingsPanel` label-beside-control settings row.
419
+
420
+ ---
421
+
422
+ ## Documented exceptions summary
423
+
424
+ No raw CSS is required by this pattern's canonical snippets; if you add any,
425
+ list it here with a justification and use the `css` prop.
426
+
427
+ | Value | Property | Reason |
428
+ | ----- | -------- | ------------- |
429
+ | — | — | none required |
430
+
431
+ ---
432
+
433
+ ## Do NOTs
434
+
435
+ - NEVER place `DatePicker` or `CurrencyInput` outside a `Field` — both read
436
+ `disabled` from `Field` context (`useFieldContext()`); without a `Field`
437
+ wrapper their disabled/placeholder behaviour breaks.
438
+ - NEVER use a `tone` outside `critical | neutral | positive` on `Checkbox` or
439
+ `Field` — there is no `info` or `caution` tone there (those exist on `Alert`,
440
+ not on fields/checkboxes).
441
+ - NEVER pass a `Checkbox` `size` other than `small | medium`.
442
+ - NEVER set an error `message` on a `Field` without also setting
443
+ `tone="critical"` — the error styling only renders when both are present.
444
+ - NEVER use `Stack height="full"` as the form-page wrapper — the form page is
445
+ centered/bounded with `Container size="medium"` (that full-height wrapper
446
+ belongs to the list page).
447
+ - NEVER replace the page title with `PageHeader` or promote it to `level="1"` —
448
+ vendor-admin page titles are `Heading level="2"`, section titles are
449
+ `level="3"`.
450
+ - NEVER open a settings/form screen in a side panel — settings is a full page
451
+ (see the surface "Detail interaction rule").
452
+ - NEVER render an empty titled section or a dangling `Divider` for a hidden
453
+ section — return `null` instead.
454
+ - NEVER left-align or center the primary save — it is right-aligned via
455
+ `Stack align="right"`.
456
+ - NEVER render an external loading spinner for the save — use the Button
457
+ `loading` prop.
458
+ - NEVER pass a `color` to `Divider` for plain section separation — the default
459
+ `'standard'` is correct.
460
+ - NEVER import `Divider` from a non-Spark package (e.g. `rsuite`) — always
461
+ `@spark-web/divider`.
462
+ - NEVER hand-wire `@spark-web/field` error props on a vendor-portal page when
463
+ the `FormField` (`Field`) wrapper already maps `errors[name]` — use the
464
+ overlay substitution.
465
+
466
+ ---
467
+
468
+ ## Validation checklist
469
+
470
+ Run this checklist before marking any form-page task complete. Fix every
471
+ violation first. The uplift protocol in
472
+ `node_modules/@spark-web/design-system/CLAUDE.md` also runs this checklist
473
+ against existing pages and reports PASS/FAIL per item.
474
+
475
+ 1. Page wrapper is `Container size="medium"` (not `Stack height="full"`, not a
476
+ missing Container), with an inner
477
+ `Stack gap="xxlarge" marginY="xxlarge" marginX="large"`.
478
+ 2. Page title is `Heading level="2"`; every section title is `Heading level="3"`
479
+ — no `PageHeader`, no `level="1"`, no `level="2"` section titles.
480
+ 3. Sections are separated by `<Divider />` from `@spark-web/divider` with the
481
+ default `color="standard"`; hidden sections return `null` (no empty section,
482
+ no dangling Divider).
483
+ 4. Every input is wrapped in a `Field` from `@spark-web/field` (or the
484
+ consumer-overlay `Field`/`FormField`), with the label on the `Field`.
485
+ 5. `DatePicker` and `CurrencyInput` are each inside a `Field` — verified by
486
+ inspection (they read `disabled` from Field context).
487
+ 6. `Field`/`Checkbox` tones are only `critical | neutral | positive`; an error
488
+ `message` is always paired with `tone="critical"`; `Checkbox` `size` is
489
+ `small` or `medium` only.
490
+ 7. Feedback uses `Alert` from `@spark-web/alert` (or the overlay `FlashMessage`)
491
+ with `tone` `positive`/`critical` and paired `onClose` + `closeLabel`.
492
+ 8. The save is a single right-aligned `Button` per save-scope inside
493
+ `Stack align="right"`, with `loading` bound to the in-flight state — no
494
+ external spinner.
495
+ 9. Save scope is consistent — section-level saves live inside each section; a
496
+ page-level save is a single trailing save row; the two are not mixed for the
497
+ same fields.
498
+ 10. If the PRD describes a vendor/branch-scoped form, a context-selection guard
499
+ renders a `Container size="small"` prompt when no context is selected, and
500
+ the form renders only once the context exists; if the form is account-wide,
501
+ no guard is required.
502
+ 11. No raw CSS values are used (the Documented exceptions table is empty); every
503
+ component is `@spark-web/*` or an explicit consumer-overlay substitute, and
504
+ anything missing is flagged with a `// COMPONENT GAP:` comment.