@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.
- package/dist/app-shell-core.css +1 -1
- package/dist/app-shell.d.ts +811 -131
- package/dist/app-shell.js +7155 -5797
- package/dist/{appshell-DLVBFo5J.js → appshell-CahhZy7v.js} +656 -524
- package/dist/semantic-contrast.test.ts +154 -0
- package/dist/style-entry.d.ts +147 -92
- package/dist/testing.d.ts +152 -95
- package/dist/testing.js +1 -1
- package/dist/theme-bridge.test.ts +101 -1
- package/dist/theme.bridge.css +91 -0
- package/dist/theme.css +6 -1
- package/dist/themes/_template.css +7 -1
- package/dist/themes/bloom.css +0 -6
- package/dist/themes/cream.css +0 -6
- package/dist/themes/default.css +389 -69
- package/dist/vite-plugin.d.ts +147 -92
- package/package.json +8 -8
- package/skills/app-shell-patterns/SKILL.md +108 -19
- package/skills/app-shell-patterns/references/api/create-ai-gateway-client.md +115 -0
- package/skills/app-shell-patterns/references/api/create-typed-paths.md +117 -0
- package/skills/app-shell-patterns/references/api/define-i18n-labels.md +143 -0
- package/skills/app-shell-patterns/references/api/define-module.md +247 -0
- package/skills/app-shell-patterns/references/api/define-resource.md +307 -0
- package/skills/app-shell-patterns/references/api/guards/hidden.md +202 -0
- package/skills/app-shell-patterns/references/api/guards/overview.md +354 -0
- package/skills/app-shell-patterns/references/api/guards/pass.md +129 -0
- package/skills/app-shell-patterns/references/api/guards/redirect-to.md +307 -0
- package/skills/app-shell-patterns/references/api/router/use-location.md +240 -0
- package/skills/app-shell-patterns/references/api/router/use-navigate.md +138 -0
- package/skills/app-shell-patterns/references/api/router/use-params.md +218 -0
- package/skills/app-shell-patterns/references/api/use-ai-chat.md +110 -0
- package/skills/app-shell-patterns/references/api/use-app-shell-config.md +132 -0
- package/skills/app-shell-patterns/references/api/use-app-shell-data.md +150 -0
- package/skills/app-shell-patterns/references/api/use-app-shell.md +150 -0
- package/skills/app-shell-patterns/references/api/use-auth.md +145 -0
- package/skills/app-shell-patterns/references/api/use-override-breadcrumb.md +74 -0
- package/skills/app-shell-patterns/references/api/use-page-meta.md +96 -0
- package/skills/app-shell-patterns/references/api/use-register-command-palette-actions.md +90 -0
- package/skills/app-shell-patterns/references/api/use-resolved-locale.md +67 -0
- package/skills/app-shell-patterns/references/api/use-route-error.md +214 -0
- package/skills/app-shell-patterns/references/api/use-theme.md +108 -0
- package/skills/app-shell-patterns/references/api/use-time-zone.md +80 -0
- package/skills/app-shell-patterns/references/api/use-toast.md +104 -0
- package/skills/app-shell-patterns/references/components/action-panel.md +168 -0
- package/skills/app-shell-patterns/references/components/activity-card.md +142 -0
- package/skills/app-shell-patterns/references/components/ai-chat.md +315 -0
- package/skills/app-shell-patterns/references/components/alert.md +202 -0
- package/skills/app-shell-patterns/references/components/app-shell.md +530 -0
- package/skills/app-shell-patterns/references/components/appearance-switcher.md +87 -0
- package/skills/app-shell-patterns/references/components/attachment.md +138 -0
- package/skills/app-shell-patterns/references/components/autocomplete.md +278 -0
- package/skills/app-shell-patterns/references/components/avatar.md +80 -0
- package/skills/app-shell-patterns/references/components/badge.md +309 -0
- package/skills/app-shell-patterns/references/components/button.md +164 -0
- package/skills/app-shell-patterns/references/components/card.md +181 -0
- package/skills/app-shell-patterns/references/components/checkbox.md +169 -0
- package/skills/app-shell-patterns/references/components/combobox.md +318 -0
- package/skills/app-shell-patterns/references/components/command-palette.md +413 -0
- package/skills/app-shell-patterns/references/components/csv-importer.md +214 -0
- package/skills/app-shell-patterns/references/components/data-table.md +1168 -0
- package/skills/app-shell-patterns/references/components/date-picker.md +259 -0
- package/skills/app-shell-patterns/references/components/default-header.md +75 -0
- package/skills/app-shell-patterns/references/components/default-sidebar.md +136 -0
- package/skills/app-shell-patterns/references/components/description-card.md +569 -0
- package/skills/app-shell-patterns/references/components/dialog.md +164 -0
- package/skills/app-shell-patterns/references/components/document-progress-card.md +154 -0
- package/skills/app-shell-patterns/references/components/filter-rail.md +165 -0
- package/skills/app-shell-patterns/references/components/form.md +378 -0
- package/skills/app-shell-patterns/references/components/global-header-layout.md +132 -0
- package/skills/app-shell-patterns/references/components/grid.md +184 -0
- package/skills/app-shell-patterns/references/components/input.md +83 -0
- package/skills/app-shell-patterns/references/components/layout.md +539 -0
- package/skills/app-shell-patterns/references/components/menu.md +218 -0
- package/skills/app-shell-patterns/references/components/metric-card.md +94 -0
- package/skills/app-shell-patterns/references/components/select.md +283 -0
- package/skills/app-shell-patterns/references/components/sheet.md +237 -0
- package/skills/app-shell-patterns/references/components/sidebar-group.md +472 -0
- package/skills/app-shell-patterns/references/components/sidebar-item.md +458 -0
- package/skills/app-shell-patterns/references/components/sidebar-layout.md +258 -0
- package/skills/app-shell-patterns/references/components/sidebar-section.md +114 -0
- package/skills/app-shell-patterns/references/components/spinner.md +81 -0
- package/skills/app-shell-patterns/references/components/table.md +188 -0
- package/skills/app-shell-patterns/references/components/tabs.md +158 -0
- package/skills/app-shell-patterns/references/components/textarea.md +122 -0
- package/skills/app-shell-patterns/references/components/timeline.md +317 -0
- package/skills/app-shell-patterns/references/components/toolbar.md +138 -0
- package/skills/app-shell-patterns/references/components/tooltip.md +144 -0
- package/skills/app-shell-patterns/references/components/with-guard.md +445 -0
- package/skills/app-shell-patterns/references/concepts/authentication.md +301 -0
- package/skills/app-shell-patterns/references/concepts/custom-components.md +59 -0
- package/skills/app-shell-patterns/references/concepts/file-based-routing.md +356 -0
- package/skills/app-shell-patterns/references/{fundamental → concepts}/graphql.md +31 -18
- package/skills/app-shell-patterns/references/concepts/modules-and-resources.md +229 -0
- package/skills/app-shell-patterns/references/concepts/routing-navigation.md +425 -0
- package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +420 -0
- package/skills/app-shell-patterns/references/concepts/styling-theming.md +785 -0
- package/skills/app-shell-patterns/references/migrations.md +55 -0
- package/skills/app-shell-patterns/references/pages/{detail.md → document-detail.md} +10 -33
- package/skills/app-shell-patterns/references/patterns/form-composer.md +43 -45
- package/skills/app-shell-patterns/references/patterns/form-modal.md +37 -92
- package/skills/app-shell-patterns/references/patterns/form-sectioned.md +12 -38
- package/skills/app-shell-patterns/references/patterns/form-single-page.md +11 -32
- package/skills/app-shell-patterns/references/patterns/form-wizard.md +12 -53
- package/skills/app-shell-patterns/references/patterns/interaction-confirm.md +11 -27
- package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +136 -139
- package/skills/app-shell-patterns/references/patterns/interaction-toast.md +10 -34
- package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +13 -88
- package/skills/app-shell-patterns/references/fundamental/components.md +0 -1009
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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={() =>
|
|
105
|
-
|
|
95
|
+
onClick={() => {
|
|
96
|
+
setBody("");
|
|
97
|
+
setErrors({});
|
|
98
|
+
}}
|
|
99
|
+
disabled={body.length === 0}
|
|
106
100
|
>
|
|
107
101
|
Discard
|
|
108
102
|
</Button>
|
|
109
|
-
<Button
|
|
110
|
-
|
|
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
|
-
</
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
* `
|
|
56
|
-
*
|
|
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<
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
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>
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|