@tailor-platform/app-shell 1.13.0 → 1.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.
@@ -433,7 +433,7 @@ const table = useDataTable({
433
433
 
434
434
  **Column alignment:** each `Column` accepts **`align`** (`"left" | "right"`) applied to both header and body cell. Numeric `type` columns (`"number"`, `"money"`) default to `"right"` automatically so digits align on the decimal place — pass `align="left"` to opt out; everything else defaults to `"left"`.
435
435
 
436
- **Metadata path:** Prefer `createColumnHelper` + `inferColumns(tableMetadata.order)` (`@tailor-platform/app-shell-sdk-plugin` codegen) when available so enum/datetime/string filters bind to the right editors.
436
+ **Metadata path:** Prefer `createColumnHelper` + `inferColumns(tableMetadata.order)` (`@tailor-platform/sdk-plugin-app-shell` codegen) when available so enum/datetime/string filters bind to the right editors.
437
437
 
438
438
  **Bucket tabs / segmented UX:** AppShell defines **toolbar chips**, not lifecycle tabs. When design places **`Tabs`** (All / Draft / …) inside the card, compose them **above** `DataTable.Root` and synchronize tab-driven bucket state with **`useCollectionVariables`** (`variables.query` / filters)—see **`patterns/list/dense-scan.md`**.
439
439
 
@@ -619,13 +619,41 @@ const table = useDataTable({
619
619
 
620
620
  ## Forms
621
621
 
622
+ > **Which form stack?** AppShell's `Form` / `Field` / `Fieldset` wrap Base UI primitives and are the
623
+ > default — they need no extra dependency. They own **accessibility wiring and visual state only**
624
+ > (`htmlFor`, `aria-describedby`, `data-invalid` / `data-dirty` / `data-touched`); they do **not** own
625
+ > your values. React Hook Form is **optional** and consumer-installed — reach for it only when a form
626
+ > genuinely needs cross-field validation, field arrays, or a Zod resolver. The two compose; they are
627
+ > not alternatives. `react-hook-form` stopped being a runtime dependency in 1.4.0; it is a dev-only
628
+ > dependency of the package today, used to test the RHF integration contracts — if you use it,
629
+ > install it in your own app.
630
+
622
631
  ### `Form`
623
632
 
624
633
  > Full API: [https://raw.githubusercontent.com/tailor-platform/app-shell/refs/heads/main/docs/components/form.md](https://raw.githubusercontent.com/tailor-platform/app-shell/refs/heads/main/docs/components/form.md)
625
634
 
626
635
  **Import:** `import { Form } from '@tailor-platform/app-shell'`
627
- **Purpose:** Form root wired to react-hook-form. Use with `Field`, `Fieldset`, and Zod for validation.
628
- **API:** `FormProps` — `errors`, `actionsRef`, `validationMode`, `noValidate`, plus a namespace exposing form-related sub-helpers. Generic over `FormValues`.
636
+ **Purpose:** Form root that wraps Base UI's form primitive. Provides shared validation context for
637
+ child `Field.Root`s, consolidated error display, and server-error routing by field `name`.
638
+ **API:** `FormProps` — `onFormSubmit`, `onSubmit`, `errors`, `actionsRef`, `validationMode`,
639
+ `noValidate`, `id`. Generic over `FormValues`.
640
+ **Actions outside the form:** give the `Form` an `id` and point a detached submit button at it with
641
+ the native `form` attribute. This is how a page-header Save reaches a body form (1.13.0+):
642
+
643
+ ```tsx
644
+ <Layout.Header
645
+ title="Create product"
646
+ actions={[<Button key="save" type="submit" form="product-form">Save</Button>]}
647
+ />
648
+ <Form id="product-form" onFormSubmit={save}>…</Form>
649
+ ```
650
+
651
+ **Submit:** use `onFormSubmit(values)` — it receives parsed form values and is the default. Use the
652
+ low-level `onSubmit` **only** when handing the native event to React Hook Form's `handleSubmit`.
653
+ **Never** hand-roll `<form onSubmit={e => new FormData(e.currentTarget)}>` — that skips validation
654
+ and error routing.
655
+ **Server errors:** pass `errors={{ fieldName: "message" }}`; they route to the matching
656
+ `Field.Error` and clear when the user edits that field.
629
657
  **Example:** see `form/single-page.md`.
630
658
  **Used in patterns:** all `form/*`.
631
659
 
@@ -633,9 +661,71 @@ const table = useDataTable({
633
661
 
634
662
  **Import:** `import { Field } from '@tailor-platform/app-shell'`
635
663
  **Purpose:** Single form field with label + control + error message wiring.
636
- **API:** Compound. Wraps any input control (`Input`, `Select`, `Combobox`, etc.) and binds it to react-hook-form via `name`.
664
+ **API:** Compound — `Field.Root`, `Field.Label`, `Field.Control`, `Field.Description`, `Field.Error`,
665
+ `Field.Validity`. `Field.Root` establishes a context boundary; every Base UI-backed AppShell control
666
+ placed inside it inherits label association, `aria-describedby`, `disabled`, and invalid state
667
+ automatically.
668
+ **`Field.Control` is already a styled input** — write `<Field.Control />`, not
669
+ `<Field.Control render={<Input />} />`. `Input` and `Field.Control` share the same base classes, so
670
+ the `render` form just double-wraps. Reach for `Input` directly when you need a control outside a
671
+ `Field.Root`, or when the value is React-controlled.
672
+ **RHF interop (optional):** `Field.Root` accepts `isTouched`, `isDirty`, `invalid`, and `error`,
673
+ matching RHF's `fieldState` shape — so `<Field.Root {...fieldState}>` works with no adapter.
637
674
  **Used in patterns:** all `form/*`.
638
675
 
676
+ ### How values reach your submit handler
677
+
678
+ There are three separate mechanisms. Picking the wrong mental model is the most common
679
+ form mistake, so be explicit about which one a screen uses.
680
+
681
+ **1. `Form` + `Field.Root` → `onFormSubmit` (the default).** `onFormSubmit` does **not** read
682
+ `FormData`; it collects values from the `Field.Root`s registered inside the `Form`, keyed by each
683
+ field's `name`. Every AppShell control works this way once wrapped in a `Field.Root name="…"` —
684
+ including `Select`, `Combobox`, and `Autocomplete`. They need **no `name` of their own and no React
685
+ state**:
686
+
687
+ ```tsx
688
+ <Form onFormSubmit={(values) => save(values)}>
689
+ <Field.Root name="category">
690
+ <Field.Label>Category</Field.Label>
691
+ <Select items={CATEGORIES} placeholder="Select category" />
692
+ </Field.Root>
693
+ </Form>
694
+ ```
695
+
696
+ **2. Native submission** — a plain `<form>`, `new FormData(form)`, or a server action. This reads the
697
+ DOM, so each control needs its own `name`. `Select`, `Combobox`, and `Autocomplete` gained `name`
698
+ (plus `form`, `required`, `inputRef`) in 1.13.0; before that they contributed nothing to a native
699
+ payload.
700
+
701
+ **3. React Hook Form** — optional, consumer-installed. RHF owns the values; drive the control with
702
+ `value` / `onValueChange` from a `Controller` and spread `fieldState` onto `Field.Root`. Warranted
703
+ for cross-field validation, field arrays, or a Zod resolver — not for ordinary CRUD forms.
704
+
705
+ #### Object items
706
+
707
+ Under mechanism 1, a non-string item is serialised into the submitted value:
708
+
709
+ | Item shape | Value in `onFormSubmit` |
710
+ | -------------------------------------- | ------------------------------------ |
711
+ | `string` | the string |
712
+ | `{ value, label }` | `value`, automatically |
713
+ | any other object | **JSON string** — usually not wanted |
714
+ | any other object + `itemToStringValue` | whatever that function returns |
715
+
716
+ So for arbitrary objects, pass `itemToStringValue` to choose the submitted key. It is distinct from
717
+ `mapItem`, which controls what the user sees:
718
+
719
+ ```tsx
720
+ <Combobox
721
+ items={warehouses}
722
+ mapItem={(w) => ({ label: w.name, key: String(w.id) })}
723
+ itemToStringValue={(w) => String(w.id)}
724
+ />
725
+ ```
726
+
727
+ Multi-select submits an array.
728
+
639
729
  ### `Fieldset`
640
730
 
641
731
  **Import:** `import { Fieldset } from '@tailor-platform/app-shell'`
@@ -108,7 +108,7 @@ For `pnpm gql-tada:check` to pass, always destructure with optional chaining and
108
108
 
109
109
  For **standard list pages**, do not hand-roll only `first` / `after`. Compose AppShell **`DataTable`** + **`useCollectionVariables`** + **`useDataTable`**: derive query variables from `variables.pagination`, `variables.query`, `variables.order`, map `edges` / `pageInfo` / `total` into `useDataTable`’s `data`. See **`patterns/list/dense-scan.md`** and **`components.md` → DataTable**.
110
110
 
111
- When `@tailor-platform/app-shell-sdk-plugin` codegen is enabled, pass generated **`tableMetadata`** into **`useCollectionVariables`** so `variables.query` / `variables.order` are typed against your Tailor GraphQL inputs (upstream **data-table.md** Typed query variables).
111
+ When `@tailor-platform/sdk-plugin-app-shell` codegen is enabled, pass generated **`tableMetadata`** into **`useCollectionVariables`** so `variables.query` / `variables.order` are typed against your Tailor GraphQL inputs (upstream **data-table.md** Typed query variables).
112
112
 
113
113
  ## Mutations and cache invalidation
114
114
 
@@ -4,7 +4,7 @@ name: Modal Form
4
4
  category: pattern
5
5
  subcategory: form
6
6
  description: Default form pattern for Create/Edit — keeps user in context on the parent screen
7
- requiredImports: [Dialog, Button, Form, Field, Input]
7
+ requiredImports: [Dialog, Button, Form, Field, Layout]
8
8
  tags: [form, modal, dialog, create, edit, inline-add]
9
9
  do:
10
10
  - Default for most Create and Edit forms — keeps user in context on parent screen
@@ -30,10 +30,16 @@ dont:
30
30
 
31
31
  ```tsx
32
32
  /* pattern: form/modal */
33
- import { Button, Dialog, Input, Field } from "@tailor-platform/app-shell";
33
+ import { Button, Dialog, Field, Form } from "@tailor-platform/app-shell";
34
+
35
+ type Address = {
36
+ label: string;
37
+ street: string;
38
+ city: string;
39
+ };
34
40
 
35
41
  type Props = {
36
- onSave: (data: { label: string; street: string; city: string }) => void;
42
+ onSave: (data: Address) => void;
37
43
  };
38
44
 
39
45
  export default function ModalForm({ onSave }: Props) {
@@ -45,36 +51,34 @@ export default function ModalForm({ onSave }: Props) {
45
51
  <Dialog.Title>Add address</Dialog.Title>
46
52
  <Dialog.Description>Add a shipping address to this order.</Dialog.Description>
47
53
  </Dialog.Header>
48
- <form
49
- onSubmit={(e) => {
50
- e.preventDefault();
51
- const formData = new FormData(e.currentTarget);
52
- onSave({
53
- label: formData.get("label") as string,
54
- street: formData.get("street") as string,
55
- city: formData.get("city") as string,
56
- });
57
- }}
58
- >
54
+ {/*
55
+ * `Form` + `onFormSubmit` replaces a hand-rolled `<form onSubmit>` +
56
+ * `FormData`: values arrive parsed, and validation runs before the
57
+ * handler fires. Every text field is uncontrolled — no state needed.
58
+ */}
59
+ <Form<Address> noValidate onFormSubmit={(values) => onSave(values)}>
59
60
  <div className="space-y-4 py-4">
60
61
  <Field.Root name="label">
61
62
  <Field.Label>Label</Field.Label>
62
- <Field.Control render={<Input />} />
63
+ <Field.Control required placeholder="Head office" />
64
+ <Field.Error match="valueMissing">Label is required.</Field.Error>
63
65
  </Field.Root>
64
66
  <Field.Root name="street">
65
67
  <Field.Label>Street</Field.Label>
66
- <Field.Control render={<Input />} />
68
+ <Field.Control required />
69
+ <Field.Error match="valueMissing">Street is required.</Field.Error>
67
70
  </Field.Root>
68
71
  <Field.Root name="city">
69
72
  <Field.Label>City</Field.Label>
70
- <Field.Control render={<Input />} />
73
+ <Field.Control required />
74
+ <Field.Error match="valueMissing">City is required.</Field.Error>
71
75
  </Field.Root>
72
76
  </div>
73
77
  <Dialog.Footer>
74
78
  <Dialog.Close render={<Button variant="ghost" />}>Cancel</Dialog.Close>
75
79
  <Button type="submit">Save</Button>
76
80
  </Dialog.Footer>
77
- </form>
81
+ </Form>
78
82
  </Dialog.Content>
79
83
  </Dialog.Root>
80
84
  );
@@ -85,13 +89,18 @@ export default function ModalForm({ onSave }: Props) {
85
89
 
86
90
  ```tsx
87
91
  /* pattern: form/modal (route-driven variant) */
88
- import { Button, Dialog, Input, Layout, Field } from "@tailor-platform/app-shell";
92
+ import { useState } from "react";
93
+ import { Button, Dialog, Field, Form, Layout } from "@tailor-platform/app-shell";
94
+
95
+ type ProductDraft = {
96
+ name: string;
97
+ };
89
98
 
90
99
  type Props = {
91
100
  isCreateOpen: boolean;
92
101
  onNavigateToCreate: () => void;
93
102
  onNavigateToList: () => void;
94
- onSave: (data: { name: string }) => void;
103
+ onSave: (data: ProductDraft) => Promise<{ errors?: Record<string, string> }>;
95
104
  };
96
105
 
97
106
  /**
@@ -105,6 +114,21 @@ export default function ModalFormRouted({
105
114
  onNavigateToList,
106
115
  onSave,
107
116
  }: Props) {
117
+ // Server-side validation errors, keyed by field `name`. `Form` routes each
118
+ // one to the matching `Field.Error` and clears it when the user edits that
119
+ // field — so failures never need their own alert banner.
120
+ const [errors, setErrors] = useState<Record<string, string>>({});
121
+
122
+ const handleSubmit = async (values: ProductDraft) => {
123
+ const result = await onSave(values);
124
+ if (result.errors) {
125
+ setErrors(result.errors);
126
+ return;
127
+ }
128
+ setErrors({});
129
+ onNavigateToList();
130
+ };
131
+
108
132
  return (
109
133
  <Layout>
110
134
  <Layout.Header
@@ -127,26 +151,23 @@ export default function ModalFormRouted({
127
151
  <Dialog.Header>
128
152
  <Dialog.Title>Create product</Dialog.Title>
129
153
  </Dialog.Header>
130
- <form
131
- onSubmit={(e) => {
132
- e.preventDefault();
133
- const formData = new FormData(e.currentTarget);
134
- onSave({ name: formData.get("name") as string });
135
- }}
136
- >
154
+ <Form<ProductDraft> noValidate errors={errors} onFormSubmit={handleSubmit}>
137
155
  <div className="space-y-4 py-4">
138
156
  <Field.Root name="name">
139
157
  <Field.Label>Name</Field.Label>
140
- <Field.Control render={<Input />} />
158
+ <Field.Control required />
159
+ {/* Catch-all: renders the native message, a `match` message, or
160
+ the server error routed in via the `errors` prop above. */}
161
+ <Field.Error />
141
162
  </Field.Root>
142
163
  </div>
143
164
  <Dialog.Footer>
144
- <Button variant="ghost" onClick={onNavigateToList}>
165
+ <Button type="button" variant="ghost" onClick={onNavigateToList}>
145
166
  Cancel
146
167
  </Button>
147
168
  <Button type="submit">Save</Button>
148
169
  </Dialog.Footer>
149
- </form>
170
+ </Form>
150
171
  </Dialog.Content>
151
172
  </Dialog.Root>
152
173
  </Layout>
@@ -159,6 +180,36 @@ export default function ModalFormRouted({
159
180
  - Dialog renders full-screen sheet below 1024px; centered max-w-md at 1024–1280px
160
181
  - Route-driven variant requires both parent path and create/edit path to render the same component
161
182
  - `onOpenChange` must navigate back — just calling `setOpen(false)` leaves the URL broken
183
+ - Use `<Form onFormSubmit>`, never a bare `<form onSubmit>` — see **Form state** below
184
+ - Cancel must be `type="button"`; inside a `<Form>` an untyped `<button>` defaults to `submit`
185
+
186
+ ## Form state
187
+
188
+ Applies to every `form/*` pattern. Full detail in **`components.md`** → Forms.
189
+
190
+ `form/composer` follows all of this too, with one documented exception noted below: its body is
191
+ controlled, because a composer reads the value during render to gate its submit and swap its
192
+ placeholder.
193
+
194
+ - **`Form` + `Field` is the default stack.** They wrap Base UI and ship with AppShell — no extra
195
+ dependency.
196
+ - **Submit via `onFormSubmit(values)`.** It fires only after validation passes. Do not hand-roll
197
+ `<form onSubmit>` + `new FormData(...)` — that skips validation and server-error routing.
198
+ - **`onFormSubmit` reads registered `Field.Root`s, not the DOM.** So every control — including
199
+ `Select`, `Combobox`, and `Autocomplete` — just needs wrapping in a `Field.Root name="…"`. They
200
+ need **no `name` of their own and no `useState`**. Mirroring field values into React state is the
201
+ most common thing to get wrong here. The exception is a value the component must read _during
202
+ render_ — a submit gate, a dependent placeholder, a live character count. That state is
203
+ load-bearing, not mirrored; see `form/composer`.
204
+ - **`Field.Control` is already a styled input.** Write `<Field.Control />`, not
205
+ `<Field.Control render={<Input />} />`.
206
+ - **Object items need `itemToStringValue`.** Items shaped `{ value, label }` submit `value`
207
+ automatically; any other object submits as a JSON string unless you supply it.
208
+ - **Server errors go through `Form`'s `errors` prop**, keyed by field `name` — not a toast or a
209
+ banner. They clear when the user edits the field.
210
+ - **React Hook Form is optional**, consumer-installed, and only warranted for cross-field
211
+ validation, field arrays, or a Zod resolver. It composes with `Field`: drive the control from a
212
+ `Controller` and spread `fieldState` onto `Field.Root`.
162
213
 
163
214
  ## Anti-patterns
164
215
 
@@ -167,3 +218,6 @@ export default function ModalFormRouted({
167
218
  - Save closes the dialog but parent state is stale — wire refetch or optimistic update
168
219
  - Building a routed Create/Edit page when the design didn't explicitly call for one — modal is the default
169
220
  - Registering the create path as a separate top-level route — that unmounts the parent list
221
+ - Reaching for React Hook Form on a form this size — `onFormSubmit` already covers it
222
+ - Holding a `Select`/`Combobox` value in `useState` just to submit it — `Field.Root` already does
223
+ - Surfacing API validation failures in a toast or banner instead of routing them via `errors`
@@ -4,7 +4,7 @@ name: Sectioned Form
4
4
  category: pattern
5
5
  subcategory: form
6
6
  description: Complex form with 15+ fields organized into named fieldset sections
7
- requiredImports: [Layout, Form, Fieldset, Field, Input, Select, Combobox, Button]
7
+ requiredImports: [Layout, Card, Form, Fieldset, Field, Combobox, Button]
8
8
  tags: [form, sections, fieldset, settings, complex]
9
9
  do:
10
10
  - Form is complex with 15+ fields or multiple grouped sections (Identity, Pricing, Inventory)
@@ -26,10 +26,26 @@ dont:
26
26
 
27
27
  ```tsx
28
28
  /* pattern: form/sectioned */
29
- import { Button, Layout, Input, Select, Field, Fieldset } from "@tailor-platform/app-shell";
29
+ import { Button, Card, Combobox, Field, Fieldset, Form, Layout } from "@tailor-platform/app-shell";
30
+
31
+ const CURRENCIES = ["USD", "EUR", "GBP", "JPY"];
32
+
33
+ const WAREHOUSES = [
34
+ { id: 1, name: "Tokyo DC" },
35
+ { id: 2, name: "Osaka DC" },
36
+ { id: 3, name: "Nagoya DC" },
37
+ ];
38
+
39
+ const SECTIONS = [
40
+ { id: "identity", title: "Identity", description: "How the product is named and referenced." },
41
+ { id: "pricing", title: "Pricing", description: "List price and the currency it is quoted in." },
42
+ { id: "inventory", title: "Inventory", description: "Opening stock and replenishment trigger." },
43
+ ];
44
+
45
+ const anchorIdFor = (id: string) => `section-${id}`;
30
46
 
31
47
  type Props = {
32
- onSave: (data: Record<string, string>) => void;
48
+ onSave: (data: Record<string, unknown>) => void;
33
49
  onCancel: () => void;
34
50
  };
35
51
 
@@ -42,71 +58,105 @@ export default function SectionedForm({ onSave, onCancel }: Props) {
42
58
  <Button key="cancel" variant="outline" onClick={onCancel}>
43
59
  Cancel
44
60
  </Button>,
45
- <Button key="save" type="submit" form="sectioned-form">
61
+ <Button key="save" type="submit" form="product-settings-form">
46
62
  Save
47
63
  </Button>,
48
64
  ]}
49
65
  />
50
66
  <Layout.Column>
51
- <form
52
- id="sectioned-form"
53
- onSubmit={(e) => {
54
- e.preventDefault();
55
- const formData = new FormData(e.currentTarget);
56
- const entries: Record<string, string> = {};
57
- formData.forEach((value, key) => {
58
- entries[key] = value as string;
59
- });
60
- onSave(entries);
61
- }}
62
- className="space-y-8"
67
+ <Form
68
+ id="product-settings-form"
69
+ noValidate
70
+ className="space-y-4"
71
+ onFormSubmit={(values) => onSave(values)}
63
72
  >
64
- <Fieldset.Root>
65
- <Fieldset.Legend>Identity</Fieldset.Legend>
66
- <div className="space-y-4">
67
- <Field.Root name="name">
68
- <Field.Label>Name</Field.Label>
69
- <Field.Control render={<Input />} />
70
- </Field.Root>
71
- <Field.Root name="sku">
72
- <Field.Label>SKU</Field.Label>
73
- <Field.Control render={<Input />} />
74
- </Field.Root>
75
- <Field.Root name="description">
76
- <Field.Label>Description</Field.Label>
77
- <Field.Control render={<Input />} />
78
- </Field.Root>
79
- </div>
80
- </Fieldset.Root>
81
-
82
- <Fieldset.Root>
83
- <Fieldset.Legend>Pricing</Fieldset.Legend>
84
- <div className="space-y-4">
85
- <Field.Root name="price">
86
- <Field.Label>Price</Field.Label>
87
- <Field.Control render={<Input type="number" />} />
88
- </Field.Root>
89
- <Field.Root name="currency">
90
- <Field.Label>Currency</Field.Label>
91
- <Select items={["USD", "EUR", "GBP", "JPY"]} placeholder="Select currency" />
92
- </Field.Root>
93
- </div>
94
- </Fieldset.Root>
95
-
96
- <Fieldset.Root>
97
- <Fieldset.Legend>Inventory</Fieldset.Legend>
98
- <div className="space-y-4">
99
- <Field.Root name="quantity">
100
- <Field.Label>Initial quantity</Field.Label>
101
- <Field.Control render={<Input type="number" />} />
102
- </Field.Root>
103
- <Field.Root name="reorderPoint">
104
- <Field.Label>Reorder point</Field.Label>
105
- <Field.Control render={<Input type="number" />} />
106
- </Field.Root>
107
- </div>
108
- </Fieldset.Root>
109
- </form>
73
+ {/* Anchor nav — each entry targets its section's Card by id. */}
74
+ <nav aria-label="Sections" className="flex gap-4 text-sm">
75
+ {SECTIONS.map((section) => (
76
+ <a
77
+ key={section.id}
78
+ href={`#${anchorIdFor(section.id)}`}
79
+ className="text-muted-foreground hover:text-foreground"
80
+ >
81
+ {section.title}
82
+ </a>
83
+ ))}
84
+ </nav>
85
+
86
+ <Card.Root id={anchorIdFor("identity")}>
87
+ <Card.Header title={SECTIONS[0].title} description={SECTIONS[0].description} />
88
+ <Card.Content>
89
+ <Fieldset.Root className="grid gap-4 md:grid-cols-2">
90
+ <Field.Root name="name">
91
+ <Field.Label>Name</Field.Label>
92
+ <Field.Control required />
93
+ <Field.Error match="valueMissing">Name is required.</Field.Error>
94
+ </Field.Root>
95
+ <Field.Root name="sku">
96
+ <Field.Label>SKU</Field.Label>
97
+ <Field.Control required />
98
+ <Field.Error match="valueMissing">SKU is required.</Field.Error>
99
+ </Field.Root>
100
+ <Field.Root name="description" className="md:col-span-2">
101
+ <Field.Label>Description</Field.Label>
102
+ <Field.Control />
103
+ </Field.Root>
104
+ </Fieldset.Root>
105
+ </Card.Content>
106
+ </Card.Root>
107
+
108
+ <Card.Root id={anchorIdFor("pricing")}>
109
+ <Card.Header title={SECTIONS[1].title} description={SECTIONS[1].description} />
110
+ <Card.Content>
111
+ <Fieldset.Root className="grid gap-4 md:grid-cols-2">
112
+ <Field.Root name="price">
113
+ <Field.Label>Price</Field.Label>
114
+ <Field.Control type="number" required min={0} step="0.01" />
115
+ <Field.Error match="valueMissing">Price is required.</Field.Error>
116
+ </Field.Root>
117
+ <Field.Root name="currency">
118
+ <Field.Label>Currency</Field.Label>
119
+ <Combobox items={CURRENCIES} defaultValue="USD" placeholder="Select currency" />
120
+ <Field.Error />
121
+ </Field.Root>
122
+ </Fieldset.Root>
123
+ </Card.Content>
124
+ </Card.Root>
125
+
126
+ <Card.Root id={anchorIdFor("inventory")}>
127
+ <Card.Header title={SECTIONS[2].title} description={SECTIONS[2].description} />
128
+ <Card.Content>
129
+ <Fieldset.Root className="grid gap-4 md:grid-cols-2">
130
+ <Field.Root name="quantity">
131
+ <Field.Label>Initial quantity</Field.Label>
132
+ <Field.Control type="number" required min={0} />
133
+ <Field.Error match="valueMissing">Initial quantity is required.</Field.Error>
134
+ </Field.Root>
135
+ <Field.Root name="reorderPoint">
136
+ <Field.Label>Reorder point</Field.Label>
137
+ <Field.Control type="number" min={0} />
138
+ <Field.Description>
139
+ Leave blank to disable replenishment alerts.
140
+ </Field.Description>
141
+ </Field.Root>
142
+ {/*
143
+ * Object items would otherwise reach `onFormSubmit` as JSON.
144
+ * `itemToStringValue` picks the field to submit; `mapItem`
145
+ * stays responsible for what the user sees.
146
+ */}
147
+ <Field.Root name="warehouse">
148
+ <Field.Label>Default warehouse</Field.Label>
149
+ <Combobox
150
+ items={WAREHOUSES}
151
+ mapItem={(w) => ({ label: w.name, key: String(w.id) })}
152
+ itemToStringValue={(w) => String(w.id)}
153
+ placeholder="Select warehouse"
154
+ />
155
+ </Field.Root>
156
+ </Fieldset.Root>
157
+ </Card.Content>
158
+ </Card.Root>
159
+ </Form>
110
160
  </Layout.Column>
111
161
  </Layout>
112
162
  );
@@ -117,10 +167,32 @@ export default function SectionedForm({ onSave, onCancel }: Props) {
117
167
 
118
168
  - Max ~6 sections — more than that is too hard to scan; promote to `form/wizard`
119
169
  - Required-marker convention must be consistent across all sections
120
- - Section legends must match anchor-nav labels
170
+ - One `Card.Root` per section, titled via `Card.Header title` + `description`; `Fieldset.Root`
171
+ inside supplies the field grouping and the responsive grid
172
+ - Save/Cancel belong in `Layout.Header`, wired with `<Button type="submit" form="…">` matching the
173
+ `Form`'s `id` (requires 1.13.0+)
174
+ - Section headings must match anchor-nav labels — derive both from one `SECTIONS` array so they
175
+ cannot drift
176
+ - A single `<Form>` wraps every section — do not nest a `Form` per section
177
+
178
+ ## Form state
179
+
180
+ Rules that apply to every `form/*` pattern are in **`components.md`** → Forms, with a worked example
181
+ in **`form/modal`** → Form state: `Form` + `Field` is the default stack, submit via `onFormSubmit`,
182
+ dropdowns need only a wrapping `Field.Root` (no `name`, no state), server errors route through
183
+ `errors`, and React Hook Form is an optional escape hatch.
184
+
185
+ At 15+ fields the temptation to reach for a form library is strongest, and it is usually wrong:
186
+ every field here — text, number, and dropdown alike — is uncontrolled and arrives through
187
+ `onFormSubmit`. The only per-field extra is `itemToStringValue` on dropdowns whose items are
188
+ objects rather than strings.
121
189
 
122
190
  ## Anti-patterns
123
191
 
124
192
  - More than ~6 sections — hard to scan; promote to `form/wizard`
125
193
  - Required-marker convention varies between sections — pick one rule and apply everywhere
126
- - Section legends that don't match anchor-nav labels
194
+ - Section headings that don't match anchor-nav labels
195
+ - A separate `<Form>` per section — submit and validation then fragment across the page
196
+ - Mirroring field values into `useState` to "keep the payload together" — `onFormSubmit` already
197
+ returns the whole payload
198
+ - Submitting an object-valued dropdown without `itemToStringValue` — the value arrives as JSON