@terpjs/react-core 0.8.0 → 0.10.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 +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- 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 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- 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/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- 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 +3 -0
- package/src/markers.test.ts +141 -15
- 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/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- 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 +192 -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 +71 -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 +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- 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.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
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 />
|
|
@@ -8,10 +8,14 @@ import { DataViewExpandToggle, DataViewExpandableRow } from "./DataViewExpandabl
|
|
|
8
8
|
import { DataViewRowActions } from "./DataViewRowActions";
|
|
9
9
|
import type { DataViewRowActionsLayout } from "./DataViewRowActions";
|
|
10
10
|
import { SortAscGlyph, SortDescGlyph, SortNoneGlyph } from "./glyphs";
|
|
11
|
-
import { useDataViewText } from "./internal";
|
|
11
|
+
import { useCellFormatter, useDataViewText } from "./internal";
|
|
12
12
|
|
|
13
13
|
injectTerpStyles();
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
ColumnWidth,
|
|
16
|
+
DataViewColumn,
|
|
17
|
+
DataViewRowAction,
|
|
18
|
+
} from "./types";
|
|
15
19
|
|
|
16
20
|
const MIN_COLUMN_WIDTH = 60;
|
|
17
21
|
|
|
@@ -54,6 +58,7 @@ export interface DataViewTableProps<T> {
|
|
|
54
58
|
*/
|
|
55
59
|
export function DataViewTable<T>(props: DataViewTableProps<T>) {
|
|
56
60
|
const { strings, resolve, format } = useDataViewText();
|
|
61
|
+
const formatCell = useCellFormatter();
|
|
57
62
|
const tableRef = useRef<HTMLTableElement>(null);
|
|
58
63
|
|
|
59
64
|
// Live widths during a resize drag only — persisted once, on pointer-up.
|
|
@@ -93,7 +98,13 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
|
|
|
93
98
|
const onPointerUp = () => {
|
|
94
99
|
cleanup();
|
|
95
100
|
setLiveSizing(null);
|
|
96
|
-
|
|
101
|
+
// ONLY the dragged column is committed, though `current` holds a width for every one of
|
|
102
|
+
// them. The snapshot exists to stop the other columns jumping when the layout flips to
|
|
103
|
+
// fixed for the duration of the drag; persisting it would tell the view state the user had
|
|
104
|
+
// sized the whole table, and `stepOf` would then suppress every declared track — one drag
|
|
105
|
+
// anywhere would switch the floors off table-wide, and durably so for an app with a view
|
|
106
|
+
// state repository. `commitColumnSizing` merges, so earlier resizes survive this.
|
|
107
|
+
props.onCommitColumnSizing({ [columnId]: current[columnId] ?? startWidth }); // one write per drag
|
|
97
108
|
};
|
|
98
109
|
window.addEventListener("pointermove", onPointerMove);
|
|
99
110
|
window.addEventListener("pointerup", onPointerUp);
|
|
@@ -102,17 +113,21 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
|
|
|
102
113
|
[props.onCommitColumnSizing],
|
|
103
114
|
);
|
|
104
115
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The user's own width for a column, in px, or `undefined` if they have not resized it.
|
|
118
|
+
*
|
|
119
|
+
* This and {@link stepOf} are deliberately exclusive: a resized column emits an inline `width`
|
|
120
|
+
* and NO `data-width`, so the declared step stops applying the moment the user disagrees with
|
|
121
|
+
* it. Emitting both would put a `min-inline-size` from the sheet against an inline `width`, and
|
|
122
|
+
* the minimum wins — dragging a column below its declared step would spring back and the
|
|
123
|
+
* resizer would look broken.
|
|
124
|
+
*/
|
|
125
|
+
const resizedWidthOf = (column: DataViewColumn<T>): number | undefined =>
|
|
126
|
+
liveSizing?.[column.id] ?? props.columnSizing[column.id];
|
|
127
|
+
|
|
128
|
+
/** The declared track, which applies only while the column is at its default width. */
|
|
129
|
+
const stepOf = (column: DataViewColumn<T>): ColumnWidth | undefined =>
|
|
130
|
+
resizedWidthOf(column) === undefined ? column.meta?.width : undefined;
|
|
116
131
|
|
|
117
132
|
const hasExpand = props.renderExpanded !== undefined;
|
|
118
133
|
const hasActions = props.rowActions !== undefined;
|
|
@@ -149,11 +164,13 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
|
|
|
149
164
|
{props.columns.map((column) => {
|
|
150
165
|
const sort = props.sorting.find((entry) => entry.id === column.id);
|
|
151
166
|
const sortable = column.enableSorting !== false;
|
|
152
|
-
const width =
|
|
167
|
+
const width = resizedWidthOf(column);
|
|
168
|
+
const step = stepOf(column);
|
|
153
169
|
return (
|
|
154
170
|
<th
|
|
155
171
|
key={column.id}
|
|
156
172
|
data-column-id={column.id}
|
|
173
|
+
data-width={step}
|
|
157
174
|
aria-sort={
|
|
158
175
|
sort === undefined ? undefined : sort.desc ? "descending" : "ascending"
|
|
159
176
|
}
|
|
@@ -279,9 +296,3 @@ function RowGroup({ children }: { children: ReactNode }) {
|
|
|
279
296
|
return <>{children}</>;
|
|
280
297
|
}
|
|
281
298
|
|
|
282
|
-
function formatCell(value: unknown): ReactNode {
|
|
283
|
-
if (value === null || value === undefined) {
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
return String(value);
|
|
287
|
-
}
|
package/src/dataview/README.md
CHANGED
|
@@ -31,7 +31,7 @@ interface Ticket { id: string; title: string; status: string; created: string }
|
|
|
31
31
|
const columns: DataViewColumn<Ticket>[] = [
|
|
32
32
|
{ id: "title", header: "Title", accessor: (t) => t.title, meta: { mobileSlot: "title" } },
|
|
33
33
|
{ id: "status", header: "Status", accessor: (t) => t.status, meta: { mobileSlot: "status" } },
|
|
34
|
-
{ id: "created", header: "Created", accessor: (t) => t.created, meta: { mobileSlot: "date", width:
|
|
34
|
+
{ id: "created", header: "Created", accessor: (t) => t.created, meta: { mobileSlot: "date", width: "sm" } },
|
|
35
35
|
];
|
|
36
36
|
|
|
37
37
|
const repository = new InMemoryDataViewRepository(tickets, {
|
|
@@ -113,12 +113,23 @@ versioned envelope; corrupt data falls back to defaults) and
|
|
|
113
113
|
|
|
114
114
|
## Behaviour notes
|
|
115
115
|
|
|
116
|
+
- **Default cell rendering** is shared by the table and the card layouts, so a column reads
|
|
117
|
+
the same on a desktop and a phone: `null` / `undefined` render nothing, a `Date` renders
|
|
118
|
+
through the app's locale, and anything else is `String(value)`. Pass `cell` to override.
|
|
119
|
+
- **Dates and numbers** in a `cell` renderer should go through `useFormatDate` /
|
|
120
|
+
`useFormatDateTime` / `useFormatNumber`; `toLocaleDateString()` with no argument asks the
|
|
121
|
+
visitor's browser rather than the app, and a repo-wide check refuses it.
|
|
116
122
|
- **System columns** are auto-injected in a fixed order — expand toggle, selection
|
|
117
123
|
checkbox, user columns, row-actions (sr-only header) — pinned to narrow widths and
|
|
118
124
|
never hideable/reorderable/resizable.
|
|
119
125
|
- **Column resizing**: drag the header handle; widths update live with no persistence
|
|
120
126
|
writes per pointermove and are persisted once, on pointer-up. Width precedence:
|
|
121
|
-
pinned system columns → user-resized →
|
|
127
|
+
pinned system columns → user-resized → declared `meta.width` step → auto.
|
|
128
|
+
- **Declared column tracks**: `meta.width` is a step (`"xs"` / `"sm"` / `"md"`), not a
|
|
129
|
+
length, and it binds as a **minimum** — under `table-layout: auto` a specified width is
|
|
130
|
+
only a preference the algorithm shrinks to fit, so the px hint this replaced did nothing
|
|
131
|
+
at all. A user resize replaces the step outright rather than competing with it: a resized
|
|
132
|
+
column stops carrying the attribute, so the floor can never spring a drag back.
|
|
122
133
|
- **Row tone**: `getRowTone={(row) => tone | null}` marks the *row* as being in a
|
|
123
134
|
state (a refused link, a failed run) — the right altitude when the verdict belongs
|
|
124
135
|
to the record, not to one of its cells. The row/card is tinted with the tone's soft
|
package/src/dataview/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { createContext, useContext } from "react";
|
|
1
|
+
import { createContext, useCallback, useContext } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
|
+
import { useFormatDate } from "../format";
|
|
4
5
|
import { injectTerpStyles } from "../styles";
|
|
5
6
|
import { Menu, MenuItem } from "../ui/Menu";
|
|
6
7
|
import { useUiText } from "../uiText";
|
|
@@ -29,6 +30,35 @@ export function useDataViewText(): DataViewTextApi {
|
|
|
29
30
|
return useContext(DataViewTextContext);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Stringify a cell for a column that declares no `cell` renderer.
|
|
35
|
+
*
|
|
36
|
+
* One hook rather than a helper per renderer, because there WERE two: the table had a private
|
|
37
|
+
* `formatCell` and the mobile card list inlined the same three lines. They agreed, so nothing
|
|
38
|
+
* caught that they were two, and the first change to either would have made a row render one way
|
|
39
|
+
* on a desktop and another on a phone.
|
|
40
|
+
*
|
|
41
|
+
* The `Date` branch is why that mattered. `accessor` returns `unknown`, so a `Date` is type-legal
|
|
42
|
+
* and `String(value)` renders `Wed Aug 21 2026 00:00:00 GMT+0200 (Central European Summer Time)`
|
|
43
|
+
* in a table cell. Nothing in this tree returns one today, which is exactly why it was worth
|
|
44
|
+
* closing now rather than after an app discovered it.
|
|
45
|
+
*/
|
|
46
|
+
export function useCellFormatter(): (value: unknown) => ReactNode {
|
|
47
|
+
const formatDate = useFormatDate();
|
|
48
|
+
return useCallback(
|
|
49
|
+
(value: unknown) => {
|
|
50
|
+
if (value === null || value === undefined) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (value instanceof Date) {
|
|
54
|
+
return formatDate(value);
|
|
55
|
+
}
|
|
56
|
+
return String(value);
|
|
57
|
+
},
|
|
58
|
+
[formatDate],
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
export function DataViewTextProvider({
|
|
33
63
|
overrides,
|
|
34
64
|
children,
|
package/src/dataview/types.ts
CHANGED
|
@@ -107,14 +107,32 @@ export type DataViewMobileSlot = "title" | "subtitle" | "status" | "date";
|
|
|
107
107
|
*/
|
|
108
108
|
export type DataViewDensity = "comfortable" | "compact";
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* A column's declared track: a step, not a length.
|
|
112
|
+
*
|
|
113
|
+
* The three steps are the three bands the framework's own tables actually declare, and there are
|
|
114
|
+
* deliberately no others. `lg` and a content-hugging step were both drafted and dropped for the
|
|
115
|
+
* same reason a component with no consumer is dropped — nothing asks for them, and a step is
|
|
116
|
+
* additive to add and breaking to remove. The scale is rem, so a declared track follows the root
|
|
117
|
+
* font size instead of pinning a column to one display's pixels.
|
|
118
|
+
*/
|
|
119
|
+
export type ColumnWidth = "xs" | "sm" | "md";
|
|
120
|
+
|
|
110
121
|
/** Typed column meta the DataView-specific features read. */
|
|
111
122
|
export interface DataViewColumnMeta {
|
|
112
123
|
/** Human-readable name used in the column-settings menu (falls back to the header). */
|
|
113
124
|
label?: UiText;
|
|
114
125
|
/** Slot in the auto-composed card layout. */
|
|
115
126
|
mobileSlot?: DataViewMobileSlot;
|
|
116
|
-
/**
|
|
117
|
-
|
|
127
|
+
/**
|
|
128
|
+
* The column's declared minimum track. Omit for content-based auto sizing.
|
|
129
|
+
*
|
|
130
|
+
* A **minimum**, because that is the only thing `table-layout: auto` cannot take away: a
|
|
131
|
+
* specified `width` is a preference the algorithm shrinks to fit, which is why the px hint this
|
|
132
|
+
* replaced did nothing at all. A user's own resize replaces the declared track entirely rather
|
|
133
|
+
* than fighting it.
|
|
134
|
+
*/
|
|
135
|
+
width?: ColumnWidth;
|
|
118
136
|
}
|
|
119
137
|
|
|
120
138
|
/** Generic, typed column definition for {@link DataView}. */
|
|
@@ -125,7 +143,12 @@ export interface DataViewColumn<T> {
|
|
|
125
143
|
header: UiText;
|
|
126
144
|
/** The raw value of this column for a row (used by default cell rendering). */
|
|
127
145
|
accessor?: (row: T) => unknown;
|
|
128
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Custom cell renderer. Without one the accessor's value is rendered by the shared default:
|
|
148
|
+
* `null` / `undefined` render nothing, a `Date` renders through the app's locale (the same
|
|
149
|
+
* `useFormatDate` the framework's own screens use), and anything else is `String(value)`.
|
|
150
|
+
* Table and card layouts share that default, so a column reads the same on both.
|
|
151
|
+
*/
|
|
129
152
|
cell?: (row: T) => ReactNode;
|
|
130
153
|
/** Whether the header offers the 3-state sort toggle (default true). */
|
|
131
154
|
enableSorting?: boolean;
|