@terpjs/react-core 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -20
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +17 -4
- 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 +3 -0
- package/src/markers.test.ts +25 -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 +202 -19
- package/src/styles.test.ts +483 -24
- package/src/styles.ts +956 -85
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +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 +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
|
@@ -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
|
+
}
|
package/src/icons.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IconName } from "@terpjs/contract";
|
|
1
2
|
import type { ReactNode } from "react";
|
|
2
3
|
|
|
3
4
|
import { injectTerpStyles } from "./styles";
|
|
@@ -27,8 +28,28 @@ const svgProps = {
|
|
|
27
28
|
focusable: false,
|
|
28
29
|
} as const;
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
// `IconName` must be the literal union, not `string`. Drop the `as const` from `ICON_NAMES` in
|
|
32
|
+
// the contract and this directive becomes unused, which TypeScript reports as an error — which
|
|
33
|
+
// is the point of writing it here rather than trusting the annotation over there. A scan cannot
|
|
34
|
+
// check this, because the thing being checked is what the scan would be built out of.
|
|
35
|
+
// @ts-expect-error the union must reject a name that is not in it
|
|
36
|
+
const _iconNameIsNotWidened: IconName = "no-such-glyph";
|
|
37
|
+
void _iconNameIsNotWidened;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The bundled glyphs, keyed by the names a manifest's `NavItem.icon` may use.
|
|
41
|
+
*
|
|
42
|
+
* `satisfies Record<IconName, ReactNode>` is exhaustive in **both** directions at compile time:
|
|
43
|
+
* a glyph whose name is not in {@link ICON_NAMES} is an excess-property error, and a name with
|
|
44
|
+
* no glyph is a missing-property error. That is strictly stronger than the parity test ADR 0097
|
|
45
|
+
* §5 asked for — a test can only run after a build that already succeeded, and it would have to
|
|
46
|
+
* be kept in sync by hand — and it costs one keyword.
|
|
47
|
+
*
|
|
48
|
+
* The exported type stays `Record<string, ReactNode>` deliberately. `NavIcon` indexes this table
|
|
49
|
+
* with a `string` (its fallback is a designed behaviour, not an error), so narrowing the export
|
|
50
|
+
* would make that a type error at a call site the design wants to keep working.
|
|
51
|
+
*/
|
|
52
|
+
const GLYPHS = {
|
|
32
53
|
home: (
|
|
33
54
|
<svg {...svgProps}>
|
|
34
55
|
<path d="M3 10.5 12 3l9 7.5" />
|
|
@@ -433,7 +454,34 @@ export const ICON_GLYPHS: Record<string, ReactNode> = {
|
|
|
433
454
|
<path d="M13 3 4 14h6l-1 7 9-11h-6l1-7Z" />
|
|
434
455
|
</svg>
|
|
435
456
|
),
|
|
436
|
-
|
|
457
|
+
// The password reveal, and the only pair here where one glyph is the other struck through.
|
|
458
|
+
// The slash is part of `eye-off` rather than an overlay, so the two swap as whole glyphs and
|
|
459
|
+
// neither depends on the other rendering underneath it.
|
|
460
|
+
eye: (
|
|
461
|
+
<svg {...svgProps}>
|
|
462
|
+
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z" />
|
|
463
|
+
<circle cx="12" cy="12" r="3" />
|
|
464
|
+
</svg>
|
|
465
|
+
),
|
|
466
|
+
"eye-off": (
|
|
467
|
+
<svg {...svgProps}>
|
|
468
|
+
<path d="M10.6 6.2A9.9 9.9 0 0 1 12 5c6.4 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.1" />
|
|
469
|
+
<path d="M6.6 6.8A17.7 17.7 0 0 0 2 12s3.6 7 10 7a9.8 9.8 0 0 0 4.5-1.1" />
|
|
470
|
+
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
|
471
|
+
<path d="m3 3 18 18" />
|
|
472
|
+
</svg>
|
|
473
|
+
),
|
|
474
|
+
} satisfies Record<IconName, ReactNode>;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* The glyph table as the package publishes it.
|
|
478
|
+
*
|
|
479
|
+
* Typed as a string-keyed record rather than an IconName-keyed one, and that is not laziness:
|
|
480
|
+
* NavIcon looks a glyph up by a plain string on purpose, so a narrower index signature would
|
|
481
|
+
* break the one call site whose unknown-name behaviour is designed. The exhaustiveness lives on
|
|
482
|
+
* the declaration above, where it belongs.
|
|
483
|
+
*/
|
|
484
|
+
export const ICON_GLYPHS: Record<string, ReactNode> = GLYPHS;
|
|
437
485
|
|
|
438
486
|
export interface NavIconProps {
|
|
439
487
|
/** Glyph name (a `NavItem.icon` value); unknown / missing falls back to the initial. */
|
|
@@ -460,8 +508,22 @@ export function NavIcon({ name, label }: NavIconProps) {
|
|
|
460
508
|
}
|
|
461
509
|
|
|
462
510
|
export interface IconProps {
|
|
463
|
-
/**
|
|
464
|
-
|
|
511
|
+
/**
|
|
512
|
+
* Which glyph to render, by checked name.
|
|
513
|
+
*
|
|
514
|
+
* A checked name here and a plain `string` on {@link NavIconProps}, and the difference is the
|
|
515
|
+
* failure mode rather than the audience. `NavIcon` falls back to the label's initial in a
|
|
516
|
+
* tile — visible, designed, and gated by a specimen — so an unknown name there produces
|
|
517
|
+
* something. `Icon` rendered **nothing**: an empty box where a glyph should be, which is
|
|
518
|
+
* exactly the silent no-op this codebase refuses everywhere else.
|
|
519
|
+
*
|
|
520
|
+
* That was not hypothetical. The workbench's own icon gallery rendered
|
|
521
|
+
* `<Icon name="close" />` — there is no `close` glyph, the glyph is `x` — and shipped a blank
|
|
522
|
+
* cell with a caption under it. Its baseline recorded the blank and passed for releases,
|
|
523
|
+
* because a missing glyph and a glyph that is not there look identical. The type is what
|
|
524
|
+
* turns that into a build failure.
|
|
525
|
+
*/
|
|
526
|
+
name: IconName;
|
|
465
527
|
/**
|
|
466
528
|
* CSS length applied to width & height (default `1em`, so the glyph tracks
|
|
467
529
|
* the surrounding text size). All strokes use `currentColor`.
|
package/src/index.ts
CHANGED
|
@@ -23,11 +23,23 @@ export type {
|
|
|
23
23
|
} from "./realtime";
|
|
24
24
|
export { unwrap, unwrapOptional, ApiError } from "./unwrap";
|
|
25
25
|
export type { FetchResult } from "./unwrap";
|
|
26
|
+
export {
|
|
27
|
+
formatDate,
|
|
28
|
+
formatDateTime,
|
|
29
|
+
formatNumber,
|
|
30
|
+
useFormatDate,
|
|
31
|
+
useFormatDateTime,
|
|
32
|
+
useFormatNumber,
|
|
33
|
+
} from "./format";
|
|
34
|
+
export type { FormattableDate } from "./format";
|
|
26
35
|
export { ResourceList } from "./ResourceList";
|
|
27
36
|
export type { ResourceListProps } from "./ResourceList";
|
|
28
37
|
export { RequireAuth } from "./RequireAuth";
|
|
29
38
|
export type { RequireAuthProps } from "./RequireAuth";
|
|
30
|
-
export { visibleNav } from "./nav";
|
|
39
|
+
export { groupNav, isDeclarationVisible, visibleNav } from "./nav";
|
|
40
|
+
export type { NavSection, NavVisibilityContext } from "./nav";
|
|
41
|
+
export { activeNavPath, isNavItemActive } from "./navActive";
|
|
42
|
+
export type { NavActiveCandidate } from "./navActive";
|
|
31
43
|
export {
|
|
32
44
|
AppShell,
|
|
33
45
|
SIDEBAR_STORAGE_KEY,
|
|
@@ -51,6 +63,9 @@ export { Page } from "./Page";
|
|
|
51
63
|
export type { PageProps } from "./Page";
|
|
52
64
|
export { LAYOUT_CONTRACTS } from "./layoutContract";
|
|
53
65
|
export type { LayoutContractSpec, LayoutSlotSpec } from "./layoutContract";
|
|
66
|
+
// `ResolvedLayout` is deliberately NOT exported: nothing hands one to app code, so it
|
|
67
|
+
// would be public surface a consumer can never hold.
|
|
68
|
+
export type { LayoutDeclaration, LayoutShellDeclaration } from "./layoutDeclaration";
|
|
54
69
|
export { PageActions } from "./PageActions";
|
|
55
70
|
export type { OverflowAction, PageActionsProps } from "./PageActions";
|
|
56
71
|
export { ModuleNav } from "./ModuleNav";
|
|
@@ -59,6 +74,17 @@ export { OverviewPage } from "./OverviewPage";
|
|
|
59
74
|
export type { OverviewPageProps } from "./OverviewPage";
|
|
60
75
|
export { DetailPage } from "./DetailPage";
|
|
61
76
|
export type { DetailPageProps } from "./DetailPage";
|
|
77
|
+
export { FormPage } from "./FormPage";
|
|
78
|
+
export type { FormPageProps } from "./FormPage";
|
|
79
|
+
export { SettingsPage } from "./SettingsPage";
|
|
80
|
+
export type { SettingsPageProps } from "./SettingsPage";
|
|
81
|
+
export { SplitPage, SplitPane } from "./SplitPage";
|
|
82
|
+
export type {
|
|
83
|
+
SplitPageProps,
|
|
84
|
+
SplitPaneProps,
|
|
85
|
+
SplitPaneRole,
|
|
86
|
+
SplitListWidth,
|
|
87
|
+
} from "./SplitPage";
|
|
62
88
|
export { HubPage, HubCard } from "./HubPage";
|
|
63
89
|
export type { HubPageProps, HubCardProps, RenderHubCardLink } from "./HubPage";
|
|
64
90
|
export { UiTextProvider, useStrings, useUiText, resolveUiText, DEFAULT_STRINGS } from "./uiText";
|
|
@@ -76,11 +102,11 @@ export type { LoadingStateProps, InlineSpinnerProps } from "./LoadingState";
|
|
|
76
102
|
export { ToastProvider, useToast } from "./toast";
|
|
77
103
|
export type { ToastApi, ToastOptions, ToastProviderProps, ToastVariant } from "./toast";
|
|
78
104
|
export { Button } from "./ui/Button";
|
|
79
|
-
export type { ButtonProps, ButtonVariant } from "./ui/Button";
|
|
105
|
+
export type { ButtonProps, ButtonSize, ButtonVariant } from "./ui/Button";
|
|
80
106
|
export { Input } from "./ui/Input";
|
|
81
107
|
export type { InputProps } from "./ui/Input";
|
|
82
108
|
export { Select } from "./ui/Select";
|
|
83
|
-
export type { SelectProps } from "./ui/Select";
|
|
109
|
+
export type { SelectProps, SelectOption } from "./ui/Select";
|
|
84
110
|
export { Textarea } from "./ui/Textarea";
|
|
85
111
|
export type { TextareaProps } from "./ui/Textarea";
|
|
86
112
|
export { Popover } from "./ui/Popover";
|
|
@@ -99,10 +125,12 @@ export { Switch } from "./ui/Switch";
|
|
|
99
125
|
export type { SwitchProps } from "./ui/Switch";
|
|
100
126
|
export { Tabs } from "./ui/Tabs";
|
|
101
127
|
export type { TabItem, TabsProps } from "./ui/Tabs";
|
|
128
|
+
export { Avatar } from "./ui/Avatar";
|
|
129
|
+
export type { AvatarProps } from "./ui/Avatar";
|
|
102
130
|
export { Badge } from "./ui/Badge";
|
|
103
131
|
export type { BadgeProps, BadgeTone } from "./ui/Badge";
|
|
104
132
|
export { Card } from "./ui/Card";
|
|
105
|
-
export type { CardProps } from "./ui/Card";
|
|
133
|
+
export type { CardProps, CardVariant } from "./ui/Card";
|
|
106
134
|
export { Tooltip } from "./ui/Tooltip";
|
|
107
135
|
export type { TooltipProps } from "./ui/Tooltip";
|
|
108
136
|
export { Alert } from "./ui/Alert";
|
|
@@ -111,10 +139,32 @@ export { Markdown } from "./ui/Markdown";
|
|
|
111
139
|
export type { MarkdownProps } from "./ui/Markdown";
|
|
112
140
|
export { saveBlob, useEndpointDownload, fetchDownload, downloadUrl } from "./download";
|
|
113
141
|
export type { DownloadTarget } from "./download";
|
|
142
|
+
export { Heading, Text, Code, Link } from "./typography";
|
|
143
|
+
export type {
|
|
144
|
+
HeadingProps,
|
|
145
|
+
HeadingLevel,
|
|
146
|
+
HeadingSize,
|
|
147
|
+
TextProps,
|
|
148
|
+
TextTone,
|
|
149
|
+
TextSize,
|
|
150
|
+
CodeProps,
|
|
151
|
+
LinkProps,
|
|
152
|
+
} from "./typography";
|
|
114
153
|
export { Field } from "./Field";
|
|
115
154
|
export type { FieldProps } from "./Field";
|
|
116
|
-
export { Stack, DetailList } from "./layout";
|
|
117
|
-
export type {
|
|
155
|
+
export { Stack, Grid, Divider, DetailList } from "./layout";
|
|
156
|
+
export type {
|
|
157
|
+
StackProps,
|
|
158
|
+
GridProps,
|
|
159
|
+
GridColumns,
|
|
160
|
+
GridMinColumn,
|
|
161
|
+
DividerProps,
|
|
162
|
+
Responsive,
|
|
163
|
+
DetailListProps,
|
|
164
|
+
DetailListLayout,
|
|
165
|
+
DetailItem,
|
|
166
|
+
SpaceToken,
|
|
167
|
+
} from "./layout";
|
|
118
168
|
export {
|
|
119
169
|
buildAppRouter,
|
|
120
170
|
DEFAULT_ROLE_RANKS,
|