@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.
- package/README.md +1 -1
- package/dist/app-shell.d.ts +223 -120
- package/dist/app-shell.js +2102 -2065
- package/dist/{appshell-CSQWUk6q.js → appshell-CtS8Y3LW.js} +4 -4
- package/dist/style-entry.d.ts +110 -110
- package/dist/testing.d.ts +110 -110
- package/dist/testing.js +1 -1
- package/dist/vite-plugin.d.ts +117 -111
- package/dist/vite-plugin.js +1 -1
- package/package.json +3 -3
- package/skills/app-shell-patterns/SKILL.md +7 -0
- package/skills/app-shell-patterns/references/fundamental/components.md +94 -4
- package/skills/app-shell-patterns/references/fundamental/graphql.md +1 -1
- 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
|
@@ -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
|
|
@@ -4,15 +4,16 @@ name: Wizard Form
|
|
|
4
4
|
category: pattern
|
|
5
5
|
subcategory: form
|
|
6
6
|
description: Multi-stage create flow with 3-7 steps and per-step validation gates
|
|
7
|
-
requiredImports: [Layout, Card, Form, Fieldset, Field,
|
|
8
|
-
tags: [form, wizard, multi-step,
|
|
7
|
+
requiredImports: [Layout, Card, Badge, Form, Fieldset, Field, Select, Button]
|
|
8
|
+
tags: [form, wizard, multi-step, stepper, onboarding]
|
|
9
9
|
do:
|
|
10
10
|
- Multi-stage Create with 3-7 steps
|
|
11
|
-
-
|
|
11
|
+
- Onboarding and request flows where users should focus on one step at a time
|
|
12
12
|
- Per-step validation gates progression
|
|
13
13
|
dont:
|
|
14
14
|
- Single screen of fields — use form/modal or form/single-page
|
|
15
15
|
- More than 7 steps — split into separate routed pages or reduce scope
|
|
16
|
+
- CSV/spreadsheet import — use the CsvImporter component, not a hand-built wizard
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
# pattern/form/wizard
|
|
@@ -20,102 +21,166 @@ dont:
|
|
|
20
21
|
## When to Use
|
|
21
22
|
|
|
22
23
|
- Multi-stage Create with 3–7 steps
|
|
23
|
-
-
|
|
24
|
+
- Onboarding and request flows where users should focus on one step at a time
|
|
24
25
|
- Per-step validation gates progression
|
|
25
26
|
|
|
27
|
+
For CSV/spreadsheet import specifically, use the `CsvImporter` component — it already implements
|
|
28
|
+
the upload → map → validate → confirm flow. Don't rebuild it here.
|
|
29
|
+
|
|
26
30
|
## Page Implementation
|
|
27
31
|
|
|
28
32
|
```tsx
|
|
29
33
|
/* pattern: form/wizard */
|
|
30
34
|
import { useState } from "react";
|
|
31
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
Badge,
|
|
37
|
+
Button,
|
|
38
|
+
Card,
|
|
39
|
+
Field,
|
|
40
|
+
Fieldset,
|
|
41
|
+
Form,
|
|
42
|
+
Layout,
|
|
43
|
+
Select,
|
|
44
|
+
} from "@tailor-platform/app-shell";
|
|
45
|
+
|
|
46
|
+
const OWNERS = ["Tanaka", "Sato", "Suzuki", "Yamada"];
|
|
47
|
+
const STEPS = ["Basic info", "Assignment", "Schedule", "Review"] as const;
|
|
48
|
+
|
|
49
|
+
type Draft = {
|
|
50
|
+
title: string;
|
|
51
|
+
description: string;
|
|
52
|
+
owner: string;
|
|
53
|
+
startDate: string;
|
|
54
|
+
estimate: string;
|
|
55
|
+
};
|
|
32
56
|
|
|
33
|
-
const
|
|
57
|
+
const INITIAL: Draft = {
|
|
58
|
+
title: "",
|
|
59
|
+
description: "",
|
|
60
|
+
owner: "",
|
|
61
|
+
startDate: "",
|
|
62
|
+
estimate: "",
|
|
63
|
+
};
|
|
34
64
|
|
|
35
65
|
type Props = {
|
|
36
|
-
onComplete: () => void;
|
|
66
|
+
onComplete: (draft: Draft) => void;
|
|
67
|
+
onCancel: () => void;
|
|
37
68
|
};
|
|
38
69
|
|
|
39
|
-
export default function WizardForm({ onComplete }: Props) {
|
|
40
|
-
const [
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
export default function WizardForm({ onComplete, onCancel }: Props) {
|
|
71
|
+
const [step, setStep] = useState(0);
|
|
72
|
+
// Accumulated values. Each step's Form unmounts on navigation, so the draft
|
|
73
|
+
// is what makes Back non-destructive — fields re-read it via `defaultValue`.
|
|
74
|
+
const [draft, setDraft] = useState<Draft>(INITIAL);
|
|
75
|
+
|
|
76
|
+
const isLastStep = step === STEPS.length - 1;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Each step is its own `Form`, and "Next" is a `type="submit"` button.
|
|
80
|
+
* `onFormSubmit` fires only after that step's fields pass validation, so
|
|
81
|
+
* progression is gated natively — no manual per-step validity check, and
|
|
82
|
+
* no deferring every error to the final submit.
|
|
83
|
+
*/
|
|
84
|
+
const handleStepSubmit = (values: Record<string, unknown>) => {
|
|
85
|
+
const merged = { ...draft, ...(values as Partial<Draft>) };
|
|
86
|
+
setDraft(merged);
|
|
87
|
+
if (isLastStep) {
|
|
88
|
+
onComplete(merged);
|
|
89
|
+
return;
|
|
53
90
|
}
|
|
91
|
+
setStep(step + 1);
|
|
54
92
|
};
|
|
55
93
|
|
|
56
94
|
return (
|
|
57
95
|
<Layout>
|
|
58
|
-
<Layout.Header title="
|
|
96
|
+
<Layout.Header title="Create task" />
|
|
59
97
|
<Layout.Column>
|
|
60
98
|
<Card.Root>
|
|
61
99
|
<Card.Content>
|
|
62
100
|
<div className="flex items-center gap-2">
|
|
63
|
-
{STEPS.map((
|
|
101
|
+
{STEPS.map((label, i) => (
|
|
64
102
|
<Badge
|
|
65
|
-
key={
|
|
66
|
-
variant={i ===
|
|
103
|
+
key={label}
|
|
104
|
+
variant={i === step ? "default" : i < step ? "success" : "neutral"}
|
|
67
105
|
>
|
|
68
|
-
{i + 1}. {
|
|
106
|
+
{i + 1}. {label}
|
|
69
107
|
</Badge>
|
|
70
108
|
))}
|
|
71
109
|
</div>
|
|
72
110
|
</Card.Content>
|
|
73
111
|
</Card.Root>
|
|
74
112
|
|
|
75
|
-
<
|
|
76
|
-
<Card.
|
|
77
|
-
{
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<Field.
|
|
113
|
+
<Form key={step} noValidate onFormSubmit={handleStepSubmit}>
|
|
114
|
+
<Card.Root>
|
|
115
|
+
<Card.Header title={STEPS[step]} />
|
|
116
|
+
<Card.Content>
|
|
117
|
+
{step === 0 && (
|
|
118
|
+
<Fieldset.Root className="space-y-4">
|
|
119
|
+
<Field.Root name="title">
|
|
120
|
+
<Field.Label>Title</Field.Label>
|
|
121
|
+
<Field.Control required defaultValue={draft.title} />
|
|
122
|
+
<Field.Error match="valueMissing">Title is required.</Field.Error>
|
|
123
|
+
</Field.Root>
|
|
124
|
+
<Field.Root name="description">
|
|
125
|
+
<Field.Label>Description</Field.Label>
|
|
126
|
+
<Field.Control defaultValue={draft.description} />
|
|
127
|
+
</Field.Root>
|
|
128
|
+
</Fieldset.Root>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
{step === 1 && (
|
|
132
|
+
<Field.Root name="owner">
|
|
133
|
+
<Field.Label>Owner</Field.Label>
|
|
134
|
+
{/* Uncontrolled like every other field — `defaultValue`
|
|
135
|
+
restores the prior choice when the user steps Back. */}
|
|
136
|
+
<Select
|
|
137
|
+
items={OWNERS}
|
|
138
|
+
defaultValue={draft.owner || null}
|
|
139
|
+
placeholder="Assign owner"
|
|
140
|
+
/>
|
|
82
141
|
</Field.Root>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{step === 2 && (
|
|
145
|
+
<Fieldset.Root className="grid gap-4 md:grid-cols-2">
|
|
146
|
+
<Field.Root name="startDate">
|
|
147
|
+
<Field.Label>Start date</Field.Label>
|
|
148
|
+
<Field.Control type="date" required defaultValue={draft.startDate} />
|
|
149
|
+
<Field.Error match="valueMissing">Start date is required.</Field.Error>
|
|
150
|
+
</Field.Root>
|
|
151
|
+
<Field.Root name="estimate">
|
|
152
|
+
<Field.Label>Estimate (hours)</Field.Label>
|
|
153
|
+
<Field.Control type="number" min={0} defaultValue={draft.estimate} />
|
|
154
|
+
</Field.Root>
|
|
155
|
+
</Fieldset.Root>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{step === 3 && (
|
|
159
|
+
<dl className="grid grid-cols-2 gap-2 text-sm">
|
|
160
|
+
<dt className="text-muted-foreground">Title</dt>
|
|
161
|
+
<dd>{draft.title}</dd>
|
|
162
|
+
<dt className="text-muted-foreground">Owner</dt>
|
|
163
|
+
<dd>{draft.owner || "—"}</dd>
|
|
164
|
+
<dt className="text-muted-foreground">Start date</dt>
|
|
165
|
+
<dd>{draft.startDate || "—"}</dd>
|
|
166
|
+
<dt className="text-muted-foreground">Estimate</dt>
|
|
167
|
+
<dd>{draft.estimate ? `${draft.estimate}h` : "—"}</dd>
|
|
168
|
+
</dl>
|
|
169
|
+
)}
|
|
170
|
+
</Card.Content>
|
|
171
|
+
</Card.Root>
|
|
172
|
+
|
|
173
|
+
<div className="flex justify-between pt-4">
|
|
174
|
+
<Button
|
|
175
|
+
type="button"
|
|
176
|
+
variant="ghost"
|
|
177
|
+
onClick={() => (step === 0 ? onCancel() : setStep(step - 1))}
|
|
178
|
+
>
|
|
179
|
+
{step === 0 ? "Cancel" : "Back"}
|
|
180
|
+
</Button>
|
|
181
|
+
<Button type="submit">{isLastStep ? "Create" : "Next"}</Button>
|
|
182
|
+
</div>
|
|
183
|
+
</Form>
|
|
119
184
|
</Layout.Column>
|
|
120
185
|
</Layout>
|
|
121
186
|
);
|
|
@@ -128,9 +193,32 @@ export default function WizardForm({ onComplete }: Props) {
|
|
|
128
193
|
- Back-navigation must preserve prior step's input
|
|
129
194
|
- Validation must be per-step — don't defer until final submit
|
|
130
195
|
- Step indicator collapses to "Step 2 of 4" label below 1024px
|
|
196
|
+
- One `<Form>` rendered per step, keyed by step index so it remounts cleanly
|
|
197
|
+
- Accumulated values live in a `draft` state object above the `Form`; each step's fields read
|
|
198
|
+
their initial value from it via `defaultValue`
|
|
199
|
+
|
|
200
|
+
## Form state
|
|
201
|
+
|
|
202
|
+
Rules that apply to every `form/*` pattern are in **`components.md`** → Forms, with a worked example
|
|
203
|
+
in **`form/modal`** → Form state: `Form` + `Field` is the default stack, submit via `onFormSubmit`,
|
|
204
|
+
dropdowns need only a wrapping `Field.Root` (no `name`, no state), server errors route through
|
|
205
|
+
`errors`, and React Hook Form is an optional escape hatch.
|
|
206
|
+
|
|
207
|
+
The wizard's specific mechanic: **make "Next" a `type="submit"` button.** `onFormSubmit` fires only
|
|
208
|
+
after the current step's fields pass validation, so progression is gated natively — no manual
|
|
209
|
+
validity check, and no deferring errors to the final submit. The handler merges that step's values
|
|
210
|
+
into `draft` and advances; on the last step it calls the completion callback instead.
|
|
211
|
+
|
|
212
|
+
Because each step's `Form` unmounts on navigation, values must be lifted into `draft` — that is
|
|
213
|
+
what makes Back non-destructive. Every field, dropdowns included, is uncontrolled and re-reads its
|
|
214
|
+
prior value from `draft` via `defaultValue`; nothing needs an `onChange`.
|
|
131
215
|
|
|
132
216
|
## Anti-patterns
|
|
133
217
|
|
|
134
218
|
- More than 7 steps — users lose context and abandon
|
|
135
219
|
- No back-navigation preservation — pressing Back loses prior step's input
|
|
136
220
|
- Validation deferred until final submit — failures force full re-traversal
|
|
221
|
+
- A plain `onClick` "Next" that advances without validating — use `type="submit"` and let
|
|
222
|
+
`onFormSubmit` gate it
|
|
223
|
+
- Wrapping all steps in one `<Form>` and hiding inactive ones — hidden required fields block submit
|
|
224
|
+
- Hand-building a CSV import wizard — use `CsvImporter`
|