@tailor-platform/app-shell 1.15.0 → 1.17.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.
Files changed (109) hide show
  1. package/dist/app-shell-core.css +1 -1
  2. package/dist/app-shell.d.ts +811 -131
  3. package/dist/app-shell.js +7155 -5797
  4. package/dist/{appshell-DLVBFo5J.js → appshell-CahhZy7v.js} +656 -524
  5. package/dist/semantic-contrast.test.ts +154 -0
  6. package/dist/style-entry.d.ts +147 -92
  7. package/dist/testing.d.ts +152 -95
  8. package/dist/testing.js +1 -1
  9. package/dist/theme-bridge.test.ts +101 -1
  10. package/dist/theme.bridge.css +91 -0
  11. package/dist/theme.css +6 -1
  12. package/dist/themes/_template.css +7 -1
  13. package/dist/themes/bloom.css +0 -6
  14. package/dist/themes/cream.css +0 -6
  15. package/dist/themes/default.css +389 -69
  16. package/dist/vite-plugin.d.ts +147 -92
  17. package/package.json +8 -8
  18. package/skills/app-shell-patterns/SKILL.md +108 -19
  19. package/skills/app-shell-patterns/references/api/create-ai-gateway-client.md +115 -0
  20. package/skills/app-shell-patterns/references/api/create-typed-paths.md +117 -0
  21. package/skills/app-shell-patterns/references/api/define-i18n-labels.md +143 -0
  22. package/skills/app-shell-patterns/references/api/define-module.md +247 -0
  23. package/skills/app-shell-patterns/references/api/define-resource.md +307 -0
  24. package/skills/app-shell-patterns/references/api/guards/hidden.md +202 -0
  25. package/skills/app-shell-patterns/references/api/guards/overview.md +354 -0
  26. package/skills/app-shell-patterns/references/api/guards/pass.md +129 -0
  27. package/skills/app-shell-patterns/references/api/guards/redirect-to.md +307 -0
  28. package/skills/app-shell-patterns/references/api/router/use-location.md +240 -0
  29. package/skills/app-shell-patterns/references/api/router/use-navigate.md +138 -0
  30. package/skills/app-shell-patterns/references/api/router/use-params.md +218 -0
  31. package/skills/app-shell-patterns/references/api/use-ai-chat.md +110 -0
  32. package/skills/app-shell-patterns/references/api/use-app-shell-config.md +132 -0
  33. package/skills/app-shell-patterns/references/api/use-app-shell-data.md +150 -0
  34. package/skills/app-shell-patterns/references/api/use-app-shell.md +150 -0
  35. package/skills/app-shell-patterns/references/api/use-auth.md +145 -0
  36. package/skills/app-shell-patterns/references/api/use-override-breadcrumb.md +74 -0
  37. package/skills/app-shell-patterns/references/api/use-page-meta.md +96 -0
  38. package/skills/app-shell-patterns/references/api/use-register-command-palette-actions.md +90 -0
  39. package/skills/app-shell-patterns/references/api/use-resolved-locale.md +67 -0
  40. package/skills/app-shell-patterns/references/api/use-route-error.md +214 -0
  41. package/skills/app-shell-patterns/references/api/use-theme.md +108 -0
  42. package/skills/app-shell-patterns/references/api/use-time-zone.md +80 -0
  43. package/skills/app-shell-patterns/references/api/use-toast.md +104 -0
  44. package/skills/app-shell-patterns/references/components/action-panel.md +168 -0
  45. package/skills/app-shell-patterns/references/components/activity-card.md +142 -0
  46. package/skills/app-shell-patterns/references/components/ai-chat.md +315 -0
  47. package/skills/app-shell-patterns/references/components/alert.md +202 -0
  48. package/skills/app-shell-patterns/references/components/app-shell.md +530 -0
  49. package/skills/app-shell-patterns/references/components/appearance-switcher.md +87 -0
  50. package/skills/app-shell-patterns/references/components/attachment.md +138 -0
  51. package/skills/app-shell-patterns/references/components/autocomplete.md +278 -0
  52. package/skills/app-shell-patterns/references/components/avatar.md +80 -0
  53. package/skills/app-shell-patterns/references/components/badge.md +309 -0
  54. package/skills/app-shell-patterns/references/components/button.md +164 -0
  55. package/skills/app-shell-patterns/references/components/card.md +181 -0
  56. package/skills/app-shell-patterns/references/components/checkbox.md +169 -0
  57. package/skills/app-shell-patterns/references/components/combobox.md +318 -0
  58. package/skills/app-shell-patterns/references/components/command-palette.md +413 -0
  59. package/skills/app-shell-patterns/references/components/csv-importer.md +214 -0
  60. package/skills/app-shell-patterns/references/components/data-table.md +1168 -0
  61. package/skills/app-shell-patterns/references/components/date-picker.md +259 -0
  62. package/skills/app-shell-patterns/references/components/default-header.md +75 -0
  63. package/skills/app-shell-patterns/references/components/default-sidebar.md +136 -0
  64. package/skills/app-shell-patterns/references/components/description-card.md +569 -0
  65. package/skills/app-shell-patterns/references/components/dialog.md +164 -0
  66. package/skills/app-shell-patterns/references/components/document-progress-card.md +154 -0
  67. package/skills/app-shell-patterns/references/components/filter-rail.md +165 -0
  68. package/skills/app-shell-patterns/references/components/form.md +378 -0
  69. package/skills/app-shell-patterns/references/components/global-header-layout.md +132 -0
  70. package/skills/app-shell-patterns/references/components/grid.md +184 -0
  71. package/skills/app-shell-patterns/references/components/input.md +83 -0
  72. package/skills/app-shell-patterns/references/components/layout.md +539 -0
  73. package/skills/app-shell-patterns/references/components/menu.md +218 -0
  74. package/skills/app-shell-patterns/references/components/metric-card.md +94 -0
  75. package/skills/app-shell-patterns/references/components/select.md +283 -0
  76. package/skills/app-shell-patterns/references/components/sheet.md +237 -0
  77. package/skills/app-shell-patterns/references/components/sidebar-group.md +472 -0
  78. package/skills/app-shell-patterns/references/components/sidebar-item.md +458 -0
  79. package/skills/app-shell-patterns/references/components/sidebar-layout.md +258 -0
  80. package/skills/app-shell-patterns/references/components/sidebar-section.md +114 -0
  81. package/skills/app-shell-patterns/references/components/spinner.md +81 -0
  82. package/skills/app-shell-patterns/references/components/table.md +188 -0
  83. package/skills/app-shell-patterns/references/components/tabs.md +158 -0
  84. package/skills/app-shell-patterns/references/components/textarea.md +122 -0
  85. package/skills/app-shell-patterns/references/components/timeline.md +317 -0
  86. package/skills/app-shell-patterns/references/components/toolbar.md +138 -0
  87. package/skills/app-shell-patterns/references/components/tooltip.md +144 -0
  88. package/skills/app-shell-patterns/references/components/with-guard.md +445 -0
  89. package/skills/app-shell-patterns/references/concepts/authentication.md +301 -0
  90. package/skills/app-shell-patterns/references/concepts/custom-components.md +59 -0
  91. package/skills/app-shell-patterns/references/concepts/file-based-routing.md +356 -0
  92. package/skills/app-shell-patterns/references/{fundamental → concepts}/graphql.md +31 -18
  93. package/skills/app-shell-patterns/references/concepts/modules-and-resources.md +229 -0
  94. package/skills/app-shell-patterns/references/concepts/routing-navigation.md +425 -0
  95. package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +420 -0
  96. package/skills/app-shell-patterns/references/concepts/styling-theming.md +785 -0
  97. package/skills/app-shell-patterns/references/migrations.md +55 -0
  98. package/skills/app-shell-patterns/references/pages/{detail.md → document-detail.md} +10 -33
  99. package/skills/app-shell-patterns/references/patterns/form-composer.md +43 -45
  100. package/skills/app-shell-patterns/references/patterns/form-modal.md +37 -92
  101. package/skills/app-shell-patterns/references/patterns/form-sectioned.md +12 -38
  102. package/skills/app-shell-patterns/references/patterns/form-single-page.md +11 -32
  103. package/skills/app-shell-patterns/references/patterns/form-wizard.md +12 -53
  104. package/skills/app-shell-patterns/references/patterns/interaction-confirm.md +11 -27
  105. package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +136 -139
  106. package/skills/app-shell-patterns/references/patterns/interaction-toast.md +10 -34
  107. package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +13 -88
  108. package/skills/app-shell-patterns/references/fundamental/components.md +0 -1009
  109. package/skills/app-shell-patterns/references/fundamental/design-system.md +0 -440
