@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
|
@@ -433,7 +433,7 @@ const table = useDataTable({
|
|
|
433
433
|
|
|
434
434
|
**Column alignment:** each `Column` accepts **`align`** (`"left" | "right"`) applied to both header and body cell. Numeric `type` columns (`"number"`, `"money"`) default to `"right"` automatically so digits align on the decimal place — pass `align="left"` to opt out; everything else defaults to `"left"`.
|
|
435
435
|
|
|
436
|
-
**Metadata path:** Prefer `createColumnHelper` + `inferColumns(tableMetadata.order)` (`@tailor-platform/app-shell
|
|
436
|
+
**Metadata path:** Prefer `createColumnHelper` + `inferColumns(tableMetadata.order)` (`@tailor-platform/sdk-plugin-app-shell` codegen) when available so enum/datetime/string filters bind to the right editors.
|
|
437
437
|
|
|
438
438
|
**Bucket tabs / segmented UX:** AppShell defines **toolbar chips**, not lifecycle tabs. When design places **`Tabs`** (All / Draft / …) inside the card, compose them **above** `DataTable.Root` and synchronize tab-driven bucket state with **`useCollectionVariables`** (`variables.query` / filters)—see **`patterns/list/dense-scan.md`**.
|
|
439
439
|
|
|
@@ -619,13 +619,41 @@ const table = useDataTable({
|
|
|
619
619
|
|
|
620
620
|
## Forms
|
|
621
621
|
|
|
622
|
+
> **Which form stack?** AppShell's `Form` / `Field` / `Fieldset` wrap Base UI primitives and are the
|
|
623
|
+
> default — they need no extra dependency. They own **accessibility wiring and visual state only**
|
|
624
|
+
> (`htmlFor`, `aria-describedby`, `data-invalid` / `data-dirty` / `data-touched`); they do **not** own
|
|
625
|
+
> your values. React Hook Form is **optional** and consumer-installed — reach for it only when a form
|
|
626
|
+
> genuinely needs cross-field validation, field arrays, or a Zod resolver. The two compose; they are
|
|
627
|
+
> not alternatives. `react-hook-form` stopped being a runtime dependency in 1.4.0; it is a dev-only
|
|
628
|
+
> dependency of the package today, used to test the RHF integration contracts — if you use it,
|
|
629
|
+
> install it in your own app.
|
|
630
|
+
|
|
622
631
|
### `Form`
|
|
623
632
|
|
|
624
633
|
> Full API: [https://raw.githubusercontent.com/tailor-platform/app-shell/refs/heads/main/docs/components/form.md](https://raw.githubusercontent.com/tailor-platform/app-shell/refs/heads/main/docs/components/form.md)
|
|
625
634
|
|
|
626
635
|
**Import:** `import { Form } from '@tailor-platform/app-shell'`
|
|
627
|
-
**Purpose:** Form root
|
|
628
|
-
|
|
636
|
+
**Purpose:** Form root that wraps Base UI's form primitive. Provides shared validation context for
|
|
637
|
+
child `Field.Root`s, consolidated error display, and server-error routing by field `name`.
|
|
638
|
+
**API:** `FormProps` — `onFormSubmit`, `onSubmit`, `errors`, `actionsRef`, `validationMode`,
|
|
639
|
+
`noValidate`, `id`. Generic over `FormValues`.
|
|
640
|
+
**Actions outside the form:** give the `Form` an `id` and point a detached submit button at it with
|
|
641
|
+
the native `form` attribute. This is how a page-header Save reaches a body form (1.13.0+):
|
|
642
|
+
|
|
643
|
+
```tsx
|
|
644
|
+
<Layout.Header
|
|
645
|
+
title="Create product"
|
|
646
|
+
actions={[<Button key="save" type="submit" form="product-form">Save</Button>]}
|
|
647
|
+
/>
|
|
648
|
+
<Form id="product-form" onFormSubmit={save}>…</Form>
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
**Submit:** use `onFormSubmit(values)` — it receives parsed form values and is the default. Use the
|
|
652
|
+
low-level `onSubmit` **only** when handing the native event to React Hook Form's `handleSubmit`.
|
|
653
|
+
**Never** hand-roll `<form onSubmit={e => new FormData(e.currentTarget)}>` — that skips validation
|
|
654
|
+
and error routing.
|
|
655
|
+
**Server errors:** pass `errors={{ fieldName: "message" }}`; they route to the matching
|
|
656
|
+
`Field.Error` and clear when the user edits that field.
|
|
629
657
|
**Example:** see `form/single-page.md`.
|
|
630
658
|
**Used in patterns:** all `form/*`.
|
|
631
659
|
|
|
@@ -633,9 +661,71 @@ const table = useDataTable({
|
|
|
633
661
|
|
|
634
662
|
**Import:** `import { Field } from '@tailor-platform/app-shell'`
|
|
635
663
|
**Purpose:** Single form field with label + control + error message wiring.
|
|
636
|
-
**API:** Compound
|
|
664
|
+
**API:** Compound — `Field.Root`, `Field.Label`, `Field.Control`, `Field.Description`, `Field.Error`,
|
|
665
|
+
`Field.Validity`. `Field.Root` establishes a context boundary; every Base UI-backed AppShell control
|
|
666
|
+
placed inside it inherits label association, `aria-describedby`, `disabled`, and invalid state
|
|
667
|
+
automatically.
|
|
668
|
+
**`Field.Control` is already a styled input** — write `<Field.Control />`, not
|
|
669
|
+
`<Field.Control render={<Input />} />`. `Input` and `Field.Control` share the same base classes, so
|
|
670
|
+
the `render` form just double-wraps. Reach for `Input` directly when you need a control outside a
|
|
671
|
+
`Field.Root`, or when the value is React-controlled.
|
|
672
|
+
**RHF interop (optional):** `Field.Root` accepts `isTouched`, `isDirty`, `invalid`, and `error`,
|
|
673
|
+
matching RHF's `fieldState` shape — so `<Field.Root {...fieldState}>` works with no adapter.
|
|
637
674
|
**Used in patterns:** all `form/*`.
|
|
638
675
|
|
|
676
|
+
### How values reach your submit handler
|
|
677
|
+
|
|
678
|
+
There are three separate mechanisms. Picking the wrong mental model is the most common
|
|
679
|
+
form mistake, so be explicit about which one a screen uses.
|
|
680
|
+
|
|
681
|
+
**1. `Form` + `Field.Root` → `onFormSubmit` (the default).** `onFormSubmit` does **not** read
|
|
682
|
+
`FormData`; it collects values from the `Field.Root`s registered inside the `Form`, keyed by each
|
|
683
|
+
field's `name`. Every AppShell control works this way once wrapped in a `Field.Root name="…"` —
|
|
684
|
+
including `Select`, `Combobox`, and `Autocomplete`. They need **no `name` of their own and no React
|
|
685
|
+
state**:
|
|
686
|
+
|
|
687
|
+
```tsx
|
|
688
|
+
<Form onFormSubmit={(values) => save(values)}>
|
|
689
|
+
<Field.Root name="category">
|
|
690
|
+
<Field.Label>Category</Field.Label>
|
|
691
|
+
<Select items={CATEGORIES} placeholder="Select category" />
|
|
692
|
+
</Field.Root>
|
|
693
|
+
</Form>
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
**2. Native submission** — a plain `<form>`, `new FormData(form)`, or a server action. This reads the
|
|
697
|
+
DOM, so each control needs its own `name`. `Select`, `Combobox`, and `Autocomplete` gained `name`
|
|
698
|
+
(plus `form`, `required`, `inputRef`) in 1.13.0; before that they contributed nothing to a native
|
|
699
|
+
payload.
|
|
700
|
+
|
|
701
|
+
**3. React Hook Form** — optional, consumer-installed. RHF owns the values; drive the control with
|
|
702
|
+
`value` / `onValueChange` from a `Controller` and spread `fieldState` onto `Field.Root`. Warranted
|
|
703
|
+
for cross-field validation, field arrays, or a Zod resolver — not for ordinary CRUD forms.
|
|
704
|
+
|
|
705
|
+
#### Object items
|
|
706
|
+
|
|
707
|
+
Under mechanism 1, a non-string item is serialised into the submitted value:
|
|
708
|
+
|
|
709
|
+
| Item shape | Value in `onFormSubmit` |
|
|
710
|
+
| -------------------------------------- | ------------------------------------ |
|
|
711
|
+
| `string` | the string |
|
|
712
|
+
| `{ value, label }` | `value`, automatically |
|
|
713
|
+
| any other object | **JSON string** — usually not wanted |
|
|
714
|
+
| any other object + `itemToStringValue` | whatever that function returns |
|
|
715
|
+
|
|
716
|
+
So for arbitrary objects, pass `itemToStringValue` to choose the submitted key. It is distinct from
|
|
717
|
+
`mapItem`, which controls what the user sees:
|
|
718
|
+
|
|
719
|
+
```tsx
|
|
720
|
+
<Combobox
|
|
721
|
+
items={warehouses}
|
|
722
|
+
mapItem={(w) => ({ label: w.name, key: String(w.id) })}
|
|
723
|
+
itemToStringValue={(w) => String(w.id)}
|
|
724
|
+
/>
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
Multi-select submits an array.
|
|
728
|
+
|
|
639
729
|
### `Fieldset`
|
|
640
730
|
|
|
641
731
|
**Import:** `import { Fieldset } from '@tailor-platform/app-shell'`
|
|
@@ -108,7 +108,7 @@ For `pnpm gql-tada:check` to pass, always destructure with optional chaining and
|
|
|
108
108
|
|
|
109
109
|
For **standard list pages**, do not hand-roll only `first` / `after`. Compose AppShell **`DataTable`** + **`useCollectionVariables`** + **`useDataTable`**: derive query variables from `variables.pagination`, `variables.query`, `variables.order`, map `edges` / `pageInfo` / `total` into `useDataTable`’s `data`. See **`patterns/list/dense-scan.md`** and **`components.md` → DataTable**.
|
|
110
110
|
|
|
111
|
-
When `@tailor-platform/app-shell
|
|
111
|
+
When `@tailor-platform/sdk-plugin-app-shell` codegen is enabled, pass generated **`tableMetadata`** into **`useCollectionVariables`** so `variables.query` / `variables.order` are typed against your Tailor GraphQL inputs (upstream **data-table.md** Typed query variables).
|
|
112
112
|
|
|
113
113
|
## Mutations and cache invalidation
|
|
114
114
|
|
|
@@ -4,7 +4,7 @@ name: Modal Form
|
|
|
4
4
|
category: pattern
|
|
5
5
|
subcategory: form
|
|
6
6
|
description: Default form pattern for Create/Edit — keeps user in context on the parent screen
|
|
7
|
-
requiredImports: [Dialog, Button, Form, Field,
|
|
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
|