@terpjs/react-core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  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.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
@@ -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;
@@ -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
+ }