@tailor-platform/app-shell 1.13.0 → 1.15.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.
@@ -11,7 +11,7 @@ do:
11
11
  - The prose IS the interaction; any other controls only qualify how the text is submitted
12
12
  dont:
13
13
  - The text is one field among many on a record form — use form/modal, form/single-page or form/sectioned with a Textarea field
14
- - A chat input where Enter sends and there is no Discard — that is a different layout
14
+ - A chat input where Enter sends and there is no Discard — use the `AIChat` component instead
15
15
  ---
16
16
 
17
17
  # pattern/form/composer
@@ -124,7 +124,7 @@ export default function Composer({ onSubmit, submitting = false }: Props) {
124
124
  ## Constraints
125
125
 
126
126
  - The body control is `Textarea`, never `Input` — `Input` is locked to `h-9` and clips prose to one 36px line.
127
- - Size the body with `rows` (3–5 is the usual range). Don't reach for an `astw:h-*` / `astw:min-h-*` override: `astw:` utilities only resolve if that exact class was compiled into the AppShell package CSS, so an arbitrary one written in a consuming app silently does nothing.
127
+ - Size the body with `rows` (3–5 is the usual range). Do not override its height with classes: `astw:` is internal to AppShell, and `Textarea`'s supported sizing API is `rows`.
128
128
  - The `Textarea` needs an accessible name. There is usually no visible label in a composer, so pass `aria-label`; a placeholder is not a label.
129
129
  - One primary `Button` in the action row. Discard is `ghost`, and any left-side action is `ghost` too.
130
130
  - The action row is one row at every width — the left side shrinks, the buttons do not wrap under the body.
@@ -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
@@ -4,7 +4,7 @@ name: Single Page Form
4
4
  category: pattern
5
5
  subcategory: form
6
6
  description: Routed full-page form for moderate field count (6-15) without natural sectioning
7
- requiredImports: [Layout, Form, Field, Fieldset, Input, Textarea, Select, Button]
7
+ requiredImports: [Layout, Form, Field, Textarea, Select, Button]
8
8
  tags: [form, page, create, edit, routed]
9
9
  do:
10
10
  - A routed Create or Edit page that the design has explicitly called out (e.g. /orders/create)
@@ -26,65 +26,81 @@ dont:
26
26
 
27
27
  ```tsx
28
28
  /* pattern: form/single-page */
29
- import { Button, Layout, Input, Textarea, Select, Field } from "@tailor-platform/app-shell";
29
+ import { Button, Field, Form, Textarea, Layout, Select } from "@tailor-platform/app-shell";
30
+
31
+ const CATEGORIES = ["Electronics", "Furniture", "Clothing", "Food"];
32
+
33
+ type ProductDraft = {
34
+ name: string;
35
+ sku: string;
36
+ category: string;
37
+ price: string;
38
+ description: string;
39
+ };
30
40
 
31
41
  type Props = {
32
- onSave: (data: Record<string, string>) => void;
42
+ onSave: (data: ProductDraft) => void;
33
43
  onCancel: () => void;
34
44
  };
35
45
 
36
46
  export default function SinglePageForm({ onSave, onCancel }: Props) {
37
47
  return (
38
48
  <Layout>
49
+ {/*
50
+ * Save/Cancel live in the page header and reach the form through the
51
+ * native `form` attribute, matching `Form`'s `id`. No state plumbing.
52
+ */}
39
53
  <Layout.Header
40
- title="Create Product"
54
+ title="Create product"
41
55
  actions={[
42
56
  <Button key="cancel" variant="outline" onClick={onCancel}>
43
57
  Cancel
44
58
  </Button>,
45
- <Button key="save" type="submit" form="single-page-form">
59
+ <Button key="save" type="submit" form="product-form">
46
60
  Save
47
61
  </Button>,
48
62
  ]}
49
63
  />
50
64
  <Layout.Column>
51
- <form
52
- id="single-page-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-4 max-w-2xl"
65
+ <Form<ProductDraft>
66
+ id="product-form"
67
+ noValidate
68
+ className="max-w-2xl space-y-4"
69
+ onFormSubmit={(values) => onSave(values)}
63
70
  >
64
71
  <Field.Root name="name">
65
72
  <Field.Label>Name</Field.Label>
66
- <Field.Control render={<Input />} />
73
+ <Field.Control required />
74
+ <Field.Error match="valueMissing">Name is required.</Field.Error>
67
75
  </Field.Root>
68
76
  <Field.Root name="sku">
69
77
  <Field.Label>SKU</Field.Label>
70
- <Field.Control render={<Input />} />
78
+ <Field.Control required pattern="[A-Z]{3}-[0-9]{4}" />
79
+ <Field.Description>Format: ABC-1234</Field.Description>
80
+ <Field.Error match="patternMismatch">Use the format ABC-1234.</Field.Error>
81
+ <Field.Error match="valueMissing">SKU is required.</Field.Error>
71
82
  </Field.Root>
83
+ {/*
84
+ * Dropdowns need no `name` and no React state: `Field.Root` registers
85
+ * the control, so its value arrives in `onFormSubmit` under the
86
+ * field's name like any other input.
87
+ */}
72
88
  <Field.Root name="category">
73
89
  <Field.Label>Category</Field.Label>
74
- <Select
75
- items={["Electronics", "Furniture", "Clothing", "Food"]}
76
- placeholder="Select category"
77
- />
90
+ <Select items={CATEGORIES} placeholder="Select category" />
91
+ <Field.Error />
78
92
  </Field.Root>
79
93
  <Field.Root name="price">
80
94
  <Field.Label>Price</Field.Label>
81
- <Field.Control render={<Input type="number" />} />
95
+ <Field.Control type="number" required min={0} step="0.01" />
96
+ <Field.Error match="rangeUnderflow">Price cannot be negative.</Field.Error>
97
+ <Field.Error match="valueMissing">Price is required.</Field.Error>
82
98
  </Field.Root>
83
99
  <Field.Root name="description">
84
100
  <Field.Label>Description</Field.Label>
85
101
  <Textarea rows={4} />
86
102
  </Field.Root>
87
- </form>
103
+ </Form>
88
104
  </Layout.Column>
89
105
  </Layout>
90
106
  );
@@ -96,6 +112,20 @@ export default function SinglePageForm({ onSave, onCancel }: Props) {
96
112
  - Single column full width below 1024px; single column max-w constrained at 1024–1280px
97
113
  - Without an explicit routed-page requirement, the answer is `form/modal`
98
114
  - A `/create` or `/edit` route in the screen spec does NOT require a full-page replacement
115
+ - Save/Cancel belong in `Layout.Header`, wired with `<Button type="submit" form="…">` matching the
116
+ `Form`'s `id` (requires 1.13.0+)
117
+ - Validation lives on the fields (`required`, `pattern`, `min`, `type`) with a matching
118
+ `Field.Error match="…"`; `Form` gates submit on it
119
+
120
+ ## Form state
121
+
122
+ Rules that apply to every `form/*` pattern are in **`components.md`** → Forms, with a worked example
123
+ in **`form/modal`** → Form state: `Form` + `Field` is the default stack, submit via `onFormSubmit`,
124
+ dropdowns need only a wrapping `Field.Root` (no `name`, no state), server errors route through
125
+ `errors`, and React Hook Form is an optional escape hatch.
126
+
127
+ This pattern is the reference for the simple case: every field, dropdowns included, is
128
+ uncontrolled and arrives through `onFormSubmit`. No component state at all.
99
129
 
100
130
  ## Anti-patterns
101
131
 
@@ -103,3 +133,5 @@ export default function SinglePageForm({ onSave, onCancel }: Props) {
103
133
  - No required-field markers — users can't predict which fields will error
104
134
  - Errors shown above the form rather than below the offending field
105
135
  - Choosing this pattern for a Create flow because it's a Create flow — without explicit need, use `form/modal`
136
+ - Making fields controlled "for consistency" — nothing on this page needs React state
137
+ - Giving `Select` a `name` to make it submit — inside a `Field.Root` that is already handled