@terpjs/react-core 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- 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 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- 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/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- 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 +141 -15
- 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/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- 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 +71 -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,272 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Checkbox } from "./ui/Checkbox";
|
|
6
|
+
import { Radio } from "./ui/Radio";
|
|
7
|
+
import { Switch } from "./ui/Switch";
|
|
8
|
+
import { Popover } from "./ui/Popover";
|
|
9
|
+
import { Tabs } from "./ui/Tabs";
|
|
10
|
+
import { TERP_STYLES_CSS } from "./styles";
|
|
11
|
+
|
|
12
|
+
// Defects the phases 1-4 review found, gated.
|
|
13
|
+
//
|
|
14
|
+
// Every one of these shipped past four browser lanes and a full unit suite, so an assertion that
|
|
15
|
+
// merely restates the fix would be worth nothing. Each row names the mutation that turns it red
|
|
16
|
+
// and was checked against it.
|
|
17
|
+
//
|
|
18
|
+
// Several read SOURCE TEXT or the stylesheet rather than behaviour, which is unusual here and
|
|
19
|
+
// deliberate rather than lazy. Each of those is a fact about something this suite cannot execute:
|
|
20
|
+
// what a DIFFERENT React major serializes, what an entry point no test mounts forwards, or which
|
|
21
|
+
// rule wins a cascade jsdom does not compute. A behavioural assertion would pass under the bug in
|
|
22
|
+
// every one of those cases. Where behaviour CAN see it — the frozen controls, the tabpanel's
|
|
23
|
+
// name, the popover's focus return — the test renders and asserts on the result instead.
|
|
24
|
+
//
|
|
25
|
+
// No count here on purpose: an earlier version of this comment said "two of the four" and was
|
|
26
|
+
// wrong by the time the file had ten. A citation keeps; a tally rots.
|
|
27
|
+
//
|
|
28
|
+
// One trap has now appeared twice in gates written for this very review, so it is worth stating
|
|
29
|
+
// where the next reader will meet it: a `toContain` on a CSS selector is satisfied by any LONGER
|
|
30
|
+
// selector containing it. `:root[data-density="x"]` contains `[data-density="x"]`, and the pager's
|
|
31
|
+
// own disabled rule contains the shared one. Both mutations came back GREEN until the assertions
|
|
32
|
+
// were anchored to the start of a line with a regex.
|
|
33
|
+
|
|
34
|
+
const sources = import.meta.glob("./**/*.tsx", {
|
|
35
|
+
query: "?raw",
|
|
36
|
+
import: "default",
|
|
37
|
+
eager: true,
|
|
38
|
+
}) as Record<string, string>;
|
|
39
|
+
|
|
40
|
+
/** The package manifest as text, so the peer range can be asserted without importing it. */
|
|
41
|
+
const packageJson = import.meta.glob("../package.json", {
|
|
42
|
+
query: "?raw",
|
|
43
|
+
import: "default",
|
|
44
|
+
eager: true,
|
|
45
|
+
}) as Record<string, string>;
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
cleanup();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("defects the phases 1-4 review found", () => {
|
|
52
|
+
it("warns when a checked control is given no onChange, instead of freezing silently", () => {
|
|
53
|
+
// React's own guard is the only thing that would tell an author they pinned `checked` and
|
|
54
|
+
// forgot the handler. Passing `onChange` unconditionally suppresses it, so the control looked
|
|
55
|
+
// operable, never changed, and said nothing — the exact failure `Select` documents at its own
|
|
56
|
+
// call site (ui/Select.tsx) and avoids. Mutation: restore the unconditional handler in any of
|
|
57
|
+
// the three and the count drops.
|
|
58
|
+
const seen: string[] = [];
|
|
59
|
+
const spy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
60
|
+
seen.push(args.map(String).join(" "));
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
render(
|
|
64
|
+
<>
|
|
65
|
+
<Checkbox label="frozen box" checked />
|
|
66
|
+
<Switch label="frozen switch" checked />
|
|
67
|
+
<Radio name="group" value="a" label="frozen radio" checked />
|
|
68
|
+
</>,
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
spy.mockRestore();
|
|
72
|
+
}
|
|
73
|
+
// React de-duplicates this warning per component type, so three distinct components warn
|
|
74
|
+
// three times. Matched loosely because the exact wording differs across React majors.
|
|
75
|
+
const complaints = seen.filter((line) => /without an .?onChange.? handler/.test(line));
|
|
76
|
+
expect(complaints).toHaveLength(3);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not repaint an invalid field's border on hover", () => {
|
|
80
|
+
// Both rules live in terp.state, so the cascade is decided by specificity alone: the hover
|
|
81
|
+
// selector weighed (0,4,0) against the danger border's (0,2,0) and won. Pointing at a field
|
|
82
|
+
// that had just failed validation removed its error border for as long as the pointer rested
|
|
83
|
+
// there — and the pointer rests there precisely because the user is about to fix it.
|
|
84
|
+
// Mutation: drop the :not([aria-invalid="true"]).
|
|
85
|
+
expect(TERP_STYLES_CSS).toContain(
|
|
86
|
+
'[data-terp="input"]:hover:not(:disabled):not(:focus):not([aria-invalid="true"])',
|
|
87
|
+
);
|
|
88
|
+
// And the unnarrowed aggressor must not exist anywhere, in any form.
|
|
89
|
+
expect(TERP_STYLES_CSS).not.toContain(
|
|
90
|
+
'[data-terp="input"]:hover:not(:disabled):not(:focus) {',
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does not promise a React major on which the drawer's containment does not exist", () => {
|
|
95
|
+
// The drawer inerts the page column and marks it aria-hidden. Measured with
|
|
96
|
+
// renderToStaticMarkup against both renderers:
|
|
97
|
+
//
|
|
98
|
+
// spelling React 18.3.1 React 19.2.8
|
|
99
|
+
// inert={true} DROPPED (warns) inert=""
|
|
100
|
+
// inert="" inert="" DROPPED (warns: treated as false)
|
|
101
|
+
//
|
|
102
|
+
// So under the old `^18.3.0 || ^19.0.0` peer range, an app on 18.3 got the worst half of
|
|
103
|
+
// the pair — a subtree announced as hidden to assistive technology with every control in it
|
|
104
|
+
// still focusable and clickable — and no spelling is correct and quiet on both. The review
|
|
105
|
+
// that found this proposed `inert=""`, which would have broken React 19 instead; the
|
|
106
|
+
// measurement is the only reason that did not ship. The defect is the promise, so the fix
|
|
107
|
+
// is the range. Mutation: widen the peer range back to include ^18.3.0.
|
|
108
|
+
const manifest = JSON.parse(
|
|
109
|
+
(packageJson as Record<string, string>)["../package.json"] ?? "{}",
|
|
110
|
+
) as { peerDependencies?: Record<string, string> };
|
|
111
|
+
const peers = manifest.peerDependencies ?? {};
|
|
112
|
+
for (const name of ["react", "react-dom"] as const) {
|
|
113
|
+
expect(peers[name], `${name} peer range`).toBeDefined();
|
|
114
|
+
expect(peers[name]).not.toMatch(/18\./);
|
|
115
|
+
}
|
|
116
|
+
// And the attribute stays the spelling React 19 actually renders.
|
|
117
|
+
const shell = sources["./AppShell.tsx"] ?? "";
|
|
118
|
+
expect(shell, "AppShell.tsx is not in the scanned sources").not.toBe("");
|
|
119
|
+
expect(shell).toContain("inert={isMobile && drawerOpen ? true : undefined}");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("gives a tabpanel an accessible name even when the tab value contains whitespace", () => {
|
|
123
|
+
// `Tabs` built element ids by interpolating the caller's `value`. A value is caller data and
|
|
124
|
+
// an id is an IDREF, so a value with a space turned `aria-labelledby` into a list of two
|
|
125
|
+
// tokens, neither of which resolves — and the tabpanel silently lost its name. Nothing
|
|
126
|
+
// reported it: axe sees a well-formed reference to nothing, and the visible tab still reads
|
|
127
|
+
// correctly. Ids key on the tab's index now.
|
|
128
|
+
// Mutation: interpolate `tab.value` / `selectedTab.value` back into the two ids.
|
|
129
|
+
render(
|
|
130
|
+
<Tabs
|
|
131
|
+
label="Sections"
|
|
132
|
+
tabs={[
|
|
133
|
+
{ value: "my tab", label: "My tab", content: <p>first</p> },
|
|
134
|
+
{ value: "other one", label: "Other one", content: <p>second</p> },
|
|
135
|
+
]}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
expect(screen.getByRole("tabpanel", { name: "My tab" })).toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("wins the density tie against the contract's own :root declarations", () => {
|
|
142
|
+
// The shell's density attribute sets the same custom properties the contract declares on
|
|
143
|
+
// :root, both unlayered. A bare [data-density="..."] on <html> weighs (0,1,0) — exactly what
|
|
144
|
+
// :root weighs — so the two tied and only the order the sheets happened to load decided it.
|
|
145
|
+
// A production build extracts tokens.css to a <link> that precedes the injected sheet, so
|
|
146
|
+
// react-core won by construction and the exposure was the dev server and any host loading
|
|
147
|
+
// the tokens late. The :root-qualified copy is (0,2,0) and wins outright.
|
|
148
|
+
// Mutation: drop either :root-qualified selector.
|
|
149
|
+
for (const density of ["comfortable", "compact"] as const) {
|
|
150
|
+
expect(TERP_STYLES_CSS).toContain(`:root[data-density="${density}"]`);
|
|
151
|
+
// The unqualified copy stays, for a density island on a subtree where there is no :root.
|
|
152
|
+
// Anchored to the start of a line, because `:root[data-density="x"]` CONTAINS
|
|
153
|
+
// `[data-density="x"]` — a bare toContain here would be satisfied by the qualified copy
|
|
154
|
+
// alone and could never notice the unqualified one being deleted.
|
|
155
|
+
expect(TERP_STYLES_CSS).toMatch(
|
|
156
|
+
new RegExp(`^\\[data-density="${density}"\\]`, "m"),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("forwards logoDark from both sanctioned entry points", () => {
|
|
162
|
+
// The third slot to exist on the shell and be unreachable from the entry points every app
|
|
163
|
+
// uses, after `headerActions` — which ADR 0097's own Context complains about. This one was
|
|
164
|
+
// worse: the project template instructs every new app to pass `logoDark` to `renderTerpApp`
|
|
165
|
+
// (template/project/frontend/src/main.tsx.jinja), so the documented example did not
|
|
166
|
+
// typecheck. Mutation: delete either forwarding line.
|
|
167
|
+
//
|
|
168
|
+
// Matched as "the option appears in what the prop is given" rather than as one exact
|
|
169
|
+
// expression, because the exact expression changed the day `shell.brand` landed and the
|
|
170
|
+
// invariant did not: the slot now prefers a declared PATH and falls back to the option, and
|
|
171
|
+
// the old `toContain("logoDark={options.logoDark}")` failed on a router that still forwards
|
|
172
|
+
// it perfectly. An assertion on a spelling fails for the wrong reason, which is only one
|
|
173
|
+
// step better than passing for the wrong reason.
|
|
174
|
+
const router = sources["./router.tsx"] ?? "";
|
|
175
|
+
const bootstrap = sources["./bootstrap.tsx"] ?? "";
|
|
176
|
+
expect(router, "router.tsx is not in the scanned sources").not.toBe("");
|
|
177
|
+
expect(bootstrap, "bootstrap.tsx is not in the scanned sources").not.toBe("");
|
|
178
|
+
expect(router).toMatch(/logoDark=\{[^}]*options\.logoDark/);
|
|
179
|
+
// `logo=` alone would also match inside `logoDark=`; the negated class stops at the
|
|
180
|
+
// first `}`, so this reads the whole expression the slot is given.
|
|
181
|
+
expect(router).toMatch(/[^a-zA-Z]logo=\{[^}]*options\.logo}/);
|
|
182
|
+
expect(bootstrap).toContain("logoDark: options.logoDark,");
|
|
183
|
+
expect(bootstrap).toMatch(/logoDark\?: ReactNode;/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns focus to the trigger when Tab leaves a popover panel", () => {
|
|
187
|
+
// The panel is portalled to the END of document.body, so with no Tab branch the
|
|
188
|
+
// sequential-navigation starting point stayed inside a node at the wrong end of the
|
|
189
|
+
// document: Tab out landed past every piece of page content, and Shift+Tab landed on the
|
|
190
|
+
// last focusable element on the page rather than back on the control that opened it. `Menu`
|
|
191
|
+
// has implemented the APG contract for this all along and documents the exact failure;
|
|
192
|
+
// `Popover` carried the same portal with an Escape-only handler.
|
|
193
|
+
// Mutation: delete the Tab branch from the panel's onKeyDown.
|
|
194
|
+
render(
|
|
195
|
+
<Popover trigger={<button type="button">Open</button>}>
|
|
196
|
+
{() => (
|
|
197
|
+
<button type="button">Inside</button>
|
|
198
|
+
)}
|
|
199
|
+
</Popover>,
|
|
200
|
+
);
|
|
201
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
202
|
+
fireEvent.click(trigger);
|
|
203
|
+
const inside = screen.getByRole("button", { name: "Inside" });
|
|
204
|
+
inside.focus();
|
|
205
|
+
expect(document.activeElement).toBe(inside);
|
|
206
|
+
fireEvent.keyDown(inside, { key: "Tab" });
|
|
207
|
+
// Closed, and focus is back on the trigger — from which the browser's own Tab default
|
|
208
|
+
// moves on to whatever genuinely follows it in the document.
|
|
209
|
+
expect(screen.queryByRole("button", { name: "Inside" })).not.toBeInTheDocument();
|
|
210
|
+
expect(document.activeElement).toBe(trigger);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps a pager button focusable when its own click disables it", () => {
|
|
214
|
+
// Each of the pager's four buttons has a bound condition recomputed from what its own click
|
|
215
|
+
// just changed, so pressing "next" until the last page disabled the control the user was
|
|
216
|
+
// operating — and a disabled element cannot hold focus, so the browser dropped it to
|
|
217
|
+
// <body>. A keyboard user paging to the end lost their place at the moment they arrived.
|
|
218
|
+
// Mutation: change aria-disabled back to disabled on the next/last pair.
|
|
219
|
+
const pagination = sources["./dataview/DataViewPagination.tsx"] ?? "";
|
|
220
|
+
expect(pagination, "DataViewPagination.tsx is not in the scanned sources").not.toBe("");
|
|
221
|
+
expect(pagination, "the native disabled attribute drops focus to <body>").not.toContain(
|
|
222
|
+
"disabled={atFirst}",
|
|
223
|
+
);
|
|
224
|
+
expect(pagination).not.toContain("disabled={atLast}");
|
|
225
|
+
expect(pagination).toContain("aria-disabled={atLast || undefined}");
|
|
226
|
+
expect(pagination).toContain("aria-disabled={atFirst || undefined}");
|
|
227
|
+
// And the sheet must paint the announced state exactly as it painted the native one, or the
|
|
228
|
+
// fix trades a focus bug for a visual one.
|
|
229
|
+
// Both anchored to the start of a line. The pager rule CONTAINS the shared selector as a
|
|
230
|
+
// substring, so a bare toContain on the shared one is satisfied by the pager rule alone
|
|
231
|
+
// and cannot notice it being deleted — which is exactly what the mutation showed.
|
|
232
|
+
expect(TERP_STYLES_CSS).toMatch(
|
|
233
|
+
/^\[data-terp="iconbutton"\]\[aria-disabled="true"\]/m,
|
|
234
|
+
);
|
|
235
|
+
expect(TERP_STYLES_CSS).toMatch(
|
|
236
|
+
/^\[data-terp="dataview-pager"\] > \[data-terp="iconbutton"\]\[aria-disabled="true"\]/m,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("caps Markdown's blocks in a measured shell, past its own boxless wrapper", () => {
|
|
241
|
+
// Markdown is display: contents, so it generates no box — and a non-inherited property on a
|
|
242
|
+
// boxless element is dropped. The measured-width rule is a child-star selector on the page's
|
|
243
|
+
// body children, so it MATCHED the markdown wrapper and then had nothing to apply a width
|
|
244
|
+
// to: prose ran the full width of a measured shell, on the one component whose entire
|
|
245
|
+
// purpose is long-form text. The sheet's own comment beside the wrapper asserted that no
|
|
246
|
+
// child-star selector existed in the sheet, which the measured-width rule had already
|
|
247
|
+
// falsified. Mutation: delete the reach-through rule.
|
|
248
|
+
expect(TERP_STYLES_CSS).toContain('[data-terp="page"] > [data-terp="markdown"] > *');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("paints the account menu from the sidebar family it actually sits on", () => {
|
|
252
|
+
// The trigger renders inside the sidebar (and inside the header group under
|
|
253
|
+
// navPlacement="header", which takes the sidebar surface), so its ink against that
|
|
254
|
+
// background is a pairing in play — but it read the NEUTRAL family, which the contrast gate
|
|
255
|
+
// has no sidebar-scoped pairing for. Reading the sidebar family instead puts it under
|
|
256
|
+
// `sidebar-text` and `sidebar-muted-text`, both already declared, so the gate covers it with
|
|
257
|
+
// no new entries. Provably zero-diff: the two families are byte-equal in all five themes.
|
|
258
|
+
//
|
|
259
|
+
// The role marker renders TWICE — trigger and portalled panel — so it is scoped rather than
|
|
260
|
+
// swapped; the panel is in document.body and the sidebar palette does not apply there.
|
|
261
|
+
// Mutation: put --color-neutral-900 back on the trigger.
|
|
262
|
+
const menuRule = TERP_STYLES_CSS.slice(
|
|
263
|
+
TERP_STYLES_CSS.indexOf('[data-terp="user-menu"] [data-terp="menu-trigger"] {'),
|
|
264
|
+
).slice(0, 900);
|
|
265
|
+
expect(menuRule).toContain("color: var(--color-sidebar-fg)");
|
|
266
|
+
expect(menuRule).not.toContain("color: var(--color-neutral-900)");
|
|
267
|
+
const roleRule = TERP_STYLES_CSS.slice(
|
|
268
|
+
TERP_STYLES_CSS.indexOf('[data-terp="user-menu"] [data-terp="user-menu-role"] {'),
|
|
269
|
+
).slice(0, 120);
|
|
270
|
+
expect(roleRule).toContain("color: var(--color-sidebar-muted)");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ModuleManifest } from "@terpjs/contract";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The runtime half of a route's declared query-string keys (ADR 0096).
|
|
7
|
+
*
|
|
8
|
+
* The generated `routes.gen.d.ts` is types only — it vanishes at runtime — so
|
|
9
|
+
* `useRouteSearch` needs the same declarations as data. They come from the manifests the
|
|
10
|
+
* router was built from, published through a context rather than a module-level table:
|
|
11
|
+
* a table would be shared by every router composed in one process, so an app embedding
|
|
12
|
+
* another (or a test file composing two) would read declarations it never mounted.
|
|
13
|
+
*/
|
|
14
|
+
export const RouteSearchContext = createContext<ReadonlyMap<string, readonly string[]> | null>(
|
|
15
|
+
null,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
/** The manifest spelling (`:id`), so a `$id` manifest and a `:id` read agree on one key. */
|
|
19
|
+
export function canonicalRoutePath(path: string): string {
|
|
20
|
+
return path.replace(/(^|\/)\$([A-Za-z_][A-Za-z0-9_]*)/g, "$1:$2");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Index every manifest route's declared search keys by its canonical path.
|
|
25
|
+
*
|
|
26
|
+
* Two manifests mounting one path union their keys, matching what the generator emits into
|
|
27
|
+
* the type table — so the runtime read and the compile-time check agree.
|
|
28
|
+
*/
|
|
29
|
+
export function indexSearchKeys(
|
|
30
|
+
manifests: readonly ModuleManifest[],
|
|
31
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
32
|
+
const index = new Map<string, readonly string[]>();
|
|
33
|
+
for (const manifest of manifests) {
|
|
34
|
+
for (const route of manifest.routes) {
|
|
35
|
+
const key = canonicalRoutePath(route.path);
|
|
36
|
+
index.set(key, [...new Set([...(index.get(key) ?? []), ...(route.search ?? [])])]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return index;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The search keys declared for *path*, or a refusal naming what is mounted.
|
|
44
|
+
*
|
|
45
|
+
* A path the router never mounted is a programming error, not an empty result: returning
|
|
46
|
+
* `{}` would hand a screen `undefined` for every key it asked for, which reads as "no
|
|
47
|
+
* filters applied" — the silent-wrong-answer this refusal replaces.
|
|
48
|
+
*/
|
|
49
|
+
export function declaredSearchKeys(
|
|
50
|
+
index: ReadonlyMap<string, readonly string[]> | null,
|
|
51
|
+
path: string,
|
|
52
|
+
): readonly string[] {
|
|
53
|
+
if (index === null) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`useRouteSearch("${path}") was called outside a Terp router. It reads the declarations ` +
|
|
56
|
+
"the router was built from, so it only works under a view mounted by buildAppRouter.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const declared = index.get(canonicalRoutePath(path));
|
|
60
|
+
if (declared === undefined) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Route "${path}" is not a mounted route, so its search keys are unknown (mounted: ` +
|
|
63
|
+
`${[...index.keys()].join(", ") || "none"}). useRouteSearch takes the path as written ` +
|
|
64
|
+
"in the module manifest — check it there, and run `terp routes` if the manifest changed.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return declared;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The mounted routes' search-key index, or null outside a Terp router. */
|
|
71
|
+
export function useRouteSearchIndex(): ReadonlyMap<string, readonly string[]> | null {
|
|
72
|
+
return useContext(RouteSearchContext);
|
|
73
|
+
}
|
package/src/routeTypes.ts
CHANGED
|
@@ -26,6 +26,24 @@
|
|
|
26
26
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
|
|
27
27
|
export interface TerpRouteTable {}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The query-string half of the same table (ADR 0096): path -> its declared search keys.
|
|
31
|
+
*
|
|
32
|
+
* Params were checked and search keys were not, which sounds like a gap and behaves like
|
|
33
|
+
* a hole: a list screen's filters, its sort and its page cursor all live in the query
|
|
34
|
+
* string, so *every* screen with a filter had to leave the typed seam and reach for the
|
|
35
|
+
* router's own `useNavigate` / `useSearch` — losing path checking too, on the majority of
|
|
36
|
+
* screens. Declaring the keys in the manifest (`search: ["status", "page"]`) is what lets
|
|
37
|
+
* navigation and reads stay inside the checked seam.
|
|
38
|
+
*
|
|
39
|
+
* Keyed only for routes that declare keys, so a route with none refuses `search` outright
|
|
40
|
+
* rather than accepting anything. Values are `string | undefined`: a query parameter is
|
|
41
|
+
* text and is absent until set — parsing is the screen's business, and the declaration is
|
|
42
|
+
* what stops the *key* being a typo.
|
|
43
|
+
*/
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
|
|
45
|
+
export interface TerpRouteSearchTable {}
|
|
46
|
+
|
|
29
47
|
/** True when no `routes.gen.d.ts` has augmented {@link TerpRouteTable}. */
|
|
30
48
|
type Ungenerated = keyof TerpRouteTable extends never ? true : false;
|
|
31
49
|
|
|
@@ -50,14 +68,40 @@ export type TerpRouteParamName = Ungenerated extends true
|
|
|
50
68
|
: { [P in keyof TerpRouteTable]: keyof TerpRouteTable[P] }[keyof TerpRouteTable] & string;
|
|
51
69
|
|
|
52
70
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* the
|
|
71
|
+
* One route's declared query-string keys, all optional.
|
|
72
|
+
*
|
|
73
|
+
* Three cases, and the middle one is the point. Before `terp routes` has generated, the
|
|
74
|
+
* shape is loose (like {@link TerpRouteParams}) so an app that has not adopted keeps
|
|
75
|
+
* today's behavior. Once generated, a route that declared keys gets exactly those; a
|
|
76
|
+
* route that declared **none** gets `Record<never, never>`, so passing `search` to it is a
|
|
77
|
+
* typecheck error rather than a value silently dropped into the URL.
|
|
78
|
+
*/
|
|
79
|
+
export type TerpRouteSearch<P extends TerpRoutePath> = P extends keyof TerpRouteSearchTable
|
|
80
|
+
? TerpRouteSearchTable[P]
|
|
81
|
+
: Ungenerated extends true
|
|
82
|
+
? Record<string, string | undefined>
|
|
83
|
+
: Record<never, never>;
|
|
84
|
+
|
|
85
|
+
/** Every search key any route declares — or `string` before generating. */
|
|
86
|
+
export type TerpRouteSearchKey = Ungenerated extends true
|
|
87
|
+
? string
|
|
88
|
+
: { [P in keyof TerpRouteSearchTable]: keyof TerpRouteSearchTable[P] }[keyof TerpRouteSearchTable] &
|
|
89
|
+
string;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A navigation target: a declared path, that path's params when it takes any, and that
|
|
93
|
+
* path's declared search keys when it reads any.
|
|
94
|
+
*
|
|
95
|
+
* A paramless route refuses a `params` object; a parameterised one requires it, with the
|
|
96
|
+
* names the manifest declared. `search` is always optional (a route may be visited with
|
|
97
|
+
* no filters applied) but its *keys* are the declared ones, so a typo is a typecheck
|
|
98
|
+
* error and a route that declares no search keys refuses the property. Before generating,
|
|
99
|
+
* this is the loose shape.
|
|
56
100
|
*/
|
|
57
101
|
export type TerpNavigateTarget = Ungenerated extends true
|
|
58
|
-
? { to: string; params?: Record<string, string> }
|
|
102
|
+
? { to: string; params?: Record<string, string>; search?: Record<string, string | undefined> }
|
|
59
103
|
: {
|
|
60
|
-
[P in keyof TerpRouteTable & string]: keyof TerpRouteTable[P] extends never
|
|
104
|
+
[P in keyof TerpRouteTable & string]: (keyof TerpRouteTable[P] extends never
|
|
61
105
|
? { to: P; params?: undefined }
|
|
62
|
-
: { to: P; params: TerpRouteTable[P] };
|
|
106
|
+
: { to: P; params: TerpRouteTable[P] }) & { search?: TerpRouteSearch<P> };
|
|
63
107
|
}[keyof TerpRouteTable & string];
|