Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/lucky-pandas-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@tailor-platform/app-shell": minor
---

Let forms be driven natively: `Form` now accepts `id`, and `Select`, `Combobox`, and `Autocomplete` accept `name`, `form`, `required`, and `inputRef` (plus `itemToStringValue` on `Select` and `Combobox`). These props were already supported by the underlying Base UI roots but were filtered out by the wrapper `Pick<>` types, so there was no way to reach them.

**`Form` `id`** — a submit button rendered outside the form can now target it with the native `form` attribute, which is what the common "Save in the page header, fields in the body" layout needs:

```tsx
<Layout.Header
title="Create product"
actions={[<Button key="save" type="submit" form="product-form">Save</Button>]}
/>
<Form id="product-form" onFormSubmit={save}>…</Form>
```

**`name` on the dropdowns** — Base UI renders a hidden input under that name, so the selected value is now visible to native submission: `new FormData(form)`, an uncontrolled `<form>`, and server actions. Previously these controls contributed nothing to the DOM payload, so a native form silently submitted without them.

For non-string items, `itemToStringValue` controls serialisation (items shaped `{ value, label }` use `value` automatically). It is not available on `Combobox`'s creatable variants, which derive it internally so the pending-item sentinel serialises correctly.

Note this is a _separate_ mechanism from `Form`'s `onFormSubmit`, which collects values from registered `Field.Root`s keyed by the field's `name` — that path already worked without `name` on the control and is unchanged. Consequently, **inside a `Field.Root` the field's `name` wins and the control's own `name` is ignored**; set it only when the control is used outside a `Field.Root`.
11 changes: 11 additions & 0 deletions .changeset/tidy-pianos-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@tailor-platform/app-shell": patch
---

Fix the bundled `app-shell-patterns` skill, whose form guidance contradicted the package. `components.md` described `Form` as "wired to react-hook-form" and `Field` as binding "to react-hook-form via `name`" — neither is true. `Form`/`Field`/`Fieldset` wrap Base UI and own accessibility wiring and visual state only; `react-hook-form` stopped being a runtime dependency in 1.4.0. Meanwhile every `form/*` reference implementation ignored `Form` entirely and hand-rolled `<form onSubmit>` + `new FormData(...)`, which skips validation and server-error routing — while the skill's own rules say to use AppShell components over raw HTML.

The four `form/*` patterns now use `Form` with `onFormSubmit`, and document the model they implement: `onFormSubmit` collects values from registered `Field.Root`s rather than reading `FormData`, so **every** control — `Select`, `Combobox` and `Autocomplete` included — participates simply by being wrapped in a `Field.Root name="…"`. No `name` on the control, no `useState`, no merging in the submit handler.

Documents two things that were previously undiscoverable: an object-valued dropdown submits as a JSON string unless `itemToStringValue` is supplied (items shaped `{ value, label }` use `value` automatically), and a page-header Save reaches a body form by matching `Form`'s `id` with a detached `<Button type="submit" form="…">`.

