@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.
Files changed (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. 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
- /** Starting theme when the user has not chosen one; default "system" (OS preference). */
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). Keep it in sync with the app's checked-in
140
- * `layout-contract.json` (the `terp/layout-contract` lint half).
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={options.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
- * knowing: inside an already-compact subtree, `density="comfortable"` does not make
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(MOBILE_BREAKPOINT).matches,
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(MOBILE_BREAKPOINT);
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 === "compact" ? "compact" : undefined;
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
- const value = column.accessor?.(row);
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 title = slotValue(columns, row, "title");
133
- const subtitle = slotValue(columns, row, "subtitle");
134
- const status = slotValue(columns, row, "status");
135
- const date = slotValue(columns, row, "date");
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 { DataViewColumn, DataViewRowAction } from "./types";
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
- props.onCommitColumnSizing(current); // one persistence write per drag
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
- const widthOf = (column: DataViewColumn<T>): number | string | undefined => {
106
- const live = liveSizing?.[column.id];
107
- if (live !== undefined) {
108
- return live;
109
- }
110
- const resized = props.columnSizing[column.id];
111
- if (resized !== undefined) {
112
- return resized;
113
- }
114
- return column.meta?.width;
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 = widthOf(column);
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
- }
@@ -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: 120 } },
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 → static `meta.width` hint → auto.
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
@@ -22,6 +22,7 @@ export type {
22
22
  DataViewRepository,
23
23
  DataViewState,
24
24
  ViewStateRepository,
25
+ ColumnWidth,
25
26
  DataViewColumn,
26
27
  DataViewColumnMeta,
27
28
  DataViewDensity,
@@ -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,
@@ -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
- /** Fixed width hint (number = px, or any CSS length). */
117
- width?: number | string;
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
- /** Custom cell renderer; defaults to `String(accessor(row))`. */
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;