@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
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
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Which navigation item is current — one predicate, owned here (ADR 0097 §6, amended in 4e).
3
+ *
4
+ * The framework had two notions of "active" and they disagreed. `ModuleNav` compared
5
+ * `pathname === item.to` raw while the `Link` it rendered compared through the router, and the
6
+ * sheet's own comment beside the rule said so and deferred the fix to "the navigation model" —
7
+ * this file. The sidebar had no predicate at all: it took whatever the router put on the anchor.
8
+ *
9
+ * The part that decides the shape is not the comparison, it is **arity**. "At most one item is
10
+ * current" is a property of the SET, and a router computes `isActive` per link with no knowledge
11
+ * of siblings. So a nav listing `/settings` and `/settings/users` gets two links the router
12
+ * considers active at `/settings/users`, two `aria-current="page"` attributes and two painted
13
+ * tabs — a screen reader says "current page" twice and neither is wrong on its own terms. Per
14
+ * item `exact` cannot fix it either: turn it on and `/settings/appearance`, a real route that is
15
+ * not itself a nav item, lights nothing at all. Only something looking at every item at once can
16
+ * pick one, which is why {@link activeNavPath} exists and why the shell calls it rather than
17
+ * asking each link how it feels.
18
+ *
19
+ * The comparison itself deliberately mirrors the router's, so the two never disagree about a
20
+ * single item — see `node_modules/@tanstack/router-core/dist/esm/path.js` for
21
+ * `removeTrailingSlash` and `exactPathTest`, and the non-exact branch in
22
+ * `@tanstack/react-router/dist/esm/link.js`. Reimplemented rather than imported because
23
+ * `@terpjs/contract` and a future non-React adapter need the same rule, and neither can take a
24
+ * TanStack dependency to get it.
25
+ *
26
+ * Search and hash are ignored on purpose. A nav tab's identity is its path: filtering a list
27
+ * must not unhighlight the tab the user is standing on.
28
+ */
29
+
30
+ /**
31
+ * The router's own normalisation: collapse repeated slashes, then drop one trailing slash
32
+ * (except from the root itself). `cleanPath` and `removeTrailingSlash` in router-core, in that
33
+ * order.
34
+ *
35
+ * The collapse is load-bearing rather than defensive tidying, and it is what removes a special
36
+ * case rather than adding one. `/` has length 1, so the segment-boundary test below asks whether
37
+ * `current[1]` is a slash — which is true for exactly one shape, `//foo`, and would let the root
38
+ * item claim it. The router never produces that (`router.js:210` collapses on the way in), but
39
+ * `activePath` is a plain string prop and the shell cannot assume its caller went through
40
+ * TanStack: `window.location.pathname` does not. Normalising both operands the way the router
41
+ * does makes the root behave exactly with no rule of its own — an earlier draft special-cased
42
+ * `target === "/"` and justified it with `/profile`, which the boundary test already rejects.
43
+ */
44
+ function normalise(path: string): string {
45
+ const collapsed = path.replace(/\/{2,}/g, "/");
46
+ return collapsed.endsWith("/") && collapsed !== "/" ? collapsed.slice(0, -1) : collapsed;
47
+ }
48
+
49
+ /**
50
+ * Whether `to` matches `pathname` on its own — the per-item half of the predicate.
51
+ *
52
+ * `exact` compares normalised equality; otherwise it is a **segment-aligned** prefix, so
53
+ * `/settings` matches `/settings/users` but not `/settings-users`. Both operands are normalised
54
+ * in **both** branches, which the router also does and which an earlier draft of this rule
55
+ * attached to the exact branch only.
56
+ *
57
+ * The root needs no special case: `/` matches another path only when that path's second
58
+ * character is a slash, and {@link normalise} has already collapsed those away.
59
+ *
60
+ * A `to` that does not begin with `/` is never active, and that is a guard rather than
61
+ * tidiness: the router resolves a relative or empty `to` against the *current* location, so
62
+ * `to: ""` produces a link that always points at the current page — and a raw prefix test would
63
+ * light every item in the sidebar at once, since every path starts with the empty string.
64
+ */
65
+ export function isNavItemActive(pathname: string, to: string, exact = false): boolean {
66
+ if (!to.startsWith("/")) {
67
+ return false;
68
+ }
69
+ const current = normalise(pathname);
70
+ const target = normalise(to);
71
+ if (exact) {
72
+ return current === target;
73
+ }
74
+ return (
75
+ current.startsWith(target) &&
76
+ (current.length === target.length || current[target.length] === "/")
77
+ );
78
+ }
79
+
80
+ /** The shape {@link activeNavPath} needs from an item: where it goes, and how it matches. */
81
+ export interface NavActiveCandidate {
82
+ to: string;
83
+ exact?: boolean;
84
+ }
85
+
86
+ /**
87
+ * The `to` of the one item that is current, or `undefined` when none is.
88
+ *
89
+ * **Longest match wins**, and that single rule replaces two special cases. Two items where one
90
+ * path prefixes the other resolve to the deeper one, so exactly one is ever current. A URL below
91
+ * a nav item still lights its parent, so `/settings/appearance` keeps `/settings` lit rather
92
+ * than emptying the sidebar.
93
+ *
94
+ * It also retires the hand-written `exact: item.to === "/"` the router adapter used to carry.
95
+ * That flag was there because `/` prefixes every path — but only as a STRING, and
96
+ * {@link isNavItemActive} matches on segments, so `/` claims nothing but itself regardless.
97
+ * The adapter was working around a prefix test it did not have.
98
+ *
99
+ * Ties are impossible: two items can only both match at the same length if their normalised
100
+ * paths are equal, in which case the first wins and they were the same destination anyway.
101
+ */
102
+ export function activeNavPath(
103
+ pathname: string,
104
+ items: readonly NavActiveCandidate[],
105
+ ): string | undefined {
106
+ let winner: string | undefined;
107
+ let longest = -1;
108
+ for (const item of items) {
109
+ if (!isNavItemActive(pathname, item.to, item.exact)) {
110
+ continue;
111
+ }
112
+ const length = normalise(item.to).length;
113
+ if (length > longest) {
114
+ longest = length;
115
+ winner = item.to;
116
+ }
117
+ }
118
+ return winner;
119
+ }
package/src/navLink.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, useContext } from "react";
2
- import type { ReactNode } from "react";
2
+ import type { AnchorHTMLAttributes, ReactNode } from "react";
3
3
 
