UNPKG

@spark-web/design-system

Version:

--- title: Design System ---

505 lines (407 loc) 20.7 kB
# 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 1Page 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 2Sectioned 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 3Form fields (every input wrapped in Field) Every input on a vendor-admin form page is wrapped in `Field` from `@spark-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. ### CRITICALDatePicker and CurrencyInput MUST be inside a Field `DatePicker` (`@spark-web/date-picker`) and `CurrencyInput` (`@spark-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 `@spark-web/text-input` (single line), `TextArea` from `@spark-web/text-area` (multi line). - **Single-select** — `Select` from `@spark-web/select`: pass `options` (`Array<Option | Group>`), `value`, `onChange={e => e.target.value}`, and a `placeholder`. Always inside a `Field`. - **Multi-select** — `MultiSelect` from `@spark-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 `@spark-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 `@hookform/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 `@spark-web/field`. See the overlay (below); do not re-derive the mapping by hand on vendor-portal pages. --- ## Section 4Feedback 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 5Submit The save action is a single `Button` from `@spark-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 `@spark-web/field` error props; `FlashMessage` in place of a hand-built `Alert` success/error toggle; `MultiselectCheckbox` is being superseded by `@spark-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 `@spark-web/divider`. - NEVER hand-wire `@spark-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 `@spark-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 `@spark-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 `@spark-web/*` or an explicit consumer-overlay substitute, and anything missing is flagged with a `// COMPONENT GAP:` comment.