@terpjs/react-core 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. package/src/unwrap.ts +118 -32
@@ -0,0 +1,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
- * A navigation target: a declared path, plus that path's params when it takes any.
54
- * A paramless route refuses a `params` object; a parameterised one requires it, with
55
- * the names the manifest declared. Before generating, this is the loose shape.
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];