@terpjs/react-core 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
63
|
+
context: NavVisibilityContext,
|
|
11
64
|
): NavItem[] {
|
|
12
|
-
return manifests
|
|
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
|
+
});
|
package/src/navActive.ts
ADDED
|
@@ -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: {
|
|
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
|
|