@terpjs/react-core 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -21
- package/package.json +6 -5
- package/src/AppShell.test.tsx +323 -4
- package/src/AppShell.tsx +401 -66
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +43 -18
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +12 -0
- package/src/markers.test.ts +27 -5
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/router.test.tsx +575 -2
- package/src/router.tsx +212 -19
- package/src/styles.test.ts +535 -58
- package/src/styles.ts +1130 -111
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +239 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +69 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +139 -0
- package/src/ui/Combobox.tsx +255 -43
- package/src/ui/DatePicker.tsx +44 -12
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +30 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +36 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/bootstrap.test.tsx
CHANGED
|
@@ -64,4 +64,212 @@ describe("renderTerpApp", () => {
|
|
|
64
64
|
);
|
|
65
65
|
root.remove();
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
it("forwards the layout declaration to buildAppRouter", () => {
|
|
69
|
+
// Same shape as the navGroups test below, for the same reason: the forwarding is one line in
|
|
70
|
+
// a hand-written enumeration, and a missing line leaves every gate on the declaration green
|
|
71
|
+
// while the one-call entry point — the one apps actually use — ignores the file entirely.
|
|
72
|
+
//
|
|
73
|
+
// Gated through a refusal only `buildAppRouter` can raise, and that took a second attempt.
|
|
74
|
+
// The first version asserted the resolver's enum refusal — which `renderTerpApp` now raises
|
|
75
|
+
// ITSELF, from its own resolve call, before `buildAppRouter` is reached. So it threw with
|
|
76
|
+
// the forwarding line deleted and reported green: a test that passes with the bug. An
|
|
77
|
+
// UNKNOWN CONTRACT is refused only inside `buildAppRouter`, against its own table, so the
|
|
78
|
+
// throw here observes the one line under test and nothing else.
|
|
79
|
+
//
|
|
80
|
+
// Mutation: delete `layout: options.layout` from the buildAppRouter call, and this stops
|
|
81
|
+
// throwing.
|
|
82
|
+
const root = document.createElement("div");
|
|
83
|
+
document.body.appendChild(root);
|
|
84
|
+
try {
|
|
85
|
+
expect(() =>
|
|
86
|
+
renderTerpApp({
|
|
87
|
+
title: "Test",
|
|
88
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
89
|
+
layout: { contract: "bespoke" },
|
|
90
|
+
rootElement: root,
|
|
91
|
+
}),
|
|
92
|
+
).toThrow(/Unknown layout contract "bespoke"/);
|
|
93
|
+
} finally {
|
|
94
|
+
root.remove();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("opens on the palette the declaration names, and on the option when it does not", async () => {
|
|
99
|
+
// The forwarding tests above gate their line through a THROW, because a signed-out mount
|
|
100
|
+
// renders no shell to read. The palette is the exception and gets the stronger assertion:
|
|
101
|
+
// `ThemeProvider` wraps `RequireAuth`, so it mounts signed out, and the attribute it writes
|
|
102
|
+
// on <html> is the actual observable effect an app ships. A throw here would only prove the
|
|
103
|
+
// file was validated, not that the value reached anything.
|
|
104
|
+
//
|
|
105
|
+
// Mutations, both red, and both by reading NULL rather than "midnight": `"system"` is the
|
|
106
|
+
// provider's default and it REMOVES the attribute, so neither mutation can produce the
|
|
107
|
+
// string. (An earlier version of this comment said the first one reads "system", which is
|
|
108
|
+
// not a value `data-theme` ever holds.) Pass `options.defaultTheme` to `ThemeProvider`
|
|
109
|
+
// instead of `layout.defaultTheme`, or drop `defaultTheme` from the explicit set handed to
|
|
110
|
+
// the resolver, and the corresponding assertion reads null.
|
|
111
|
+
for (const [label, options] of [
|
|
112
|
+
["from the file", { layout: { defaultTheme: "midnight" } }],
|
|
113
|
+
["from the option", { defaultTheme: "midnight" as const }],
|
|
114
|
+
] as const) {
|
|
115
|
+
// A stored choice outranks the default — that is the whole point of a *default* — so the
|
|
116
|
+
// assertion would be about localStorage rather than about the declaration without this.
|
|
117
|
+
window.localStorage.clear();
|
|
118
|
+
document.documentElement.removeAttribute("data-theme");
|
|
119
|
+
const root = document.createElement("div");
|
|
120
|
+
document.body.appendChild(root);
|
|
121
|
+
try {
|
|
122
|
+
renderTerpApp({
|
|
123
|
+
title: "Test",
|
|
124
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
125
|
+
rootElement: root,
|
|
126
|
+
...options,
|
|
127
|
+
});
|
|
128
|
+
await waitFor(() =>
|
|
129
|
+
expect(
|
|
130
|
+
document.documentElement.getAttribute("data-theme"),
|
|
131
|
+
`${label}: the declared palette must reach <html>`,
|
|
132
|
+
).toBe("midnight"),
|
|
133
|
+
);
|
|
134
|
+
} finally {
|
|
135
|
+
root.remove();
|
|
136
|
+
document.documentElement.removeAttribute("data-theme");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("refuses the palette named in the file and passed as an option", () => {
|
|
142
|
+
// Non-exclusive per ADR 0009 — either source alone is complete, as the test above shows —
|
|
143
|
+
// but not both, and not even when they agree. Reachable only through `renderTerpApp`,
|
|
144
|
+
// because `buildAppRouter` has no palette option to conflict with.
|
|
145
|
+
const root = document.createElement("div");
|
|
146
|
+
document.body.appendChild(root);
|
|
147
|
+
try {
|
|
148
|
+
expect(() =>
|
|
149
|
+
renderTerpApp({
|
|
150
|
+
title: "Test",
|
|
151
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
152
|
+
layout: { defaultTheme: "midnight" },
|
|
153
|
+
defaultTheme: "midnight",
|
|
154
|
+
rootElement: root,
|
|
155
|
+
}),
|
|
156
|
+
).toThrow(/both declare "defaultTheme" \(file: "midnight", code: "midnight"\)/);
|
|
157
|
+
} finally {
|
|
158
|
+
root.remove();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("names every doubly-declared key at once, groups included", async () => {
|
|
163
|
+
// The property the resolver's `conflicts.join("; ")` exists for, asserted through the entry
|
|
164
|
+
// point apps use — and it was broken here. `renderTerpApp` resolves the declaration itself
|
|
165
|
+
// (the palette mounts outside the router) and its explicit set omitted `navGroups`, so a
|
|
166
|
+
// groups conflict was invisible from this side: the author fixed the density conflict,
|
|
167
|
+
// re-ran, and hit a second, different refusal. Mutation: drop `navGroups: options.navGroups`
|
|
168
|
+
// from the resolve call at the top of `renderTerpApp` and this names only the density.
|
|
169
|
+
const root = document.createElement("div");
|
|
170
|
+
document.body.appendChild(root);
|
|
171
|
+
let message = "";
|
|
172
|
+
try {
|
|
173
|
+
renderTerpApp({
|
|
174
|
+
title: "Test",
|
|
175
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
176
|
+
layout: {
|
|
177
|
+
shell: {
|
|
178
|
+
density: "comfortable",
|
|
179
|
+
navGroups: [{ id: "work", label: "Workspace" }],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
density: "compact",
|
|
183
|
+
navGroups: [{ id: "admin", label: "Admin" }],
|
|
184
|
+
rootElement: root,
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
message = (error as Error).message;
|
|
188
|
+
} finally {
|
|
189
|
+
root.remove();
|
|
190
|
+
}
|
|
191
|
+
expect(message).toContain('"shell.density"');
|
|
192
|
+
expect(message).toContain('"shell.navGroups"');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuses the navigation groups named in the file and passed as an option", () => {
|
|
196
|
+
// The array key travelling the same seam as the scalars, end to end: the resolver sees the
|
|
197
|
+
// file, `buildAppRouter` hands the resolved list to the shell, and declaring the groups in
|
|
198
|
+
// both places is one fact declared twice. Named by id, because a list of objects stringified
|
|
199
|
+
// into an error message tells the reader nothing they can match up.
|
|
200
|
+
const root = document.createElement("div");
|
|
201
|
+
document.body.appendChild(root);
|
|
202
|
+
try {
|
|
203
|
+
expect(() =>
|
|
204
|
+
renderTerpApp({
|
|
205
|
+
title: "Test",
|
|
206
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
207
|
+
layout: { shell: { navGroups: [{ id: "work", label: "Workspace" }] } },
|
|
208
|
+
navGroups: [{ id: "admin", label: "Admin" }],
|
|
209
|
+
rootElement: root,
|
|
210
|
+
}),
|
|
211
|
+
).toThrow(/both declare "shell\.navGroups" \(file: "work", code: "admin"\)/);
|
|
212
|
+
} finally {
|
|
213
|
+
root.remove();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("refuses duplicate ids declared in the file, through the router's own check", () => {
|
|
218
|
+
// The check lives in `buildAppRouter` and now reads the RESOLVED list, so this is the proof
|
|
219
|
+
// that a duplicate reaches it from the file and not only from the option. Mutation: point
|
|
220
|
+
// the check back at `options.navGroups` and this stops throwing while the option-side test
|
|
221
|
+
// above stays green.
|
|
222
|
+
const root = document.createElement("div");
|
|
223
|
+
document.body.appendChild(root);
|
|
224
|
+
try {
|
|
225
|
+
expect(() =>
|
|
226
|
+
renderTerpApp({
|
|
227
|
+
title: "Test",
|
|
228
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
229
|
+
layout: {
|
|
230
|
+
shell: {
|
|
231
|
+
navGroups: [
|
|
232
|
+
{ id: "work", label: "Workspace" },
|
|
233
|
+
{ id: "work", label: "Again" },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
rootElement: root,
|
|
238
|
+
}),
|
|
239
|
+
).toThrow(/duplicate id\(s\): work/);
|
|
240
|
+
} finally {
|
|
241
|
+
root.remove();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("forwards navGroups to buildAppRouter", () => {
|
|
246
|
+
// `renderTerpApp` hands its options to `buildAppRouter` through a hand-written enumeration of
|
|
247
|
+
// names, and nothing covered it — `navPlacement`, `contentWidth` and `density` all travel that
|
|
248
|
+
// same list with no assertion that any of them arrives. A missing line there leaves every
|
|
249
|
+
// other gate on groups green while the feature does nothing for every app using the one-call
|
|
250
|
+
// entry point, which is the entry point apps actually use.
|
|
251
|
+
//
|
|
252
|
+
// Gated through the duplicate-id refusal rather than through a rendered label, and that is
|
|
253
|
+
// the point of doing it this way: this test mounts the SIGNED-OUT app, so the shell never
|
|
254
|
+
// renders and there is no nav to read. The throw only happens if the option was forwarded, so
|
|
255
|
+
// it observes the one line under test and nothing else. Mutation: delete
|
|
256
|
+
// `navGroups: options.navGroups` from the buildAppRouter call, and this stops throwing.
|
|
257
|
+
const root = document.createElement("div");
|
|
258
|
+
document.body.appendChild(root);
|
|
259
|
+
try {
|
|
260
|
+
expect(() =>
|
|
261
|
+
renderTerpApp({
|
|
262
|
+
title: "Test",
|
|
263
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
264
|
+
navGroups: [
|
|
265
|
+
{ id: "work", label: "Werkruimte" },
|
|
266
|
+
{ id: "work", label: "Weer werkruimte" },
|
|
267
|
+
],
|
|
268
|
+
rootElement: root,
|
|
269
|
+
}),
|
|
270
|
+
).toThrow(/duplicate id\(s\): work/);
|
|
271
|
+
} finally {
|
|
272
|
+
root.remove();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
67
275
|
});
|
package/src/bootstrap.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { RouterProvider } from "@tanstack/react-router";
|
|
|
2
2
|
import { StrictMode } from "react";
|
|
3
3
|
import type { ComponentType, ReactNode } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import type { ModuleManifest } from "@terpjs/contract";
|
|
5
|
+
import type { ModuleManifest, NavGroup } from "@terpjs/contract";
|
|
6
6
|
|
|
7
7
|
import { LoginView } from "./LoginView";
|
|
8
8
|
import type { DevCredentials } from "./LoginView";
|
|
@@ -10,7 +10,10 @@ import { RequireAuth } from "./RequireAuth";
|
|
|
10
10
|
import { TerpProvider } from "./TerpProvider";
|
|
11
11
|
import { AdminHub } from "./admin/AdminHub";
|
|
12
12
|
import { adminModule } from "./admin/module";
|
|
13
|
+
import { resolveLayoutDeclaration } from "./layoutDeclaration";
|
|
14
|
+
import type { LayoutDeclaration } from "./layoutDeclaration";
|
|
13
15
|
import { LocaleProvider } from "./locale";
|
|
16
|
+
import { installPreviewBridge } from "./previewBridge";
|
|
14
17
|
import type { LocaleCatalog } from "./locale";
|
|
15
18
|
import { buildAppRouter } from "./router";
|
|
16
19
|
import type { SsoProvider } from "./sso";
|
|
@@ -97,8 +100,61 @@ export interface RenderTerpAppOptions {
|
|
|
97
100
|
modules: Record<string, unknown>;
|
|
98
101
|
/** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
|
|
99
102
|
logo?: ReactNode;
|
|
103
|
+
/**
|
|
104
|
+
* The dark-theme brand mark ({@link AppShell.logoDark}); the stylesheet picks per appearance,
|
|
105
|
+
* with no code of the app's involved.
|
|
106
|
+
*
|
|
107
|
+
* Forwarded because it was not, and the template already told every new app to pass it — so
|
|
108
|
+
* the documented example did not typecheck. Third instance of the shape this ADR's Context
|
|
109
|
+
* names for `headerActions`: a slot that exists on the shell and cannot be reached from the
|
|
110
|
+
* one-call bootstrap.
|
|
111
|
+
*/
|
|
112
|
+
logoDark?: ReactNode;
|
|
113
|
+
/**
|
|
114
|
+
* Extra header content, rendered before the theme / language controls.
|
|
115
|
+
*
|
|
116
|
+
* `AppShell` has had this slot all along and `renderTerpApp` did not pass it, so the only way
|
|
117
|
+
* to reach it was to abandon the one-call bootstrap for `TerpProvider` + `buildAppRouter` —
|
|
118
|
+
* a slot that existed and was unreachable from the entry point every app uses.
|
|
119
|
+
*/
|
|
120
|
+
headerActions?: ReactNode;
|
|
100
121
|
/** Footer line under the content; default: a muted line with the app title. */
|
|
101
122
|
footer?: ReactNode;
|
|
123
|
+
/**
|
|
124
|
+
* Cap routed content at the published measure, with each page's header on the full track
|
|
125
|
+
* ({@link AppShell.contentWidth}); default `"full"`, which changes nothing. Move the measure
|
|
126
|
+
* itself from an app's own `theme.css`: `--shell-content-max-width`.
|
|
127
|
+
*/
|
|
128
|
+
contentWidth?: "full" | "measured";
|
|
129
|
+
/**
|
|
130
|
+
* App-wide density — one attribute on the shell root, from which every control height and cell
|
|
131
|
+
* padding follows by token inheritance.
|
|
132
|
+
*
|
|
133
|
+
* **No default.** Omitting it stamps nothing, so an app setting `data-density` on `<html>`
|
|
134
|
+
* (ADR 0094 §4's app-wide case) still reaches everything; a shell default would silently win
|
|
135
|
+
* against it.
|
|
136
|
+
*/
|
|
137
|
+
density?: "comfortable" | "compact";
|
|
138
|
+
/**
|
|
139
|
+
* Where the primary navigation lives on desktop ({@link AppShell.navPlacement}): the
|
|
140
|
+
* full-height `"sidebar"` (default, and what every shell renders today) or `"header"`, a
|
|
141
|
+
* horizontal row in the header with no sidebar at all. Below the mobile breakpoint both are
|
|
142
|
+
* the drawer.
|
|
143
|
+
*/
|
|
144
|
+
navPlacement?: "sidebar" | "header";
|
|
145
|
+
/**
|
|
146
|
+
* The app's navigation groups ({@link AppShell.navGroups}), referenced by manifest items
|
|
147
|
+
* through `NavItem.group`. Omit for the flat, unlabelled sidebar every app renders today.
|
|
148
|
+
*
|
|
149
|
+
* This is the app's half of the model and there is no module-side equivalent: a group spans
|
|
150
|
+
* modules, so its label and its position cannot belong to any one of them. A duplicate id is
|
|
151
|
+
* refused when the router is built.
|
|
152
|
+
*
|
|
153
|
+
* Prefer {@link RenderTerpAppOptions.layout}: `shell.navGroups` in the app's own
|
|
154
|
+
* `frontend/layout-contract.json` says the same thing in the one document a tool can read and
|
|
155
|
+
* rewrite. Declaring the groups in both places is refused rather than silently resolved.
|
|
156
|
+
*/
|
|
157
|
+
navGroups?: readonly NavGroup[];
|
|
102
158
|
/**
|
|
103
159
|
* Ship the packaged admin area (default `true`): the admin-gated sidebar entry, the
|
|
104
160
|
* `/admin` hub, and the users / groups / audit screens over the base-profile
|
|
@@ -131,15 +187,40 @@ export interface RenderTerpAppOptions {
|
|
|
131
187
|
locales?: Record<string, LocaleCatalog>;
|
|
132
188
|
/** Starting locale when the user has not chosen one; default: the first `locales` key. */
|
|
133
189
|
defaultLocale?: string;
|
|
134
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Starting theme when the user has not chosen one; default `"system"` (OS preference).
|
|
192
|
+
*
|
|
193
|
+
* Prefer {@link RenderTerpAppOptions.layout}: `"defaultTheme"` in the app's own
|
|
194
|
+
* `frontend/layout-contract.json` says the same thing in the one document a tool can read and
|
|
195
|
+
* rewrite, which is the whole reason the declaration exists. Declaring it in both places is
|
|
196
|
+
* refused rather than silently resolved.
|
|
197
|
+
*/
|
|
135
198
|
defaultTheme?: Theme;
|
|
136
199
|
/**
|
|
137
200
|
* Opt into a slot-typed layout contract (ADR 0079), e.g. `"standard"`: every routed
|
|
138
201
|
* archetype's body slot then accepts only the components the contract allows there,
|
|
139
|
-
* verified at runtime (fail closed).
|
|
140
|
-
*
|
|
202
|
+
* verified at runtime (fail closed).
|
|
203
|
+
*
|
|
204
|
+
* Prefer {@link RenderTerpAppOptions.layout} — importing the app's own
|
|
205
|
+
* `frontend/layout-contract.json` declares this once for both halves. This option used to
|
|
206
|
+
* carry the instruction "keep it in sync with the checked-in file", which is a defect
|
|
207
|
+
* written as advice: the lint rule reads the file, this read the option, and nothing
|
|
208
|
+
* compared them.
|
|
141
209
|
*/
|
|
142
210
|
layoutContract?: string;
|
|
211
|
+
/**
|
|
212
|
+
* The app's checked-in layout declaration: `import layout from "../layout-contract.json"`.
|
|
213
|
+
*
|
|
214
|
+
* One file declaring the layout contract for both the lint rule and the runtime check, plus
|
|
215
|
+
* the palette the app opens on and the shell's own shape — density, navigation placement,
|
|
216
|
+
* content measure, and the navigation groups a module's items name by id. Declaring a key
|
|
217
|
+
* here and passing the matching option is refused rather than silently resolved.
|
|
218
|
+
*
|
|
219
|
+
* The authoritative list is `TOP_LEVEL_KEYS` and `SHELL_KEYS` in
|
|
220
|
+
* {@link ./layoutDeclaration}, and the published `layout.manifest.json` beside them. This
|
|
221
|
+
* sentence is a restatement and has already drifted once.
|
|
222
|
+
*/
|
|
223
|
+
layout?: LayoutDeclaration;
|
|
143
224
|
/** Mount point; default `document.getElementById("root")`. */
|
|
144
225
|
rootElement?: HTMLElement | null;
|
|
145
226
|
}
|
|
@@ -223,6 +304,27 @@ export function withAdminArea(
|
|
|
223
304
|
* stylesheet import. Drop to `TerpProvider` + `buildAppRouter` for full control.
|
|
224
305
|
*/
|
|
225
306
|
export function renderTerpApp(options: RenderTerpAppOptions): void {
|
|
307
|
+
// Resolved here as well as inside `buildAppRouter`, because the one key mounted OUTSIDE the
|
|
308
|
+
// router is the palette: `ThemeProvider` wraps everything, the router included.
|
|
309
|
+
//
|
|
310
|
+
// Two calls, one answer — but only while both are handed the SAME option set, and that is a
|
|
311
|
+
// standing obligation rather than something the code enforces. It was broken once already,
|
|
312
|
+
// within a day: `navGroups` was added to the router's set and not to this one, so a groups
|
|
313
|
+
// conflict declared through `renderTerpApp` was invisible here and the resolver's
|
|
314
|
+
// report-every-conflict-at-once property quietly became report-some. Anything added to
|
|
315
|
+
// `BuildAppRouterOptions` and passed at the bottom of this function belongs in this call too;
|
|
316
|
+
// `bootstrap.test.tsx` names both conflicts in one message to hold that.
|
|
317
|
+
//
|
|
318
|
+
// `layout` and every option still go down untouched, so a key added to the declaration later
|
|
319
|
+
// reaches the router without being threaded through this function first.
|
|
320
|
+
const layout = resolveLayoutDeclaration(options.layout, {
|
|
321
|
+
contract: options.layoutContract,
|
|
322
|
+
density: options.density,
|
|
323
|
+
navPlacement: options.navPlacement,
|
|
324
|
+
contentWidth: options.contentWidth,
|
|
325
|
+
navGroups: options.navGroups,
|
|
326
|
+
defaultTheme: options.defaultTheme,
|
|
327
|
+
});
|
|
226
328
|
const collected = collectModules(options.modules);
|
|
227
329
|
const { manifests, views } = withAdminArea(
|
|
228
330
|
collected.manifests,
|
|
@@ -233,16 +335,30 @@ export function renderTerpApp(options: RenderTerpAppOptions): void {
|
|
|
233
335
|
views,
|
|
234
336
|
title: options.title,
|
|
235
337
|
logo: options.logo,
|
|
338
|
+
logoDark: options.logoDark,
|
|
339
|
+
headerActions: options.headerActions,
|
|
236
340
|
footer: options.footer,
|
|
341
|
+
contentWidth: options.contentWidth,
|
|
342
|
+
density: options.density,
|
|
343
|
+
navPlacement: options.navPlacement,
|
|
344
|
+
navGroups: options.navGroups,
|
|
237
345
|
layoutContract: options.layoutContract,
|
|
346
|
+
layout: options.layout,
|
|
238
347
|
});
|
|
239
348
|
const root = options.rootElement ?? document.getElementById("root");
|
|
240
349
|
if (!root) {
|
|
241
350
|
throw new Error('renderTerpApp: no root element (add <div id="root"> or pass rootElement).');
|
|
242
351
|
}
|
|
352
|
+
// The channel a tool showing this app in an iframe can ask it questions through, and it exists
|
|
353
|
+
// ONLY in a development build: `import.meta.env.DEV` folds to false in production and the
|
|
354
|
+
// module goes with it, so a deployed app carries no listener at all. Same mechanism, and the
|
|
355
|
+
// same reason, as the template's dev sign-in credentials. See ./previewBridge.
|
|
356
|
+
if (import.meta.env.DEV) {
|
|
357
|
+
installPreviewBridge();
|
|
358
|
+
}
|
|
243
359
|
createRoot(root).render(
|
|
244
360
|
<StrictMode>
|
|
245
|
-
<ThemeProvider defaultTheme={
|
|
361
|
+
<ThemeProvider defaultTheme={layout.defaultTheme}>
|
|
246
362
|
<LocaleProvider
|
|
247
363
|
locales={options.locales ?? { en: {} }}
|
|
248
364
|
defaultLocale={options.defaultLocale}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one viewport cutover the framework has, in the one place it is written.
|
|
3
|
+
*
|
|
4
|
+
* It was written in three: `AppShell` and `DataView` each declared
|
|
5
|
+
* `const MOBILE_BREAKPOINT = "(max-width: 768px)"` verbatim — the duplication the diagnosis
|
|
6
|
+
* named — and the responsive `Stack` props would have made a third copy, this time in the
|
|
7
|
+
* stylesheet where the first two could not see it.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the value is a literal and not `var(--breakpoint-md)`
|
|
10
|
+
*
|
|
11
|
+
* The contract publishes `--breakpoint-md: 768px`, and neither consumer can read it. CSS
|
|
12
|
+
* forbids a custom property in a media-query condition, and `matchMedia` takes a string, so
|
|
13
|
+
* a component would have to resolve the property off `document.documentElement` at runtime —
|
|
14
|
+
* which is a layout read on every mount, breaks under SSR where there is no document, and
|
|
15
|
+
* turns a static query into a value that can change after first paint.
|
|
16
|
+
*
|
|
17
|
+
* So the literal stays, and the drift it invites is gated instead:
|
|
18
|
+
* `styles.test.ts` reads `--breakpoint-md` out of the contract's token sheet and refuses a
|
|
19
|
+
* mismatch here or in the stylesheet. The token remains the published source of truth about
|
|
20
|
+
* what the number IS; this module is the single place it is spelled for use.
|
|
21
|
+
*
|
|
22
|
+
* ## Why WIDE is a negation
|
|
23
|
+
*
|
|
24
|
+
* `NARROW` and `WIDE` have to partition the viewport exactly: a width that satisfies both
|
|
25
|
+
* would render the shell's drawer beside a row-direction toolbar, and a width satisfying
|
|
26
|
+
* neither would render nothing at all. Two independent queries cannot guarantee that — the
|
|
27
|
+
* conventional pairing is `max-width: 767.98px` with `min-width: 768px`, which works but
|
|
28
|
+
* makes the partition depend on an epsilon somebody chose, and picking one here would also
|
|
29
|
+
* have moved the shell's existing behaviour at exactly 768px.
|
|
30
|
+
*
|
|
31
|
+
* `not all and (max-width: 768px)` is the complement of `NARROW` by construction. The
|
|
32
|
+
* `not all and` spelling rather than the shorter `not (…)` because it is the form every
|
|
33
|
+
* browser has supported since media queries existed, and this is a stylesheet a consumer
|
|
34
|
+
* cannot patch.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The condition both narrow-viewport components match on (`matchMedia`, so no `@media`). */
|
|
38
|
+
export const NARROW_VIEWPORT = "(max-width: 768px)";
|
|
39
|
+
|
|
40
|
+
/** The stylesheet's complement of {@link NARROW_VIEWPORT} — everything above the cutover. */
|
|
41
|
+
export const WIDE_VIEWPORT_QUERY = "not all and (max-width: 768px)";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
|
+
import { NARROW_VIEWPORT } from "../breakpoints";
|
|
4
5
|
import { EmptyState } from "../EmptyState";
|
|
5
6
|
import { ErrorState } from "../ErrorState";
|
|
6
7
|
import type { BadgeTone } from "../ui/Badge";
|
|
@@ -26,7 +27,6 @@ import type {
|
|
|
26
27
|
ViewStateRepository,
|
|
27
28
|
} from "./types";
|
|
28
29
|
|
|
29
|
-
const MOBILE_BREAKPOINT = "(max-width: 768px)";
|
|
30
30
|
/** Embedded views render all rows; the parent owns paging. */
|
|
31
31
|
const EMBEDDED_PAGE_SIZE = 10_000;
|
|
32
32
|
|
|
@@ -49,7 +49,14 @@ interface DataViewBaseProps<T> {
|
|
|
49
49
|
*
|
|
50
50
|
* `"comfortable"` stamps NO attribute, because comfortable IS the token sheet's
|
|
51
51
|
* `:root` value and an attribute for it would match no rule. The consequence worth
|
|
52
|
-
*
|
|
52
|
+
* (Historic note, since the prop's behaviour changed: `"comfortable"` used to stamp
|
|
53
|
+
* nothing, on the grounds that comfortable was the sheet's own `:root` value — which was
|
|
54
|
+
* true until the shell could make an ancestor compact. Both values are stamped now, and
|
|
55
|
+
* both have a rule. Where nothing above is compact the two compute identically, so the
|
|
56
|
+
* change is zero-diff by construction.)
|
|
57
|
+
*
|
|
58
|
+
* What used to be worth knowing: inside an already-compact subtree, `density="comfortable"`
|
|
59
|
+
* did not make
|
|
53
60
|
* this view comfortable again. Expressing that needs a named comfortable copy of
|
|
54
61
|
* each live token, which ADR 0094 defers until something asks — nothing can ask
|
|
55
62
|
* until the shell takes a density of its own.
|
|
@@ -129,13 +136,13 @@ function useIsMobile(): boolean {
|
|
|
129
136
|
() =>
|
|
130
137
|
typeof window !== "undefined" &&
|
|
131
138
|
typeof window.matchMedia === "function" &&
|
|
132
|
-
window.matchMedia(
|
|
139
|
+
window.matchMedia(NARROW_VIEWPORT).matches,
|
|
133
140
|
);
|
|
134
141
|
useEffect(() => {
|
|
135
142
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
136
143
|
return;
|
|
137
144
|
}
|
|
138
|
-
const media = window.matchMedia(
|
|
145
|
+
const media = window.matchMedia(NARROW_VIEWPORT);
|
|
139
146
|
const onChange = () => setIsMobile(media.matches);
|
|
140
147
|
media.addEventListener("change", onChange);
|
|
141
148
|
return () => media.removeEventListener("change", onChange);
|
|
@@ -147,7 +154,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
|
|
|
147
154
|
const { strings, resolve } = useDataViewText();
|
|
148
155
|
const embedded = props.variant === "embedded";
|
|
149
156
|
// Only the compact value is expressible as an attribute; see the prop's doc comment.
|
|
150
|
-
const densityAttribute = props.density
|
|
157
|
+
const densityAttribute = props.density;
|
|
151
158
|
// Hoisted rather than written inline at the two roots, which is what the density
|
|
152
159
|
// attribute above already does and what keeps the marker scanner out of a trap: it
|
|
153
160
|
// reads a whole expression container, so every string literal inside one counts as a
|
|
@@ -5,7 +5,7 @@ import type { UiText } from "../uiText";
|
|
|
5
5
|
|
|
6
6
|
import { DataViewExpandToggle } from "./DataViewExpandableRow";
|
|
7
7
|
import { DataViewRowActions } from "./DataViewRowActions";
|
|
8
|
-
import { useDataViewText } from "./internal";
|
|
8
|
+
import { useCellFormatter, useDataViewText } from "./internal";
|
|
9
9
|
import type { DataViewColumn, DataViewRowAction } from "./types";
|
|
10
10
|
|
|
11
11
|
export interface DataViewCardListProps<T> {
|
|
@@ -33,6 +33,7 @@ function slotValue<T>(
|
|
|
33
33
|
columns: DataViewColumn<T>[],
|
|
34
34
|
row: T,
|
|
35
35
|
slot: "title" | "subtitle" | "status" | "date",
|
|
36
|
+
formatCell: (value: unknown) => ReactNode,
|
|
36
37
|
): ReactNode {
|
|
37
38
|
const column = columns.find((candidate) => candidate.meta?.mobileSlot === slot);
|
|
38
39
|
if (column === undefined) {
|
|
@@ -41,8 +42,7 @@ function slotValue<T>(
|
|
|
41
42
|
if (column.cell !== undefined) {
|
|
42
43
|
return column.cell(row);
|
|
43
44
|
}
|
|
44
|
-
|
|
45
|
-
return value === null || value === undefined ? null : String(value);
|
|
45
|
+
return formatCell(column.accessor?.(row));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -129,10 +129,11 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
function DefaultCardBody<T>({ row, columns }: { row: T; columns: DataViewColumn<T>[] }) {
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
135
|
-
const
|
|
132
|
+
const formatCell = useCellFormatter();
|
|
133
|
+
const title = slotValue(columns, row, "title", formatCell);
|
|
134
|
+
const subtitle = slotValue(columns, row, "subtitle", formatCell);
|
|
135
|
+
const status = slotValue(columns, row, "status", formatCell);
|
|
136
|
+
const date = slotValue(columns, row, "date", formatCell);
|
|
136
137
|
return (
|
|
137
138
|
<div data-terp="dataview-card-fields">
|
|
138
139
|
<div data-terp="dataview-card-heading">
|
|
@@ -37,14 +37,21 @@ export function DataViewPagination({
|
|
|
37
37
|
return (
|
|
38
38
|
<div data-terp="dataview-pagination">
|
|
39
39
|
<span>{format(strings.resultsRange, { from, to, total: totalCount })}</span>
|
|
40
|
+
{/* aria-disabled, not disabled, and the difference is where focus goes. Each of these
|
|
41
|
+
four buttons has a bound condition recomputed from what its own click just changed, so
|
|
42
|
+
pressing "next" until the last page disabled the very control the user was operating —
|
|
43
|
+
and a disabled element cannot hold focus, so the browser dropped it to <body>. A
|
|
44
|
+
keyboard user paging to the end lost their place in the document at the exact moment
|
|
45
|
+
they arrived. Kept focusable and announced as disabled instead, with the handler inert
|
|
46
|
+
on the bound; the sheet paints [aria-disabled="true"] identically to :disabled. */}
|
|
40
47
|
{pageCount > 1 && (
|
|
41
48
|
<span data-terp="dataview-pager">
|
|
42
49
|
<span>{format(strings.pageOf, { page: pageIndex + 1, pages: pageCount })}</span>
|
|
43
50
|
<button
|
|
44
51
|
type="button"
|
|
45
52
|
aria-label={format(strings.firstPage, {})}
|
|
46
|
-
disabled={atFirst}
|
|
47
|
-
onClick={() => goTo(0)}
|
|
53
|
+
aria-disabled={atFirst || undefined}
|
|
54
|
+
onClick={() => (atFirst ? undefined : goTo(0))}
|
|
48
55
|
data-terp="iconbutton"
|
|
49
56
|
>
|
|
50
57
|
<PageFirstGlyph />
|
|
@@ -52,8 +59,8 @@ export function DataViewPagination({
|
|
|
52
59
|
<button
|
|
53
60
|
type="button"
|
|
54
61
|
aria-label={format(strings.previousPage, {})}
|
|
55
|
-
disabled={atFirst}
|
|
56
|
-
onClick={() => goTo(pageIndex - 1)}
|
|
62
|
+
aria-disabled={atFirst || undefined}
|
|
63
|
+
onClick={() => (atFirst ? undefined : goTo(pageIndex - 1))}
|
|
57
64
|
data-terp="iconbutton"
|
|
58
65
|
>
|
|
59
66
|
<PagePrevGlyph />
|
|
@@ -61,8 +68,8 @@ export function DataViewPagination({
|
|
|
61
68
|
<button
|
|
62
69
|
type="button"
|
|
63
70
|
aria-label={format(strings.nextPage, {})}
|
|
64
|
-
disabled={atLast}
|
|
65
|
-
onClick={() => goTo(pageIndex + 1)}
|
|
71
|
+
aria-disabled={atLast || undefined}
|
|
72
|
+
onClick={() => (atLast ? undefined : goTo(pageIndex + 1))}
|
|
66
73
|
data-terp="iconbutton"
|
|
67
74
|
>
|
|
68
75
|
<PageNextGlyph />
|
|
@@ -70,8 +77,8 @@ export function DataViewPagination({
|
|
|
70
77
|
<button
|
|
71
78
|
type="button"
|
|
72
79
|
aria-label={format(strings.lastPage, {})}
|
|
73
|
-
disabled={atLast}
|
|
74
|
-
onClick={() => goTo(pageCount - 1)}
|
|
80
|
+
aria-disabled={atLast || undefined}
|
|
81
|
+
onClick={() => (atLast ? undefined : goTo(pageCount - 1))}
|
|
75
82
|
data-terp="iconbutton"
|
|
76
83
|
>
|
|
77
84
|
<PageLastGlyph />
|