@tailor-platform/app-shell 1.2.0 → 1.3.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/dist/app-shell.css +1 -1
- package/dist/app-shell.d.ts +92 -17
- package/dist/app-shell.js +2391 -2185
- package/package.json +11 -10
- package/skills/app-shell-patterns/SKILL.md +67 -0
- package/skills/app-shell-patterns/references/fundamental/components.md +704 -0
- package/skills/app-shell-patterns/references/fundamental/design-system.md +355 -0
- package/skills/app-shell-patterns/references/fundamental/graphql.md +205 -0
- package/skills/app-shell-patterns/references/patterns/detail-hero-with-actions.md +157 -0
- package/skills/app-shell-patterns/references/patterns/form-modal.md +169 -0
- package/skills/app-shell-patterns/references/patterns/form-sectioned.md +126 -0
- package/skills/app-shell-patterns/references/patterns/form-single-page.md +105 -0
- package/skills/app-shell-patterns/references/patterns/form-wizard.md +134 -0
- package/skills/app-shell-patterns/references/patterns/interaction-confirm.md +75 -0
- package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +173 -0
- package/skills/app-shell-patterns/references/patterns/interaction-toast.md +69 -0
- package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +132 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
slug: pattern/detail/hero-with-actions
|
|
3
|
+
name: Hero With Actions Detail
|
|
4
|
+
category: pattern
|
|
5
|
+
subcategory: detail
|
|
6
|
+
description: Single-record detail view with workflow actions and activity timeline
|
|
7
|
+
requiredImports:
|
|
8
|
+
[Layout, Badge, Button, Menu, DescriptionCard, Card, Table, ActionPanel, ActivityCard]
|
|
9
|
+
tags: [detail, actions, timeline, workflow, two-column]
|
|
10
|
+
do:
|
|
11
|
+
- Single-record detail view (Order #1234, Supplier ABC, Product SKU-42)
|
|
12
|
+
- Record has a workflow with named actions (Approve, Cancel, Ship)
|
|
13
|
+
- An activity/audit history is meaningful to surface
|
|
14
|
+
dont:
|
|
15
|
+
- The page primarily compares two grids or record sets (match/reconcile)
|
|
16
|
+
- The record is purely informational with no actions and no history — use list/dense-scan row click into a sheet instead
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# pattern/detail/hero-with-actions
|
|
20
|
+
|
|
21
|
+
## When to Use
|
|
22
|
+
|
|
23
|
+
- Single-record detail view (Order #1234, Supplier ABC, Product SKU-42)
|
|
24
|
+
- Record has a workflow with named actions (Approve, Cancel, Ship)
|
|
25
|
+
- An activity/audit history is meaningful to surface
|
|
26
|
+
|
|
27
|
+
## Layout
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
+---------------------------------------------------------+
|
|
31
|
+
| Layout.Header title [Badge] [Primary CTA] |
|
|
32
|
+
+----------------------------------+----------------------+
|
|
33
|
+
| Layout.Column (main) | Layout.Column right |
|
|
34
|
+
| DescriptionCard Summary | ActionPanel |
|
|
35
|
+
| - field : value | - Approve |
|
|
36
|
+
| - field : value | - Cancel |
|
|
37
|
+
| Card Attachments | |
|
|
38
|
+
| - file rows | ActivityCard |
|
|
39
|
+
| Card Line items | - history entry |
|
|
40
|
+
| inline Table.Root | - history entry |
|
|
41
|
+
| | (…N more side cards |
|
|
42
|
+
| | per use case) |
|
|
43
|
+
+----------------------------------+----------------------+
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Composition rules:
|
|
47
|
+
|
|
48
|
+
- Both columns accept N cards based on the use case — add as many sections as the record requires.
|
|
49
|
+
- Each main-column section sits in a `Card` container — except `DescriptionCard`, which already provides its own container and must not be double-wrapped in a `Card`.
|
|
50
|
+
- Right-column cards (`ActionPanel`, `ActivityCard`, related summaries, etc.) are composed the same way. `ActionPanel` and `ActivityCard` are the most common; neither is mandatory.
|
|
51
|
+
|
|
52
|
+
Responsive: <1024 collapses to single column with right column rendered below main; 1024–1280 two columns with narrow side; >1280 two columns at full width.
|
|
53
|
+
|
|
54
|
+
## Page Implementation
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
/* pattern: detail/hero-with-actions */
|
|
58
|
+
import {
|
|
59
|
+
Layout,
|
|
60
|
+
Badge,
|
|
61
|
+
Button,
|
|
62
|
+
Card,
|
|
63
|
+
DescriptionCard,
|
|
64
|
+
Table,
|
|
65
|
+
ActionPanel,
|
|
66
|
+
ActivityCard,
|
|
67
|
+
} from "@tailor-platform/app-shell";
|
|
68
|
+
import type { Order } from "./mock";
|
|
69
|
+
|
|
70
|
+
type Props = {
|
|
71
|
+
order: Order;
|
|
72
|
+
onApprove: () => void;
|
|
73
|
+
onCancel: () => void;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export default function HeroWithActionsDetail({ order, onApprove, onCancel }: Props) {
|
|
77
|
+
return (
|
|
78
|
+
<Layout>
|
|
79
|
+
<Layout.Header
|
|
80
|
+
title={`Order ${order.number}`}
|
|
81
|
+
actions={[
|
|
82
|
+
<Badge key="status" variant="success">
|
|
83
|
+
{order.status}
|
|
84
|
+
</Badge>,
|
|
85
|
+
<Button key="edit">Edit</Button>,
|
|
86
|
+
]}
|
|
87
|
+
/>
|
|
88
|
+
<Layout.Column>
|
|
89
|
+
<DescriptionCard
|
|
90
|
+
title="Summary"
|
|
91
|
+
data={order}
|
|
92
|
+
columns={3}
|
|
93
|
+
fields={[
|
|
94
|
+
{ key: "number", label: "Order number" },
|
|
95
|
+
{
|
|
96
|
+
key: "status",
|
|
97
|
+
label: "Status",
|
|
98
|
+
type: "badge",
|
|
99
|
+
meta: { badgeVariantMap: { Confirmed: "success", Draft: "neutral" } },
|
|
100
|
+
},
|
|
101
|
+
{ key: "customer", label: "Customer" },
|
|
102
|
+
{ key: "total", label: "Total" },
|
|
103
|
+
]}
|
|
104
|
+
/>
|
|
105
|
+
<Card.Root>
|
|
106
|
+
<Card.Header title="Line items" />
|
|
107
|
+
<Card.Content className="astw:px-0">
|
|
108
|
+
<Table.Root containerClassName="astw:px-6">
|
|
109
|
+
<Table.Header>
|
|
110
|
+
<Table.Row>
|
|
111
|
+
<Table.Head>SKU</Table.Head>
|
|
112
|
+
<Table.Head>Qty</Table.Head>
|
|
113
|
+
<Table.Head>Total</Table.Head>
|
|
114
|
+
</Table.Row>
|
|
115
|
+
</Table.Header>
|
|
116
|
+
<Table.Body>
|
|
117
|
+
{order.lineItems.map((item) => (
|
|
118
|
+
<Table.Row key={item.id}>
|
|
119
|
+
<Table.Cell>{item.sku}</Table.Cell>
|
|
120
|
+
<Table.Cell>{item.qty}</Table.Cell>
|
|
121
|
+
<Table.Cell>${item.total.toLocaleString()}</Table.Cell>
|
|
122
|
+
</Table.Row>
|
|
123
|
+
))}
|
|
124
|
+
</Table.Body>
|
|
125
|
+
</Table.Root>
|
|
126
|
+
</Card.Content>
|
|
127
|
+
</Card.Root>
|
|
128
|
+
</Layout.Column>
|
|
129
|
+
<Layout.Column area="right">
|
|
130
|
+
<ActionPanel
|
|
131
|
+
title="Actions"
|
|
132
|
+
actions={[
|
|
133
|
+
{ key: "approve", label: "Approve", icon: <span>✓</span>, onClick: onApprove },
|
|
134
|
+
{ key: "cancel", label: "Cancel", icon: <span>✕</span>, onClick: onCancel },
|
|
135
|
+
]}
|
|
136
|
+
/>
|
|
137
|
+
<ActivityCard title="Activity" items={order.activities} />
|
|
138
|
+
</Layout.Column>
|
|
139
|
+
</Layout>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Constraints
|
|
145
|
+
|
|
146
|
+
- More than 2 primary actions in the header → move overflow into a `Menu`.
|
|
147
|
+
- Every content section MUST sit inside `Card.Root` (or `DescriptionCard`, which already self-contains). Raw divs are not allowed.
|
|
148
|
+
- `ActionPanel` is workflow-only — never back-navigation.
|
|
149
|
+
- `Table.Root` inside a Card requires `containerClassName="astw:px-6"`.
|
|
150
|
+
|
|
151
|
+
## Anti-patterns
|
|
152
|
+
|
|
153
|
+
- No status `Badge` on stateful entities — users can't tell where the record is in its lifecycle.
|
|
154
|
+
- `ActionPanel` mixed with metadata in the same card — keep workflow separate from descriptive fields.
|
|
155
|
+
- Bare `<div>` sections in the main column — every content section MUST sit inside `Card.Root`.
|
|
156
|
+
- `ActionPanel` containing back-navigation (e.g. "Back to Product List") — back navigation lives in `Layout.Header`'s breadcrumb.
|
|
157
|
+
- A `Table.Root` inside a Card without `containerClassName="astw:px-6"` — the first column lands flush against the card edge.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
slug: pattern/form/modal
|
|
3
|
+
name: Modal Form
|
|
4
|
+
category: pattern
|
|
5
|
+
subcategory: form
|
|
6
|
+
description: Default form pattern for Create/Edit — keeps user in context on the parent screen
|
|
7
|
+
requiredImports: [Dialog, Button, Form, Field, Input]
|
|
8
|
+
tags: [form, modal, dialog, create, edit, inline-add]
|
|
9
|
+
do:
|
|
10
|
+
- Default for most Create and Edit forms — keeps user in context on parent screen
|
|
11
|
+
- Inline add of a related entity from another screen (add address from order detail)
|
|
12
|
+
- Quick configuration changes and single-purpose forms (rename, change status)
|
|
13
|
+
- Any form the design hasn't explicitly called out as a full-page routed screen
|
|
14
|
+
dont:
|
|
15
|
+
- Design explicitly calls for a full-page (non-overlay) routed Create or Edit
|
|
16
|
+
- Form is complex with 15+ fields or multiple grouped sections — use form/sectioned
|
|
17
|
+
- Multi-stage flow with per-step validation — use form/wizard
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
# pattern/form/modal
|
|
21
|
+
|
|
22
|
+
## When to Use
|
|
23
|
+
|
|
24
|
+
- Default for most Create and Edit forms — keeps user in context on parent screen
|
|
25
|
+
- Inline add of a related entity from another screen (add address from order detail)
|
|
26
|
+
- Quick configuration changes and single-purpose forms (rename, change status)
|
|
27
|
+
- Any form the design hasn't explicitly called out as a full-page routed screen
|
|
28
|
+
|
|
29
|
+
## Page Implementation
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
/* pattern: form/modal */
|
|
33
|
+
import { Button, Dialog, Input, Field } from "@tailor-platform/app-shell";
|
|
34
|
+
|
|
35
|
+
type Props = {
|
|
36
|
+
onSave: (data: { label: string; street: string; city: string }) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default function ModalForm({ onSave }: Props) {
|
|
40
|
+
return (
|
|
41
|
+
<Dialog.Root>
|
|
42
|
+
<Dialog.Trigger render={<Button />}>Add address</Dialog.Trigger>
|
|
43
|
+
<Dialog.Content>
|
|
44
|
+
<Dialog.Header>
|
|
45
|
+
<Dialog.Title>Add address</Dialog.Title>
|
|
46
|
+
<Dialog.Description>Add a shipping address to this order.</Dialog.Description>
|
|
47
|
+
</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
|
+
>
|
|
59
|
+
<div className="space-y-4 py-4">
|
|
60
|
+
<Field.Root name="label">
|
|
61
|
+
<Field.Label>Label</Field.Label>
|
|
62
|
+
<Field.Control render={<Input />} />
|
|
63
|
+
</Field.Root>
|
|
64
|
+
<Field.Root name="street">
|
|
65
|
+
<Field.Label>Street</Field.Label>
|
|
66
|
+
<Field.Control render={<Input />} />
|
|
67
|
+
</Field.Root>
|
|
68
|
+
<Field.Root name="city">
|
|
69
|
+
<Field.Label>City</Field.Label>
|
|
70
|
+
<Field.Control render={<Input />} />
|
|
71
|
+
</Field.Root>
|
|
72
|
+
</div>
|
|
73
|
+
<Dialog.Footer>
|
|
74
|
+
<Dialog.Close render={<Button variant="ghost" />}>Cancel</Dialog.Close>
|
|
75
|
+
<Button type="submit">Save</Button>
|
|
76
|
+
</Dialog.Footer>
|
|
77
|
+
</form>
|
|
78
|
+
</Dialog.Content>
|
|
79
|
+
</Dialog.Root>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Route-driven Variant
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
/* pattern: form/modal (route-driven variant) */
|
|
88
|
+
import { Button, Dialog, Input, Layout, Field } from "@tailor-platform/app-shell";
|
|
89
|
+
|
|
90
|
+
type Props = {
|
|
91
|
+
isCreateOpen: boolean;
|
|
92
|
+
onNavigateToCreate: () => void;
|
|
93
|
+
onNavigateToList: () => void;
|
|
94
|
+
onSave: (data: { name: string }) => void;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Route-driven modal: the form has its own URL but renders as a popup
|
|
99
|
+
* over the list. Both `/products` and `/products/create` render this
|
|
100
|
+
* same component — the parent list stays visible underneath.
|
|
101
|
+
*/
|
|
102
|
+
export default function ModalFormRouted({
|
|
103
|
+
isCreateOpen,
|
|
104
|
+
onNavigateToCreate,
|
|
105
|
+
onNavigateToList,
|
|
106
|
+
onSave,
|
|
107
|
+
}: Props) {
|
|
108
|
+
return (
|
|
109
|
+
<Layout>
|
|
110
|
+
<Layout.Header
|
|
111
|
+
title="Products"
|
|
112
|
+
actions={[
|
|
113
|
+
<Button key="create" onClick={onNavigateToCreate}>
|
|
114
|
+
Create
|
|
115
|
+
</Button>,
|
|
116
|
+
]}
|
|
117
|
+
/>
|
|
118
|
+
<Layout.Column>{/* products list — see list/dense-scan */}</Layout.Column>
|
|
119
|
+
|
|
120
|
+
<Dialog.Root
|
|
121
|
+
open={isCreateOpen}
|
|
122
|
+
onOpenChange={(open) => {
|
|
123
|
+
if (!open) onNavigateToList();
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<Dialog.Content>
|
|
127
|
+
<Dialog.Header>
|
|
128
|
+
<Dialog.Title>Create product</Dialog.Title>
|
|
129
|
+
</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
|
+
>
|
|
137
|
+
<div className="space-y-4 py-4">
|
|
138
|
+
<Field.Root name="name">
|
|
139
|
+
<Field.Label>Name</Field.Label>
|
|
140
|
+
<Field.Control render={<Input />} />
|
|
141
|
+
</Field.Root>
|
|
142
|
+
</div>
|
|
143
|
+
<Dialog.Footer>
|
|
144
|
+
<Button variant="ghost" onClick={onNavigateToList}>
|
|
145
|
+
Cancel
|
|
146
|
+
</Button>
|
|
147
|
+
<Button type="submit">Save</Button>
|
|
148
|
+
</Dialog.Footer>
|
|
149
|
+
</form>
|
|
150
|
+
</Dialog.Content>
|
|
151
|
+
</Dialog.Root>
|
|
152
|
+
</Layout>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Constraints
|
|
158
|
+
|
|
159
|
+
- Dialog renders full-screen sheet below 1024px; centered max-w-md at 1024–1280px
|
|
160
|
+
- Route-driven variant requires both parent path and create/edit path to render the same component
|
|
161
|
+
- `onOpenChange` must navigate back — just calling `setOpen(false)` leaves the URL broken
|
|
162
|
+
|
|
163
|
+
## Anti-patterns
|
|
164
|
+
|
|
165
|
+
- Nesting modals — opening a Dialog from inside another Dialog
|
|
166
|
+
- Modal containing a wizard — promote to a routed `form/wizard`
|
|
167
|
+
- Save closes the dialog but parent state is stale — wire refetch or optimistic update
|
|
168
|
+
- Building a routed Create/Edit page when the design didn't explicitly call for one — modal is the default
|
|
169
|
+
- Registering the create path as a separate top-level route — that unmounts the parent list
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
slug: pattern/form/sectioned
|
|
3
|
+
name: Sectioned Form
|
|
4
|
+
category: pattern
|
|
5
|
+
subcategory: form
|
|
6
|
+
description: Complex form with 15+ fields organized into named fieldset sections
|
|
7
|
+
requiredImports: [Layout, Form, Fieldset, Field, Input, Select, Combobox, Button]
|
|
8
|
+
tags: [form, sections, fieldset, settings, complex]
|
|
9
|
+
do:
|
|
10
|
+
- Form is complex with 15+ fields or multiple grouped sections (Identity, Pricing, Inventory)
|
|
11
|
+
- Configure-style settings pages with named boundaries
|
|
12
|
+
dont:
|
|
13
|
+
- Simple Create/Edit — use form/modal (the default)
|
|
14
|
+
- Routed Create/Edit at moderate size with no grouping — use form/single-page
|
|
15
|
+
- Step-gated validation across stages — use form/wizard
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# pattern/form/sectioned
|
|
19
|
+
|
|
20
|
+
## When to Use
|
|
21
|
+
|
|
22
|
+
- Form is complex with 15+ fields or multiple grouped sections (Identity, Pricing, Inventory)
|
|
23
|
+
- Configure-style settings pages with named boundaries
|
|
24
|
+
|
|
25
|
+
## Page Implementation
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
/* pattern: form/sectioned */
|
|
29
|
+
import { Button, Layout, Input, Select, Field, Fieldset } from "@tailor-platform/app-shell";
|
|
30
|
+
|
|
31
|
+
type Props = {
|
|
32
|
+
onSave: (data: Record<string, string>) => void;
|
|
33
|
+
onCancel: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default function SectionedForm({ onSave, onCancel }: Props) {
|
|
37
|
+
return (
|
|
38
|
+
<Layout>
|
|
39
|
+
<Layout.Header
|
|
40
|
+
title="Product settings"
|
|
41
|
+
actions={[
|
|
42
|
+
<Button key="cancel" variant="outline" onClick={onCancel}>
|
|
43
|
+
Cancel
|
|
44
|
+
</Button>,
|
|
45
|
+
<Button key="save" type="submit" form="sectioned-form">
|
|
46
|
+
Save
|
|
47
|
+
</Button>,
|
|
48
|
+
]}
|
|
49
|
+
/>
|
|
50
|
+
<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"
|
|
63
|
+
>
|
|
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>
|
|
110
|
+
</Layout.Column>
|
|
111
|
+
</Layout>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Constraints
|
|
117
|
+
|
|
118
|
+
- Max ~6 sections — more than that is too hard to scan; promote to `form/wizard`
|
|
119
|
+
- Required-marker convention must be consistent across all sections
|
|
120
|
+
- Section legends must match anchor-nav labels
|
|
121
|
+
|
|
122
|
+
## Anti-patterns
|
|
123
|
+
|
|
124
|
+
- More than ~6 sections — hard to scan; promote to `form/wizard`
|
|
125
|
+
- Required-marker convention varies between sections — pick one rule and apply everywhere
|
|
126
|
+
- Section legends that don't match anchor-nav labels
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
slug: pattern/form/single-page
|
|
3
|
+
name: Single Page Form
|
|
4
|
+
category: pattern
|
|
5
|
+
subcategory: form
|
|
6
|
+
description: Routed full-page form for moderate field count (6-15) without natural sectioning
|
|
7
|
+
requiredImports: [Layout, Form, Field, Fieldset, Input, Select, Button]
|
|
8
|
+
tags: [form, page, create, edit, routed]
|
|
9
|
+
do:
|
|
10
|
+
- A routed Create or Edit page that the design has explicitly called out (e.g. /orders/create)
|
|
11
|
+
- Moderate field count (roughly 6-15) without natural sectioning, completed in one pass
|
|
12
|
+
dont:
|
|
13
|
+
- Default Create/Edit with no explicit routing requirement — use form/modal (the default)
|
|
14
|
+
- 15+ fields or grouped concerns — use form/sectioned
|
|
15
|
+
- Multi-stage flow — use form/wizard
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# pattern/form/single-page
|
|
19
|
+
|
|
20
|
+
## When to Use
|
|
21
|
+
|
|
22
|
+
- A routed Create or Edit page that the design has explicitly called out
|
|
23
|
+
- Moderate field count (roughly 6–15) without natural sectioning, completed in one pass
|
|
24
|
+
|
|
25
|
+
## Page Implementation
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
/* pattern: form/single-page */
|
|
29
|
+
import { Button, Layout, Input, Select, Field } from "@tailor-platform/app-shell";
|
|
30
|
+
|
|
31
|
+
type Props = {
|
|
32
|
+
onSave: (data: Record<string, string>) => void;
|
|
33
|
+
onCancel: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default function SinglePageForm({ onSave, onCancel }: Props) {
|
|
37
|
+
return (
|
|
38
|
+
<Layout>
|
|
39
|
+
<Layout.Header
|
|
40
|
+
title="Create Product"
|
|
41
|
+
actions={[
|
|
42
|
+
<Button key="cancel" variant="outline" onClick={onCancel}>
|
|
43
|
+
Cancel
|
|
44
|
+
</Button>,
|
|
45
|
+
<Button key="save" type="submit" form="single-page-form">
|
|
46
|
+
Save
|
|
47
|
+
</Button>,
|
|
48
|
+
]}
|
|
49
|
+
/>
|
|
50
|
+
<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"
|
|
63
|
+
>
|
|
64
|
+
<Field.Root name="name">
|
|
65
|
+
<Field.Label>Name</Field.Label>
|
|
66
|
+
<Field.Control render={<Input />} />
|
|
67
|
+
</Field.Root>
|
|
68
|
+
<Field.Root name="sku">
|
|
69
|
+
<Field.Label>SKU</Field.Label>
|
|
70
|
+
<Field.Control render={<Input />} />
|
|
71
|
+
</Field.Root>
|
|
72
|
+
<Field.Root name="category">
|
|
73
|
+
<Field.Label>Category</Field.Label>
|
|
74
|
+
<Select
|
|
75
|
+
items={["Electronics", "Furniture", "Clothing", "Food"]}
|
|
76
|
+
placeholder="Select category"
|
|
77
|
+
/>
|
|
78
|
+
</Field.Root>
|
|
79
|
+
<Field.Root name="price">
|
|
80
|
+
<Field.Label>Price</Field.Label>
|
|
81
|
+
<Field.Control render={<Input type="number" />} />
|
|
82
|
+
</Field.Root>
|
|
83
|
+
<Field.Root name="description">
|
|
84
|
+
<Field.Label>Description</Field.Label>
|
|
85
|
+
<Field.Control render={<Input />} />
|
|
86
|
+
</Field.Root>
|
|
87
|
+
</form>
|
|
88
|
+
</Layout.Column>
|
|
89
|
+
</Layout>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Constraints
|
|
95
|
+
|
|
96
|
+
- Single column full width below 1024px; single column max-w constrained at 1024–1280px
|
|
97
|
+
- Without an explicit routed-page requirement, the answer is `form/modal`
|
|
98
|
+
- A `/create` or `/edit` route in the screen spec does NOT require a full-page replacement
|
|
99
|
+
|
|
100
|
+
## Anti-patterns
|
|
101
|
+
|
|
102
|
+
- Two-column layout for unrelated fields — breaks the linear reading order
|
|
103
|
+
- No required-field markers — users can't predict which fields will error
|
|
104
|
+
- Errors shown above the form rather than below the offending field
|
|
105
|
+
- Choosing this pattern for a Create flow because it's a Create flow — without explicit need, use `form/modal`
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
slug: pattern/form/wizard
|
|
3
|
+
name: Wizard Form
|
|
4
|
+
category: pattern
|
|
5
|
+
subcategory: form
|
|
6
|
+
description: Multi-stage create flow with 3-7 steps and per-step validation gates
|
|
7
|
+
requiredImports: [Layout, Card, Form, Fieldset, Field, Input, Select, Badge, Button]
|
|
8
|
+
tags: [form, wizard, multi-step, import, stepper]
|
|
9
|
+
do:
|
|
10
|
+
- Multi-stage Create with 3-7 steps
|
|
11
|
+
- Import flows (upload → map → validate → confirm)
|
|
12
|
+
- Per-step validation gates progression
|
|
13
|
+
dont:
|
|
14
|
+
- Single screen of fields — use form/modal or form/single-page
|
|
15
|
+
- More than 7 steps — split into separate routed pages or reduce scope
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# pattern/form/wizard
|
|
19
|
+
|
|
20
|
+
## When to Use
|
|
21
|
+
|
|
22
|
+
- Multi-stage Create with 3–7 steps
|
|
23
|
+
- Import flows (upload → map → validate → confirm)
|
|
24
|
+
- Per-step validation gates progression
|
|
25
|
+
|
|
26
|
+
## Page Implementation
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
/* pattern: form/wizard */
|
|
30
|
+
import { useState } from "react";
|
|
31
|
+
import { Button, Card, Layout, Badge, Input, Field } from "@tailor-platform/app-shell";
|
|
32
|
+
|
|
33
|
+
const STEPS = ["Upload", "Map", "Review", "Done"] as const;
|
|
34
|
+
|
|
35
|
+
type Props = {
|
|
36
|
+
onComplete: () => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default function WizardForm({ onComplete }: Props) {
|
|
40
|
+
const [currentStep, setCurrentStep] = useState(0);
|
|
41
|
+
|
|
42
|
+
const handleNext = () => {
|
|
43
|
+
if (currentStep < STEPS.length - 1) {
|
|
44
|
+
setCurrentStep(currentStep + 1);
|
|
45
|
+
} else {
|
|
46
|
+
onComplete();
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleBack = () => {
|
|
51
|
+
if (currentStep > 0) {
|
|
52
|
+
setCurrentStep(currentStep - 1);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Layout>
|
|
58
|
+
<Layout.Header title="Import products" />
|
|
59
|
+
<Layout.Column>
|
|
60
|
+
<Card.Root>
|
|
61
|
+
<Card.Content>
|
|
62
|
+
<div className="flex items-center gap-2">
|
|
63
|
+
{STEPS.map((step, i) => (
|
|
64
|
+
<Badge
|
|
65
|
+
key={step}
|
|
66
|
+
variant={i === currentStep ? "default" : i < currentStep ? "success" : "neutral"}
|
|
67
|
+
>
|
|
68
|
+
{i + 1}. {step}
|
|
69
|
+
</Badge>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
</Card.Content>
|
|
73
|
+
</Card.Root>
|
|
74
|
+
|
|
75
|
+
<Card.Root>
|
|
76
|
+
<Card.Content>
|
|
77
|
+
{currentStep === 0 && (
|
|
78
|
+
<div className="space-y-4">
|
|
79
|
+
<Field.Root name="file">
|
|
80
|
+
<Field.Label>CSV file</Field.Label>
|
|
81
|
+
<Field.Control render={<Input type="file" accept=".csv" />} />
|
|
82
|
+
</Field.Root>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
{currentStep === 1 && (
|
|
86
|
+
<div className="space-y-4">
|
|
87
|
+
<p className="text-fg-muted">Map CSV columns to product fields</p>
|
|
88
|
+
<Field.Root name="nameColumn">
|
|
89
|
+
<Field.Label>Name column</Field.Label>
|
|
90
|
+
<Field.Control render={<Input placeholder="e.g. Column A" />} />
|
|
91
|
+
</Field.Root>
|
|
92
|
+
<Field.Root name="skuColumn">
|
|
93
|
+
<Field.Label>SKU column</Field.Label>
|
|
94
|
+
<Field.Control render={<Input placeholder="e.g. Column B" />} />
|
|
95
|
+
</Field.Root>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
{currentStep === 2 && (
|
|
99
|
+
<div className="space-y-4">
|
|
100
|
+
<p className="text-fg-muted">Review your import — 42 products will be created.</p>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
{currentStep === 3 && (
|
|
104
|
+
<div className="space-y-4">
|
|
105
|
+
<p className="text-fg-default">Import complete! 42 products created.</p>
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</Card.Content>
|
|
109
|
+
</Card.Root>
|
|
110
|
+
|
|
111
|
+
<div className="flex justify-between">
|
|
112
|
+
<Button variant="ghost" onClick={handleBack} disabled={currentStep === 0}>
|
|
113
|
+
Back
|
|
114
|
+
</Button>
|
|
115
|
+
<Button onClick={handleNext}>{currentStep === STEPS.length - 1 ? "Done" : "Next"}</Button>
|
|
116
|
+
</div>
|
|
117
|
+
</Layout.Column>
|
|
118
|
+
</Layout>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Constraints
|
|
124
|
+
|
|
125
|
+
- Max 7 steps — more than that causes user abandonment
|
|
126
|
+
- Back-navigation must preserve prior step's input
|
|
127
|
+
- Validation must be per-step — don't defer until final submit
|
|
128
|
+
- Step indicator collapses to "Step 2 of 4" label below 1024px
|
|
129
|
+
|
|
130
|
+
## Anti-patterns
|
|
131
|
+
|
|
132
|
+
- More than 7 steps — users lose context and abandon
|
|
133
|
+
- No back-navigation preservation — pressing Back loses prior step's input
|
|
134
|
+
- Validation deferred until final submit — failures force full re-traversal
|