@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
@@ -0,0 +1,153 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { downloadUrl, saveBlob, useEndpointDownload } from "./download";
6
+ import { TerpProvider } from "./TerpProvider";
7
+
8
+ // Downloading a *generated* artifact (ADR 0096). The two things a hand-rolled version
9
+ // gets wrong are what these tests pin: a raw `<a href>` carries no bearer token (so it
10
+ // saves an error page under the intended filename), and a raw fetch leaks the object URL.
11
+
12
+ afterEach(() => {
13
+ cleanup();
14
+ vi.unstubAllGlobals();
15
+ vi.restoreAllMocks();
16
+ restoreObjectUrl();
17
+ });
18
+
19
+ // jsdom ships no object-URL implementation, so it is installed per test rather than
20
+ // stubbed over the global `URL` — replacing that breaks the client's own URL building,
21
+ // which is exactly the machinery these tests are here to exercise.
22
+ type ObjectUrlHost = { createObjectURL?: (blob: Blob) => string; revokeObjectURL?: (url: string) => void };
23
+ const objectUrlHost = URL as unknown as ObjectUrlHost;
24
+ const originalCreate = objectUrlHost.createObjectURL;
25
+ const originalRevoke = objectUrlHost.revokeObjectURL;
26
+
27
+ function installObjectUrl(): { revoked: string[] } {
28
+ const revoked: string[] = [];
29
+ objectUrlHost.createObjectURL = () => "blob:x";
30
+ objectUrlHost.revokeObjectURL = (url: string) => void revoked.push(url);
31
+ return { revoked };
32
+ }
33
+
34
+ function restoreObjectUrl(): void {
35
+ objectUrlHost.createObjectURL = originalCreate;
36
+ objectUrlHost.revokeObjectURL = originalRevoke;
37
+ }
38
+
39
+ describe("downloadUrl", () => {
40
+ it("fills path placeholders and appends only the set query keys", () => {
41
+ expect(
42
+ downloadUrl({
43
+ path: "/api/v1/revisions/{revisionId}/evidence",
44
+ filename: "x.json",
45
+ params: { revisionId: "r-1" },
46
+ query: { format: "json", locale: undefined },
47
+ }),
48
+ ).toBe("/api/v1/revisions/r-1/evidence?format=json");
49
+ });
50
+
51
+ it("encodes a param rather than splicing it into the path raw", () => {
52
+ expect(downloadUrl({ path: "/api/v1/x/{id}", filename: "f", params: { id: "a/b" } })).toBe(
53
+ "/api/v1/x/a%2Fb",
54
+ );
55
+ });
56
+
57
+ it("refuses an unfilled placeholder instead of requesting a literal {id}", () => {
58
+ // Requesting `/revisions/{revisionId}/evidence` would 404 — or, on a permissive
59
+ // route, hand back somebody else's bytes.
60
+ expect(() =>
61
+ downloadUrl({ path: "/api/v1/revisions/{revisionId}/evidence", filename: "x" }),
62
+ ).toThrow(/still contains the placeholder "\{revisionId\}"/);
63
+ });
64
+ });
65
+
66
+ describe("saveBlob", () => {
67
+ it("offers the bytes under the given filename and revokes the object URL", () => {
68
+ const { revoked } = installObjectUrl();
69
+ const clicked: string[] = [];
70
+ const click = vi
71
+ .spyOn(HTMLAnchorElement.prototype, "click")
72
+ .mockImplementation(function (this: HTMLAnchorElement) {
73
+ clicked.push(this.download);
74
+ });
75
+
76
+ saveBlob(new Blob(["body"]), "evidence.json");
77
+
78
+ expect(clicked).toEqual(["evidence.json"]);
79
+ // Revoked in a `finally`, which is the leak every hand-rolled copy forgets.
80
+ expect(revoked).toEqual(["blob:x"]);
81
+ // ...and nothing is left in the document.
82
+ expect(document.querySelector("a")).toBeNull();
83
+ click.mockRestore();
84
+ });
85
+ });
86
+
87
+ describe("useEndpointDownload", () => {
88
+ function DownloadButton() {
89
+ const download = useEndpointDownload();
90
+ return (
91
+ <button
92
+ type="button"
93
+ onClick={() =>
94
+ void download({
95
+ path: "/api/v1/revisions/{revisionId}/evidence",
96
+ params: { revisionId: "r-1" },
97
+ filename: "evidence.json",
98
+ }).catch((error: unknown) => {
99
+ document.title = (error as Error).message;
100
+ })
101
+ }
102
+ >
103
+ download
104
+ </button>
105
+ );
106
+ }
107
+
108
+ it("fetches through the session client, so the request carries the session", async () => {
109
+ const fetchMock = vi.fn<typeof fetch>(
110
+ async () => new Response("bytes", { status: 200, headers: { "content-type": "application/octet-stream" } }),
111
+ );
112
+ vi.stubGlobal("fetch", fetchMock);
113
+ installObjectUrl();
114
+ const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
115
+
116
+ render(
117
+ <TerpProvider baseUrl="https://api.test">
118
+ <DownloadButton />
119
+ </TerpProvider>,
120
+ );
121
+ fireEvent.click(screen.getByRole("button", { name: "download" }));
122
+
123
+ // The provider's own boot session-probe is also on this mock, so match the download
124
+ // rather than assuming it is the first request.
125
+ await waitFor(() =>
126
+ expect(
127
+ fetchMock.mock.calls.map((call) => (call[0] as Request).url),
128
+ // Resolved against the client's base URL — the thing a raw <a href> could not do.
129
+ ).toContain("https://api.test/api/v1/revisions/r-1/evidence"),
130
+ );
131
+ click.mockRestore();
132
+ });
133
+
134
+ it("rejects on a non-2xx instead of saving the error body under the filename", async () => {
135
+ vi.stubGlobal(
136
+ "fetch",
137
+ vi.fn<typeof fetch>(async () => new Response("nope", { status: 403 })),
138
+ );
139
+ const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
140
+
141
+ render(
142
+ <TerpProvider baseUrl="https://api.test">
143
+ <DownloadButton />
144
+ </TerpProvider>,
145
+ );
146
+ fireEvent.click(screen.getByRole("button", { name: "download" }));
147
+
148
+ await waitFor(() => expect(document.title).toMatch(/failed with HTTP 403/));
149
+ // Nothing was handed to the browser: the failure surfaces instead of downloading.
150
+ expect(click).not.toHaveBeenCalled();
151
+ click.mockRestore();
152
+ });
153
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Handing bytes to the browser as a named download (ADR 0096).
3
+ *
4
+ * `useFileDownload` covers the stored-file case: a `FileMeta` id, fetched from the files
5
+ * capability's content endpoint. What it does not cover is the other half of the same
6
+ * need — **an artifact the backend generates on demand** ("download this revision as
7
+ * proof", a CSV export, a signed evidence bundle). Those have no stored file id, and the
8
+ * only ways to reach them were a raw `fetch` (refused: one typed egress path) or a raw
9
+ * `<a href>` (which carries no bearer token, so it 401s or, worse, silently downloads an
10
+ * error page). The observed outcome was the feature being dropped rather than built.
11
+ *
12
+ * So the blob-to-anchor dance lives here once, and `useEndpointDownload` reaches any
13
+ * authorized GET through the session client. `path` is a plain string, deliberately and
14
+ * unusually: the generated client is keyed by the app's own schema, which this package
15
+ * cannot see, and a byte-stream route is app-specific by nature. Everything else that
16
+ * matters — the base URL, the bearer token, cookie credentials, the refusal on a non-2xx —
17
+ * still comes from the client, which is what the raw alternatives threw away.
18
+ */
19
+
20
+ import { useCallback } from "react";
21
+
22
+ import { useTerpClient } from "./TerpProvider";
23
+ import type { TerpClient } from "@terpjs/contract";
24
+
25
+ /** What to download: where the bytes come from, and what the file should be called. */
26
+ export interface DownloadTarget {
27
+ /** API path, e.g. `/api/v1/revisions/{id}/evidence` with `params` filling `{id}`. */
28
+ path: string;
29
+ /** Filename offered to the browser (extension included). */
30
+ filename: string;
31
+ /** Path placeholders to substitute, e.g. `{ id: revision.id }`. */
32
+ params?: Record<string, string>;
33
+ /** Query string to append; `undefined` values are omitted. */
34
+ query?: Record<string, string | undefined>;
35
+ }
36
+
37
+ /**
38
+ * Save *blob* as a named download.
39
+ *
40
+ * Exported because it is the one piece a screen cannot avoid re-implementing when it
41
+ * already holds the bytes (a client-side CSV, a canvas export) — and every hand-rolled
42
+ * copy leaks the object URL, which is why the revoke is in a `finally` here.
43
+ */
44
+ export function saveBlob(blob: Blob, filename: string): void {
45
+ const url = URL.createObjectURL(blob);
46
+ try {
47
+ const anchor = document.createElement("a");
48
+ anchor.href = url;
49
+ anchor.download = filename;
50
+ document.body.appendChild(anchor);
51
+ anchor.click();
52
+ anchor.remove();
53
+ } finally {
54
+ URL.revokeObjectURL(url);
55
+ }
56
+ }
57
+
58
+ /** Fill `{placeholder}` segments and append the query string, dropping unset values. */
59
+ export function downloadUrl(target: DownloadTarget): string {
60
+ let path = target.path;
61
+ for (const [name, value] of Object.entries(target.params ?? {})) {
62
+ path = path.replaceAll(`{${name}}`, encodeURIComponent(value));
63
+ }
64
+ const unfilled = /\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(path);
65
+ if (unfilled !== null) {
66
+ // Fail closed rather than requesting a literal `{id}`: the server would 404 or, on a
67
+ // permissive route, hand back somebody else's bytes.
68
+ throw new Error(
69
+ `Download path "${target.path}" still contains the placeholder "${unfilled[0]}" — ` +
70
+ `pass it in \`params\` (e.g. params: { ${unfilled[1]}: row.id }).`,
71
+ );
72
+ }
73
+ const query = new URLSearchParams();
74
+ for (const [name, value] of Object.entries(target.query ?? {})) {
75
+ if (value !== undefined) {
76
+ query.append(name, value);
77
+ }
78
+ }
79
+ const suffix = query.toString();
80
+ return suffix.length > 0 ? `${path}?${suffix}` : path;
81
+ }
82
+
83
+ /**
84
+ * Download a generated artifact from an authorized endpoint (ADR 0096).
85
+ *
86
+ * ```tsx
87
+ * const download = useEndpointDownload();
88
+ * void download({
89
+ * path: "/api/v1/revisions/{revisionId}/evidence",
90
+ * params: { revisionId: revision.id },
91
+ * filename: `revision-${revision.number}.json`,
92
+ * });
93
+ * ```
94
+ *
95
+ * Rejects on a non-2xx response, so a caller can surface the failure — a raw anchor would
96
+ * have saved the error body under the intended filename instead.
97
+ */
98
+ export function useEndpointDownload(): (target: DownloadTarget) => Promise<void> {
99
+ const client = useTerpClient();
100
+ return useCallback(
101
+ async (target: DownloadTarget) => {
102
+ const blob = await fetchDownload(client as unknown as TerpClient, target);
103
+ saveBlob(blob, target.filename);
104
+ },
105
+ [client],
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Fetch a download target's bytes through the session client.
111
+ *
112
+ * Separate from the hook so a caller that wants the blob for something *other* than
113
+ * saving it (a preview, a checksum) does not have to save it first.
114
+ */
115
+ export async function fetchDownload(client: TerpClient, target: DownloadTarget): Promise<Blob> {
116
+ const url = downloadUrl(target);
117
+ const { data, error, response } = await (
118
+ client as unknown as {
119
+ GET: (
120
+ path: string,
121
+ init: { parseAs: "blob" },
122
+ ) => Promise<{ data?: unknown; error?: unknown; response: Response }>;
123
+ }
124
+ ).GET(url, { parseAs: "blob" });
125
+ if (error !== undefined || !response.ok) {
126
+ throw new Error(
127
+ `Download of ${url} failed with HTTP ${response.status}. The endpoint must be a GET ` +
128
+ "the current session is authorized for.",
129
+ );
130
+ }
131
+ return data as Blob;
132
+ }
package/src/files.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
2
  import type { ChangeEvent } from "react";
3
3
 
4
+ import { saveBlob } from "./download";
4
5
  import { useTerpClient } from "./TerpProvider";
5
6
  import { Button } from "./ui/Button";
6
7
  import { useStrings, useUiText, type UiText } from "./uiText";
@@ -91,17 +92,7 @@ export function useFileDownload(): (file: Pick<FileMeta, "id" | "filename">) =>
91
92
  return useCallback(
92
93
  async (file: Pick<FileMeta, "id" | "filename">) => {
93
94
  const blob = await fetchFileContent(client as unknown as TerpClient, file.id);
94
- const url = URL.createObjectURL(blob);
95
- try {
96
- const anchor = document.createElement("a");
97
- anchor.href = url;
98
- anchor.download = file.filename;
99
- document.body.appendChild(anchor);
100
- anchor.click();
101
- anchor.remove();
102
- } finally {
103
- URL.revokeObjectURL(url);
104
- }
95
+ saveBlob(blob, file.filename);
105
96
  },
106
97
  [client],
107
98
  );
@@ -0,0 +1,213 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import type { ReactNode } from "react";
5
+
6
+ import { DataView } from "./dataview";
7
+ import { InMemoryDataViewRepository } from "./dataview";
8
+ import type { DataViewColumn } from "./dataview";
9
+ import {
10
+ formatDate,
11
+ formatDateTime,
12
+ formatNumber,
13
+ useFormatDate,
14
+ useFormatNumber,
15
+ } from "./format";
16
+ import { LocaleProvider } from "./locale";
17
+
18
+ afterEach(() => {
19
+ cleanup();
20
+ window.localStorage.clear();
21
+ });
22
+
23
+ // Midday UTC keeps the calendar date stable across most zones, but only most: UTC+13 and UTC+14
24
+ // exist (Pacific/Apia, Pacific/Kiritimati), so at 12:00Z the local date there is already the 8th.
25
+ // No assertion below depends on WHICH day it is — the locale assertions compare two locales against
26
+ // each other, and the shape assertions ask whether the day or the month comes first. An earlier
27
+ // version asserted the literal digit 7 and would have failed in Kiritimati and nowhere else.
28
+ const WHEN = "2026-07-07T12:00:00Z";
29
+
30
+ const NL = { label: "Nederlands", strings: {} };
31
+ const EN = { label: "English", strings: {} };
32
+
33
+ describe("the locale-explicit formatters", () => {
34
+ it("actually varies with the locale it is given", () => {
35
+ // The whole defect was a missing argument, so the assertion that matters is that the argument
36
+ // changes the answer. Comparing against one hard-coded string would pass just as happily if
37
+ // the locale were ignored and both calls fell through to the runner's default.
38
+ const dutch = formatDate(WHEN, "nl");
39
+ const american = formatDate(WHEN, "en-US");
40
+ expect(dutch).not.toBe(american);
41
+ // Day-first versus month-first is the visible difference, and it survives both an ICU update
42
+ // and a runner in a zone where the local calendar date is already the next day.
43
+ expect(dutch).toMatch(/^\d/);
44
+ expect(american).toMatch(/^[A-Za-z]/);
45
+ });
46
+
47
+ it("adds a time of day only in the date-time form", () => {
48
+ // The audit log's column is titled "when"; dropping its clock would be a silent downgrade.
49
+ expect(formatDateTime(WHEN, "en-US")).toMatch(/\d{1,2}:\d{2}/);
50
+ expect(formatDate(WHEN, "en-US")).not.toMatch(/\d{1,2}:\d{2}/);
51
+ });
52
+
53
+ it("groups and separates numbers the way the locale does", () => {
54
+ expect(formatNumber(1234.5, "nl")).not.toBe(formatNumber(1234.5, "en-US"));
55
+ expect(formatNumber(1234.5, "en-US")).toBe("1,234.5");
56
+ });
57
+
58
+ it("renders an em dash for nothing, rather than throwing or printing Invalid Date", () => {
59
+ // `new Date("whenever")` yields an Invalid Date whose `format` throws a RangeError, so one
60
+ // malformed row from an API would take down a whole table instead of showing one dash.
61
+ for (const value of [null, undefined, "", "not a date", Number.NaN]) {
62
+ expect(formatDate(value, "nl")).toBe("—");
63
+ expect(formatDateTime(value, "nl")).toBe("—");
64
+ }
65
+ expect(formatNumber(null, "nl")).toBe("—");
66
+ expect(formatNumber(Number.NaN, "nl")).toBe("—");
67
+ });
68
+
69
+ it("builds one formatter per locale, not one per value", () => {
70
+ // Constructing an Intl formatter costs ~55x using one, and every table cell comes through
71
+ // here, so the first version of this file turned a locale fix into a rendering cost: a 200-row
72
+ // table with three date columns built 600 formatters per render. Counting constructions is the
73
+ // only way to see it — the output is identical either way, which is exactly why it shipped.
74
+ const original = Intl.DateTimeFormat;
75
+ let constructed = 0;
76
+ try {
77
+ (Intl as { DateTimeFormat: unknown }).DateTimeFormat = function counted(
78
+ ...args: ConstructorParameters<typeof Intl.DateTimeFormat>
79
+ ) {
80
+ constructed += 1;
81
+ return new original(...args);
82
+ };
83
+ for (let index = 0; index < 50; index += 1) {
84
+ formatDate(`2026-07-${String((index % 28) + 1).padStart(2, "0")}T12:00:00Z`, "en-GB");
85
+ }
86
+ expect(constructed).toBe(1);
87
+ // A second locale is a second formatter, not a cache that answers with the wrong one.
88
+ formatDate(WHEN, "en-IE");
89
+ expect(constructed).toBe(2);
90
+ } finally {
91
+ (Intl as { DateTimeFormat: unknown }).DateTimeFormat = original;
92
+ }
93
+ });
94
+
95
+ it("accepts the three shapes a date field arrives in", () => {
96
+ const iso = formatDate(WHEN, "en-US");
97
+ expect(formatDate(new Date(WHEN), "en-US")).toBe(iso);
98
+ expect(formatDate(new Date(WHEN).getTime(), "en-US")).toBe(iso);
99
+ });
100
+ });
101
+
102
+ function ShownDate() {
103
+ return <p data-testid="shown">{useFormatDate()(WHEN)}</p>;
104
+ }
105
+
106
+ function ShownNumber() {
107
+ return <p data-testid="shown">{useFormatNumber()(1234.5)}</p>;
108
+ }
109
+
110
+ /** Render `body` under one app locale and return what it printed. */
111
+ function shownUnder(locale: string, body: ReactNode): string {
112
+ const { unmount } = render(
113
+ <LocaleProvider locales={{ nl: NL, "en-US": EN }} defaultLocale={locale}>
114
+ {body}
115
+ </LocaleProvider>,
116
+ );
117
+ const text = screen.getByTestId("shown").textContent ?? "";
118
+ unmount();
119
+ return text;
120
+ }
121
+
122
+ describe("the hooks", () => {
123
+ // These compare TWO app locales against each other rather than one against an expected string,
124
+ // and the reason is worth stating because the first version of this file did the latter and a
125
+ // mutation proved it worthless. This machine's Node resolves to nl-NL, so
126
+ // `formatDate(value, undefined)` and `formatDate(value, "nl")` are the same string: a test that
127
+ // rendered a Dutch provider and asserted the Dutch spelling passed with the hook ignoring its
128
+ // locale entirely. It would have failed on an English host and passed here, which is worse than
129
+ // no test. Two locales cannot agree unless the locale is being dropped, on any host.
130
+
131
+ it("reads the app's locale, not the machine's", () => {
132
+ const dutch = shownUnder("nl", <ShownDate />);
133
+ const american = shownUnder("en-US", <ShownDate />);
134
+ expect(dutch).not.toBe(american);
135
+ expect(dutch).toBe(formatDate(WHEN, "nl"));
136
+ expect(american).toBe(formatDate(WHEN, "en-US"));
137
+ });
138
+
139
+ it("formats numbers through the same locale", () => {
140
+ const dutch = shownUnder("nl", <ShownNumber />);
141
+ const american = shownUnder("en-US", <ShownNumber />);
142
+ expect(dutch).not.toBe(american);
143
+ expect(american).toBe("1,234.5");
144
+ });
145
+
146
+ it("falls back to the runtime default outside a LocaleProvider", () => {
147
+ // `useLocale()` returns null with no provider, and that must mean "let Intl decide" rather
148
+ // than throw: `Field`, `DataView` and the admin screens all render fine without one.
149
+ render(<ShownDate />);
150
+ expect(screen.getByTestId("shown")).toHaveTextContent(formatDate(WHEN, undefined));
151
+ });
152
+ });
153
+
154
+ interface Dated {
155
+ id: string;
156
+ when: Date;
157
+ }
158
+
159
+ const DATED_COLUMNS: DataViewColumn<Dated>[] = [
160
+ { id: "when", header: "When", accessor: (row) => row.when, meta: { mobileSlot: "title" } },
161
+ ];
162
+
163
+ function datedView(locale: string, when: Date) {
164
+ const repository = new InMemoryDataViewRepository([{ id: "1", when }], {
165
+ getRowId: (row) => row.id,
166
+ getValue: (row, column) => row[column as keyof Dated],
167
+ });
168
+ return (
169
+ <LocaleProvider locales={{ nl: NL, "en-US": EN }} defaultLocale={locale}>
170
+ <DataView<Dated> repository={repository} columns={DATED_COLUMNS} />
171
+ </LocaleProvider>
172
+ );
173
+ }
174
+
175
+ describe("a Date in a cell", () => {
176
+ // Rendered under BOTH locales, for the reason stated at the top of the hook block: the runner is
177
+ // nl-NL, so a single Dutch render would go green with `useCellFormatter` ignoring its locale
178
+ // entirely. That is what the first version of this test did — twenty lines under a comment
179
+ // explaining why not to. Two locales cannot both be right unless the locale is being read.
180
+ const when = new Date(WHEN);
181
+
182
+ it("guards its own premise: the two spellings differ", () => {
183
+ expect(formatDate(when, "nl")).not.toBe(formatDate(when, "en-US"));
184
+ });
185
+
186
+ it("is formatted for the app's locale instead of stringified — table view", async () => {
187
+ // `accessor` returns `unknown`, so a Date is type-legal, and `String(value)` renders
188
+ // "Tue Jul 07 2026 14:00:00 GMT+0200 (Central European Summer Time)" into a table cell.
189
+ render(datedView("en-US", when));
190
+ expect(await screen.findByText(formatDate(when, "en-US"))).toBeInTheDocument();
191
+ expect(screen.queryByText(formatDate(when, "nl"))).toBeNull();
192
+ expect(screen.queryByText(String(when))).toBeNull();
193
+ cleanup();
194
+
195
+ render(datedView("nl", when));
196
+ expect(await screen.findByText(formatDate(when, "nl"))).toBeInTheDocument();
197
+ expect(screen.queryByText(formatDate(when, "en-US"))).toBeNull();
198
+ });
199
+
200
+ it("is formatted for the app's locale instead of stringified — card view", async () => {
201
+ // The stated reason for extracting `useCellFormatter` was that the two renderers had drifted
202
+ // apart unnoticed, so pinning only the desktop one would reproduce the defect the extraction
203
+ // was for. `DataView` reads `matchMedia` to pick a layout and jsdom always says no, so the
204
+ // card list is only reachable through the explicit toggle.
205
+ render(datedView("en-US", when));
206
+ expect(await screen.findByRole("table")).toBeInTheDocument();
207
+ fireEvent.click(screen.getByRole("button", { name: "Card view" }));
208
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
209
+ expect(screen.getByText(formatDate(when, "en-US"))).toBeInTheDocument();
210
+ expect(screen.queryByText(formatDate(when, "nl"))).toBeNull();
211
+ expect(screen.queryByText(String(when))).toBeNull();
212
+ });
213
+ });
package/src/format.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { useCallback } from "react";
2
+
3
+ import { useLocale } from "./locale";
4
+
5
+ /**
6
+ * Locale-aware date and number formatting.
7
+ *
8
+ * Seven places in this package formatted a date with `toLocaleDateString()` or
9
+ * `toLocaleString()` and no locale argument, which asks the *browser* what language to use. An app
10
+ * that ships Dutch through `LocaleProvider` therefore rendered its own admin tables in whatever
11
+ * the visitor's OS was set to, one row above a `DatePicker` that got it right — because the
12
+ * correct helper already existed, private, in a file about calendars.
13
+ *
14
+ * That helper is now here, unchanged, and `DatePicker` imports it back. Adopting its exact shape
15
+ * rather than inventing one is deliberate: it is the only date rendering in the package that was
16
+ * already locale-correct, so it is the one that defines the house shape, and moving it moves no
17
+ * pixels.
18
+ *
19
+ * Each formatter comes in two forms. The hook reads the app's locale from context and is what a
20
+ * component should use; the plain function takes the locale explicitly, for a caller that already
21
+ * has one or is not a component. The hooks are `useCallback`-stable so a column list built in a
22
+ * `useMemo` can depend on one without rebuilding every render.
23
+ */
24
+
25
+ /** Anything a record's date field plausibly arrives as. */
26
+ export type FormattableDate = Date | string | number | null | undefined;
27
+
28
+ /**
29
+ * What an absent or unparseable date renders as.
30
+ *
31
+ * An em dash rather than an empty cell, because a blank reads as "still loading" in a table and as
32
+ * a layout bug in a detail list. This is the glyph the audit screen already used for the same job.
33
+ */
34
+ const EMPTY = "—";
35
+
36
+ /**
37
+ * Parse without throwing, and treat an unparseable value as absent.
38
+ *
39
+ * `new Date("not a date")` yields an Invalid Date whose `format` throws a RangeError, so a single
40
+ * malformed row from an API would take down the whole table rather than showing one dash.
41
+ */
42
+ function toDate(value: FormattableDate): Date | null {
43
+ if (value === null || value === undefined) {
44
+ return null;
45
+ }
46
+ const date = value instanceof Date ? value : new Date(value);
47
+ return Number.isNaN(date.getTime()) ? null : date;
48
+ }
49
+
50
+ const DATE_OPTIONS: Intl.DateTimeFormatOptions = {
51
+ year: "numeric",
52
+ month: "short",
53
+ day: "numeric",
54
+ };
55
+
56
+ const DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
57
+ ...DATE_OPTIONS,
58
+ hour: "2-digit",
59
+ minute: "2-digit",
60
+ };
61
+
62
+ /**
63
+ * Formatters, kept.
64
+ *
65
+ * Constructing an `Intl` formatter is expensive out of all proportion to using one — measured on
66
+ * this repository's Node, roughly 55x a cached instance and 69x the `toLocaleDateString()` these
67
+ * helpers replaced, because the built-in call is serviced from V8's own cache for the default
68
+ * locale and an explicit constructor is not. Every cell of every table goes through here, so
69
+ * building one per value turned a locale fix into a rendering cost: a 200-row table with three
70
+ * date columns is 600 constructions per render.
71
+ *
72
+ * The keys are a closed set in practice — an app declares its locales in `LocaleProvider` — so
73
+ * this is a cache with no eviction on purpose rather than by oversight.
74
+ */
75
+ const DATE_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
76
+ const NUMBER_FORMATTERS = new Map<string, Intl.NumberFormat>();
77
+
78
+ function dateFormatter(locale: string | undefined, withTime: boolean): Intl.DateTimeFormat {
79
+ const key = `${withTime ? "t" : "d"}|${locale ?? ""}`;
80
+ let formatter = DATE_FORMATTERS.get(key);
81
+ if (formatter === undefined) {
82
+ formatter = new Intl.DateTimeFormat(locale, withTime ? DATE_TIME_OPTIONS : DATE_OPTIONS);
83
+ DATE_FORMATTERS.set(key, formatter);
84
+ }
85
+ return formatter;
86
+ }
87
+
88
+ function numberFormatter(
89
+ locale: string | undefined,
90
+ options: Intl.NumberFormatOptions | undefined,
91
+ ): Intl.NumberFormat {
92
+ // Options are a caller's object rather than one of two constants, so they join the key. Two
93
+ // equivalent objects written in a different order miss each other, which costs one extra
94
+ // formatter and never a wrong answer.
95
+ const key = `${locale ?? ""}|${options === undefined ? "" : JSON.stringify(options)}`;
96
+ let formatter = NUMBER_FORMATTERS.get(key);
97
+ if (formatter === undefined) {
98
+ formatter = new Intl.NumberFormat(locale, options);
99
+ NUMBER_FORMATTERS.set(key, formatter);
100
+ }
101
+ return formatter;
102
+ }
103
+
104
+ /** Locale-explicit short date, e.g. `7 jul 2026` under `nl`. `EMPTY` for absent or unparseable. */
105
+ export function formatDate(value: FormattableDate, locale: string | undefined): string {
106
+ const date = toDate(value);
107
+ return date === null ? EMPTY : dateFormatter(locale, false).format(date);
108
+ }
109
+
110
+ /** The same date with the time of day, for a column whose subject is *when* something happened. */
111
+ export function formatDateTime(value: FormattableDate, locale: string | undefined): string {
112
+ const date = toDate(value);
113
+ return date === null ? EMPTY : dateFormatter(locale, true).format(date);
114
+ }
115
+
116
+ /** Locale-explicit number, with the grouping and decimal separators the locale expects. */
117
+ export function formatNumber(
118
+ value: number | null | undefined,
119
+ locale: string | undefined,
120
+ options?: Intl.NumberFormatOptions,
121
+ ): string {
122
+ return value === null || value === undefined || Number.isNaN(value)
123
+ ? EMPTY
124
+ : numberFormatter(locale, options).format(value);
125
+ }
126
+
127
+ /** {@link formatDate} bound to the app's locale. */
128
+ export function useFormatDate(): (value: FormattableDate) => string {
129
+ const locale = useLocale()?.locale;
130
+ return useCallback((value: FormattableDate) => formatDate(value, locale), [locale]);
131
+ }
132
+
133
+ /** {@link formatDateTime} bound to the app's locale. */
134
+ export function useFormatDateTime(): (value: FormattableDate) => string {
135
+ const locale = useLocale()?.locale;
136
+ return useCallback((value: FormattableDate) => formatDateTime(value, locale), [locale]);
137
+ }
138
+
139
+ /** {@link formatNumber} bound to the app's locale. */
140
+ export function useFormatNumber(): (
141
+ value: number | null | undefined,
142
+ options?: Intl.NumberFormatOptions,
143
+ ) => string {
144
+ const locale = useLocale()?.locale;
145
+ return useCallback(
146
+ (value: number | null | undefined, options?: Intl.NumberFormatOptions) =>
147
+ formatNumber(value, locale, options),
148
+ [locale],
149
+ );
150
+ }