@terpjs/react-core 0.8.0 → 0.9.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 +6 -2
- package/package.json +2 -2
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/LoginView.tsx +22 -75
- package/src/ModuleNav.test.tsx +19 -0
- package/src/ModuleNav.tsx +10 -35
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +15 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +8 -33
- package/src/ResourceList.tsx +13 -24
- package/src/admin/AuditLogAdmin.tsx +1 -10
- package/src/admin/GroupCreate.tsx +1 -1
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserCreate.tsx +1 -1
- package/src/admin/admin.test.tsx +31 -0
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/index.ts +7 -1
- package/src/markers.test.ts +118 -12
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +191 -1
- package/src/router.tsx +75 -9
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +37 -5
- package/src/styles.ts +356 -6
- package/src/ui/Button.test.tsx +5 -3
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
|
|
|
63
63
|
| Export | Use |
|
|
64
64
|
|---|---|
|
|
65
65
|
| `Authorized`, `useCan` | Gate UI on `can(module, action)` — write buttons, admin panels. |
|
|
66
|
+
| `usePermissions`, `useHasPermission` | The caller's **named** grants from `GET /me` (ADR 0096), for a screen whose write needs `definitions.publish` rather than a rank. `Authorized` takes an optional `permission` alongside `action`; both must pass, as the server's own guard does. Display only — the backend re-checks. |
|
|
66
67
|
| `canPerform`, `DEFAULT_RANK_THRESHOLDS` | The role-rank predicate behind the gate. |
|
|
67
68
|
| `visibleNav` | Filter nav items to what the current user may see. |
|
|
68
69
|
|
|
@@ -85,6 +86,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
|
|
|
85
86
|
| `useRouteParam` | Read one route param, fail closed: the declared param comes back as a string, an undeclared name throws a directive error instead of silently yielding `undefined`. Replaces the unchecked `useParams({ strict: false }) as {…}` cast (ADR 0092). Checked against the generated route table when the app has one. |
|
|
86
87
|
| `useRouteParams` | Read a whole declared route's params, typed exactly: `const { recordId } = useRouteParams("/records/:recordId")`. With a generated route table, a typo in the path *or* a param name is a typecheck error. |
|
|
87
88
|
| `useTerpNavigate` | Navigate by manifest path: `navigate({ to: "/records/:recordId", params: { recordId } })`. An undeclared path is a typecheck error and a parameterised route requires its params — a typo'd path used to be a dead link that shipped green. Takes the manifest's `:id` spelling and translates to the router's `$id`. |
|
|
89
|
+
| `useRouteSearch` | Read a declared route's query-string keys, typed: `const { status, page } = useRouteSearch("/records")` (ADR 0096). Every value is `string | undefined`, and an undeclared key is a typecheck error — so a filtered list screen stays inside the checked seam instead of reaching for the router's own `useSearch`. Declare them in the manifest: `search: ["status", "page"]`. |
|
|
88
90
|
| `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
|
|
89
91
|
| `PageActions` | Primary action + overflow menu for a page header. |
|
|
90
92
|
|
|
@@ -112,7 +114,8 @@ declare module "@terpjs/react-core" {
|
|
|
112
114
|
```
|
|
113
115
|
|
|
114
116
|
From then on `useRouteParams("/records/:recordId")` is exact, `useRouteParam` refuses a
|
|
115
|
-
param no route declares,
|
|
117
|
+
param no route declares, `useRouteSearch` is keyed to the route's declared query-string
|
|
118
|
+
keys, and `useTerpNavigate` refuses an undeclared path (or an undeclared `search` key). Regenerate
|
|
116
119
|
after changing a manifest route — `terp verify`'s `routes-drift` check refuses a stale
|
|
117
120
|
table and names the command (it runs before the typecheck, so a stale table reads as
|
|
118
121
|
"regenerate", not as errors in your own screens). A route whose `path` is not a plain
|
|
@@ -158,6 +161,7 @@ marker, counted by the escape-hatch budget.
|
|
|
158
161
|
| `ResourceList` | The standard simple CRUD list screen: titled section, write-gated create form, loading/error/empty states. Composable — screens needing more render their own React. |
|
|
159
162
|
| `unwrap`, `unwrapOptional`, `ApiError` | Turn a generated-client result into data-or-throw; `ApiError` carries the envelope's `code` / `status` / `requestId`. `unwrapOptional` returns `null` on a 404 instead — for resources whose absence is a normal state (a `/latest` snapshot not yet published), the client-side analog of `BaseService.find` beside `get`. |
|
|
160
163
|
| `FileUpload`, `useFileDownload` | The files-capability surface (ADR 0056/0057): a token-styled attachment picker that uploads through the typed client, and an authenticated download helper (a raw `<a href>` would carry no bearer token). |
|
|
164
|
+
| `useEndpointDownload`, `saveBlob` | Download an artifact the backend **generates** — an evidence bundle, a CSV export — which has no stored file id (ADR 0096). Goes through the session client, so it carries the base URL and bearer token and rejects a non-2xx instead of saving the error body under the intended filename. |
|
|
161
165
|
|
|
162
166
|
## Feedback & states
|
|
163
167
|
|
|
@@ -168,7 +172,7 @@ marker, counted by the escape-hatch budget.
|
|
|
168
172
|
| `ErrorState`, `describeError` | Human-readable failure block for a caught error. |
|
|
169
173
|
| `ErrorMessagesProvider`, `useErrorMessage`, `DEFAULT_ERROR_MESSAGES` | Map stable backend error codes to copy; falls back to the envelope `detail`. |
|
|
170
174
|
| `ToastProvider`, `useToast` | Transient success/error feedback (no toast library). |
|
|
171
|
-
| `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. |
|
|
175
|
+
| `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. A modal is for a confirmation or an explicit post-action moment — an edit form or a detail view belongs in a routed page, or in an expanded row beside the thing it edits (ADR 0096 §4). |
|
|
172
176
|
|
|
173
177
|
## Forms & primitives
|
|
174
178
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/react-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@tanstack/react-router": "^1.170.16",
|
|
16
|
-
"@terpjs/contract": "^0.
|
|
16
|
+
"@terpjs/contract": "^0.9.0"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/Authorized.test.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
|
-
import { Authorized } from "./Authorized";
|
|
6
|
+
import { Authorized, useHasPermission, usePermissions } from "./Authorized";
|
|
7
7
|
import { TerpProvider, useAuth } from "./TerpProvider";
|
|
8
8
|
|
|
9
9
|
function jsonResponse(body: unknown): Response {
|
|
@@ -57,4 +57,66 @@ describe("Authorized", () => {
|
|
|
57
57
|
expect(screen.getByText("no-admin")).toBeInTheDocument();
|
|
58
58
|
expect(screen.queryByText("admin-only")).not.toBeInTheDocument();
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
it("gates on a named grant, not only rank (ADR 0096)", async () => {
|
|
62
|
+
// The hole this closes: rank was all the wire carried, so a screen whose write needs
|
|
63
|
+
// `definitions.publish` hid by rank as a proxy and handled the 403 anyway.
|
|
64
|
+
vi.stubGlobal(
|
|
65
|
+
"fetch",
|
|
66
|
+
vi.fn<typeof fetch>(async (input) => {
|
|
67
|
+
const url = (input as Request).url;
|
|
68
|
+
if (url.endsWith("/api/v1/auth/login")) {
|
|
69
|
+
return jsonResponse({ access_token: "token", token_type: "bearer" });
|
|
70
|
+
}
|
|
71
|
+
return jsonResponse({
|
|
72
|
+
id: "u1",
|
|
73
|
+
email: "editor@example.com",
|
|
74
|
+
role_rank: 20,
|
|
75
|
+
role_name: "editor",
|
|
76
|
+
permissions: ["definitions.manage"],
|
|
77
|
+
});
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
render(
|
|
82
|
+
<TerpProvider baseUrl="https://api.test">
|
|
83
|
+
<LogInOnMount />
|
|
84
|
+
<Authorized action="write" permission="definitions.manage">
|
|
85
|
+
<span>held</span>
|
|
86
|
+
</Authorized>
|
|
87
|
+
<Authorized action="write" permission="definitions.publish">
|
|
88
|
+
<span>not held</span>
|
|
89
|
+
</Authorized>
|
|
90
|
+
{/* Rank alone still fails closed even when the grant is held. */}
|
|
91
|
+
<Authorized action="admin" permission="definitions.manage">
|
|
92
|
+
<span>rank too low</span>
|
|
93
|
+
</Authorized>
|
|
94
|
+
</TerpProvider>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(screen.getByText("held")).toBeInTheDocument());
|
|
98
|
+
expect(screen.queryByText("not held")).toBeNull();
|
|
99
|
+
expect(screen.queryByText("rank too low")).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reports no permissions when signed out, and none for an app that grants none", async () => {
|
|
103
|
+
vi.stubGlobal(
|
|
104
|
+
"fetch",
|
|
105
|
+
vi.fn<typeof fetch>(async () => new Response("", { status: 401 })),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
function Probe() {
|
|
109
|
+
return (
|
|
110
|
+
<span>{`n=${usePermissions().length} has=${String(useHasPermission("anything"))}`}</span>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
render(
|
|
114
|
+
<TerpProvider baseUrl="https://api.test">
|
|
115
|
+
<Probe />
|
|
116
|
+
</TerpProvider>,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Empty rather than undefined, so a screen can read it without guarding first.
|
|
120
|
+
await waitFor(() => expect(screen.getByText("n=0 has=false")).toBeInTheDocument());
|
|
121
|
+
});
|
|
60
122
|
});
|
package/src/Authorized.tsx
CHANGED
|
@@ -8,14 +8,47 @@ export function useCan(action: Action): boolean {
|
|
|
8
8
|
return useAuth().can(action);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The caller's effective permission names, from `GET /me` (ADR 0096).
|
|
13
|
+
*
|
|
14
|
+
* `useCan` compares role *rank*, which is all the wire used to carry. A screen whose write
|
|
15
|
+
* needs a **named** grant (`definitions.publish`) therefore had nothing to ask: it hid by
|
|
16
|
+
* rank as a proxy and handled the 403 anyway — showing a button it knew might fail, or
|
|
17
|
+
* hiding one the user was entitled to. This is the same set the server's guard enforces,
|
|
18
|
+
* projected for display.
|
|
19
|
+
*
|
|
20
|
+
* Empty when signed out, and empty for an app that mounts no grant capability (it has no
|
|
21
|
+
* named permissions). A *display* input only: the server re-checks every request, and a
|
|
22
|
+
* client that treats this as authority has moved the gate to the wrong side of the wire.
|
|
23
|
+
*/
|
|
24
|
+
export function usePermissions(): readonly string[] {
|
|
25
|
+
return useAuth().currentUser()?.permissions ?? [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether the current user holds the named permission grant (the UI gate). */
|
|
29
|
+
export function useHasPermission(permission: string): boolean {
|
|
30
|
+
return usePermissions().includes(permission);
|
|
31
|
+
}
|
|
32
|
+
|
|
11
33
|
export interface AuthorizedProps {
|
|
12
34
|
action: Action;
|
|
13
35
|
children: ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* Also require this named permission grant.
|
|
38
|
+
*
|
|
39
|
+
* Both must pass, which is deliberately what the server does: a `Policy` carrying a
|
|
40
|
+
* `Permission` enforces the permission's role floor **and** the grant, so a UI that
|
|
41
|
+
* checked only one would disagree with the endpoint in one direction or the other.
|
|
42
|
+
*/
|
|
43
|
+
permission?: string;
|
|
14
44
|
/** Rendered when the user may not perform `action` (default: nothing). */
|
|
15
45
|
fallback?: ReactNode;
|
|
16
46
|
}
|
|
17
47
|
|
|
18
48
|
/** Render `children` only when the current user may perform `action`, else `fallback`. */
|
|
19
|
-
export function Authorized({ action, children, fallback = null }: AuthorizedProps) {
|
|
20
|
-
|
|
49
|
+
export function Authorized({ action, permission, children, fallback = null }: AuthorizedProps) {
|
|
50
|
+
const allowedByRank = useCan(action);
|
|
51
|
+
const permissions = usePermissions();
|
|
52
|
+
const allowed = allowedByRank && (permission === undefined || permissions.includes(permission));
|
|
53
|
+
return <>{allowed ? children : fallback}</>;
|
|
21
54
|
}
|
package/src/LoginView.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { FormEvent } from "react";
|
|
3
3
|
|
|
4
4
|
import { TerpMark } from "./icons";
|
|
5
5
|
import { useAuth, useSso } from "./TerpProvider";
|
|
@@ -8,67 +8,6 @@ import { Button } from "./ui/Button";
|
|
|
8
8
|
import { Input } from "./ui/Input";
|
|
9
9
|
import type { SsoProvider } from "./sso";
|
|
10
10
|
|
|
11
|
-
const pageStyle: CSSProperties = {
|
|
12
|
-
minHeight: "100vh",
|
|
13
|
-
display: "grid",
|
|
14
|
-
placeItems: "center",
|
|
15
|
-
padding: "var(--space-6)",
|
|
16
|
-
background: "var(--color-neutral-50)",
|
|
17
|
-
fontFamily: "var(--font-family-sans)",
|
|
18
|
-
color: "var(--color-neutral-900)",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const cardStyle: CSSProperties = {
|
|
22
|
-
width: "100%",
|
|
23
|
-
maxWidth: "24rem",
|
|
24
|
-
display: "grid",
|
|
25
|
-
gap: "var(--space-4)",
|
|
26
|
-
padding: "var(--space-6)",
|
|
27
|
-
background: "var(--color-neutral-0)",
|
|
28
|
-
border: "1px solid var(--color-neutral-200)",
|
|
29
|
-
borderRadius: "var(--radius-lg)",
|
|
30
|
-
boxShadow: "var(--shadow-md)",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const brandStyle: CSSProperties = {
|
|
34
|
-
display: "flex",
|
|
35
|
-
alignItems: "center",
|
|
36
|
-
gap: "var(--space-2)",
|
|
37
|
-
color: "var(--color-neutral-900)",
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const brandTitleStyle: CSSProperties = {
|
|
41
|
-
margin: 0,
|
|
42
|
-
fontSize: "var(--font-size-xl)",
|
|
43
|
-
fontWeight: "var(--font-weight-bold)" as CSSProperties["fontWeight"],
|
|
44
|
-
letterSpacing: 0,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const formStyle: CSSProperties = { display: "grid", gap: "var(--space-3)" };
|
|
48
|
-
|
|
49
|
-
const fullWidthStyle: CSSProperties = { width: "100%" };
|
|
50
|
-
|
|
51
|
-
const separatorStyle: CSSProperties = {
|
|
52
|
-
display: "flex",
|
|
53
|
-
alignItems: "center",
|
|
54
|
-
gap: "var(--space-2)",
|
|
55
|
-
color: "var(--color-neutral-500)",
|
|
56
|
-
fontSize: "var(--font-size-xs)",
|
|
57
|
-
textTransform: "uppercase",
|
|
58
|
-
letterSpacing: "0.06em",
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const separatorRuleStyle: CSSProperties = {
|
|
62
|
-
flex: 1,
|
|
63
|
-
borderTop: "1px solid var(--color-neutral-200)",
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const errorStyle: CSSProperties = {
|
|
67
|
-
margin: 0,
|
|
68
|
-
color: "var(--color-status-danger)",
|
|
69
|
-
fontSize: "var(--font-size-sm)",
|
|
70
|
-
};
|
|
71
|
-
|
|
72
11
|
export interface LoginViewProps {
|
|
73
12
|
/**
|
|
74
13
|
* SSO providers to offer alongside the credentials form (ADR 0058). Each renders a
|
|
@@ -95,6 +34,12 @@ export interface DevCredentials {
|
|
|
95
34
|
* The default signed-out screen: collects credentials and calls the auth session. Used by
|
|
96
35
|
* {@link renderTerpApp}/`RequireAuth` unless an app supplies its own login view. Pass
|
|
97
36
|
* {@link LoginViewProps.ssoProviders} to add SSO provider buttons under the form.
|
|
37
|
+
*
|
|
38
|
+
* It renders no inline styles: the full-viewport page, the card, the brand row, both button
|
|
39
|
+
* groups, the separator and the error line take their geometry and ink from the injected
|
|
40
|
+
* react-core sheet (ADR 0094). The buttons fill their group through a rule on the group
|
|
41
|
+
* rather than a prop on `Button` — `Button` declares `width: fit-content`, so a grid does not
|
|
42
|
+
* stretch them for free, and a `block` prop for one internal caller is API this does not need.
|
|
98
43
|
*/
|
|
99
44
|
export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps = {}) {
|
|
100
45
|
const auth = useAuth();
|
|
@@ -133,13 +78,13 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
133
78
|
const ssoError = sso.error !== null && sso.error !== undefined ? strings.ssoFailed : null;
|
|
134
79
|
|
|
135
80
|
return (
|
|
136
|
-
<main
|
|
137
|
-
<div
|
|
138
|
-
<div
|
|
81
|
+
<main data-terp="login-view">
|
|
82
|
+
<div data-terp="login-card">
|
|
83
|
+
<div data-terp="login-brand">
|
|
139
84
|
<TerpMark />
|
|
140
|
-
<h1
|
|
85
|
+
<h1 data-terp="login-title">{strings.signIn}</h1>
|
|
141
86
|
</div>
|
|
142
|
-
<form
|
|
87
|
+
<form data-terp="login-form" onSubmit={onSubmit}>
|
|
143
88
|
<Input
|
|
144
89
|
type="email"
|
|
145
90
|
placeholder={strings.email}
|
|
@@ -154,7 +99,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
154
99
|
onChange={(event) => setPassword(event.target.value)}
|
|
155
100
|
required
|
|
156
101
|
/>
|
|
157
|
-
<Button type="submit" disabled={busy}
|
|
102
|
+
<Button type="submit" disabled={busy}>
|
|
158
103
|
{busy ? strings.signingIn : strings.signIn}
|
|
159
104
|
</Button>
|
|
160
105
|
{devCredentials ? (
|
|
@@ -162,7 +107,6 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
162
107
|
type="button"
|
|
163
108
|
variant="secondary"
|
|
164
109
|
disabled={busy}
|
|
165
|
-
style={fullWidthStyle}
|
|
166
110
|
onClick={() => {
|
|
167
111
|
setEmail(devCredentials.email);
|
|
168
112
|
setPassword(devCredentials.password);
|
|
@@ -175,19 +119,18 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
175
119
|
</form>
|
|
176
120
|
{ssoProviders.length > 0 ? (
|
|
177
121
|
<>
|
|
178
|
-
<div
|
|
179
|
-
<span
|
|
122
|
+
<div data-terp="login-separator" aria-hidden="true">
|
|
123
|
+
<span data-terp="login-separator-rule" />
|
|
180
124
|
<span>{strings.orSeparator}</span>
|
|
181
|
-
<span
|
|
125
|
+
<span data-terp="login-separator-rule" />
|
|
182
126
|
</div>
|
|
183
|
-
<div
|
|
127
|
+
<div data-terp="login-sso">
|
|
184
128
|
{ssoProviders.map((provider) => (
|
|
185
129
|
<Button
|
|
186
130
|
key={provider.name}
|
|
187
131
|
type="button"
|
|
188
132
|
variant="secondary"
|
|
189
133
|
disabled={busy}
|
|
190
|
-
style={fullWidthStyle}
|
|
191
134
|
onClick={() => void onSso(provider)}
|
|
192
135
|
>
|
|
193
136
|
{`${strings.continueWith} ${provider.label ?? provider.name}`}
|
|
@@ -196,7 +139,11 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
196
139
|
</div>
|
|
197
140
|
</>
|
|
198
141
|
) : null}
|
|
199
|
-
{error ?? ssoError ?
|
|
142
|
+
{error ?? ssoError ? (
|
|
143
|
+
<p role="alert" data-terp="login-error">
|
|
144
|
+
{error ?? ssoError}
|
|
145
|
+
</p>
|
|
146
|
+
) : null}
|
|
200
147
|
</div>
|
|
201
148
|
</main>
|
|
202
149
|
);
|
package/src/ModuleNav.test.tsx
CHANGED
|
@@ -60,6 +60,25 @@ describe("ModuleNav", () => {
|
|
|
60
60
|
"page",
|
|
61
61
|
);
|
|
62
62
|
expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current");
|
|
63
|
+
|
|
64
|
+
// The styling key is data-active, and the point is that it is NOT the aria-current
|
|
65
|
+
// beside it. A router Link merges its own active props last, so aria-current on this
|
|
66
|
+
// element has a second author — keying the accent edge on it would be the breadcrumb
|
|
67
|
+
// defect again the moment the two disagreed about "active", which they can, in BOTH
|
|
68
|
+
// directions: the router also demands an exact query-string match (so it is narrower
|
|
69
|
+
// on a filtered URL), and it compares paths through removeTrailingSlash while this
|
|
70
|
+
// component compares them raw (so it is broader on a trailing slash, and then the
|
|
71
|
+
// accent edge goes missing on a tab the router calls current). The second is a real
|
|
72
|
+
// defect, older than the marker — the inline styling read the same isActive — and it
|
|
73
|
+
// is the navigation model's to fix. Neither case is reachable from this specimen.
|
|
74
|
+
const active = screen.getByRole("link", { name: "Projects" });
|
|
75
|
+
const inactive = screen.getByRole("link", { name: "Overview" });
|
|
76
|
+
expect(active).toHaveAttribute("data-terp", "module-nav-link");
|
|
77
|
+
expect(active).toHaveAttribute("data-active", "true");
|
|
78
|
+
expect(inactive).toHaveAttribute("data-terp", "module-nav-link");
|
|
79
|
+
expect(inactive).not.toHaveAttribute("data-active");
|
|
80
|
+
expect(active.getAttribute("style")).toBeNull();
|
|
81
|
+
expect(inactive.getAttribute("style")).toBeNull();
|
|
63
82
|
});
|
|
64
83
|
|
|
65
84
|
it("returns nothing for an empty tab list", () => {
|
package/src/ModuleNav.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
|
-
import type { CSSProperties } from "react";
|
|
3
2
|
|
|
4
3
|
import { useStrings, useUiText } from "./uiText";
|
|
5
4
|
import type { UiText } from "./uiText";
|
|
@@ -17,38 +16,17 @@ export interface ModuleNavProps {
|
|
|
17
16
|
ariaLabel?: UiText;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
const navStyle: CSSProperties = {
|
|
21
|
-
borderBottom: "1px solid var(--color-neutral-200)",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const listStyle: CSSProperties = {
|
|
25
|
-
listStyle: "none",
|
|
26
|
-
margin: 0,
|
|
27
|
-
padding: 0,
|
|
28
|
-
display: "flex",
|
|
29
|
-
flexWrap: "wrap",
|
|
30
|
-
gap: "var(--space-3)",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const linkStyle: CSSProperties = {
|
|
34
|
-
display: "inline-flex",
|
|
35
|
-
alignItems: "center",
|
|
36
|
-
padding: "var(--space-2) 0",
|
|
37
|
-
color: "var(--color-neutral-600)",
|
|
38
|
-
textDecoration: "none",
|
|
39
|
-
borderBottom: "2px solid transparent",
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const activeLinkStyle: CSSProperties = {
|
|
43
|
-
color: "var(--color-neutral-900)",
|
|
44
|
-
borderBottomColor: "var(--color-fg-accent)",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
19
|
/**
|
|
48
20
|
* Secondary horizontal navigation for intra-module sub-pages.
|
|
49
21
|
*
|
|
50
22
|
* Each tab links to a real TanStack Router route so sub-pages keep their own URL,
|
|
51
23
|
* lazy loading, and back-button behavior. The active tab is matched exactly.
|
|
24
|
+
*
|
|
25
|
+
* It renders no inline styles: the strip, the list and the links take their geometry and
|
|
26
|
+
* ink from the injected react-core sheet (ADR 0094). The active tab is styled from
|
|
27
|
+
* `data-active`, which this component alone writes, rather than from the `aria-current` it
|
|
28
|
+
* also sets — a router `Link` merges its own `aria-current` last, so that attribute has a
|
|
29
|
+
* second author and would be the breadcrumb mistake again.
|
|
52
30
|
*/
|
|
53
31
|
export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
54
32
|
const strings = useStrings();
|
|
@@ -60,12 +38,8 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
|
60
38
|
}
|
|
61
39
|
|
|
62
40
|
return (
|
|
63
|
-
<nav
|
|
64
|
-
|
|
65
|
-
data-terp="module-nav"
|
|
66
|
-
style={navStyle}
|
|
67
|
-
>
|
|
68
|
-
<ul style={listStyle}>
|
|
41
|
+
<nav aria-label={resolve(ariaLabel ?? strings.moduleNavigationLabel)} data-terp="module-nav">
|
|
42
|
+
<ul data-terp="module-nav-list">
|
|
69
43
|
{items.map((item) => {
|
|
70
44
|
const label = resolve(item.label);
|
|
71
45
|
const isActive = pathname === item.to;
|
|
@@ -75,7 +49,8 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
|
75
49
|
to={item.to}
|
|
76
50
|
activeOptions={{ exact: true }}
|
|
77
51
|
aria-current={isActive ? "page" : undefined}
|
|
78
|
-
|
|
52
|
+
data-terp="module-nav-link"
|
|
53
|
+
data-active={isActive ? "true" : undefined}
|
|
79
54
|
>
|
|
80
55
|
{label}
|
|
81
56
|
</Link>
|
package/src/Page.test.tsx
CHANGED
|
@@ -18,13 +18,16 @@ describe("Page", () => {
|
|
|
18
18
|
</Page>,
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toBeInTheDocument();
|
|
22
|
-
expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toHaveStyle({
|
|
23
|
-
fontSize: "var(--font-size-lg)",
|
|
24
|
-
letterSpacing: "0",
|
|
25
|
-
});
|
|
26
|
-
expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
|
|
27
21
|
const title = screen.getByRole("heading", { level: 1, name: "Tasks" });
|
|
22
|
+
expect(title).toBeInTheDocument();
|
|
23
|
+
// The marker, not the declaration. jsdom computes no cascade, so toHaveStyle could only
|
|
24
|
+
// ever see an inline style — asserting the title's font size was asserting that Page
|
|
25
|
+
// styles itself from a style object, which is the thing ADR 0094 removes. What a unit
|
|
26
|
+
// test can hold is the fact the sheet keys on; the type is gated by styles.test.ts for
|
|
27
|
+
// existence and by the page-header baseline for what it renders as.
|
|
28
|
+
expect(title).toHaveAttribute("data-terp", "page-title");
|
|
29
|
+
expect(title.getAttribute("style")).toBeNull();
|
|
30
|
+
expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
|
|
28
31
|
const action = screen.getByRole("button", { name: "New" });
|
|
29
32
|
expect(title.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
30
33
|
expect(screen.getByText("body")).toBeInTheDocument();
|
package/src/Page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
import { useContext, useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
import { Breadcrumbs } from "./Breadcrumbs";
|
|
@@ -40,39 +40,6 @@ export interface PageProps {
|
|
|
40
40
|
children: ReactNode;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const pageStyle: CSSProperties = {
|
|
44
|
-
display: "grid",
|
|
45
|
-
gridTemplateColumns: "minmax(0, 1fr)",
|
|
46
|
-
gap: "var(--space-4)",
|
|
47
|
-
alignContent: "start",
|
|
48
|
-
minWidth: 0,
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const headerStyle: CSSProperties = { display: "grid", gap: "var(--space-2)" };
|
|
52
|
-
|
|
53
|
-
const breadcrumbRowStyle: CSSProperties = {
|
|
54
|
-
display: "flex",
|
|
55
|
-
alignItems: "center",
|
|
56
|
-
minHeight: "2rem",
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const titleRowStyle: CSSProperties = {
|
|
60
|
-
display: "flex",
|
|
61
|
-
alignItems: "center",
|
|
62
|
-
justifyContent: "space-between",
|
|
63
|
-
gap: "var(--space-3)",
|
|
64
|
-
flexWrap: "wrap",
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const titleStyle: CSSProperties = {
|
|
68
|
-
margin: 0,
|
|
69
|
-
fontSize: "var(--font-size-lg)",
|
|
70
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
71
|
-
letterSpacing: 0,
|
|
72
|
-
color: "var(--color-neutral-900)",
|
|
73
|
-
lineHeight: 1.3,
|
|
74
|
-
};
|
|
75
|
-
|
|
76
43
|
/**
|
|
77
44
|
* The base content-page frame: every routed view is constructed the same way — one
|
|
78
45
|
* header holding the breadcrumb trail (when there is a path back up through the
|
|
@@ -85,6 +52,9 @@ const titleStyle: CSSProperties = {
|
|
|
85
52
|
* The frame also owns the async body states: `error` (which wins, so a failed
|
|
86
53
|
* query never hides behind a spinner) then `isLoading` replace the body while the
|
|
87
54
|
* header stays put, so the user keeps their place in the layers.
|
|
55
|
+
*
|
|
56
|
+
* It renders no inline styles: the frame's geometry and the title's type come from the
|
|
57
|
+
* injected react-core sheet, matched on the `data-terp` markers stamped below (ADR 0094).
|
|
88
58
|
*/
|
|
89
59
|
export function Page({
|
|
90
60
|
title,
|
|
@@ -138,15 +108,21 @@ export function Page({
|
|
|
138
108
|
children
|
|
139
109
|
);
|
|
140
110
|
return (
|
|
141
|
-
<article ref={articleRef}
|
|
142
|
-
<header
|
|
111
|
+
<article ref={articleRef} data-terp="page">
|
|
112
|
+
{/* A <header> ELEMENT, and it has to stay one. The slot check above drops the header
|
|
113
|
+
from the body set by tagName, so re-rendering this as a marked <div> would put it
|
|
114
|
+
back in and fail every governed OverviewPage / DetailPage closed. The marker is
|
|
115
|
+
additive; the tag is load-bearing. For the same reason the body below takes no
|
|
116
|
+
wrapper of its own — not even a display: contents one, since article.children is a
|
|
117
|
+
DOM traversal and would see it. */}
|
|
118
|
+
<header data-terp="page-header">
|
|
143
119
|
{hasAncestors && (
|
|
144
|
-
<div
|
|
120
|
+
<div data-terp="page-breadcrumbs">
|
|
145
121
|
<Breadcrumbs items={trail} renderLink={renderLink} />
|
|
146
122
|
</div>
|
|
147
123
|
)}
|
|
148
|
-
<div
|
|
149
|
-
<h1
|
|
124
|
+
<div data-terp="page-heading">
|
|
125
|
+
<h1 data-terp="page-title">{resolve(title)}</h1>
|
|
150
126
|
{actions}
|
|
151
127
|
</div>
|
|
152
128
|
</header>
|
package/src/ProfileView.test.tsx
CHANGED
|
@@ -69,6 +69,21 @@ describe("ProfileView", () => {
|
|
|
69
69
|
);
|
|
70
70
|
expect(screen.getAllByText("jane.doe@example.com").length).toBeGreaterThanOrEqual(1);
|
|
71
71
|
expect(screen.getByText("editor (20)")).toBeInTheDocument();
|
|
72
|
+
|
|
73
|
+
// The identity block, which nothing asserted before: the initials tile was entirely
|
|
74
|
+
// unreached by any test, and is aria-hidden so axe skips it too. Markers plus the
|
|
75
|
+
// absence of a style attribute, because the geometry is a sheet rule now (ADR 0094) and
|
|
76
|
+
// jsdom computes no cascade. The address marker matters most of the three — its
|
|
77
|
+
// overflow-wrap has no specimen, since the mock session's address is short, so this is
|
|
78
|
+
// the only gate that the element a long address would need is still the marked one.
|
|
79
|
+
const avatar = document.querySelector('[data-terp="profile-avatar"]');
|
|
80
|
+
expect(avatar).not.toBeNull();
|
|
81
|
+
expect(avatar?.textContent).toBe("JD");
|
|
82
|
+
expect(avatar?.getAttribute("style")).toBeNull();
|
|
83
|
+
const email = document.querySelector('[data-terp="profile-email"]');
|
|
84
|
+
expect(email?.textContent).toBe("jane.doe@example.com");
|
|
85
|
+
expect(email?.getAttribute("style")).toBeNull();
|
|
86
|
+
expect(document.querySelectorAll('[data-terp="profile-card"]')).toHaveLength(2);
|
|
72
87
|
// The stacked preference controls live here (settings surface).
|
|
73
88
|
expect(screen.getByLabelText("Theme")).toBeInTheDocument();
|
|
74
89
|
expect(screen.getByLabelText("Language")).toBeInTheDocument();
|
package/src/ProfileView.tsx
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { CSSProperties } from "react";
|
|
2
|
-
|
|
3
1
|
import { Page } from "./Page";
|
|
4
2
|
import { useAuth } from "./TerpProvider";
|
|
5
3
|
import { LanguageSwitcher } from "./locale";
|
|
@@ -9,38 +7,15 @@ import { userInitials } from "./UserMenu";
|
|
|
9
7
|
import { Button } from "./ui/Button";
|
|
10
8
|
import { useStrings } from "./uiText";
|
|
11
9
|
|
|
12
|
-
const avatarStyle: CSSProperties = {
|
|
13
|
-
display: "inline-flex",
|
|
14
|
-
alignItems: "center",
|
|
15
|
-
justifyContent: "center",
|
|
16
|
-
width: "3.5rem",
|
|
17
|
-
height: "3.5rem",
|
|
18
|
-
flexShrink: 0,
|
|
19
|
-
borderRadius: "var(--radius-full)",
|
|
20
|
-
background: "var(--color-brand-primary)",
|
|
21
|
-
color: "var(--color-brand-primary-contrast)",
|
|
22
|
-
fontSize: "var(--font-size-lg)",
|
|
23
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const mutedStyle: CSSProperties = { margin: 0, color: "var(--color-neutral-600)" };
|
|
27
|
-
|
|
28
|
-
const cardStyle: CSSProperties = {
|
|
29
|
-
display: "grid",
|
|
30
|
-
gap: "var(--space-4)",
|
|
31
|
-
padding: "var(--space-4)",
|
|
32
|
-
maxWidth: "32rem",
|
|
33
|
-
background: "var(--color-neutral-0)",
|
|
34
|
-
border: "1px solid var(--color-neutral-200)",
|
|
35
|
-
borderRadius: "var(--radius-lg)",
|
|
36
|
-
};
|
|
37
|
-
|
|
38
10
|
/**
|
|
39
11
|
* The built-in profile / settings page the {@link UserMenu}'s Settings item opens.
|
|
40
12
|
* `buildAppRouter` mounts it at `/profile` in every app (an app manifest claiming
|
|
41
13
|
* that path wins): the signed-in identity (avatar, email, role — the server-validated
|
|
42
14
|
* `/me` session, not token claims), the standard theme + language preferences, and
|
|
43
15
|
* sign-out. A `Page` archetype, so it satisfies the routed-view frame control.
|
|
16
|
+
*
|
|
17
|
+
* It renders no inline styles: both cards, the avatar tile and the two identity lines take
|
|
18
|
+
* their geometry and ink from the injected react-core sheet (ADR 0094).
|
|
44
19
|
*/
|
|
45
20
|
export function ProfileView() {
|
|
46
21
|
const auth = useAuth();
|
|
@@ -52,14 +27,14 @@ export function ProfileView() {
|
|
|
52
27
|
return (
|
|
53
28
|
<Page title={strings.profile}>
|
|
54
29
|
<Stack gap={4}>
|
|
55
|
-
<div
|
|
30
|
+
<div data-terp="profile-card">
|
|
56
31
|
<Stack direction="row" gap={3} align="center">
|
|
57
|
-
<span aria-hidden="true"
|
|
32
|
+
<span aria-hidden="true" data-terp="profile-avatar">
|
|
58
33
|
{userInitials(user.email)}
|
|
59
34
|
</span>
|
|
60
35
|
<Stack gap={0}>
|
|
61
|
-
<strong
|
|
62
|
-
<p
|
|
36
|
+
<strong data-terp="profile-email">{user.email}</strong>
|
|
37
|
+
<p data-terp="profile-role">{user.role_name}</p>
|
|
63
38
|
</Stack>
|
|
64
39
|
</Stack>
|
|
65
40
|
<DetailList
|
|
@@ -69,7 +44,7 @@ export function ProfileView() {
|
|
|
69
44
|
]}
|
|
70
45
|
/>
|
|
71
46
|
</div>
|
|
72
|
-
<div
|
|
47
|
+
<div data-terp="profile-card">
|
|
73
48
|
<ThemeToggle />
|
|
74
49
|
<LanguageSwitcher />
|
|
75
50
|
<div>
|