Also corrects `docs/components/form.md`, which built its `Select` example from `Select.Trigger` / `Select.Popup` / `Select.Item`. That code could never have compiled: `Select` is the pre-assembled standalone component and its low-level sub-components live under `Select.Parts.*` by design, while `Select.Popup` has never existed at all — AppShell's is `Select.Content`.
1 change: 1 addition & 0 deletions catalogue/scripts/SKILL.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@ Full rationale in [`design-system.md`](references/fundamental/design-system.md)
- **Action placement:** primary CTA + status in `Layout.Header`; workflow actions in `ActionPanel`; back/navigation in the breadcrumb — never in `ActionPanel`.
- **Metric tiles always go in a `Grid`** (`columns={{ initial: 1, md: 2, xl: 4 }}`) — never one-per-row.
- **Forms default to `form/modal`** — only build a routed full-page form when the design explicitly calls for one.
- **Forms use AppShell `Form` + `Field`**, submitting via `onFormSubmit` — never a bare `<form onSubmit>` with `FormData`. `onFormSubmit` reads registered `Field.Root`s, so every control (dropdowns included) just needs a wrapping `Field.Root name="…"` — no `name` on the control, no `useState`. React Hook Form is optional, consumer-installed, and only warranted for cross-field validation, field arrays, or a Zod resolver. Details in [`components.md`](references/fundamental/components.md) → Forms.
- **Handle every state:** loading (skeleton), empty (labelled empty state), and error (inline + retry) — never ship only the happy path.
96 changes: 93 additions & 3 deletions catalogue/src/fundamental/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,23 +612,113 @@ const table = useDataTable({

## Forms

> **Which form stack?** AppShell's `Form` / `Field` / `Fieldset` wrap Base UI primitives and are the
> default — they need no extra dependency. They own **accessibility wiring and visual state only**
> (`htmlFor`, `aria-describedby`, `data-invalid` / `data-dirty` / `data-touched`); they do **not** own
> your values. React Hook Form is **optional** and consumer-installed — reach for it only when a form
> genuinely needs cross-field validation, field arrays, or a Zod resolver. The two compose; they are
> not alternatives. `react-hook-form` stopped being a runtime dependency in 1.4.0; it is a dev-only
> dependency of the package today, used to test the RHF integration contracts — if you use it,
> install it in your own app.

### `Form`

> 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)

**Import:** `import { Form } from '@tailor-platform/app-shell'`
**Purpose:** Form root wired to react-hook-form. Use with `Field`, `Fieldset`, and Zod for validation.
**API:** `FormProps` — `errors`, `actionsRef`, `validationMode`, `noValidate`, plus a namespace exposing form-related sub-helpers. Generic over `FormValues`.
**Purpose:** Form root that wraps Base UI's form primitive. Provides shared validation context for
child `Field.Root`s, consolidated error display, and server-error routing by field `name`.
**API:** `FormProps` — `onFormSubmit`, `onSubmit`, `errors`, `actionsRef`, `validationMode`,
`noValidate`, `id`. Generic over `FormValues`.
**Actions outside the form:** give the `Form` an `id` and point a detached submit button at it with
the native `form` attribute. This is how a page-header Save reaches a body form (1.13.0+):

```tsx
<Layout.Header
title="Create product"
actions={[<Button key="save" type="submit" form="product-form">Save</Button>]}
/>
<Form id="product-form" onFormSubmit={save}>…</Form>
```

**Submit:** use `onFormSubmit(values)` — it receives parsed form values and is the default. Use the
low-level `onSubmit` **only** when handing the native event to React Hook Form's `handleSubmit`.
**Never** hand-roll `<form onSubmit={e => new FormData(e.currentTarget)}>` — that skips validation
and error routing.
**Server errors:** pass `errors={{ fieldName: "message" }}`; they route to the matching
`Field.Error` and clear when the user edits that field.
**Example:** see `form/single-page.md`.
**Used in patterns:** all `form/*`.

### `Field`

**Import:** `import { Field } from '@tailor-platform/app-shell'`
**Purpose:** Single form field with label + control + error message wiring.
**API:** Compound. Wraps any input control (`Input`, `Select`, `Combobox`, etc.) and binds it to react-hook-form via `name`.
**API:** Compound — `Field.Root`, `Field.Label`, `Field.Control`, `Field.Description`, `Field.Error`,
`Field.Validity`. `Field.Root` establishes a context boundary; every Base UI-backed AppShell control
placed inside it inherits label association, `aria-describedby`, `disabled`, and invalid state
automatically.
**`Field.Control` is already a styled input** — write `<Field.Control />`, not
`<Field.Control render={<Input />} />`. `Input` and `Field.Control` share the same base classes, so
the `render` form just double-wraps. Reach for `Input` directly when you need a control outside a
`Field.Root`, or when the value is React-controlled.
**RHF interop (optional):** `Field.Root` accepts `isTouched`, `isDirty`, `invalid`, and `error`,
matching RHF's `fieldState` shape — so `<Field.Root {...fieldState}>` works with no adapter.
**Used in patterns:** all `form/*`.

### How values reach your submit handler

There are three separate mechanisms. Picking the wrong mental model is the most common
form mistake, so be explicit about which one a screen uses.

**1. `Form` + `Field.Root` → `onFormSubmit` (the default).** `onFormSubmit` does **not** read
`FormData`; it collects values from the `Field.Root`s registered inside the `Form`, keyed by each
field's `name`. Every AppShell control works this way once wrapped in a `Field.Root name="…"` —
including `Select`, `Combobox`, and `Autocomplete`. They need **no `name` of their own and no React
state**:

```tsx
<Form onFormSubmit={(values) => save(values)}>
<Field.Root name="category">
<Field.Label>Category</Field.Label>
<Select items={CATEGORIES} placeholder="Select category" />
</Field.Root>
</Form>
```

**2. Native submission** — a plain `<form>`, `new FormData(form)`, or a server action. This reads the
DOM, so each control needs its own `name`. `Select`, `Combobox`, and `Autocomplete` gained `name`
(plus `form`, `required`, `inputRef`) in 1.13.0; before that they contributed nothing to a native
payload.

**3. React Hook Form** — optional, consumer-installed. RHF owns the values; drive the control with
`value` / `onValueChange` from a `Controller` and spread `fieldState` onto `Field.Root`. Warranted
for cross-field validation, field arrays, or a Zod resolver — not for ordinary CRUD forms.

#### Object items

Under mechanism 1, a non-string item is serialised into the submitted value:

| Item shape | Value in `onFormSubmit` |
| -------------------------------------- | ------------------------------------ |
| `string` | the string |
| `{ value, label }` | `value`, automatically |
| any other object | **JSON string** — usually not wanted |
| any other object + `itemToStringValue` | whatever that function returns |

So for arbitrary objects, pass `itemToStringValue` to choose the submitted key. It is distinct from
`mapItem`, which controls what the user sees:

```tsx
<Combobox
items={warehouses}
mapItem={(w) => ({ label: w.name, key: String(w.id) })}
itemToStringValue={(w) => String(w.id)}
/>
```

Multi-select submits an array.

### `Fieldset`

**Import:** `import { Fieldset } from '@tailor-platform/app-shell'`
Expand Down
29 changes: 28 additions & 1 deletion catalogue/src/pattern/form/modal/PATTERN.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name: Modal Form
category: pattern
subcategory: form
description: Default form pattern for Create/Edit — keeps user in context on the parent screen
requiredImports: [Dialog, Button, Form, Field, Input]
requiredImports: [Dialog, Button, Form, Field, Layout]
tags: [form, modal, dialog, create, edit, inline-add]
do:
- Default for most Create and Edit forms — keeps user in context on parent screen
Expand Down Expand Up @@ -39,6 +39,30 @@ dont:
- Dialog renders full-screen sheet below 1024px; centered max-w-md at 1024–1280px
- Route-driven variant requires both parent path and create/edit path to render the same component
- `onOpenChange` must navigate back — just calling `setOpen(false)` leaves the URL broken
- Use `<Form onFormSubmit>`, never a bare `<form onSubmit>` — see **Form state** below
- Cancel must be `type="button"`; inside a `<Form>` an untyped `<button>` defaults to `submit`

## Form state

Applies to every `form/*` pattern. Full detail in **`components.md`** → Forms.

- **`Form` + `Field` is the default stack.** They wrap Base UI and ship with AppShell — no extra
dependency.
- **Submit via `onFormSubmit(values)`.** It fires only after validation passes. Do not hand-roll
`<form onSubmit>` + `new FormData(...)` — that skips validation and server-error routing.
- **`onFormSubmit` reads registered `Field.Root`s, not the DOM.** So every control — including
`Select`, `Combobox`, and `Autocomplete` — just needs wrapping in a `Field.Root name="…"`. They
need **no `name` of their own and no `useState`**. Mirroring field values into React state is the
most common thing to get wrong here.
- **`Field.Control` is already a styled input.** Write `<Field.Control />`, not
`<Field.Control render={<Input />} />`.
- **Object items need `itemToStringValue`.** Items shaped `{ value, label }` submit `value`
automatically; any other object submits as a JSON string unless you supply it.
- **Server errors go through `Form`'s `errors` prop**, keyed by field `name` — not a toast or a
banner. They clear when the user edits the field.
- **React Hook Form is optional**, consumer-installed, and only warranted for cross-field
validation, field arrays, or a Zod resolver. It composes with `Field`: drive the control from a
`Controller` and spread `fieldState` onto `Field.Root`.

## Anti-patterns

Expand All @@ -47,3 +71,6 @@ dont:
- Save closes the dialog but parent state is stale — wire refetch or optimistic update
- Building a routed Create/Edit page when the design didn't explicitly call for one — modal is the default
- Registering the create path as a separate top-level route — that unmounts the parent list
- Reaching for React Hook Form on a form this size — `onFormSubmit` already covers it
- Holding a `Select`/`Combobox` value in `useState` just to submit it — `Field.Root` already does
- Surfacing API validation failures in a toast or banner instead of routing them via `errors`
41 changes: 29 additions & 12 deletions catalogue/src/pattern/form/modal/modal-form-routed.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
/* pattern: form/modal (route-driven variant) */
import { Button, Dialog, Input, Layout, Field } from "@tailor-platform/app-shell";
import { useState } from "react";
import { Button, Dialog, Field, Form, Layout } from "@tailor-platform/app-shell";

type ProductDraft = {
name: string;
};

type Props = {
isCreateOpen: boolean;
onNavigateToCreate: () => void;
onNavigateToList: () => void;
onSave: (data: { name: string }) => void;
onSave: (data: ProductDraft) => Promise<{ errors?: Record<string, string> }>;
};

/**
Expand All @@ -19,6 +24,21 @@ export default function ModalFormRouted({
onNavigateToList,
onSave,
}: Props) {
// Server-side validation errors, keyed by field `name`. `Form` routes each
// one to the matching `Field.Error` and clears it when the user edits that
// field — so failures never need their own alert banner.
const [errors, setErrors] = useState<Record<string, string>>({});

const handleSubmit = async (values: ProductDraft) => {
const result = await onSave(values);
if (result.errors) {
setErrors(result.errors);
return;
}
setErrors({});
onNavigateToList();
};

return (
<Layout>
<Layout.Header
Expand All @@ -41,26 +61,23 @@ export default function ModalFormRouted({
<Dialog.Header>
<Dialog.Title>Create product</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
onSave({ name: formData.get("name") as string });
}}
>
<Form<ProductDraft> noValidate errors={errors} onFormSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<Field.Root name="name">
<Field.Label>Name</Field.Label>
<Field.Control render={<Input />} />
<Field.Control required />
{/* Catch-all: renders the native message, a `match` message, or
the server error routed in via the `errors` prop above. */}
<Field.Error />
</Field.Root>
</div>
<Dialog.Footer>
<Button variant="ghost" onClick={onNavigateToList}>
<Button type="button" variant="ghost" onClick={onNavigateToList}>
Cancel
</Button>
<Button type="submit">Save</Button>
</Dialog.Footer>
</form>
</Form>
</Dialog.Content>
</Dialog.Root>
</Layout>
Expand Down
38 changes: 21 additions & 17 deletions catalogue/src/pattern/form/modal/modal-form.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/* pattern: form/modal */
import { Button, Dialog, Input, Field } from "@tailor-platform/app-shell";
import { Button, Dialog, Field, Form } from "@tailor-platform/app-shell";