4
4
  /**
5
5
  * How the surrounding router renders an in-app link.
@@ -15,7 +15,25 @@ import type { ReactNode } from "react";
15
15
  * to it. The anchor remains only for a component rendered outside any Terp router (a
16
16
  * standalone story or unit test), where there is no router to navigate with.
17
17
  */
18
- export type NavLinkRenderer = (props: { to: string; children: ReactNode }) => ReactNode;
18
+ export type NavLinkRenderer = (props: {
19
+ to: string;
20
+ children: ReactNode;
21
+ /**
22
+ * HTML attributes for the rendered anchor **itself**, rather than for a wrapper around it.
23
+ *
24
+ * Added because the alternative was a silent loss. A caller's `aria-label` or `id` has to
25
+ * land on the anchor to mean anything — an `aria-label` on a `<span>` wrapping a link is
26
+ * ignored, so the link keeps its content as its accessible name and the caller's intent
27
+ * disappears with no error. `Link` hit exactly that: it renders through this seam for an
28
+ * in-app path and as a plain anchor otherwise, so the same prop worked in one branch and
29
+ * was dropped in the other, decided by whether the destination happened to start with `/`.
30
+ *
31
+ * An implementation that destructures only `{ to, children }` stays source-compatible and
32
+ * simply forwards nothing — which is why the marker a component styles itself by is NOT
33
+ * passed this way. That stays on a wrapper the component owns, so a renderer cannot lose it.
34
+ */
35
+ attributes?: Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">;
36
+ }) => ReactNode;
19
37
 
20
38
  export const NavLinkContext = createContext<NavLinkRenderer | null>(null);
21
39