@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.
Files changed (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. package/src/unwrap.ts +118 -32
package/src/nav.test.ts CHANGED
@@ -1,21 +1,251 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { ModuleManifest } from "@terpjs/contract";
3
3
 
4
- import { visibleNav } from "./nav";
4
+ import { groupNav, isDeclarationVisible, visibleNav } from "./nav";
5
+ import type { NavVisibilityContext } from "./nav";
5
6
 
6
7
  const manifests: ModuleManifest[] = [
7
8
  { name: "notes", routes: [], nav: [{ label: "Notes", to: "/notes" }] },
8
9
  { name: "admin", routes: [], nav: [{ label: "Users", to: "/users", role: "admin" }] },
10
+ {
11
+ name: "billing",
12
+ routes: [],
13
+ nav: [{ label: "Export", to: "/export", permission: "billing.export" }],
14
+ },
15
+ {
16
+ name: "audit",
17
+ // Both gates on one item, which is the case the AND exists for.
18
+ routes: [],
19
+ nav: [{ label: "Audit", to: "/audit", role: "admin", permission: "audit.read" }],
20
+ },
9
21
  { name: "noNav", routes: [] },
10
22
  ];
11
23
 
24
+ /** A context that says yes to everything, so each test can deny exactly one thing. */
25
+ function allowAll(overrides: Partial<NavVisibilityContext> = {}): NavVisibilityContext {
26
+ return {
27
+ canSeeRole: () => true,
28
+ permissions: ["billing.export", "audit.read"],
29
+ ...overrides,
30
+ };
31
+ }
32
+
12
33
  describe("visibleNav", () => {
13
34
  it("flattens nav across manifests when everything is visible", () => {
14
- expect(visibleNav(manifests, () => true).map((i) => i.to)).toEqual(["/notes", "/users"]);
35
+ expect(visibleNav(manifests, allowAll()).map((i) => i.to)).toEqual([
36
+ "/notes",
37
+ "/users",
38
+ "/export",
39
+ "/audit",
40
+ ]);
15
41
  });
16
42
 
17
43
  it("filters items by their required role", () => {
18
- const onlyPublic = visibleNav(manifests, (role) => role === undefined);
19
- expect(onlyPublic.map((i) => i.to)).toEqual(["/notes"]);
44
+ const onlyPublic = visibleNav(manifests, allowAll({ canSeeRole: (role) => role === undefined }));
45
+ // /export declares no role, so the role gate lets it through; /audit declares both.
46
+ expect(onlyPublic.map((i) => i.to)).toEqual(["/notes", "/export"]);
47
+ });
48
+
49
+ it("filters items by their required permission", () => {
50
+ const noGrants = visibleNav(manifests, allowAll({ permissions: [] }));
51
+ expect(noGrants.map((i) => i.to)).toEqual(["/notes", "/users"]);
52
+ });
53
+ });
54
+
55
+ describe("isDeclarationVisible", () => {
56
+ it("requires BOTH gates when an item declares both", () => {
57
+ // The composition, stated as a truth table. Either alone is not enough, which is what the
58
+ // server does: a Policy carrying a Permission enforces the permission's role floor AND the
59
+ // grant. A client checking one would disagree with the endpoint in one direction or another.
60
+ const both = { role: "admin", permission: "audit.read" };
61
+ expect(isDeclarationVisible(both, allowAll())).toBe(true);
62
+ expect(isDeclarationVisible(both, allowAll({ canSeeRole: () => false }))).toBe(false);
63
+ expect(isDeclarationVisible(both, allowAll({ permissions: [] }))).toBe(false);
64
+ expect(
65
+ isDeclarationVisible(both, { canSeeRole: () => false, permissions: [] }),
66
+ ).toBe(false);
67
+ });
68
+
69
+ it("is unchanged for a declaration that names neither gate", () => {
70
+ // The additivity claim. Nothing an existing manifest declares moves, because a missing
71
+ // permission short-circuits to true and `role` keeps its exact meaning.
72
+ expect(isDeclarationVisible({}, allowAll({ permissions: [] }))).toBe(true);
73
+ expect(isDeclarationVisible({}, allowAll({ canSeeRole: () => false }))).toBe(false);
74
+ });
75
+
76
+ it("fails closed on a permission nobody granted", () => {
77
+ // Three ways in, one outcome. A misspelled name is indistinguishable from an ungranted one,
78
+ // and that is correct: both mean "the server would refuse this".
79
+ expect(isDeclarationVisible({ permission: "billing.exprot" }, allowAll())).toBe(false);
80
+ // An app that mounts no grant capability has an empty list — which must hide the item, not
81
+ // wave it through. This is the branch a "treat empty as unrestricted" shortcut would break.
82
+ expect(isDeclarationVisible({ permission: "billing.export" }, allowAll({ permissions: [] }))).toBe(
83
+ false,
84
+ );
85
+ // Signed out: `canSeeRole` already answers false for a null rank, before permissions matter.
86
+ expect(
87
+ isDeclarationVisible({ permission: "billing.export" }, {
88
+ canSeeRole: () => false,
89
+ permissions: ["billing.export"],
90
+ }),
91
+ ).toBe(false);
92
+ });
93
+ });
94
+
95
+ // `groupNav` — the app's declared groups meeting the modules' declared items.
96
+ //
97
+ // Every row below names the mutation that turns it red, because two of this phase's earlier
98
+ // gates could not fail and were only found by trying. Two are worth reading before adding a row
99
+ // here. "Sorts stably" cannot be falsified by DELETING the sort: `Array.prototype.sort` is stable
100
+ // by specification, so a tied array comes out in declaration order whether or not it was sorted,
101
+ // and the assertion passes over the mutant. The comparator that does reorder ties is
102
+ // `(a, b) => ((a.order ?? 0) - (b.order ?? 0)) || -1`, which was measured in this repo's node to
103
+ // fully reverse a tied array at every length from 2 upward. And "sorts" is only falsifiable
104
+ // against `?? Infinity` if the fixture carries a POSITIVE order beside an absent one — with only
105
+ // negatives and absents the two rules agree.
106
+ describe("groupNav", () => {
107
+ const items = (...tos: string[]) => tos.map((to) => ({ label: to, to }));
108
+ const tos = (sections: ReturnType<typeof groupNav>) =>
109
+ sections.map((section) => section.items.map((item) => item.to));
110
+ /** Section identity and heading, without the items — asserted separately via `tos`. */
111
+ const shape = (sections: ReturnType<typeof groupNav>) =>
112
+ sections.map((section) => ({ id: section.id, label: section.label }));
113
+
114
+ it("renders one unlabelled section when the app declares no groups", () => {
115
+ // The additivity claim, as a test rather than an argument: same items, same order, one
116
+ // section that renders no heading. Mutation: `return [{ id: "default", label: "", items }]`,
117
+ // which falsifies the null id and the null label together.
118
+ expect(groupNav(items("/a", "/b", "/c"))).toEqual([
119
+ { id: null, label: null, items: items("/a", "/b", "/c") },
120
+ ]);
121
+ });
122
+
123
+ it("emits the ungrouped bucket last, after every declared group", () => {
124
+ // The rule that changed under review. The packaged admin entry carries no `group` and no app
125
+ // can give it one, so a first-emitted default bucket would hoist it to the top of the sidebar
126
+ // the moment an app declared its first group. Mutation: emit the ungrouped section first.
127
+ const sections = groupNav(
128
+ [
129
+ { label: "Loose", to: "/loose" },
130
+ { label: "Sales", to: "/sales", group: "work" },
131
+ ],
132
+ [{ id: "work", label: "Werkruimte" }],
133
+ );
134
+ expect(sections.map((section) => section.id)).toEqual(["work", null]);
135
+ expect(tos(sections)).toEqual([["/sales"], ["/loose"]]);
136
+ });
137
+
138
+ it("drops a group left empty rather than rendering a label over nothing", () => {
139
+ // Reachable on a first render: a group holding only the role-gated `/admin` entry is empty
140
+ // for everyone who is not an admin, because `visibleNav` has already removed it.
141
+ // Mutation: push the section regardless of `bucket.length`.
142
+ const sections = groupNav(items("/a"), [
143
+ { id: "empty", label: "Beheer" },
144
+ { id: "used", label: "Werk" },
145
+ ]);
146
+ expect(sections.map((section) => section.id)).toEqual([null]);
147
+ expect(sections.some((section) => section.label === "Beheer")).toBe(false);
148
+ });
149
+
150
+ it("falls an item naming an undeclared group open into the ungrouped bucket", () => {
151
+ // A module ships before the app declares its group; the link must still be reachable.
152
+ // Mutation: drop the `declared.has(item.group)` half of the key expression, and the item
153
+ // lands in a bucket nothing reads — it vanishes from the sidebar with nothing reporting it.
154
+ const sections = groupNav(
155
+ [{ label: "Orphan", to: "/orphan", group: "not-declared" }],
156
+ [{ id: "work", label: "Werkruimte" }],
157
+ );
158
+ expect(tos(sections)).toEqual([["/orphan"]]);
159
+ expect(sections[0]!.id).toBeNull();
160
+ });
161
+
162
+ it("sorts items on order, treating absent as 0", () => {
163
+ // Mutation: `order ?? Infinity`, which sends both unordered items to the end and yields
164
+ // /up, /down, /a, /b. The POSITIVE order is what makes the two rules disagree.
165
+ const sections = groupNav([
166
+ { label: "down", to: "/down", order: 1 },
167
+ { label: "a", to: "/a" },
168
+ { label: "up", to: "/up", order: -1 },
169
+ { label: "b", to: "/b" },
170
+ ]);
171
+ expect(tos(sections)).toEqual([["/up", "/a", "/b", "/down"]]);
172
+ });
173
+
174
+ it("keeps tied items in declaration order", () => {
175
+ // Mutation: `((a.order ?? 0) - (b.order ?? 0)) || -1` — measured to reverse a tied array at
176
+ // every length from 2 up. Deleting the sort does NOT falsify this, which is the point.
177
+ expect(tos(groupNav(items("/first", "/second", "/third")))).toEqual([
178
+ ["/first", "/second", "/third"],
179
+ ]);
180
+ });
181
+
182
+ it("sorts groups on order, treating absent as 0", () => {
183
+ // Same shape one level up, and the same `?? Infinity` mutation.
184
+ const sections = groupNav(
185
+ [
186
+ { label: "d", to: "/d", group: "down" },
187
+ { label: "p", to: "/p", group: "plain" },
188
+ { label: "u", to: "/u", group: "up" },
189
+ ],
190
+ [
191
+ { id: "down", label: "Down", order: 1 },
192
+ { id: "plain", label: "Plain" },
193
+ { id: "up", label: "Up", order: -1 },
194
+ ],
195
+ );
196
+ expect(sections.map((section) => section.id)).toEqual(["up", "plain", "down"]);
197
+ });
198
+
199
+ it("keeps tied groups in declaration order", () => {
200
+ const sections = groupNav(
201
+ [
202
+ { label: "a", to: "/a", group: "one" },
203
+ { label: "b", to: "/b", group: "two" },
204
+ { label: "c", to: "/c", group: "three" },
205
+ ],
206
+ [
207
+ { id: "one", label: "One" },
208
+ { id: "two", label: "Two" },
209
+ { id: "three", label: "Three" },
210
+ ],
211
+ );
212
+ expect(sections.map((section) => section.id)).toEqual(["one", "two", "three"]);
213
+ });
214
+
215
+ it("lets the first declaration of a duplicated id win, without duplicating its items", () => {
216
+ // `groupNav` stays total so a render can never throw; `buildAppRouter` refuses the duplicate
217
+ // at composition time instead. Mutation: drop the `declared.has` guard on insertion, and the
218
+ // second declaration overwrites the first, so the label becomes "Second".
219
+ const sections = groupNav([{ label: "a", to: "/a", group: "dup" }], [
220
+ { id: "dup", label: "First" },
221
+ { id: "dup", label: "Second" },
222
+ ]);
223
+ expect(shape(sections)).toEqual([{ id: "dup", label: "First" }]);
224
+ expect(tos(sections)).toEqual([["/a"]]);
225
+ });
226
+
227
+ it("treats the empty string as a real group id, not as the ungrouped bucket", () => {
228
+ // The default bucket is keyed on `null` rather than on a falsy sentinel precisely so that
229
+ // `id: ""` — a legal string — stays a group of its own. Mutation: key the bucket on `""`,
230
+ // and this item's group merges into the ungrouped section and loses its label.
231
+ const sections = groupNav([{ label: "a", to: "/a", group: "" }], [{ id: "", label: "Blank" }]);
232
+ expect(shape(sections)).toEqual([{ id: "", label: "Blank" }]);
233
+ expect(tos(sections)).toEqual([["/a"]]);
234
+ });
235
+
236
+ it("renders a null-labelled group as pure positioning", () => {
237
+ // The escape an app uses to place its otherwise-ungrouped items somewhere other than last.
238
+ const sections = groupNav(
239
+ [
240
+ { label: "main", to: "/main", group: "main" },
241
+ { label: "loose", to: "/loose" },
242
+ ],
243
+ [{ id: "main", label: null }],
244
+ );
245
+ expect(shape(sections)).toEqual([
246
+ { id: "main", label: null },
247
+ { id: null, label: null },
248
+ ]);
249
+ expect(tos(sections)).toEqual([["/main"], ["/loose"]]);
20
250
  });
21
251
  });
package/src/nav.ts CHANGED
@@ -1,13 +1,187 @@
1
- import type { ModuleManifest, NavItem } from "@terpjs/contract";
1
+ import type { ModuleManifest, NavGroup, NavItem } from "@terpjs/contract";
2
2
 
3
3
  /**
4
- * Flatten the nav of every module manifest into one ordered sidebar list, keeping only
5
- * the items the current user may see. `canSeeRole(role)` decides visibility for an item's
6
- * required role (an item with no `role` is visible to any authenticated user).
4
+ * What a manifest's declared visibility is resolved against.
5
+ *
6
+ * Built from what `/me` actually returns, which is the whole reason this type is two fields and
7
+ * not four. ADR 0097 §5 specified a context of module grants, per-module role ranks, superuser
8
+ * and internal-versus-external; `CurrentUser` carries none of them. It carries `role_rank`,
9
+ * `role_name` and `permissions`, and the last of those is the general mechanism the decision
10
+ * never named. A context built from what is real is honest; one built from that list would be
11
+ * vocabulary with no source behind it.
12
+ */
13
+ export interface NavVisibilityContext {
14
+ /** Whether the caller clears a named role's floor — the existing `role` gate, unchanged. */
15
+ canSeeRole: (role: string | undefined) => boolean;
16
+ /**
17
+ * The caller's named grants, from `CurrentUser.permissions`.
18
+ *
19
+ * Empty when signed out and empty for an app that mounts no grant capability, which is what
20
+ * makes the gate fail closed in both cases rather than failing open in the second.
21
+ */
22
+ permissions: readonly string[];
23
+ }
24
+
25
+ /**
26
+ * Whether one declared item is visible to the caller.
27
+ *
28
+ * `role` and `permission` are **ANDed**, which is deliberately what the server does: a `Policy`
29
+ * carrying a `Permission` enforces the permission's role floor *and* the grant, so a client that
30
+ * checked either alone would disagree with the endpoint in one direction or the other. It is the
31
+ * same composition `Authorized` already ships, and reusing it means the navigation gains the gate
32
+ * the buttons already have rather than a second vocabulary for the same idea.
33
+ *
34
+ * Exported because routes need it too. A nav-only gate would hide the link and leave the route
35
+ * reachable by URL — `role` has never had that asymmetry, since it is declared on both
36
+ * `NavItem` and `ModuleRoute`, and `permission` must not introduce one.
37
+ */
38
+ export function isDeclarationVisible(
39
+ declaration: { role?: string; permission?: string },
40
+ context: NavVisibilityContext,
41
+ ): boolean {
42
+ if (!context.canSeeRole(declaration.role)) {
43
+ return false;
44
+ }
45
+ return (
46
+ declaration.permission === undefined ||
47
+ context.permissions.includes(declaration.permission)
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Flatten the nav of every module manifest into one ordered sidebar list, keeping only the items
53
+ * the current user may see.
54
+ *
55
+ * The context is a **required** argument, and that is a breaking change taken on purpose. Making
56
+ * it optional would have been source-compatible and silently wrong: an existing two-argument
57
+ * caller would fail closed on every item that gained a `permission`, removing links from a
58
+ * sidebar with no error anywhere. A required parameter turns that into a typecheck error at the
59
+ * one call site each app has.
7
60
  */
8
61
  export function visibleNav(
9
62
  manifests: readonly ModuleManifest[],
10
- canSeeRole: (role: string | undefined) => boolean,
63
+ context: NavVisibilityContext,
11
64
  ): NavItem[] {
12
- return manifests.flatMap((manifest) => manifest.nav ?? []).filter((item) => canSeeRole(item.role));
65
+ return manifests
66
+ .flatMap((manifest) => manifest.nav ?? [])
67
+ .filter((item) => isDeclarationVisible(item, context));
68
+ }
69
+
70
+ /**
71
+ * One rendered section of the primary navigation: a declared {@link NavGroup} with the items that
72
+ * named it, or the default headerless group with everything that named nothing.
73
+ *
74
+ * The group is flattened into `id` and `label` rather than carried whole, and that is what lets
75
+ * one shape describe both cases. The default group has no declaration to carry — it is not a
76
+ * `NavGroup` an app wrote, it is the absence of one — so a section holding an optional `NavGroup`
77
+ * would force every reader through `section.group?.label` to ask a question that has the same
78
+ * answer either way: what, if anything, do I render above this list.
79
+ */
80
+ export interface NavSection {
81
+ /** The declared group's id, or `null` for the default headerless group. */
82
+ id: string | null;
83
+ /** The label to render above the list, or `null` when the section renders none. */
84
+ label: string | null;
85
+ items: NavItem[];
86
+ }
87
+
88
+ /** Stable ascending sort on `order ?? 0` — see {@link groupNav} for why absent is 0. */
89
+ function byOrder<T extends { order?: number }>(values: T[]): T[] {
90
+ return values.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
91
+ }
92
+
93
+ /**
94
+ * Arrange visible nav items into the app's declared groups.
95
+ *
96
+ * Groups fix the defect ADR 0097 names in its own Context: sidebar order is "an accident of
97
+ * `import.meta.glob` key order", because the sidebar is one flat list flattened out of whatever
98
+ * sequence the module manifests happened to arrive in. A group spans modules, so no module can
99
+ * own one — the app declares them, modules reference them by id, and this function is where the
100
+ * two halves meet.
101
+ *
102
+ * **Additive by construction.** An app that declares no groups gets exactly one section holding
103
+ * exactly the items it passed in, in the order it passed them: `groups` defaults to empty, every
104
+ * item falls into the default bucket, and a stable sort over keys that are all 0 is the identity.
105
+ * That is not an argument that it *should* be additive — it is the code path, and the shell
106
+ * renders the same list it renders today.
107
+ *
108
+ * Four rules, and each one is a decision:
109
+ *
110
+ * **An unknown group id falls into the default bucket rather than dropping the item.** A group is
111
+ * declared by the app; the item is declared by a module that ships on its own schedule. An id
112
+ * with no declaration yet is therefore the ordinary state of a module the app has not finished
113
+ * adopting, not a corruption — and the two ways to be wrong are not symmetric. An ungrouped link
114
+ * is in the wrong place and works; a dropped link is a screen the user cannot reach, with nothing
115
+ * anywhere saying why. Nothing warns about it either: this is a state the design calls normal,
116
+ * and a diagnostic for a normal state is noise that teaches people to ignore diagnostics.
117
+ *
118
+ * **A section with no items is not emitted.** Reachable on the first render of any app that
119
+ * groups the packaged admin entry's neighbours: `/admin` declares `role: "admin"`, so
120
+ * {@link visibleNav} removes it for everyone else and a group holding only it is left empty. A
121
+ * label over a void is worse than no label, because it names a place the user is being refused
122
+ * and cannot see.
123
+ *
124
+ * **The default bucket is emitted LAST, and it is not part of the group sort.** This is the rule
125
+ * that changed under review, and the case that decided it is the one case every app has. The
126
+ * packaged admin area is appended after the app's own manifests, so its nav entry renders last
127
+ * today. Emit the default bucket first and that entry — which no app authors and therefore no app
128
+ * can annotate with a `group` — jumps to the top of the sidebar the moment the app declares its
129
+ * first group. Emitting last leaves it exactly where it already is. It also puts an item naming
130
+ * an undeclared group at the bottom rather than above the app's own structure, which is the
131
+ * better place for a fail-open result to surface.
132
+ *
133
+ * The trade is real and is worth stating: an app that groups a *minority* of its items sees the
134
+ * ungrouped majority sink below them. That is app-authored, visible on the first render, and
135
+ * fixed in one line by declaring `{ id: "main", label: null, order: -1 }` and pointing those
136
+ * items at it — a group with a `null` label renders no heading, so it is pure positioning. The
137
+ * admin case has no such fix, which is what makes it decisive rather than merely also true.
138
+ *
139
+ * **Absent `order` is 0, everywhere, and the sort is stable.** Not `Infinity`: a single numbered
140
+ * item would then leap above an entire unnumbered list, which breaks the additivity the rest of
141
+ * this function is built on. Absent-is-0 is CSS `order` semantics exactly — positive sorts below
142
+ * its unordered siblings, negative above — which is the vocabulary a framework styled in tokens
143
+ * already speaks, and it means a manifest that numbers nothing is untouched.
144
+ *
145
+ * A duplicate group id is not an error here: the first declaration wins and the second is
146
+ * inert, so this function stays total and a render can never throw. `buildAppRouter` refuses the
147
+ * duplicate at composition time instead, where an authoring error belongs and where it can be
148
+ * reported once rather than on every frame.
149
+ */
150
+ export function groupNav(
151
+ items: readonly NavItem[],
152
+ groups: readonly NavGroup[] = [],
153
+ ): NavSection[] {
154
+ // First declaration wins. A Map also gives the declared ids an O(1) membership test, which is
155
+ // what separates "this item names a real group" from "this item falls open".
156
+ const declared = new Map<string, NavGroup>();
157
+ for (const group of groups) {
158
+ if (!declared.has(group.id)) {
159
+ declared.set(group.id, group);
160
+ }
161
+ }
162
+
163
+ // `null` keys the default bucket. It cannot collide with a declared id: an id is a string, and
164
+ // the empty string — which is a legal id and would collide with any falsy sentinel — is a
165
+ // distinct Map key from null.
166
+ const buckets = new Map<string | null, NavItem[]>([[null, []]]);
167
+ for (const id of declared.keys()) {
168
+ buckets.set(id, []);
169
+ }
170
+ for (const item of items) {
171
+ const key = item.group !== undefined && declared.has(item.group) ? item.group : null;
172
+ buckets.get(key)!.push(item);
173
+ }
174
+
175
+ const sections: NavSection[] = [];
176
+ for (const group of byOrder([...declared.values()])) {
177
+ const bucket = buckets.get(group.id)!;
178
+ if (bucket.length > 0) {
179
+ sections.push({ id: group.id, label: group.label, items: byOrder(bucket) });
180
+ }
181
+ }
182
+ const ungrouped = buckets.get(null)!;
183
+ if (ungrouped.length > 0) {
184
+ sections.push({ id: null, label: null, items: byOrder(ungrouped) });
185
+ }
186
+ return sections;
13
187
  }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { activeNavPath, isNavItemActive } from "./navActive";
4
+
5
+ // The predicate the sidebar and ModuleNav share (ADR 0097 §6, amended in 4e).
6
+ //
7
+ // Every case here is a URL shape where something in the framework got it wrong, or would have.
8
+ // The set-level ones are the point of the file: `activeNavPath` exists because "at most one item
9
+ // is current" cannot be decided one link at a time, and three of the four cases below are
10
+ // invisible to a per-item test.
11
+
12
+ describe("isNavItemActive", () => {
13
+ it("matches a segment-aligned prefix, not a string prefix", () => {
14
+ expect(isNavItemActive("/settings/users", "/settings")).toBe(true);
15
+ // The guard that matters: /settings-users starts with /settings as a STRING.
16
+ expect(isNavItemActive("/settings-users", "/settings")).toBe(false);
17
+ });
18
+
19
+ it("normalises a trailing slash on both operands, in both branches", () => {
20
+ // The exact branch had this from the start; the prefix branch did not, and an earlier draft
21
+ // of the rule attached the normalisation to `exact` alone.
22
+ expect(isNavItemActive("/settings/", "/settings")).toBe(true);
23
+ expect(isNavItemActive("/settings", "/settings/")).toBe(true);
24
+ expect(isNavItemActive("/settings/", "/settings", true)).toBe(true);
25
+ expect(isNavItemActive("/settings", "/settings/", true)).toBe(true);
26
+ // ...and the root is not normalised away to the empty string.
27
+ expect(isNavItemActive("/", "/")).toBe(true);
28
+ });
29
+
30
+ it("ignores the query string and the hash", () => {
31
+ // A nav tab's identity is its path. Filtering a list must not unhighlight the tab the user
32
+ // is standing on, which is what the router does on an exact link with includeSearch left at
33
+ // its default.
34
+ expect(isNavItemActive("/records", "/records")).toBe(true);
35
+ expect(isNavItemActive("/records", "/records", true)).toBe(true);
36
+ });
37
+
38
+ it("treats a `to` that is not an absolute path as inactive", () => {
39
+ // Not tidiness. The router resolves a relative or empty `to` against the CURRENT location,
40
+ // so `to: ""` is a link to wherever you already are — and a raw prefix test would light
41
+ // every item at once, because every path starts with the empty string.
42
+ expect(isNavItemActive("/records", "")).toBe(false);
43
+ expect(isNavItemActive("/records", "records")).toBe(false);
44
+ expect(isNavItemActive("/records", "./records")).toBe(false);
45
+ });
46
+
47
+ it("exact refuses the children a prefix would take", () => {
48
+ expect(isNavItemActive("/settings/users", "/settings", true)).toBe(false);
49
+ expect(isNavItemActive("/settings", "/settings", true)).toBe(true);
50
+ });
51
+
52
+ it("lets the root claim nothing but itself, with no rule of its own", () => {
53
+ // `/` prefixes every path as a STRING, which is why the router adapter carried a
54
+ // hand-written `exact: item.to === "/"`. Matching on SEGMENTS makes that unnecessary:
55
+ // `/profile`'s second character is not a slash.
56
+ expect(isNavItemActive("/profile", "/")).toBe(false);
57
+ expect(isNavItemActive("/", "/")).toBe(true);
58
+ });
59
+
60
+ it("collapses repeated slashes, which is the one shape the segment test would let through", () => {
61
+ // The gate for the normalisation, and it is the only input that distinguishes it: `/` has
62
+ // length 1, so the boundary test asks whether current[1] is a slash — true for exactly
63
+ // `//foo`. The router never emits that (it collapses on the way in), but `activePath` is a
64
+ // plain string and window.location.pathname is not normalised by anyone.
65
+ expect(isNavItemActive("//foo", "/")).toBe(false);
66
+ expect(activeNavPath("//foo", [{ to: "/" }])).toBeUndefined();
67
+ // ...and a doubled slash deeper in the path resolves to the same item either way.
68
+ expect(isNavItemActive("/settings//users", "/settings")).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("activeNavPath", () => {
73
+ const NAV = [{ to: "/" }, { to: "/settings" }, { to: "/settings/users" }];
74
+
75
+ it("returns exactly one item where a per-link predicate returns two", () => {
76
+ // The defect this whole file exists for. Both /settings and /settings/users match
77
+ // /settings/users on their own, so a router — which decides per link — marks both current,
78
+ // paints both, and announces "current page" twice.
79
+ expect(NAV.filter((item) => isNavItemActive("/settings/users", item.to))).toHaveLength(2);
80
+ expect(activeNavPath("/settings/users", NAV)).toBe("/settings/users");
81
+ });
82
+
83
+ it("keeps the parent lit on a URL that is not itself a nav item", () => {
84
+ // The other half, and the reason per-item `exact` is not the fix: turn exact on to stop the
85
+ // case above and this one goes dark, leaving a page with nothing current at all.
86
+ expect(activeNavPath("/settings/appearance", NAV)).toBe("/settings");
87
+ });
88
+
89
+ it("does not let the root item claim an unrelated page", () => {
90
+ // The example app ships `{ label: "Notes", to: "/" }`, and PROFILE_PATH is mounted by
91
+ // buildAppRouter and is in no manifest's nav. A STRING-prefix longest-match would light
92
+ // "Notes" while the user is on their profile; a segment-aligned one does not.
93
+ expect(activeNavPath("/profile", NAV)).toBeUndefined();
94
+ expect(activeNavPath("/", NAV)).toBe("/");
95
+ });
96
+
97
+ it("returns undefined when nothing matches", () => {
98
+ expect(activeNavPath("/explorer", [{ to: "/settings" }])).toBeUndefined();
99
+ });
100
+
101
+ it("honours an item's own exact flag", () => {
102
+ const nav = [{ to: "/settings", exact: true }, { to: "/records" }];
103
+ expect(activeNavPath("/settings/users", nav)).toBeUndefined();
104
+ expect(activeNavPath("/settings", nav)).toBe("/settings");
105
+ });
106
+
107
+ it("is not fooled by declaration order", () => {
108
+ // Longest wins, not first — otherwise the answer depends on `import.meta.glob` key order,
109
+ // which is the same accident 0097 names as the reason nav needs explicit ordering at all.
110
+ const shallowFirst = [{ to: "/settings" }, { to: "/settings/users" }];
111
+ const deepFirst = [{ to: "/settings/users" }, { to: "/settings" }];
112
+ expect(activeNavPath("/settings/users", shallowFirst)).toBe("/settings/users");
113
+ expect(activeNavPath("/settings/users", deepFirst)).toBe("/settings/users");
114
+ });
115
+ });