type Address = {
label: string;
street: string;
city: string;
};

type Props = {
onSave: (data: { label: string; street: string; city: string }) => void;
onSave: (data: Address) => void;
};

export default function ModalForm({ onSave }: Props) {
Expand All @@ -14,36 +20,34 @@ export default function ModalForm({ onSave }: Props) {
<Dialog.Title>Add address</Dialog.Title>
<Dialog.Description>Add a shipping address to this order.</Dialog.Description>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
onSave({
label: formData.get("label") as string,
street: formData.get("street") as string,
city: formData.get("city") as string,
});
}}
>
{/*
* `Form` + `onFormSubmit` replaces a hand-rolled `<form onSubmit>` +
* `FormData`: values arrive parsed, and validation runs before the
* handler fires. Every text field is uncontrolled — no state needed.
*/}
<Form<Address> noValidate onFormSubmit={(values) => onSave(values)}>
<div className="space-y-4 py-4">
<Field.Root name="label">
<Field.Label>Label</Field.Label>
<Field.Control render={<Input />} />
<Field.Control required placeholder="Head office" />
<Field.Error match="valueMissing">Label is required.</Field.Error>
</Field.Root>
<Field.Root name="street">
<Field.Label>Street</Field.Label>
<Field.Control render={<Input />} />
<Field.Control required />
<Field.Error match="valueMissing">Street is required.</Field.Error>
</Field.Root>
<Field.Root name="city">
<Field.Label>City</Field.Label>
<Field.Control render={<Input />} />
<Field.Control required />
<Field.Error match="valueMissing">City is required.</Field.Error>
</Field.Root>
</div>
<Dialog.Footer>
<Dialog.Close render={<Button variant="ghost" />}>Cancel</Dialog.Close>
<Button type="submit">Save</Button>
</Dialog.Footer>
</form>
</Form>
</Dialog.Content>
</Dialog.Root>
);
Expand Down
Loading
Loading