@tailor-platform/app-shell 1.16.0 → 1.18.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 +762 -51
- package/dist/app-shell.js +7642 -5636
- package/dist/{appshell-B0wh5Lq0.js → appshell-Y1AYCuVJ.js} +323 -304
- package/dist/semantic-contrast.test.ts +227 -0
- package/dist/style-entry.d.ts +61 -26
- package/dist/testing.d.ts +66 -29
- package/dist/testing.js +1 -1
- package/dist/theme-bridge.test.ts +124 -1
- package/dist/theme.bridge.css +178 -0
- package/dist/theme.css +8 -2
- package/dist/themes/_template.css +8 -3
- package/dist/themes/bloom.css +0 -8
- package/dist/themes/cream.css +0 -8
- package/dist/themes/default.css +458 -103
- package/dist/vite-plugin.d.ts +61 -26
- package/package.json +2 -2
- package/skills/app-shell-patterns/SKILL.md +48 -37
- package/skills/app-shell-patterns/references/api/define-i18n-labels.md +123 -91
- package/skills/app-shell-patterns/references/api/define-module.md +4 -6
- package/skills/app-shell-patterns/references/api/define-resource.md +2 -2
- package/skills/app-shell-patterns/references/api/use-app-shell-config.md +53 -47
- package/skills/app-shell-patterns/references/api/use-app-shell-data.md +30 -26
- package/skills/app-shell-patterns/references/api/use-app-shell.md +37 -30
- package/skills/app-shell-patterns/references/api/use-toast.md +28 -31
- package/skills/app-shell-patterns/references/components/app-shell.md +4 -4
- package/skills/app-shell-patterns/references/components/card.md +79 -7
- package/skills/app-shell-patterns/references/components/data-table.md +599 -77
- package/skills/app-shell-patterns/references/components/date-picker.md +32 -21
- package/skills/app-shell-patterns/references/components/filter-rail.md +165 -0
- package/skills/app-shell-patterns/references/components/sidebar-group.md +12 -6
- package/skills/app-shell-patterns/references/components/sidebar-section.md +114 -0
- package/skills/app-shell-patterns/references/components/text.md +198 -0
- package/skills/app-shell-patterns/references/concepts/custom-components.md +1 -1
- package/skills/app-shell-patterns/references/concepts/file-based-routing.md +7 -1
- package/skills/app-shell-patterns/references/concepts/graphql.md +20 -14
- package/skills/app-shell-patterns/references/concepts/routing-navigation.md +41 -3
- package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +32 -0
- package/skills/app-shell-patterns/references/concepts/styling-theming.md +278 -65
- package/skills/app-shell-patterns/references/migrations.md +43 -1
- package/skills/app-shell-patterns/references/pages/document-detail.md +6 -6
- package/skills/app-shell-patterns/references/patterns/form-modal.md +26 -20
- package/skills/app-shell-patterns/references/patterns/i18n-labels.md +139 -0
- package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +129 -118
- package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +5 -4
|
@@ -10,6 +10,8 @@ Each entry states which versions are affected, what breaks, how to detect it, an
|
|
|
10
10
|
|
|
11
11
|
| Version | Change |
|
|
12
12
|
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| 1.18.0 | [Destructive colours now resolve to the danger roles](#1180-destructive-colours-now-resolve-to-the-danger-roles) |
|
|
14
|
+
| 1.17.0 | [Status and alert tokens now resolve to the colour roles](#1170-status-and-alert-tokens-now-resolve-to-the-colour-roles) |
|
|
13
15
|
| 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) |
|
|
14
16
|
| 1.12.0 | [`DateField` / `DatePicker` field chrome moved to `Field.Root`](#1120-datefield--datepicker-field-chrome-moved-to-fieldroot) |
|
|
15
17
|
| 1.11.0 | [React 19.2.7 and React Router v8 required](#1110-react-1927-and-react-router-v8-are-now-required) |
|
|
@@ -21,6 +23,46 @@ Each entry states which versions are affected, what breaks, how to detect it, an
|
|
|
21
23
|
| 1.0.2 | [`Toaster` no longer accepts `richColors`](#102-toaster-no-longer-accepts-richcolors) |
|
|
22
24
|
| before 1.0 | [Pre-1.0 breaking changes](#before-10) |
|
|
23
25
|
|
|
26
|
+
## 1.18.0: destructive colours now resolve to the danger roles
|
|
27
|
+
|
|
28
|
+
**Applies to:** apps that show `Button variant="destructive"` in dark mode, apps that use `text-destructive`, `border-destructive` or `bg-destructive` on their own elements, and apps whose theme overrides `--destructive` or `--destructive-foreground`.
|
|
29
|
+
|
|
30
|
+
1.18.0 makes `--destructive` and `--destructive-foreground` aliases of `--danger-solid` and `--danger-contrast`. The names, `Button variant="destructive"` and `text-destructive` keep working. Nothing errors. `--destructive`, `--destructive-foreground` and their utilities are deprecated and will be removed in the next major; `Button variant="destructive"` will be renamed to `danger` then. Three things change silently:
|
|
31
|
+
|
|
32
|
+
- **Dark-mode `Button variant="destructive"` is a full `#dc2626` fill.** It was `#f87171` at 60% opacity. The text is `--danger-contrast` (white) and the hover fill is `--danger-solid-hover` instead of 90% opacity. Light mode keeps `#dc2626`.
|
|
33
|
+
- **`text-destructive` resolves to the solid.** In dark mode it changes from `#f87171` to `#dc2626`, which is 3.71:1 on the dark card, below 4.5:1 for text. Components shipped with AppShell now use `text-danger-text` for error text, and invalid borders and focus rings read `--danger-solid`. In dark mode the invalid border changes from `#f87171` to `#dc2626`.
|
|
34
|
+
- **Themes that override `--destructive` no longer reach the components.** `Button`, invalid `Input`, `Textarea`, `Checkbox`, `Field` and `DateField` borders and rings, error text and the `CsvImporter` error cells read `--danger-*`. An override of `--destructive` still changes `bg-destructive` and `text-destructive` on your own elements, so the two reds can drift apart. The `cream` and `bloom` palettes no longer set `--destructive`; their values were the same as the default.
|
|
35
|
+
|
|
36
|
+
How to detect it: search the app for `destructive` in class names and in theme CSS, including Theme Generator output that sets `--destructive`. Compare `Button variant="destructive"`, an invalid `Input` and an error message in light and dark mode after upgrading.
|
|
37
|
+
|
|
38
|
+
What to change:
|
|
39
|
+
|
|
40
|
+
- For text, replace `text-destructive` with `text-danger-text`. For fills, replace `bg-destructive` with `bg-danger-solid`, and `text-destructive-foreground` with `text-danger-contrast`.
|
|
41
|
+
- For tints and borders, use `bg-danger-surface` and `border-danger-border` instead of `bg-destructive/10` and `border-destructive/30`.
|
|
42
|
+
- In a theme that recolours destructive actions, set `--danger-solid` (and `--danger-solid-hover`, `--danger-text`, `--danger-contrast` as needed) instead of `--destructive`. Remove `--destructive` and `--destructive-foreground` from the theme.
|
|
43
|
+
|
|
44
|
+
The roles are documented in [Styling & theming](https://github.com/tailor-platform/app-shell/blob/main/docs/concepts/styling-theming.md#brand--action). The decision record is `decisions/semantic-color-roles.md`.
|
|
45
|
+
|
|
46
|
+
## 1.17.0: status and alert tokens now resolve to the colour roles
|
|
47
|
+
|
|
48
|
+
**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`.
|
|
49
|
+
|
|
50
|
+
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:
|
|
51
|
+
|
|
52
|
+
- **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`.
|
|
53
|
+
- **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`.
|
|
54
|
+
- **`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.
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
What to change:
|
|
59
|
+
|
|
60
|
+
- 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`.
|
|
61
|
+
- 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-*`.
|
|
62
|
+
- In a theme that recolours the status components, set the roles (`--success-solid`, `--danger-text`, ...) instead of `--status-*`, `--alert-*` or `--destructive`.
|
|
63
|
+
|
|
64
|
+
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`.
|
|
65
|
+
|
|
24
66
|
## 1.15.0: Base UI 1.8.0 breaks closed-`Select` and dialog-click tests
|
|
25
67
|
|
|
26
68
|
**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`.
|
|
@@ -158,7 +200,7 @@ body {
|
|
|
158
200
|
|
|
159
201
|
Toggle dark mode and confirm a real surface changes: inspect a `Card` and watch its computed `background-color` go from `rgb(255, 255, 255)` to `rgb(23, 23, 23)` on the default palette.
|
|
160
202
|
|
|
161
|
-
Reading the token directly also works — `getComputedStyle(document.documentElement).getPropertyValue("--card")` returns the winning declaration, so a stale copy shows up as its own value.
|
|
203
|
+
Reading the token directly also works — `getComputedStyle(document.documentElement).getPropertyValue("--card")` returns the winning declaration, so a stale copy shows up as its own value. Compare against the value the default palette resolves to: in dark mode `--card` returns `#171717`, the gray step it reads. (1.17.0 and earlier return `rgba(23, 23, 23, 1)`.) A copy in your own CSS shows up in the notation you wrote.
|
|
162
204
|
|
|
163
205
|
## 1.5.0: `loader` removed from file-based page definitions
|
|
164
206
|
|
|
@@ -183,7 +183,7 @@ time, and the record is unblocked only once every one is released.
|
|
|
183
183
|
### 4. Line items
|
|
184
184
|
|
|
185
185
|
`Card.Root` → `Card.Header` (title, plus a one-line description where the
|
|
186
|
-
columns need explaining) → `Card.Content
|
|
186
|
+
columns need explaining) → `Card.Content padding="none"` → `Table.Root`.
|
|
187
187
|
|
|
188
188
|
Fetch the line items with the record, sorted explicitly so the order is stable
|
|
189
189
|
across reloads, and render them as a plain `Table`. For the typical document —
|
|
@@ -773,7 +773,7 @@ export default function PurchaseOrderDetailPage({
|
|
|
773
773
|
would have been a link field on the summary instead. */}
|
|
774
774
|
<Card.Root>
|
|
775
775
|
<Card.Header title="Source requisitions" />
|
|
776
|
-
<Card.Content
|
|
776
|
+
<Card.Content padding="none">
|
|
777
777
|
<Table.Root>
|
|
778
778
|
<Table.Header>
|
|
779
779
|
<Table.Row>
|
|
@@ -808,7 +808,7 @@ export default function PurchaseOrderDetailPage({
|
|
|
808
808
|
enough to page. No container padding; the cells inset themselves. */}
|
|
809
809
|
<Card.Root>
|
|
810
810
|
<Card.Header title="Line items" />
|
|
811
|
-
<Card.Content
|
|
811
|
+
<Card.Content padding="none">
|
|
812
812
|
{order.lineItems.length === 0 ? (
|
|
813
813
|
<p className="px-6 text-sm text-muted-foreground">No line items on this order.</p>
|
|
814
814
|
) : (
|
|
@@ -875,7 +875,7 @@ export default function PurchaseOrderDetailPage({
|
|
|
875
875
|
relationship is possible, so the reader learns it exists. */}
|
|
876
876
|
<Card.Root>
|
|
877
877
|
<Card.Header title="Goods receipts" />
|
|
878
|
-
<Card.Content
|
|
878
|
+
<Card.Content padding="none">
|
|
879
879
|
{order.goodsReceipts.length === 0 ? (
|
|
880
880
|
<p className="px-6 text-sm text-muted-foreground">
|
|
881
881
|
No goods receipts linked to this order.
|
|
@@ -997,7 +997,7 @@ export default function PurchaseOrderDetailPage({
|
|
|
997
997
|
title="Journal entry"
|
|
998
998
|
description="Booked when this order was posted."
|
|
999
999
|
/>
|
|
1000
|
-
<Card.Content
|
|
1000
|
+
<Card.Content padding="none">
|
|
1001
1001
|
<Table.Root>
|
|
1002
1002
|
<Table.Header>
|
|
1003
1003
|
<Table.Row>
|
|
@@ -1096,7 +1096,7 @@ enforced across every detail screen once settled.
|
|
|
1096
1096
|
## Constraints
|
|
1097
1097
|
|
|
1098
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.
|
|
1099
|
-
- **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.
|
|
1100
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.
|
|
1101
1101
|
- **`ActionPanel` is workflow-only.** No navigation, no "view related record" — those are links in the cards that hold them.
|
|
1102
1102
|
- **Never rely on a query cap to bound the line-items table.** If the cap can be reached, page the lines.
|
|
@@ -28,32 +28,36 @@ function ModalForm() {
|
|
|
28
28
|
<Dialog.Title>Add address</Dialog.Title>
|
|
29
29
|
<Dialog.Description>Add a shipping address to this order.</Dialog.Description>
|
|
30
30
|
</Dialog.Header>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
{/*
|
|
32
|
+
* `onFormSubmit` fires only after validation passes and receives the
|
|
33
|
+
* registered field values — no `<form onSubmit>` + `new FormData(...)`.
|
|
34
|
+
*/}
|
|
35
|
+
<Form<AddressDraft>
|
|
36
|
+
noValidate
|
|
37
|
+
onFormSubmit={(values) => window.alert(`Saving ${values.label}`)}
|
|
37
38
|
>
|
|
38
39
|
<div className="flex flex-col gap-4 py-4">
|
|
39
40
|
<Field.Root name="label">
|
|
40
41
|
<Field.Label>Label</Field.Label>
|
|
41
|
-
<Field.Control
|
|
42
|
+
<Field.Control required />
|
|
43
|
+
<Field.Error match="valueMissing">Label is required.</Field.Error>
|
|
42
44
|
</Field.Root>
|
|
43
45
|
<Field.Root name="street">
|
|
44
46
|
<Field.Label>Street</Field.Label>
|
|
45
|
-
<Field.Control
|
|
47
|
+
<Field.Control required />
|
|
48
|
+
<Field.Error match="valueMissing">Street is required.</Field.Error>
|
|
46
49
|
</Field.Root>
|
|
47
50
|
<Field.Root name="city">
|
|
48
51
|
<Field.Label>City</Field.Label>
|
|
49
|
-
<Field.Control
|
|
52
|
+
<Field.Control required />
|
|
53
|
+
<Field.Error match="valueMissing">City is required.</Field.Error>
|
|
50
54
|
</Field.Root>
|
|
51
55
|
</div>
|
|
52
56
|
<Dialog.Footer>
|
|
53
|
-
<Dialog.Close render={<Button variant="ghost" />}>Cancel</Dialog.Close>
|
|
57
|
+
<Dialog.Close render={<Button type="button" variant="ghost" />}>Cancel</Dialog.Close>
|
|
54
58
|
<Button type="submit">Save</Button>
|
|
55
59
|
</Dialog.Footer>
|
|
56
|
-
</
|
|
60
|
+
</Form>
|
|
57
61
|
</Dialog.Content>
|
|
58
62
|
</Dialog.Root>
|
|
59
63
|
);
|
|
@@ -86,27 +90,28 @@ function ModalFormRouted() {
|
|
|
86
90
|
<Dialog.Header>
|
|
87
91
|
<Dialog.Title>Create product</Dialog.Title>
|
|
88
92
|
</Dialog.Header>
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
window.alert(`Saving ${data.get("name") ?? ""}`);
|
|
93
|
+
<Form<ProductDraft>
|
|
94
|
+
noValidate
|
|
95
|
+
onFormSubmit={(values) => {
|
|
96
|
+
window.alert(`Saving ${values.name}`);
|
|
94
97
|
setCreateOpen(false);
|
|
95
98
|
}}
|
|
96
99
|
>
|
|
97
100
|
<div className="flex flex-col gap-4 py-4">
|
|
98
101
|
<Field.Root name="name">
|
|
99
102
|
<Field.Label>Name</Field.Label>
|
|
100
|
-
<Field.Control
|
|
103
|
+
<Field.Control required />
|
|
104
|
+
<Field.Error match="valueMissing">Name is required.</Field.Error>
|
|
101
105
|
</Field.Root>
|
|
102
106
|
</div>
|
|
103
107
|
<Dialog.Footer>
|
|
104
|
-
|
|
108
|
+
{/* Inside a `<Form>` an untyped button submits — Cancel must be `type="button"`. */}
|
|
109
|
+
<Button type="button" variant="ghost" onClick={() => setCreateOpen(false)}>
|
|
105
110
|
Cancel
|
|
106
111
|
</Button>
|
|
107
112
|
<Button type="submit">Save</Button>
|
|
108
113
|
</Dialog.Footer>
|
|
109
|
-
</
|
|
114
|
+
</Form>
|
|
110
115
|
</Dialog.Content>
|
|
111
116
|
</Dialog.Root>
|
|
112
117
|
</Layout>
|
|
@@ -133,7 +138,8 @@ placeholder.
|
|
|
133
138
|
- **`Form` + `Field` is the default stack.** They wrap Base UI and ship with AppShell — no extra
|
|
134
139
|
dependency.
|
|
135
140
|
- **Submit via `onFormSubmit(values)`.** It fires only after validation passes. Do not hand-roll
|
|
136
|
-
`<form onSubmit>` + `new FormData(...)` — that
|
|
141
|
+
`<form onSubmit>` + `new FormData(...)` — that bypasses `Form`'s field validation and server-error
|
|
142
|
+
routing.
|
|
137
143
|
- **`onFormSubmit` reads registered `Field.Root`s, not the DOM.** So every control — including
|
|
138
144
|
`Select`, `Combobox`, and `Autocomplete` — just needs wrapping in a `Field.Root name="…"`. They
|
|
139
145
|
need **no `name` of their own and no `useState`**. Mirroring field values into React state is the
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: i18n Labels
|
|
3
|
+
description: Decide UI language before implementation and keep display labels separate from stable data
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<!-- GENERATED by docs-kit from docs-src/patterns/i18n-labels.docs.outline.md — do not edit by hand. -->
|
|
7
|
+
|
|
8
|
+
# i18n Labels
|
|
9
|
+
|
|
10
|
+
## When to Use
|
|
11
|
+
|
|
12
|
+
Read this cross-cutting pattern **before implementing a screen**, alongside its page or layout pattern. It applies to single-language apps as well as apps with a language selector. For the complete API and fallback rules, see [defineI18nLabels](../api/define-i18n-labels.md).
|
|
13
|
+
|
|
14
|
+
## Decide the UI Language First
|
|
15
|
+
|
|
16
|
+
Record the UI language in the screen or app specification independently of the language used to write the document. An English specification, field name, or enum does not imply English UI copy.
|
|
17
|
+
|
|
18
|
+
For a Japanese-only app, explicitly pass `locale="ja"` (or `"ja-JP"`) to the root `AppShell`; do not rely on each developer's browser preferences. Keep the same policy across navigation, headings, forms, dialogs, accessibility labels, empty/loading/error states, validation, and notifications. Browser detection is appropriate only when that is the intended product behavior.
|
|
19
|
+
|
|
20
|
+
Use `defineI18nLabels` by default, including for single-language apps, to avoid a later screen-by-screen extraction. Its current API always requires an `en` base, even when the app selects `ja`. A project may explicitly opt out of catalog-based labels for a deliberately single-language app; record that decision and keep its chosen UI language consistent. AppShell does not force catalog adoption or generate missing translations.
|
|
21
|
+
|
|
22
|
+
## Key Granularity and Naming
|
|
23
|
+
|
|
24
|
+
- Use a **flat** map with semantic dotted keys, such as `"requests.list.title"`, `"requests.actions.approve"`, and `"requests.status.PENDING"`. Dots are a naming convention, not nested objects.
|
|
25
|
+
- Name the key after its purpose, not the current wording. Copy edits should not rename keys.
|
|
26
|
+
- One key represents one independently translated message. Translate a whole sentence using typed props rather than joining fragments, so each language controls word order and pluralization.
|
|
27
|
+
- Share genuinely equivalent operations such as `"actions.cancel"`; do not reuse a key merely because two English strings currently happen to match. A navigation group, page heading, and permission description can require different wording.
|
|
28
|
+
- Keep IDs, route segments, field names, enum values, and comparison values untranslated. Translate only their user-facing presentation. User-entered content and proper names are data, not catalog keys.
|
|
29
|
+
|
|
30
|
+
## Split by Feature, Not by Locale
|
|
31
|
+
|
|
32
|
+
Start with one `i18n-labels.ts` per feature, containing all of that feature's supported locales. Keep a small shared catalog for genuinely common copy. Export each factory result and its Hook; alias the Hook at import sites when a component needs several catalogs.
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
src/
|
|
36
|
+
i18n-labels.ts # shared actions and generic feedback
|
|
37
|
+
features/
|
|
38
|
+
requests/
|
|
39
|
+
i18n-labels.ts # request keys, with en + ja together
|
|
40
|
+
request-list.tsx
|
|
41
|
+
users/
|
|
42
|
+
i18n-labels.ts # user keys, with en + ja together
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Avoid a global catalog that every feature must edit, but do not create a separate catalog for every small component. Keep definitions at module scope, outside React render functions. Do not add a new wrapper, global translator, or dictionary-merging framework: each `defineI18nLabels` result already supplies its own typed resolvers.
|
|
46
|
+
|
|
47
|
+
## Example: Stable Status, Localized Display
|
|
48
|
+
|
|
49
|
+
The shared setup for the runnable example is a feature catalog in `features/requests/i18n-labels.ts`:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { defineI18nLabels } from "@tailor-platform/app-shell";
|
|
53
|
+
|
|
54
|
+
export const labels = defineI18nLabels({
|
|
55
|
+
en: {
|
|
56
|
+
"requests.list.title": "Requests",
|
|
57
|
+
"requests.actions.approve": "Approve",
|
|
58
|
+
"requests.status.PENDING": "Pending approval",
|
|
59
|
+
"requests.status.APPROVED": "Approved",
|
|
60
|
+
"requests.status.unknown": "Unknown status",
|
|
61
|
+
},
|
|
62
|
+
ja: {
|
|
63
|
+
"requests.list.title": "申請一覧",
|
|
64
|
+
"requests.actions.approve": "承認",
|
|
65
|
+
"requests.status.PENDING": "承認待ち",
|
|
66
|
+
"requests.status.APPROVED": "承認済み",
|
|
67
|
+
"requests.status.unknown": "不明な状態",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const useRequestT = labels.useT;
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Import `useRequestT` from that module in the screen below. Here, `request` stands in for a validated server record; its ID and status remain unchanged in every locale.
|
|
75
|
+
|
|
76
|
+
<example-preview name="localized-request"></example-preview>
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
function LocalizedRequest() {
|
|
80
|
+
const t = useRequestT();
|
|
81
|
+
const request = { id: "REQ-1042", status: "PENDING" };
|
|
82
|
+
const canApprove = request.status === "PENDING";
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Layout>
|
|
86
|
+
<Layout.Header
|
|
87
|
+
title={t("requests.list.title")}
|
|
88
|
+
actions={[
|
|
89
|
+
<Button key="approve" disabled={!canApprove}>
|
|
90
|
+
{t("requests.actions.approve")}
|
|
91
|
+
</Button>,
|
|
92
|
+
]}
|
|
93
|
+
/>
|
|
94
|
+
<Layout.Column>
|
|
95
|
+
<div className="flex items-center gap-2">
|
|
96
|
+
<span>{request.id}</span>
|
|
97
|
+
<Badge variant={canApprove ? "warning" : "success"}>
|
|
98
|
+
{t.dynamic(`requests.status.${request.status}`, t("requests.status.unknown"))}
|
|
99
|
+
</Badge>
|
|
100
|
+
</div>
|
|
101
|
+
</Layout.Column>
|
|
102
|
+
</Layout>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The comparison uses `"PENDING"`, never `t("requests.status.PENDING")`. The runtime-key fallback is itself translated. For a known key, prefer `t(key)` to `t.dynamic()` so TypeScript checks it. For a function-valued label, use `t(key, props)`; `t.dynamic()` cannot supply props.
|
|
108
|
+
|
|
109
|
+
Navigation definitions use the same catalog's `labels.t("requests.list.title")`, not the component Hook. Pass it to `meta.title` or `meta.breadcrumbTitle` so AppShell resolves it with its current locale. Other component props that accept only strings use the Hook's `t()` instead.
|
|
110
|
+
|
|
111
|
+
## Server-originated Text
|
|
112
|
+
|
|
113
|
+
Keep **machine-readable meaning** separate from **display copy**:
|
|
114
|
+
|
|
115
|
+
| Server data | Frontend treatment |
|
|
116
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
117
|
+
| Stable enum or status code | Keep the value unchanged; map it to a display label |
|
|
118
|
+
| Domain error code plus structured parameters | Validate the response, select a known label by code, and pass parameters to a function-valued label |
|
|
119
|
+
| Unknown error code or unexpected response shape | Show a localized generic failure message; preserve a safe support identifier when available |
|
|
120
|
+
| User-authored text, identifiers, or proper names | Display as data, not as a label key |
|
|
121
|
+
| English prose with no usable code/parameters | It cannot be reliably localized or used for matching; show a localized fallback and treat structured errors as a backend prerequisite |
|
|
122
|
+
|
|
123
|
+
For example, an `APPROVAL_ROUTE_BLOCKED` code plus a separate `routeName` parameter can select a message like `"requests.errors.routeBlocked"` with `{ routeName }`. Do not find the route name by parsing an English sentence, translate arbitrary server text as a catalog key, or compare translated error messages to decide behavior.
|
|
124
|
+
|
|
125
|
+
If a transport collapses all domain codes into `PIPELINE_ERROR`, the frontend cannot recover the lost meaning. Fixing error propagation is a platform concern, not something this pattern or `defineI18nLabels` solves. Do not pretend that a generic code identifies a specific domain failure. Avoid showing raw technical error details to end users.
|
|
126
|
+
|
|
127
|
+
## Validation and E2E
|
|
128
|
+
|
|
129
|
+
- Compile catalogs and call sites: explicitly declared locales must supply the English base keys and compatible props. This checks structure, not the language or quality of the wording.
|
|
130
|
+
- Verify the intended locale in the browser, including navigation, dialogs, empty/error states, accessibility names, notifications, and unknown-code fallbacks. Do not assume successful type checks prove the absence of mixed-language UI.
|
|
131
|
+
- Test business state with stable codes. Test rendered labels in the selected locale; do not leave English display expectations in Japanese UI tests.
|
|
132
|
+
- Scope E2E locators by role and region, such as a heading in `main` or a button inside a named dialog. A translated phrase can legitimately occur in the sidebar, breadcrumb, and page; an unscoped text locator can become ambiguous.
|
|
133
|
+
- When adding a locale, review the app's catalogs **and** built-in component coverage. Custom catalog translations do not automatically translate AppShell's built-in labels.
|
|
134
|
+
|
|
135
|
+
## Related
|
|
136
|
+
|
|
137
|
+
- [defineI18nLabels](../api/define-i18n-labels.md) — `useT`, `t.dynamic`, function props, metadata, and fallback behavior
|
|
138
|
+
- [useResolvedLocale](../api/use-resolved-locale.md) — full locale for date and number formatting
|
|
139
|
+
- [Sidebar Navigation](../concepts/sidebar-navigation.md) — localized groups, sections, and item titles
|