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