@spark-web/design-system
Version:
--- title: Design System ---
505 lines (407 loc) • 20.7 kB
Markdown
# Vendor admin — form / settings page pattern
## Before using this pattern
Read `node_modules/@spark-web/design-system/patterns/vendor-admin/CLAUDE.md`
fully before implementing this pattern. The layout-shell rule, the page-title
rule (`Heading level="2"`), the spacing/density rule, and the role/feature-flag
gating rule defined there all apply to this pattern and take precedence over
component-level rules.
## What this pattern is
A full page layout for a vendor-admin **form or settings screen** — one or more
sections of inputs the vendor edits and saves. Unlike the list page, a form page
is a centered, bounded, standalone flow: it is **not** opened in a side panel
(see the surface "Detail interaction rule" — settings is a full page, not a
panel).
## When to use this pattern
Use this pattern when the PRD describes any of the following:
- A settings, configuration, or preferences screen the vendor edits
- A "create" or "edit a record" flow with input fields and a save action
- The words "settings", "configure", "preferences", "form", "edit", or "manage
your …"
If the screen instead displays a list of records, use the list-page pattern:
`node_modules/@spark-web/design-system/patterns/vendor-admin/list-page.md`
---
## Component docs to read
Read these before implementing — they own the component-level rules. Only read
the ones the screen actually uses:
- `node_modules/@spark-web/container/CLAUDE.md` — Container `size` keys (page
centering)
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking, gap, align
- `node_modules/@spark-web/heading/CLAUDE.md` — page title (`level="2"`) and
section titles (`level="3"`)
- `node_modules/@spark-web/divider/CLAUDE.md` — Divider, section separation
- `node_modules/@spark-web/field/CLAUDE.md` — Field API (label, message, tone),
and the Field-context disabled mechanism that wraps every input
- `node_modules/@spark-web/text-input/CLAUDE.md` — TextInput API
- `node_modules/@spark-web/text-area/CLAUDE.md` — TextArea API
- `node_modules/@spark-web/select/CLAUDE.md` — Select API (single-select)
- `node_modules/@spark-web/multi-select/CLAUDE.md` — MultiSelect API (grouped
options)
- `node_modules/@spark-web/checkbox/CLAUDE.md` — Checkbox API (tone, size)
- `node_modules/@spark-web/currency-input/CLAUDE.md` — CurrencyInput API (money
fields)
- `node_modules/@spark-web/date-picker/CLAUDE.md` — DatePicker API (date fields)
- `node_modules/@spark-web/alert/CLAUDE.md` — inline success/error feedback
- `node_modules/@spark-web/button/CLAUDE.md` — submit Button, `loading` prop
- `node_modules/@spark-web/a11y/CLAUDE.md` — VisuallyHidden (accessible labels)
---
## Page structure
```
Page
Container size="medium" — centers + bounds the page (940px)
Outer Stack — gap="xxlarge" marginY="xxlarge" marginX="large"
Heading level="2" — page title (surface rule — NOT PageHeader, NOT level 1)
Branch / context guard (conditional) — render the form only once a branch/context is selected
Section (per form section) — separated by Divider
Divider — between sections
Section Stack gap="xlarge"
Heading level="3" — section title
Alert (conditional) — section-level success/error feedback
Field-wrapped inputs — checkbox / select / multi-select / text-input /
text-area / currency-input / date-picker
Stack align="right" — submit row
Button loading={saving} — section-level save (when sections save independently)
Stack align="right" (conditional) — page-level save (when the whole form saves at once)
Button loading={saving}
```
---
## Section 1 — Page shell
The page is centered and width-bounded by `Container`, then given vertical
rhythm by an outer `Stack`.
```tsx
import { Container } from '@spark-web/container';
import { Heading } from '@spark-web/heading';
import { Stack } from '@spark-web/stack';
<Container size="medium">
<Stack gap="xxlarge" marginY="xxlarge" marginX="large">
<Heading level="2">{pageTitle}</Heading>
{/* sections */}
</Stack>
</Container>;
```
Rules:
- The outer wrapper is **`Container size="medium"`** (940px) — this is the
form-page centering rule. Do not use `Stack height="full"` here (that is the
list-page wrapper) and do not omit the Container.
- The page body is a single `Stack` with `gap="xxlarge"` (between major
sections), `marginY="xxlarge"`, and `marginX="large"`. These spacing tokens
come straight from the surface spacing/density rule — never raw pixel gaps.
- The page title is **`Heading level="2"`** per the surface page-title rule —
never `PageHeader`, never `level="1"`. The fixed shell Header already carries
product identity; the in-page title is a section heading within the shell.
`Container`'s `size` keys are `xsmall | small | medium | large | xlarge`
(`medium` = 940px). Use `size="medium"` for a standard settings/form page; use a
narrower size only when the PRD calls for a tight single-column form.
---
## Section 2 — Sectioned layout
A form page is a vertical sequence of titled sections separated by a `Divider`.
Each section groups a coherent set of fields plus (when each section saves
independently) its own save button.
```tsx
import { Divider } from '@spark-web/divider';
import { Heading } from '@spark-web/heading';
import { Stack } from '@spark-web/stack';
<>
<Divider />
<Stack gap="xlarge">
<Heading level="3">{sectionTitle}</Heading>
{/* fields */}
</Stack>
</>;
```
Rules:
- Separate major sections with `<Divider />` from `@spark-web/divider`. A
`Divider` precedes each section so sections read as distinct blocks within the
`gap="xxlarge"` outer rhythm.
- `Divider`'s `color` defaults to `'standard'` — do not pass a color unless the
PRD requires a specific divider tone; the default is correct for section
separation.
- Each section is a `Stack gap="xlarge"` titled with **`Heading level="3"`** (a
sub-section step-down from the page's `level="2"` title, per the surface
page-title rule). Never use `level="2"` for a section title (reserved for the
page) and never jump to `level="4"+`.
- Render `null` for a section that is conditionally hidden — never render an
empty titled section or a dangling `Divider`.
---
## Section 3 — Form fields (every input wrapped in Field)
Every input on a vendor-admin form page is wrapped in `Field` from
`-web/field`. `Field` owns the visible label, the help/error `message`,
and the `tone`, and it propagates `disabled` to its child input via React
context.
```tsx
import { Field } from '@spark-web/field';
import { TextInput } from '@spark-web/text-input';
<Field
label="Trading name"
message={error?.message}
tone={error ? 'critical' : 'neutral'}
>
<TextInput value={name} onChange={e => setName(e.target.value)} />
</Field>;
```
`Field` rules:
- `Field`'s `tone` union is **`critical | neutral | positive`** — there is no
`info` or `caution` tone on a field. Use `critical` for validation errors,
`positive` for confirmed-valid state, `neutral` (default) otherwise.
- A `message` only renders the error styling when paired with `tone="critical"`
(`invalid = message && tone === 'critical'`). Always set `tone="critical"`
alongside an error `message`.
- `Field` disables its child input by passing `disabled` down through context —
set `disabled` on the `Field`, not on the input, so the input, label, and
message all reflect the disabled state together.
### CRITICAL — DatePicker and CurrencyInput MUST be inside a Field
`DatePicker` (`-web/date-picker`) and `CurrencyInput`
(`-web/currency-input`) read their `disabled` state from the **Field
context** (`useFieldContext()`), not from a prop. Rendered outside a `Field`,
they have no context to read and the disabled/placeholder behaviour breaks.
**Always wrap them in a `Field`.**
```tsx
import { Field } from '@spark-web/field';
import { CurrencyInput } from '@spark-web/currency-input';
import { DatePicker } from '@spark-web/date-picker';
<Field label="Amount" disabled={isLocked}>
<CurrencyInput value={amount} onChange={setAmount} />
</Field>
<Field label="Start date" disabled={isLocked}>
<DatePicker value={startDate} onChange={setStartDate} />
</Field>;
```
`DatePicker.onChange` is `(day: Date | undefined) => void`; `CurrencyInput`
takes a controlled `value` / `onChange` pair.
### Input components
- **Text** — `TextInput` from `-web/text-input` (single line), `TextArea`
from `-web/text-area` (multi line).
- **Single-select** — `Select` from `-web/select`: pass `options`
(`Array<Option | Group>`), `value`, `onChange={e => …e.target.value}`, and a
`placeholder`. Always inside a `Field`.
- **Multi-select** — `MultiSelect` from `-web/multi-select`: pass grouped
`options` (`Array<{ label, options: Option[] }>`), `onChange` (receives a
`SelectedOptions` object keyed by group label), `placeholder`, and optional
`defaultOptions`. `MultiSelect` renders no visible label, so either wrap it in
a `Field` or label it with `aria-labelledby` pointing at a `VisuallyHidden`
element.
- **Checkbox** — `Checkbox` from `-web/checkbox`: controlled via `checked`
/ `onChange`. Its `tone` union is **`critical | neutral | positive`** (no
`info`/`caution`) and `size` is **`small | medium`** (`small` is the default).
Provide an accessible label (its `children`, or a `VisuallyHidden` child when
the visible label lives elsewhere, e.g. in a settings-row layout).
- **Currency** — `CurrencyInput` (see the CRITICAL Field-wrapping rule above).
- **Date** — `DatePicker` (see the CRITICAL Field-wrapping rule above).
### React-hook-form binding convention
Wire forms with `react-hook-form`, validating with a Zod schema via
`/resolvers` (`zodResolver`). Bind each input through `register` (or a
`Controller` for non-native inputs like `Select`/`MultiSelect`/`DatePicker`/
`CurrencyInput`) and drive each `Field`'s error presentation from the form's
`errors[name]`:
- when `errors[name]` is present → `tone="critical"` and
`message={errors[name].message}`;
- otherwise → `tone="neutral"` (and any static help `message`).
In the **vendor-portal** repo this `errors[name] → tone/message` mapping is
already packaged in a local `FormField` wrapper (exported as `Field`) — use it
as the consumer substitution for `-web/field`. See the overlay (below); do
not re-derive the mapping by hand on vendor-portal pages.
---
## Section 4 — Feedback
Surface save success/error with an `Alert` from `@spark-web/alert`, rendered
inside the section it relates to (above the submit row), or at page level for a
page-wide save.
```tsx
import { Alert } from '@spark-web/alert';
{
updateState && (
<Alert
tone={updateState.success ? 'positive' : 'critical'}
onClose={() => setUpdateState(null)}
closeLabel="Dismiss"
>
{updateState.message}
</Alert>
);
}
```
Rules:
- `Alert`'s `tone` union is `caution | critical | info | positive`. For save
feedback use `positive` (success) or `critical` (failure).
- `onClose` and `closeLabel` must be provided together — `Alert` only renders
the close button when both are set. Reset the feedback state on close and when
the form context changes.
- Working in vendor-portal? Its `FlashMessage` is the local success/error
binding over `Alert` — apply the overlay substitution.
---
## Section 5 — Submit
The save action is a single `Button` from `-web/button`, right-aligned in
a `Stack align="right"`, with its `loading` prop bound to the in-flight save
state.
```tsx
import { Button } from '@spark-web/button';
import { Stack } from '@spark-web/stack';
<Stack align="right">
<Button type="submit" onClick={handleSave} loading={saving}>
Save changes
</Button>
</Stack>;
```
Rules:
- Right-align the submit with `Stack align="right"` — do not center or
left-align the primary save.
- Bind `loading={saving}` so the button shows a spinner and is non-interactive
during the mutation — never render a separate external spinner.
- One primary save per save-scope (see conditional rules) — do not stack
multiple primary buttons in one row.
---
## Conditional rules
### Branch / context-selection guard
A vendor-admin form often depends on a selected branch/vendor context. Guard the
form: when no context is selected, render a short selection prompt instead of
the form, and render the sections only once the context exists.
```tsx
if (!selectedContextId) {
return (
<Container size="small">
<Stack padding="large" margin="large" align="center">
<Text size="large">Please select a branch first.</Text>
</Stack>
</Container>
);
}
```
- Use a narrower `Container size="small"` for the guard prompt; the form itself
uses `size="medium"`.
- Also guard each section's data: when the underlying record is absent, fall
back to the same prompt rather than rendering empty sections. Adding a `key`
per section tied to the selected context forces section state to reset when
the context changes.
### Section-level save vs page-level save
- **Section-level save** — when each section persists independently (e.g. a
settings page where "Product settings" and "Notification settings" each have
their own Save). Put a `Stack align="right"` + `Button loading` **inside each
section** and give each section its own in-flight + feedback state.
- **Page-level save** — when the whole form submits as one unit (e.g. a create
flow). Put a single `Stack align="right"` + `Button loading` **after the last
section**, driven by one form-submit handler and one in-flight state.
Choose one per section/page; do not mix a page-level save with per-section saves
for the same fields.
---
## Structural skeleton
Use this skeleton as the starting point for new builds and uplifts. Do not use
existing page implementations as a structural reference. (This example shows
section-level saves; collapse to a single trailing save row for a page-level
save.)
```tsx
import { Container } from '@spark-web/container';
import { Divider } from '@spark-web/divider';
import { Heading } from '@spark-web/heading';
import { Stack } from '@spark-web/stack';
import { Text } from '@spark-web/text';
// Branch / context guard — see Conditional rules. When a section's underlying
// record is absent, fall back to this same prompt instead of rendering empty
// sections.
if (!selectedContextId) {
return (
<Container size="small">
<Stack padding="large" margin="large" align="center">
<Text size="large">Please select a branch first.</Text>
</Stack>
</Container>
);
}
<Container size="medium">
<Stack gap="xxlarge" marginY="xxlarge" marginX="large">
<Heading level="2">{pageTitle}</Heading>
{/* One block per form section — Section 2 (see above) */}
<Divider />
<Stack gap="xlarge" key={`${record.id}-details`}>
<Heading level="3">Details</Heading>
{/* Section 4 — feedback Alert (conditional) — see above */}
{/* Section 3 — Field-wrapped inputs; DatePicker / CurrencyInput per the
CRITICAL Field-wrapping rule — see above */}
{/* Section 5 — submit row (Stack align="right" + Button loading) — see
above; section-level save shown here, or collapse to a single
trailing save row for a page-level save */}
</Stack>
</Stack>
</Container>;
```
---
## Working in vendor-portal?
Apply the substitutions in
`node_modules/@spark-web/design-system/patterns/vendor-admin/vendor-portal.md`.
The relevant ones for a form page: the `FormField` wrapper (exported as `Field`)
in place of hand-wiring `-web/field` error props; `FlashMessage` in place
of a hand-built `Alert` success/error toggle; `MultiselectCheckbox` is being
superseded by `-web/multi-select` (the shapes match — prefer `MultiSelect`
in new code); and the `SettingsPanel` label-beside-control settings row.
---
## Documented exceptions summary
No raw CSS is required by this pattern's canonical snippets; if you add any,
list it here with a justification and use the `css` prop.
| Value | Property | Reason |
| ----- | -------- | ------------- |
| — | — | none required |
---
## Do NOTs
- NEVER place `DatePicker` or `CurrencyInput` outside a `Field` — both read
`disabled` from `Field` context (`useFieldContext()`); without a `Field`
wrapper their disabled/placeholder behaviour breaks.
- NEVER use a `tone` outside `critical | neutral | positive` on `Checkbox` or
`Field` — there is no `info` or `caution` tone there (those exist on `Alert`,
not on fields/checkboxes).
- NEVER pass a `Checkbox` `size` other than `small | medium`.
- NEVER set an error `message` on a `Field` without also setting
`tone="critical"` — the error styling only renders when both are present.
- NEVER use `Stack height="full"` as the form-page wrapper — the form page is
centered/bounded with `Container size="medium"` (that full-height wrapper
belongs to the list page).
- NEVER replace the page title with `PageHeader` or promote it to `level="1"` —
vendor-admin page titles are `Heading level="2"`, section titles are
`level="3"`.
- NEVER open a settings/form screen in a side panel — settings is a full page
(see the surface "Detail interaction rule").
- NEVER render an empty titled section or a dangling `Divider` for a hidden
section — return `null` instead.
- NEVER left-align or center the primary save — it is right-aligned via
`Stack align="right"`.
- NEVER render an external loading spinner for the save — use the Button
`loading` prop.
- NEVER pass a `color` to `Divider` for plain section separation — the default
`'standard'` is correct.
- NEVER import `Divider` from a non-Spark package (e.g. `rsuite`) — always
`-web/divider`.
- NEVER hand-wire `-web/field` error props on a vendor-portal page when
the `FormField` (`Field`) wrapper already maps `errors[name]` — use the
overlay substitution.
---
## Validation checklist
Run this checklist before marking any form-page task complete. Fix every
violation first. The uplift protocol in
`node_modules/@spark-web/design-system/CLAUDE.md` also runs this checklist
against existing pages and reports PASS/FAIL per item.
1. Page wrapper is `Container size="medium"` (not `Stack height="full"`, not a
missing Container), with an inner
`Stack gap="xxlarge" marginY="xxlarge" marginX="large"`.
2. Page title is `Heading level="2"`; every section title is `Heading level="3"`
— no `PageHeader`, no `level="1"`, no `level="2"` section titles.
3. Sections are separated by `<Divider />` from `@spark-web/divider` with the
default `color="standard"`; hidden sections return `null` (no empty section,
no dangling Divider).
4. Every input is wrapped in a `Field` from `-web/field` (or the
consumer-overlay `Field`/`FormField`), with the label on the `Field`.
5. `DatePicker` and `CurrencyInput` are each inside a `Field` — verified by
inspection (they read `disabled` from Field context).
6. `Field`/`Checkbox` tones are only `critical | neutral | positive`; an error
`message` is always paired with `tone="critical"`; `Checkbox` `size` is
`small` or `medium` only.
7. Feedback uses `Alert` from `-web/alert` (or the overlay `FlashMessage`)
with `tone` `positive`/`critical` and paired `onClose` + `closeLabel`.
8. The save is a single right-aligned `Button` per save-scope inside
`Stack align="right"`, with `loading` bound to the in-flight state — no
external spinner.
9. Save scope is consistent — section-level saves live inside each section; a
page-level save is a single trailing save row; the two are not mixed for the
same fields.
10. If the PRD describes a vendor/branch-scoped form, a context-selection guard
renders a `Container size="small"` prompt when no context is selected, and
the form renders only once the context exists; if the form is account-wide,
no guard is required.
11. No raw CSS values are used (the Documented exceptions table is empty); every
component is `-web/*` or an explicit consumer-overlay substitute, and
anything missing is flagged with a `// COMPONENT GAP:` comment.