@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.
- package/LICENSE.md +21 -0
- package/README.md +1 -1
- package/dist/app-shell-core.css +1 -1
- package/dist/app-shell.d.ts +1215 -319
- package/dist/app-shell.js +4264 -2975
- package/dist/{appshell-CSQWUk6q.js → appshell-DLVBFo5J.js} +731 -543
- package/dist/style-entry.d.ts +110 -110
- package/dist/testing.d.ts +110 -110
- package/dist/testing.js +1 -1
- package/dist/themes/_template.css +1 -1
- package/dist/vite-plugin.d.ts +117 -111
- package/dist/vite-plugin.js +1 -1
- package/package.json +8 -8
- package/skills/app-shell-patterns/SKILL.md +15 -11
- package/skills/app-shell-patterns/references/fundamental/components.md +142 -24
- package/skills/app-shell-patterns/references/fundamental/design-system.md +24 -17
- package/skills/app-shell-patterns/references/fundamental/graphql.md +1 -1
- package/skills/app-shell-patterns/references/pages/detail.md +1148 -0
- package/skills/app-shell-patterns/references/patterns/form-composer.md +2 -2
- package/skills/app-shell-patterns/references/patterns/form-modal.md +84 -30
- package/skills/app-shell-patterns/references/patterns/form-sectioned.md +136 -64
- package/skills/app-shell-patterns/references/patterns/form-single-page.md +57 -25
- package/skills/app-shell-patterns/references/patterns/form-wizard.md +157 -69
- package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +1 -1
- package/skills/app-shell-patterns/references/patterns/detail-hero-with-actions.md +0 -201
|
@@ -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 —
|
|
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).
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
</
|
|
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 {
|
|
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: {
|
|
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
|
-
<
|
|
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
|
|
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
|
-
</
|
|
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,
|
|
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,
|
|
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,
|
|
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="
|
|
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
|
-
<
|
|
52
|
-
id="
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<Field.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
-
|
|
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
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
|
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="
|
|
59
|
+
<Button key="save" type="submit" form="product-form">
|
|
46
60
|
Save
|
|
47
61
|
</Button>,
|
|
48
62
|
]}
|
|
49
63
|
/>
|
|
50
64
|
<Layout.Column>
|
|
51
|
-
<
|
|
52
|
-
id="
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
</
|
|
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
|