@@ -1,3 +1,5 @@
1
+ <!-- GENERATED by docs-kit from docs-src/guides/migrations.docs.outline.md — do not edit by hand. -->
2
+
1
3
  # Migrations
2
4
 
3
5
  Every change that requires you to edit your application before or after upgrading, newest first.
@@ -8,6 +10,8 @@ Each entry states which versions are affected, what breaks, how to detect it, an
8
10
 
9
11
  | Version | Change |
10
12
  | ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
13
+ | 1.17.0 | [Status and alert tokens now resolve to the colour roles](#1170-status-and-alert-tokens-now-resolve-to-the-colour-roles) |
14
+ | 1.15.0 | [Base UI 1.8.0 breaks closed-`Select` and dialog-click tests](#1150-base-ui-180-breaks-closed-select-and-dialog-click-tests) |
11
15
  | 1.12.0 | [`DateField` / `DatePicker` field chrome moved to `Field.Root`](#1120-datefield--datepicker-field-chrome-moved-to-fieldroot) |
12
16
  | 1.11.0 | [React 19.2.7 and React Router v8 required](#1110-react-1927-and-react-router-v8-are-now-required) |
13
17
  | 1.11.0 | [Non-modal `Sheet` renders no backdrop](#1110-non-modal-sheet-no-longer-renders-a-backdrop) |
@@ -18,6 +22,57 @@ Each entry states which versions are affected, what breaks, how to detect it, an
18
22
  | 1.0.2 | [`Toaster` no longer accepts `richColors`](#102-toaster-no-longer-accepts-richcolors) |
19
23
  | before 1.0 | [Pre-1.0 breaking changes](#before-10) |
20
24
 
25
+ ## 1.17.0: status and alert tokens now resolve to the colour roles
26
+
27
+ **Applies to:** apps that use `bg-status-*`, `text-status-*` or `bg-alert-*` utilities, and apps whose theme overrides `--status-*`, `--alert-*` or `--destructive` to recolour `Badge`, `Alert`, `CsvImporter` or `MetricCard`.
28
+
29
+ 1.17.0 adds semantic colour roles, `--{info|success|warning|danger|neutral}-{surface|surface-hover|border|solid|solid-hover|text|contrast|indicator}`, bridged as Tailwind colours such as `bg-success-surface` and `text-danger-text`. The old `--status-*` and `--alert-*` tokens still exist and still work, but they no longer hold their own colours: each one now resolves to a role (for example `--status-completed` is `var(--success-solid)`). They are deprecated and will be removed in the next major. Nothing errors. Three things change silently:
30
+
31
+ - **Colours change.** `bg-status-*` and `bg-alert-*` resolve to the role values. For example `bg-status-completed` is `#15803d` instead of `#22c55e`. `--status-default` follows `--muted-foreground` instead of a fixed `#737373`.
32
+ - **Component recolouring moves to the roles.** `Badge`, `Alert`, `CsvImporter` and `MetricCard` read the roles directly. A theme that overrides `--status-*` or `--alert-*` no longer changes these components; override `--{intent}-*` instead. `Badge` `error` / `subtle-error` and `Alert` `error` read `--danger-*`, so overriding `--destructive` no longer reaches them either. `Button variant="destructive"` and `text-destructive` still follow `--destructive`.
33
+ - **`text-status-*` loses contrast in dark mode.** The `--status-*` values are now the step-9 fills, which are below 4.5:1 as text on the dark card.
34
+
35
+ How to detect it: search the app for `status-`, `alert-` and theme overrides of `--status-`, `--alert-` and `--destructive`. Compare `Badge` and `Alert` in light and dark mode after upgrading.
36
+
37
+ What to change:
38
+
39
+ - Replace `text-status-completed` with `text-success-text`, `text-status-attention` with `text-warning-text`, `text-status-danger` with `text-danger-text`, and `text-status-neutral` with `text-info-text`.
40
+ - Replace `bg-status-completed/10 border-status-completed/30` style tints with `bg-success-surface border-success-border`. The same applies to the other intents and to `bg-alert-*` / `border-alert-*`.
41
+ - In a theme that recolours the status components, set the roles (`--success-solid`, `--danger-text`, ...) instead of `--status-*`, `--alert-*` or `--destructive`.
42
+
43
+ The roles are documented in [Styling & theming](https://github.com/tailor-platform/app-shell/blob/main/docs/concepts/styling-theming.md#semantic-color-roles). The decision record is `decisions/semantic-color-roles.md`.
44
+
45
+ ## 1.15.0: Base UI 1.8.0 breaks closed-`Select` and dialog-click tests
46
+
47
+ **Applies to:** apps with unit tests that assert `Select` options without opening the `Select`, and apps with Playwright tests that click buttons inside a `Dialog` or `AlertDialog`.
48
+
49
+ 1.15.0 updates the bundled `@base-ui/react` from 1.6.0 to 1.8.0 (1.12.0 to 1.14.0 used 1.6.0). The AppShell API is unchanged and the app behaves the same for the people using it. Two upstream behaviour changes do break tests that relied on the old behaviour, so an app that changes nothing can still go red in CI after upgrading. The fix is in the tests, not the app.
50
+
51
+ ### `Select` options are not in the DOM until it opens
52
+
53
+ A closed `Select` used to render its options into the DOM. It no longer does, so a test that asserts option text with `getByText` on a closed `Select` fails with "Unable to find an element".
54
+
55
+ Open the `Select` first, then wait for the option:
56
+
57
+ ```tsx
58
+ await userEvent.click(screen.getByRole("combobox", { name: "Auto Renewal Period" }));
59
+ expect(await screen.findByText("1 Month")).toBeInTheDocument();
60
+ ```
61
+
62
+ ### Dialog scroll lock can move a button under a Playwright click
63
+
64
+ Opening a `Dialog` or `AlertDialog` applies a scroll lock that shifts the layout slightly. Playwright can judge a button stable and then click where it used to be, so the click lands on the backdrop and closes the dialog. The test then times out waiting for the dialog, and the Playwright call log shows the click being retried after "element was detached from the DOM".
65
+
66
+ Press the button instead of clicking it. `press` does not use coordinates, so the layout shift cannot affect it:
67
+
68
+ ```ts
69
+ const confirm = page.getByRole("alertdialog").getByRole("button", { name: "Delete" });
70
+ await expect(confirm).toBeVisible();
71
+ await confirm.press("Enter");
72
+ ```
73
+
74
+ Do not reach for `click({ force: true })`. It only skips Playwright's stability check, and the click can still miss.
75
+
21
76
  ## 1.12.0: `DateField` / `DatePicker` field chrome moved to `Field.Root`
22
77
 
23
78
  **Applies to:** apps passing `label`, `description`, `errorMessage`, or `hideTimeZone` to `DateField` or `DatePicker`.
@@ -1,34 +1,11 @@
1
1
  ---
2
- slug: page/detail
3
- name: Detail Page
4
- category: page
2
+ title: Document Detail Page
5
3
  description: The screen for a single record — its summary, its contents, the records around it, and the actions available on it
6
- requiredImports:
7
- [
8
- Layout,
9
- DescriptionCard,
10
- Card,
11
- Table,
12
- Badge,
13
- Alert,
14
- ActionPanel,
15
- ActivityCard,
16
- Grid,
17
- MetricCard,
18
- DocumentProgressCard,
19
- ]
20
- tags: [detail, record, document, single-record, actions, related-records, two-column, line-items]
21
- do:
22
- - A screen showing exactly one record — an order, a receipt, an invoice, a supplier, an item
23
- - The record has a lifecycle someone moves it through, or other records created from it
24
- - Someone reading the record needs to understand its current state, and may want to act on it
25
- dont:
26
- - The screen shows a set of records — that is a collection
27
- - The record is a row's worth of fields, better opened in a Sheet from the list it belongs to
28
- - Creating a new record — that is a form; this pattern is for reading and amending one that already exists
29
4
  ---
30
5
 
31
- # page/detail
6
+ <!-- GENERATED by docs-kit from docs-src/pages/document-detail.docs.outline.md — do not edit by hand. -->
7
+
8
+ # Document Detail Page
32
9
 
33
10
  The screen for a **single record**. Two columns: the record itself on the left,
34
11
  and on the right the actions available on it plus the context that sits around
@@ -206,7 +183,7 @@ time, and the record is unblocked only once every one is released.
206
183
  ### 4. Line items
207
184
 
208
185
  `Card.Root` → `Card.Header` (title, plus a one-line description where the
209
- columns need explaining) → `Card.Content className="px-0!"` → `Table.Root`.
186
+ columns need explaining) → `Card.Content padding="none"` → `Table.Root`.
210
187
 
211
188
  Fetch the line items with the record, sorted explicitly so the order is stable
212
189
  across reloads, and render them as a plain `Table`. For the typical document —
@@ -796,7 +773,7 @@ export default function PurchaseOrderDetailPage({
796
773
  would have been a link field on the summary instead. */}
797
774
  <Card.Root>
798
775
  <Card.Header title="Source requisitions" />
799
- <Card.Content className="px-0!">
776
+ <Card.Content padding="none">
800
777
  <Table.Root>
801
778
  <Table.Header>
802
779
  <Table.Row>
@@ -831,7 +808,7 @@ export default function PurchaseOrderDetailPage({
831
808
  enough to page. No container padding; the cells inset themselves. */}
832
809
  <Card.Root>
833
810
  <Card.Header title="Line items" />
834
- <Card.Content className="px-0!">
811
+ <Card.Content padding="none">
835
812
  {order.lineItems.length === 0 ? (
836
813
  <p className="px-6 text-sm text-muted-foreground">No line items on this order.</p>
837
814
  ) : (
@@ -898,7 +875,7 @@ export default function PurchaseOrderDetailPage({
898
875
  relationship is possible, so the reader learns it exists. */}
899
876
  <Card.Root>
900
877
  <Card.Header title="Goods receipts" />
901
- <Card.Content className="px-0!">
878
+ <Card.Content padding="none">
902
879
  {order.goodsReceipts.length === 0 ? (
903
880
  <p className="px-6 text-sm text-muted-foreground">
904
881
  No goods receipts linked to this order.
@@ -1020,7 +997,7 @@ export default function PurchaseOrderDetailPage({
1020
997
  title="Journal entry"
1021
998
  description="Booked when this order was posted."
1022
999
  />
1023
- <Card.Content className="px-0!">
1000
+ <Card.Content padding="none">
1024
1001
  <Table.Root>
1025
1002
  <Table.Header>
1026
1003
  <Table.Row>
@@ -1119,7 +1096,7 @@ enforced across every detail screen once settled.
1119
1096
  ## Constraints
1120
1097
 
1121
1098
  - **Every main-column section sits in a `Card.Root`** — except `DescriptionCard`, which contains itself, and `Alert`, which is a banner. No bare `<div>` sections.
1122
- - **A table in a card needs ONE geometry change, not two.** Zero the card's padding (`Card.Content className="px-0!"`, or drop `Card.Content`) and leave the table container alone. `Table.Head` and `Table.Cell` already inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title.
1099
+ - **A table in a card goes in `Card.Content padding="none"`, and nothing else changes.** Don't drop `Card.Content` or zero its padding with a class instead: the prop also keeps row backgrounds inside the card's rounded corners and removes a nested `DataTable`'s own border. Leave the table container alone too. `Table.Head` and `Table.Cell` already inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title.
1123
1100
  - **Bare `YYYY-MM-DD` dates must not use `type: "date"`.** `DescriptionCard` hands the value to `new Date(...)`, which reads a date-only string as UTC midnight and renders the previous day west of Greenwich. Pre-format those as text via `render`. Real timestamps keep `type: "date"`, with `emptyBehavior: "hide"` when nullable.
1124
1101
  - **`ActionPanel` is workflow-only.** No navigation, no "view related record" — those are links in the cards that hold them.
1125
1102
  - **Never rely on a query cap to bound the line-items table.** If the cap can be reached, page the lines.
@@ -1,20 +1,11 @@
1
1
  ---
2
- slug: pattern/form/composer
3
- name: Composer
4
- category: pattern
5
- subcategory: form
2
+ title: Composer
6
3
  description: Free-text composer — a multi-line input above a trailing action row, for notes, replies, and comments submitted in place
7
- requiredImports: [Card, Textarea, Button, Checkbox]
8
- tags: [composer, textarea, note, reply, comment, actions, discard, send]
9
- do:
10
- - A free-text box the user fills and submits in place — add a note, reply on a thread, leave a comment, give a rejection reason
11
- - The prose IS the interaction; any other controls only qualify how the text is submitted
12
- dont:
13
- - The text is one field among many on a record form — use form/modal, form/single-page or form/sectioned with a Textarea field
14
- - A chat input where Enter sends and there is no Discard — use the `AIChat` component instead
15
4
  ---
16
5
 
17
- # pattern/form/composer
6
+ <!-- GENERATED by docs-kit from docs-src/patterns/form-composer.docs.outline.md — do not edit by hand. -->
7
+
8
+ # Composer
18
9
 
19
10
  ## When to Use
20
11
 
@@ -60,35 +51,36 @@ Leave it empty and the buttons still sit correctly at the right; `justify-betwee
60
51
 
61
52
  ## Page Implementation
62
53
 
63
- ```tsx
64
- /* pattern: form/composer */
65
- import { useState } from "react";
66
- import { Button, Card, Checkbox, Textarea } from "@tailor-platform/app-shell";
67
-
68
- type Props = {
69
- onSubmit: (body: string, internal: boolean) => void;
70
- submitting?: boolean;
71
- };
54
+ <example-preview name="form-composer"></example-preview>
72
55
 
73
- export default function Composer({ onSubmit, submitting = false }: Props) {
56
+ ```tsx
57
+ function FormComposer() {
74
58
  const [body, setBody] = useState("");
75
59
  const [internal, setInternal] = useState(false);
60
+ const [errors, setErrors] = useState<Record<string, string>>({});
61
+
62
+ const handleSubmit = () => {
63
+ window.alert(`Sending ${internal ? "internal note" : "reply"}: ${body}`);
64
+ setErrors({});
65
+ setBody("");
66
+ };
76
67
 
77
68
  return (
78
69
  <Card.Root>
79
70
  <Card.Content>
80
- <div className="space-y-3">
81
- <Textarea
82
- aria-label="Reply"
83
- value={body}
84
- onChange={(e) => setBody(e.target.value)}
85
- placeholder={internal ? "Internal context for teammates…" : "Reply to customer…"}
86
- rows={4}
87
- />
71
+ <Form noValidate errors={errors} onFormSubmit={handleSubmit} className="space-y-3">
72
+ <Field.Root name="body">
73
+ <Field.Label className="sr-only">Reply</Field.Label>
74
+ <Textarea
75
+ required
76
+ rows={4}
77
+ value={body}
78
+ onChange={(event) => setBody(event.target.value)}
79
+ placeholder={internal ? "Internal context for teammates…" : "Reply to customer…"}
80
+ />
81
+ <Field.Error />
82
+ </Field.Root>
88
83
  <div className="flex items-center justify-between gap-2">
89
- {/* Options slot — 0..N controls that qualify the submission. An
90
- internal-note toggle here; swap for an attachment button, a
91
- visibility Select, a template picker, or drop it entirely. */}
92
84
  <div className="flex min-w-0 items-center gap-2">
93
85
  <Checkbox
94
86
  label="Internal note (not sent to customer)"
@@ -96,39 +88,43 @@ export default function Composer({ onSubmit, submitting = false }: Props) {
96
88
  onCheckedChange={setInternal}
97
89
  />
98
90
  </div>
99
- {/* Action slot — fixed shape: optional ghost Discard, then exactly
100
- one primary submit. Never more than one primary. */}
101
91
  <div className="flex shrink-0 gap-2">
102
92
  <Button
93
+ type="button"
103
94
  variant="ghost"
104
- onClick={() => setBody("")}
105
- disabled={body.length === 0 || submitting}
95
+ onClick={() => {
96
+ setBody("");
97
+ setErrors({});
98
+ }}
99
+ disabled={body.length === 0}
106
100
  >
107
101
  Discard
108
102
  </Button>
109
- <Button
110
- onClick={() => onSubmit(body, internal)}
111
- disabled={body.trim().length === 0 || submitting}
112
- >
113
- {submitting ? "Sending…" : "Send"}
103
+ <Button type="submit" disabled={body.trim().length === 0}>
104
+ Send
114
105
  </Button>
115
106
  </div>
116
107
  </div>
117
- </div>
108
+ </Form>
118
109
  </Card.Content>
119
110
  </Card.Root>
120
111
  );
121
112
  }
122
113
  ```
123
114
 
115
+ <!-- source: composer.tsx -->
116
+
124
117
  ## Constraints
125
118
 
126
119
  - The body control is `Textarea`, never `Input` — `Input` is locked to `h-9` and clips prose to one 36px line.
127
120
  - Size the body with `rows` (3–5 is the usual range). Do not override its height with classes: `astw:` is internal to AppShell, and `Textarea`'s supported sizing API is `rows`.
128
- - The `Textarea` needs an accessible name. There is usually no visible label in a composer, so pass `aria-label`; a placeholder is not a label.
121
+ - The `Textarea` needs an accessible name. There is usually no visible label in a composer, so use a `sr-only` `Field.Label`; a placeholder is not a label. Prefer this over `aria-label` — the `Field.Root` is already there, and a real label keeps the error and description wiring available.
129
122
  - One primary `Button` in the action row. Discard is `ghost`, and any left-side action is `ghost` too.
130
123
  - The action row is one row at every width — the left side shrinks, the buttons do not wrap under the body.
131
124
  - Disable the submit on empty or whitespace-only input, and disable both buttons while `submitting`.
125
+ - The composer is a `Form`, not a bare widget. The body sits in a `Field.Root name="…"`, Send is `type="submit"`, and Discard is `type="button"` — inside a `<Form>` an untyped `<button>` defaults to `submit`.
126
+ - Keep the body **controlled**. It is read during render to gate Send and to swap the placeholder, which a submit-time handler cannot do. This is the one `form/*` pattern where field state is load-bearing rather than redundant.
127
+ - Route server rejections (moderation, rate limit, thread closed) through `Form`'s `errors` prop into `Field.Error`, not a toast. A composer that can be rejected needs somewhere for the reason to land next to the text the user still has.
132
128
  - Wrap the composer in `Card.Root` when it sits among other cards (a detail page, the end of a thread). A composer that is already inside a `Sheet` or `Dialog` body does not need its own card.
133
129
 
134
130
  ## Anti-patterns
@@ -139,4 +135,6 @@ export default function Composer({ onSubmit, submitting = false }: Props) {
139
135
  - The action row stacked above the `Textarea`, or the buttons wrapped onto their own line — the composer stops reading as one unit.
140
136
  - A left-side toggle that silently changes what the submit does without changing the submit's own affordance — if "internal note" changes the destination, the placeholder (or the label) should say so.
141
137
  - Submit enabled on an empty body, so the user can post nothing.
138
+ - A bare `<div>` of controls with an `onClick` submit — no `Form`, so a server rejection has nowhere to go and the body has no field identity.
139
+ - Clearing the body on a _failed_ submit — the user loses what they wrote. Clear only after the submit succeeds.
142
140
  - A body so tall (`rows={12}`) that the action row falls below the fold — the composer stops reading as one unit.
@@ -1,23 +1,11 @@
1
1
  ---
2
- slug: pattern/form/modal
3
- name: Modal Form
4
- category: pattern
5
- subcategory: form
2
+ title: Modal Form
6
3
  description: Default form pattern for Create/Edit — keeps user in context on the parent screen
7
- requiredImports: [Dialog, Button, Form, Field, Layout]
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
4
  ---
19
5
 
20
- # pattern/form/modal
6
+ <!-- GENERATED by docs-kit from docs-src/patterns/form-modal.docs.outline.md — do not edit by hand. -->
7
+
8
+ # Modal Form
21
9
 
22
10
  ## When to Use
23
11
 
@@ -28,21 +16,10 @@ dont:
28
16
 
29
17
  ## Page Implementation
30
18
 
31
- ```tsx
32
- /* pattern: form/modal */
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
- };
19
+ <example-preview name="modal-form"></example-preview>
40
20
 
41
- type Props = {
42
- onSave: (data: Address) => void;
43
- };
44
-
45
- export default function ModalForm({ onSave }: Props) {
21
+ ```tsx
22
+ function ModalForm() {
46
23
  return (
47
24
  <Dialog.Root>
48
25
  <Dialog.Trigger render={<Button />}>Add address</Dialog.Trigger>
@@ -52,15 +29,17 @@ export default function ModalForm({ onSave }: Props) {
52
29
  <Dialog.Description>Add a shipping address to this order.</Dialog.Description>
53
30
  </Dialog.Header>
54
31
  {/*
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.
32
+ * `onFormSubmit` fires only after validation passes and receives the
33
+ * registered field values — no `<form onSubmit>` + `new FormData(...)`.
58
34
  */}
59
- <Form<Address> noValidate onFormSubmit={(values) => onSave(values)}>
60
- <div className="space-y-4 py-4">
35
+ <Form<AddressDraft>
36
+ noValidate
37
+ onFormSubmit={(values) => window.alert(`Saving ${values.label}`)}
38
+ >
39
+ <div className="flex flex-col gap-4 py-4">
61
40
  <Field.Root name="label">
62
41
  <Field.Label>Label</Field.Label>
63
- <Field.Control required placeholder="Head office" />
42
+ <Field.Control required />
64
43
  <Field.Error match="valueMissing">Label is required.</Field.Error>
65
44
  </Field.Root>
66
45
  <Field.Root name="street">
@@ -75,7 +54,7 @@ export default function ModalForm({ onSave }: Props) {
75
54
  </Field.Root>
76
55
  </div>
77
56
  <Dialog.Footer>
78
- <Dialog.Close render={<Button variant="ghost" />}>Cancel</Dialog.Close>
57
+ <Dialog.Close render={<Button type="button" variant="ghost" />}>Cancel</Dialog.Close>
79
58
  <Button type="submit">Save</Button>
80
59
  </Dialog.Footer>
81
60
  </Form>
@@ -87,82 +66,47 @@ export default function ModalForm({ onSave }: Props) {
87
66
 
88
67
  ## Route-driven Variant
89
68
 
90
- ```tsx
91
- /* pattern: form/modal (route-driven variant) */
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
- };
98
-
99
- type Props = {
100
- isCreateOpen: boolean;
101
- onNavigateToCreate: () => void;
102
- onNavigateToList: () => void;
103
- onSave: (data: ProductDraft) => Promise<{ errors?: Record<string, string> }>;
104
- };
105
-
106
- /**
107
- * Route-driven modal: the form has its own URL but renders as a popup
108
- * over the list. Both `/products` and `/products/create` render this
109
- * same component — the parent list stays visible underneath.
110
- */
111
- export default function ModalFormRouted({
112
- isCreateOpen,
113
- onNavigateToCreate,
114
- onNavigateToList,
115
- onSave,
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
- };
69
+ The route-driven variant renders the same component at both the parent path and the `create` / `edit` path, with `onOpenChange` navigating back so the URL stays in sync.
131
70
 
71
+ <example-preview name="modal-form-routed"></example-preview>
72
+
73
+ ```tsx
74
+ function ModalFormRouted() {
75
+ const [isCreateOpen, setCreateOpen] = useState(false);
132
76
  return (
133
77
  <Layout>
134
78
  <Layout.Header
135
79
  title="Products"
136
80
  actions={[
137
- <Button key="create" onClick={onNavigateToCreate}>
81
+ <Button key="create" onClick={() => setCreateOpen(true)}>
138
82
  Create
139
83
  </Button>,
140
84
  ]}
141
85
  />
142
86
  <Layout.Column>{/* products list — see list/dense-scan */}</Layout.Column>
143
87
 
144
- <Dialog.Root
145
- open={isCreateOpen}
146
- onOpenChange={(open) => {
147
- if (!open) onNavigateToList();
148
- }}
149
- >
88
+ <Dialog.Root open={isCreateOpen} onOpenChange={setCreateOpen}>
150
89
  <Dialog.Content>
151
90
  <Dialog.Header>
152
91
  <Dialog.Title>Create product</Dialog.Title>
153
92
  </Dialog.Header>
154
- <Form<ProductDraft> noValidate errors={errors} onFormSubmit={handleSubmit}>
155
- <div className="space-y-4 py-4">
93
+ <Form<ProductDraft>
94
+ noValidate
95
+ onFormSubmit={(values) => {
96
+ window.alert(`Saving ${values.name}`);
97
+ setCreateOpen(false);
98
+ }}
99
+ >
100
+ <div className="flex flex-col gap-4 py-4">
156
101
  <Field.Root name="name">
157
102
  <Field.Label>Name</Field.Label>
158
103
  <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 />
104
+ <Field.Error match="valueMissing">Name is required.</Field.Error>
162
105
  </Field.Root>
163
106
  </div>
164
107
  <Dialog.Footer>
165
- <Button type="button" variant="ghost" onClick={onNavigateToList}>
108
+ {/* Inside a `<Form>` an untyped button submits — Cancel must be `type="button"`. */}
109
+ <Button type="button" variant="ghost" onClick={() => setCreateOpen(false)}>
166
110
  Cancel
167
111
  </Button>
168
112
  <Button type="submit">Save</Button>
@@ -194,7 +138,8 @@ placeholder.
194
138
  - **`Form` + `Field` is the default stack.** They wrap Base UI and ship with AppShell — no extra
195
139
  dependency.
196
140
  - **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.
141
+ `<form onSubmit>` + `new FormData(...)` — that bypasses `Form`'s field validation and server-error
142
+ routing.
198
143
  - **`onFormSubmit` reads registered `Field.Root`s, not the DOM.** So every control — including
199
144
  `Select`, `Combobox`, and `Autocomplete` — just needs wrapping in a `Field.Root name="…"`. They
200
145
  need **no `name` of their own and no `useState`**. Mirroring field values into React state is the
@@ -1,21 +1,11 @@
1
1
  ---
2
- slug: pattern/form/sectioned
3
- name: Sectioned Form
4
- category: pattern
5
- subcategory: form
2
+ title: Sectioned Form
6
3
  description: Complex form with 15+ fields organized into named fieldset sections
7
- requiredImports: [Layout, Card, Form, Fieldset, Field, 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
4
  ---
17
5
 
18
- # pattern/form/sectioned
6
+ <!-- GENERATED by docs-kit from docs-src/patterns/form-sectioned.docs.outline.md — do not edit by hand. -->
7
+
8
+ # Sectioned Form
19
9
 
20
10
  ## When to Use
21
11
 
@@ -24,32 +14,14 @@ dont:
24
14
 
25
15
  ## Page Implementation
26
16
 
27
- ```tsx
28
- /* pattern: form/sectioned */
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
- ];
17
+ <example-preview name="form-sectioned"></example-preview>
38
18
 
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}`;
46
-
47
- type Props = {
48
- onSave: (data: Record<string, unknown>) => void;
49
- onCancel: () => void;
50
- };
19
+ ```tsx
20
+ function FormSectioned() {
21
+ const onSave = (values: Record<string, unknown>) =>
22
+ window.alert(`Saving ${Object.keys(values).length} fields`);
23
+ const onCancel = () => {};
51
24
 
52
- export default function SectionedForm({ onSave, onCancel }: Props) {
53
25
  return (
54
26
  <Layout>
55
27
  <Layout.Header
@@ -163,6 +135,8 @@ export default function SectionedForm({ onSave, onCancel }: Props) {
163
135
  }
164
136
  ```
165
137
 
138
+ <!-- source: sectioned-form.tsx -->
139
+
166
140
  ## Constraints
167
141
 
168
142
  - Max ~6 sections — more than that is too hard to scan; promote to `form/wizard`
@@ -1,21 +1,11 @@
1
1
  ---
2
- slug: pattern/form/single-page
3
- name: Single Page Form
4
- category: pattern
5
- subcategory: form
2
+ title: Single Page Form
6
3
  description: Routed full-page form for moderate field count (6-15) without natural sectioning
7
- requiredImports: [Layout, Form, Field, Textarea, 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
4
  ---
17
5
 
18
- # pattern/form/single-page
6
+ <!-- GENERATED by docs-kit from docs-src/patterns/form-single-page.docs.outline.md — do not edit by hand. -->
7
+
8
+ # Single Page Form
19
9
 
20
10
  ## When to Use
21
11
 
@@ -24,26 +14,13 @@ dont:
24
14
 
25
15
  ## Page Implementation
26
16
 
27
- ```tsx
28
- /* pattern: form/single-page */
29
- import { Button, Field, Form, Textarea, Layout, Select } from "@tailor-platform/app-shell";
30
-
31
- const CATEGORIES = ["Electronics", "Furniture", "Clothing", "Food"];
17
+ <example-preview name="form-single-page"></example-preview>
32
18
 
33
- type ProductDraft = {
34
- name: string;
35
- sku: string;
36
- category: string;
37
- price: string;
38
- description: string;
39
- };
40
-
41
- type Props = {
42
- onSave: (data: ProductDraft) => void;
43
- onCancel: () => void;
44
- };
19
+ ```tsx
20
+ function FormSinglePage() {
21
+ const onSave = (values: ProductDraft) => window.alert(`Saving ${values.name}`);
22
+ const onCancel = () => {};
45
23
 
46
- export default function SinglePageForm({ onSave, onCancel }: Props) {
47
24
  return (
48
25
  <Layout>
49
26
  {/*
@@ -107,6 +84,8 @@ export default function SinglePageForm({ onSave, onCancel }: Props) {
107
84
  }
108
85
  ```
109
86
 
87
+ <!-- source: single-page-form.tsx -->
88
+
110
89
  ## Constraints
111
90
 
112
91
  - Single column full width below 1024px; single column max-w constrained at 1024–1280px