@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/AppShell.tsx
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type { NavItem } from "@terpjs/contract";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import type { NavGroup, NavItem } from "@terpjs/contract";
|
|
2
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
|
|
5
|
+
import { NARROW_VIEWPORT } from "./breakpoints";
|
|
6
|
+
import { groupNav } from "./nav";
|
|
7
|
+
import { activeNavPath } from "./navActive";
|
|
5
8
|
import { Icon, NavIcon, TerpMark } from "./icons";
|
|
6
9
|
import { LanguageSwitcher } from "./locale";
|
|
7
10
|
import { injectTerpStyles } from "./styles";
|
|
@@ -25,11 +28,27 @@ export interface AppShellSlotContext {
|
|
|
25
28
|
* `[data-terp="appshell-nav"] a` and on `aria-current="page"` for the active route, so a
|
|
26
29
|
* renderer needs to return nothing but its stack's link (ADR 0094).
|
|
27
30
|
*/
|
|
28
|
-
export
|
|
31
|
+
export interface AppShellLinkContext extends AppShellSlotContext {
|
|
32
|
+
/**
|
|
33
|
+
* Whether this is the current item — the shell's verdict over the **whole set**, not this
|
|
34
|
+
* link's opinion of itself.
|
|
35
|
+
*
|
|
36
|
+
* That distinction is the reason the field exists. "At most one item is current" cannot be
|
|
37
|
+
* decided one link at a time, and a router decides exactly that way: give it `/settings` and
|
|
38
|
+
* `/settings/users` and at `/settings/users` it marks both active, because each is a prefix
|
|
39
|
+
* of the URL and neither knows the other exists. So the shell resolves the set once (longest
|
|
40
|
+
* segment-aligned match wins, {@link activeNavPath}) and tells the renderer, which is free to
|
|
41
|
+
* put the answer wherever its stack wants it — `aria-current` on a router link, in practice.
|
|
42
|
+
*
|
|
43
|
+
* `false` for every item when `activePath` is not given, so a shell that is not told where it
|
|
44
|
+
* is claims nothing.
|
|
45
|
+
*/
|
|
46
|
+
active: boolean;
|
|
47
|
+
}
|
|
29
48
|
|
|
30
49
|
export type RenderBrandLink = (props: { to: string; children: ReactNode }) => ReactNode;
|
|
31
50
|
|
|
32
|
-
|
|
51
|
+
interface AppShellBaseProps {
|
|
33
52
|
/** Product / app title shown next to the logo at the top of the sidebar. */
|
|
34
53
|
title: UiText;
|
|
35
54
|
/** Sidebar nav, already filtered for the current user (see `visibleNav`). */
|
|
@@ -44,46 +63,179 @@ export interface AppShellProps {
|
|
|
44
63
|
renderLink: (item: NavItem, children: ReactNode, context: AppShellLinkContext) => ReactNode;
|
|
45
64
|
/** Turns the product brand into the home link; defaults to a plain anchor to `/`. */
|
|
46
65
|
renderBrandLink?: RenderBrandLink;
|
|
47
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Brand mark at the top of the sidebar; default: the {@link TerpMark} placeholder.
|
|
68
|
+
*
|
|
69
|
+
* It renders inside a box of `--shell-brand-size`, so an asset larger than the icon rail no
|
|
70
|
+
* longer clips it — which is why there is no separate "collapsed mark" slot. The rail already
|
|
71
|
+
* separates the two halves of a brand: `logo` is the mark and `title` is the wordmark, and
|
|
72
|
+
* collapsing hides the title. An app whose logo is a wide lockup should split it that way
|
|
73
|
+
* rather than supply a third asset.
|
|
74
|
+
*/
|
|
48
75
|
logo?: ReactNode;
|
|
76
|
+
/**
|
|
77
|
+
* The mark to show on **dark-appearance** themes, when the app's brand does not survive one.
|
|
78
|
+
*
|
|
79
|
+
* The bundled icons all stroke in `currentColor` and need nothing here; a company mark
|
|
80
|
+
* usually cannot, and a dark-ink one is invisible on three of the five shipped themes. Pass
|
|
81
|
+
* this and both marks render, with CSS showing one — the theme is `<html data-theme>`, which
|
|
82
|
+
* an app may set with no provider mounted at all, so resolving it in React would be wrong for
|
|
83
|
+
* every shell that is not inside `renderTerpApp`.
|
|
84
|
+
*
|
|
85
|
+
* Which themes count as dark is not a list in the stylesheet. `themes.json` already declares
|
|
86
|
+
* each theme's `appearance` and the token build emits the switch from it, so a sixth theme
|
|
87
|
+
* cannot forget to answer.
|
|
88
|
+
*/
|
|
89
|
+
logoDark?: ReactNode;
|
|
49
90
|
/** Extra header content, rendered before the theme / language controls. */
|
|
50
91
|
headerActions?: ReactNode;
|
|
92
|
+
/**
|
|
93
|
+
* Cap the routed content at the published measure (`--shell-content-max-width`), leaving
|
|
94
|
+
* each page's own header spanning the full track above it.
|
|
95
|
+
*
|
|
96
|
+
* `"full"` is the default and stamps **nothing**, which is the density prop's shape and for
|
|
97
|
+
* the same reason: full width is what the sheet already does, so an attribute for it would
|
|
98
|
+
* match no rule. So no existing app moves by a pixel until it asks.
|
|
99
|
+
*
|
|
100
|
+
* The measure and the full-width band are one mechanism rather than two features — a band
|
|
101
|
+
* only reads as a band once the column beside it is narrower — and the mechanism is the page
|
|
102
|
+
* grid it already had, not a portal and not a wrapper. Both alternatives were rejected on
|
|
103
|
+
* facts about this codebase rather than taste; see ADR 0097 §2 and the rule in `styles.ts`.
|
|
104
|
+
*
|
|
105
|
+
* "Full width" means the full width of the article's own track. `appshell-main`'s padding
|
|
106
|
+
* sits outside it, so this is a measure within the content column rather than a bleed to the
|
|
107
|
+
* window edge — which would need a negative margin, and therefore an inline site.
|
|
108
|
+
*/
|
|
109
|
+
contentWidth?: "full" | "measured";
|
|
110
|
+
/**
|
|
111
|
+
* App-wide density, stamped on the shell root. **No default**, and that matters.
|
|
112
|
+
*
|
|
113
|
+
* The tokens do the work through inheritance, so every control and every cell in the tree
|
|
114
|
+
* follows without a prop of its own, and a subtree can override it — a
|
|
115
|
+
* `DataView density="comfortable"` inside a compact shell really is comfortable now, which
|
|
116
|
+
* it was not before this prop existed. That island is the vocabulary ADR 0094 deferred until
|
|
117
|
+
* something asked; this is what asked.
|
|
118
|
+
*
|
|
119
|
+
* Omitting the prop stamps **nothing**, rather than stamping `"comfortable"`. A default that
|
|
120
|
+
* stamped would silently override `data-density` on `<html>` — which ADR 0094 §4 names as
|
|
121
|
+
* *the app-wide case* and which an app sets from its own `theme.css` today. An unasked-for
|
|
122
|
+
* shell prop must not win against an app-wide choice, so absence means "inherit whatever is
|
|
123
|
+
* above me" and the two values mean what they say.
|
|
124
|
+
*/
|
|
125
|
+
density?: "comfortable" | "compact";
|
|
51
126
|
/** Pinned to the bottom of the sidebar (the {@link UserMenu}); may read the rail state. */
|
|
52
127
|
navFooter?: ReactNode | ((context: AppShellSlotContext) => ReactNode);
|
|
53
128
|
/** Footer line under the content; default: a muted line with the app title. */
|
|
54
129
|
footer?: ReactNode;
|
|
55
130
|
/**
|
|
56
|
-
*
|
|
57
|
-
* persisted yet. The user's own toggle still wins and still persists.
|
|
131
|
+
* The current URL path, so the shell can decide which nav item is current.
|
|
58
132
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
133
|
+
* Absent stamps nothing and claims nothing — the `density` idiom — so a shell that is not told
|
|
134
|
+
* where it is renders exactly what it renders today, and `renderLink` receives
|
|
135
|
+
* `active: false` for every item. `buildAppRouter` passes the router's pathname; a bare shell
|
|
136
|
+
* in a test or a specimen can pass a literal.
|
|
137
|
+
*
|
|
138
|
+
* A plain string rather than a router hook, because the shell is router-agnostic and must stay
|
|
139
|
+
* so: it imports nothing from any stack. A query string or hash is tolerated and ignored —
|
|
140
|
+
* a nav tab's identity is its path.
|
|
63
141
|
*/
|
|
64
|
-
|
|
142
|
+
activePath?: string;
|
|
143
|
+
/**
|
|
144
|
+
* The app's declared navigation groups, which {@link nav} items reference by
|
|
145
|
+
* `NavItem.group`.
|
|
146
|
+
*
|
|
147
|
+
* Absent renders exactly what the shell renders today: one unlabelled list holding every item
|
|
148
|
+
* in the order it was given. That is `groupNav`'s identity case rather than a branch here — see
|
|
149
|
+
* its docstring for the four rules, all of which are about a missing declaration.
|
|
150
|
+
*
|
|
151
|
+
* A group spans modules, so the **app** owns the label and the position and a module owns only
|
|
152
|
+
* the reference. That is why this is a shell prop and `group` is a manifest field, rather than
|
|
153
|
+
* both living on the manifest.
|
|
154
|
+
*/
|
|
155
|
+
navGroups?: readonly NavGroup[];
|
|
156
|
+
/**
|
|
157
|
+
* Start with the mobile drawer open.
|
|
158
|
+
*
|
|
159
|
+
* The same door `defaultCollapsed` opened for the icon rail, for the same reason and with the
|
|
160
|
+
* same evidence behind it. Below the breakpoint the sidebar renders **only** while
|
|
161
|
+
* `drawerOpen` is true, and that is internal state with no way in — so the drawer's own
|
|
162
|
+
* geometry (`position: fixed`, `100dvh`, the drawer z-index, the shadow) and its backdrop
|
|
163
|
+
* have shipped unpainted, asserted in `styles.test.ts` as text with "no baseline can hold it"
|
|
164
|
+
* written beside them. Four rules, true for four releases.
|
|
165
|
+
*
|
|
166
|
+
* Dev/specimen affordance rather than an app-facing one: an app opening the drawer on load
|
|
167
|
+
* is showing every mobile user a menu they did not ask for. It exists so the rules can be
|
|
168
|
+
* photographed.
|
|
169
|
+
*/
|
|
170
|
+
defaultDrawerOpen?: boolean;
|
|
65
171
|
/** The routed page content. */
|
|
66
172
|
children: ReactNode;
|
|
67
173
|
}
|
|
68
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Where the primary navigation lives, and it is a union rather than two independent props
|
|
177
|
+
* because one combination of them would be legal and inert.
|
|
178
|
+
*
|
|
179
|
+
* `"sidebar"` is the default and stamps nothing — full-height chrome on the left, collapsing
|
|
180
|
+
* to an icon rail, which is every shell the framework has rendered so far. `"header"` moves
|
|
181
|
+
* the nav into the header as a horizontal row and drops the sidebar entirely, for an app whose
|
|
182
|
+
* destinations are few enough that 15rem of permanent chrome is a tax: the template's `portal`
|
|
183
|
+
* preset names that app in as many words — "a personal landing for customers, staff or
|
|
184
|
+
* suppliers" — and today it renders into chrome designed for a 21-module internal tool.
|
|
185
|
+
*
|
|
186
|
+
* **Desktop only.** Below the breakpoint both placements are the drawer, because a horizontal
|
|
187
|
+
* row of links does not fit a 420px viewport and the drawer already exists. So this changes
|
|
188
|
+
* nothing a phone renders, which is also why the attribute is derived from the viewport rather
|
|
189
|
+
* than stamped from the prop.
|
|
190
|
+
*
|
|
191
|
+
* `defaultCollapsed` is `never` under `"header"`: with no sidebar there is nothing to collapse,
|
|
192
|
+
* so the pair would type-check, do nothing, and give no sign of it — the shape this phase keeps
|
|
193
|
+
* refusing, most recently in `Select`'s options union.
|
|
194
|
+
*/
|
|
195
|
+
type AppShellNavPlacementProps =
|
|
196
|
+
| {
|
|
197
|
+
navPlacement?: "sidebar";
|
|
198
|
+
/**
|
|
199
|
+
* Start with the desktop sidebar collapsed to its icon rail, when no choice has been
|
|
200
|
+
* persisted yet. The user's own toggle still wins and still persists.
|
|
201
|
+
*
|
|
202
|
+
* It exists for the same reason `Menu` and both date pickers take `defaultOpen`: the
|
|
203
|
+
* rail is internal state read from `localStorage`, so without a way in it can be
|
|
204
|
+
* rendered by no specimen and no test, and every rule that only applies to it is
|
|
205
|
+
* unpainted. Four were.
|
|
206
|
+
*/
|
|
207
|
+
defaultCollapsed?: boolean;
|
|
208
|
+
}
|
|
209
|
+
| { navPlacement: "header"; defaultCollapsed?: never };
|
|
210
|
+
|
|
211
|
+
export type AppShellProps = AppShellBaseProps & AppShellNavPlacementProps;
|
|
212
|
+
|
|
69
213
|
/** The `localStorage` key the sidebar's collapsed choice persists under. */
|
|
70
214
|
export const SIDEBAR_STORAGE_KEY = "terp.sidebar";
|
|
71
215
|
|
|
72
|
-
|
|
73
|
-
|
|
216
|
+
/*
|
|
217
|
+
* The skip link's target id is per-INSTANCE (see `useId` below), not a module constant.
|
|
218
|
+
*
|
|
219
|
+
* A constant was the first shape and it is wrong wherever two shells mount together: every one
|
|
220
|
+
* of them renders `<main id="terp-main">` and a link to `#terp-main`, so the ids collide and
|
|
221
|
+
* each link jumps to the first shell on the page rather than to its own content. The workbench
|
|
222
|
+
* catalogue is exactly that page — three shells at once — which is how it was found. It was
|
|
223
|
+
* also documented as exported and never actually re-exported from the entry point, so the one
|
|
224
|
+
* argument for a shared constant had no consumer either.
|
|
225
|
+
*/
|
|
74
226
|
|
|
75
227
|
function useIsMobile(): boolean {
|
|
76
228
|
const [isMobile, setIsMobile] = useState(
|
|
77
229
|
() =>
|
|
78
230
|
typeof window !== "undefined" &&
|
|
79
231
|
typeof window.matchMedia === "function" &&
|
|
80
|
-
window.matchMedia(
|
|
232
|
+
window.matchMedia(NARROW_VIEWPORT).matches,
|
|
81
233
|
);
|
|
82
234
|
useEffect(() => {
|
|
83
235
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
84
236
|
return;
|
|
85
237
|
}
|
|
86
|
-
const media = window.matchMedia(
|
|
238
|
+
const media = window.matchMedia(NARROW_VIEWPORT);
|
|
87
239
|
const onChange = (event: MediaQueryListEvent) => setIsMobile(event.matches);
|
|
88
240
|
media.addEventListener("change", onChange);
|
|
89
241
|
return () => media.removeEventListener("change", onChange);
|
|
@@ -141,7 +293,9 @@ function PanelIcon() {
|
|
|
141
293
|
* - a full-height sidebar: brand (logo + title) on top, the role-filtered nav with
|
|
142
294
|
* per-item icons, and the `navFooter` (the {@link UserMenu}) pinned to the bottom.
|
|
143
295
|
* On desktop it collapses to an icon rail (persisted in `localStorage`); below the
|
|
144
|
-
* mobile breakpoint it becomes an overlay drawer with a backdrop
|
|
296
|
+
* mobile breakpoint it becomes an overlay drawer with a backdrop. With
|
|
297
|
+
* `navPlacement="header"` there is no sidebar on desktop at all: the same brand, the same
|
|
298
|
+
* nav and the same user menu render in the header, and the drawer still handles mobile;
|
|
145
299
|
* - a **sticky** header over the content: the sidebar toggle on the left, then
|
|
146
300
|
* `headerActions` and the standard theme + language controls on the right;
|
|
147
301
|
* - the routed `children` in a `main` landmark, with a slim `footer` underneath.
|
|
@@ -155,17 +309,32 @@ export function AppShell({
|
|
|
155
309
|
renderLink,
|
|
156
310
|
renderBrandLink = defaultRenderBrandLink,
|
|
157
311
|
logo,
|
|
312
|
+
logoDark,
|
|
158
313
|
headerActions,
|
|
314
|
+
contentWidth = "full",
|
|
315
|
+
density,
|
|
316
|
+
activePath,
|
|
317
|
+
navGroups,
|
|
318
|
+
navPlacement = "sidebar",
|
|
159
319
|
navFooter,
|
|
160
320
|
footer,
|
|
161
321
|
defaultCollapsed = false,
|
|
322
|
+
defaultDrawerOpen = false,
|
|
162
323
|
children,
|
|
163
324
|
}: AppShellProps) {
|
|
164
325
|
const resolve = useUiText();
|
|
165
326
|
const strings = useStrings();
|
|
166
327
|
const isMobile = useIsMobile();
|
|
167
328
|
const [collapsed, setCollapsed] = useState(() => readStoredCollapsed(defaultCollapsed));
|
|
168
|
-
const [drawerOpen, setDrawerOpen] = useState(
|
|
329
|
+
const [drawerOpen, setDrawerOpen] = useState(defaultDrawerOpen);
|
|
330
|
+
// Per shell instance, so two shells on one page get two distinct skip targets.
|
|
331
|
+
const mainId = useId();
|
|
332
|
+
// The same per-instance guarantee, for the group labels. The workbench catalogue renders three
|
|
333
|
+
// shells on one page, and a module-constant id would make the second shell's `aria-labelledby`
|
|
334
|
+
// resolve into the first — a wrong accessible name rather than a missing one, which nothing
|
|
335
|
+
// reports: `duplicate-id` is deprecated in axe and does not run, and a resolvable IDREF is not
|
|
336
|
+
// a violation whatever it resolves to.
|
|
337
|
+
const navGroupId = useId();
|
|
169
338
|
const drawerRef = useRef<HTMLElement>(null);
|
|
170
339
|
const drawerCloseRef = useRef<HTMLButtonElement>(null);
|
|
171
340
|
const toggleRef = useRef<HTMLButtonElement>(null);
|
|
@@ -224,9 +393,22 @@ export function AppShell({
|
|
|
224
393
|
});
|
|
225
394
|
}
|
|
226
395
|
|
|
227
|
-
//
|
|
228
|
-
|
|
396
|
+
// Desktop only, and derived rather than stamped from the prop: below the breakpoint both
|
|
397
|
+
// placements ARE the drawer, so a shell asked for a header nav on a phone renders exactly
|
|
398
|
+
// what it renders today. Deriving it here is what lets every rule keyed on the attribute skip
|
|
399
|
+
// a [data-variant="desktop"] guard — the attribute is absent whenever it would not be true.
|
|
400
|
+
const headerNav = !isMobile && navPlacement === "header";
|
|
401
|
+
// The drawer always shows labels; the desktop rail hides them when collapsed. `headerNav`
|
|
402
|
+
// forces it false rather than leaving the persisted choice to leak: with no sidebar the
|
|
403
|
+
// attribute lands nowhere, but `context.collapsed` still reaches `renderLink` and
|
|
404
|
+
// `navFooter`, so a user who had collapsed the rail before the app moved its nav would get
|
|
405
|
+
// icon-only links in a header with room for labels.
|
|
406
|
+
const railCollapsed = !isMobile && !headerNav && collapsed;
|
|
229
407
|
const context: AppShellSlotContext = { collapsed: railCollapsed };
|
|
408
|
+
// Resolved once over the whole set rather than per link — see AppShellLinkContext.active for
|
|
409
|
+
// why that is the whole point. Undefined when nothing matches, and when nobody told the shell
|
|
410
|
+
// where it is.
|
|
411
|
+
const currentTo = activePath === undefined ? undefined : activeNavPath(activePath, nav);
|
|
230
412
|
// Hoisted, the density-attribute idiom: the marker scanner reads a whole expression
|
|
231
413
|
// container, so a conditional written at the attribute reports every literal in it as a
|
|
232
414
|
// marker name.
|
|
@@ -236,27 +418,128 @@ export function AppShell({
|
|
|
236
418
|
// in one place — this component's media query — rather than being restated as a CSS
|
|
237
419
|
// @media rule that could drift from it.
|
|
238
420
|
const shellVariant = isMobile ? "mobile" : "desktop";
|
|
421
|
+
// Hoisted for the same reason `collapsedAttribute` is: the default stamps nothing, so the
|
|
422
|
+
// expression has a branch, and a conditional written at the attribute is the form the marker
|
|
423
|
+
// scanner reads every literal out of.
|
|
424
|
+
const contentWidthAttribute = contentWidth === "measured" ? "measured" : undefined;
|
|
425
|
+
// Stamped for whichever value was ASKED for, and for neither when the prop is absent.
|
|
426
|
+
// Both values now have a rule — comfortable is no longer the absence of an attribute — so
|
|
427
|
+
// passing it is a real instruction rather than a no-op. Passing nothing has to stay a
|
|
428
|
+
// no-op, or the shell would override an app's own <html data-density>.
|
|
429
|
+
const densityAttribute = density;
|
|
430
|
+
const navPlacementAttribute = headerNav ? "header" : undefined;
|
|
239
431
|
const resolvedTitle = resolve(title);
|
|
240
432
|
|
|
241
433
|
// The brand takes no style object and needs none: its three looks are the resting one,
|
|
242
434
|
// the collapsed one (reached from the sidebar's data-collapsed) and the mobile one
|
|
243
435
|
// (reached from the drawer's brand row, which only exists on mobile). The DOM already
|
|
244
436
|
// says which it is.
|
|
437
|
+
// A box of its own around the mark, which is the thing that makes an app's asset usable:
|
|
438
|
+
// the rail is 4rem wide and the brand link used to hand whatever it was given straight to a
|
|
439
|
+
// flex row, so an oversized logo was clipped by the aside's `overflow-x: hidden` with nothing
|
|
440
|
+
// to say so. One declared size caps it in every placement.
|
|
441
|
+
//
|
|
442
|
+
// Both marks render when a dark one is given, and the SHEET picks — see `logoDark`. When it
|
|
443
|
+
// is not, there is one child and no attribute, so the common case adds a wrapper and nothing
|
|
444
|
+
// else.
|
|
445
|
+
const mark = logo ?? <TerpMark />;
|
|
245
446
|
const brand = renderBrandLink({
|
|
246
447
|
to: "/",
|
|
247
448
|
children: (
|
|
248
449
|
<>
|
|
249
|
-
|
|
450
|
+
<span data-terp="appshell-mark">
|
|
451
|
+
{logoDark === undefined ? (
|
|
452
|
+
mark
|
|
453
|
+
) : (
|
|
454
|
+
<>
|
|
455
|
+
<span data-appearance="light">{mark}</span>
|
|
456
|
+
<span data-appearance="dark">{logoDark}</span>
|
|
457
|
+
</>
|
|
458
|
+
)}
|
|
459
|
+
</span>
|
|
250
460
|
<strong data-terp="appshell-brand-title">{resolvedTitle}</strong>
|
|
251
461
|
</>
|
|
252
462
|
),
|
|
253
463
|
});
|
|
254
464
|
|
|
465
|
+
// Hoisted out of the aside, because the header placement renders the SAME nodes in a
|
|
466
|
+
// different parent — same markers, same link renderer, same labels. Which is the point:
|
|
467
|
+
// the two placements are one navigation with two geometries, not two navigations, so
|
|
468
|
+
// nothing about a link's identity or its active state depends on where it sits.
|
|
469
|
+
const navigation = (
|
|
470
|
+
<nav
|
|
471
|
+
data-terp="appshell-nav"
|
|
472
|
+
aria-label={strings.primaryNavigationLabel}
|
|
473
|
+
onClick={isMobile ? closeDrawer : undefined}
|
|
474
|
+
>
|
|
475
|
+
{groupNav(nav, navGroups).map((section, index) => {
|
|
476
|
+
// Only a labelled section needs an id, and only a DECLARED section can be labelled — the
|
|
477
|
+
// default one has no declaration to carry a label. Keyed on the index rather than on
|
|
478
|
+
// `section.id`: a group id is an app-supplied string, and whitespace in one would
|
|
479
|
+
// silently break the IDREF rather than fail anywhere.
|
|
480
|
+
const labelId = section.label === null ? undefined : `${navGroupId}-${index}`;
|
|
481
|
+
return (
|
|
482
|
+
// No heading element, and this is the decision rather than an oversight. `Heading`
|
|
483
|
+
// refuses level 1 to reserve it for the routed view's title (see typography.tsx), and
|
|
484
|
+
// the sidebar renders BEFORE `<main>` — so a heading per group would put chrome above
|
|
485
|
+
// every page's h1 in the document outline, on every page in the product. axe cannot
|
|
486
|
+
// see it either: `heading-order` is a best-practice rule, outside the tags the a11y
|
|
487
|
+
// lane runs, and h2 -> h1 is a decrease that the rule passes anyway. A labelled list
|
|
488
|
+
// says the same thing to a screen reader and says nothing to the outline.
|
|
489
|
+
//
|
|
490
|
+
// The wrapper is rendered even for the single default section, which costs one <div>
|
|
491
|
+
// and no pixels: every rule in the sheet that reaches this subtree is an attribute or
|
|
492
|
+
// descendant selector, so none of them cares that the <ul> gained a parent. One code
|
|
493
|
+
// path is worth more than a branch that exists to save an element.
|
|
494
|
+
//
|
|
495
|
+
// A nav with NO visible items renders no wrapper and no list at all, where it used to
|
|
496
|
+
// render an empty <ul>. That is the same "a section with no items is not emitted" rule
|
|
497
|
+
// reaching its degenerate case rather than a second decision, it moves nothing (an
|
|
498
|
+
// empty grid list has no height), and it takes an empty `list` role back out of the
|
|
499
|
+
// accessibility tree. Reachable whenever every item is gated away by role or grant.
|
|
500
|
+
<div key={index} data-terp="appshell-nav-group">
|
|
501
|
+
{labelId !== undefined && (
|
|
502
|
+
<span id={labelId} data-terp="appshell-nav-group-label">
|
|
503
|
+
{section.label}
|
|
504
|
+
</span>
|
|
505
|
+
)}
|
|
506
|
+
<ul data-terp="appshell-nav-list" aria-labelledby={labelId}>
|
|
507
|
+
{section.items.map((item) => (
|
|
508
|
+
<li key={item.to} title={railCollapsed ? item.label : undefined}>
|
|
509
|
+
{renderLink(
|
|
510
|
+
item,
|
|
511
|
+
<>
|
|
512
|
+
<NavIcon name={item.icon} label={item.label} />
|
|
513
|
+
<span data-terp="appshell-nav-label">{item.label}</span>
|
|
514
|
+
</>,
|
|
515
|
+
{ collapsed: railCollapsed, active: item.to === currentTo },
|
|
516
|
+
)}
|
|
517
|
+
</li>
|
|
518
|
+
))}
|
|
519
|
+
</ul>
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
})}
|
|
523
|
+
</nav>
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// The user menu. Pinned to the bottom of the sidebar when there is one, and last in the
|
|
527
|
+
// header group when there is not — losing it entirely is the failure a placement prop
|
|
528
|
+
// invites, since it is where an app puts sign-out.
|
|
529
|
+
const footerSlot = typeof navFooter === "function" ? navFooter(context) : navFooter;
|
|
530
|
+
|
|
255
531
|
const sidebar = (
|
|
256
532
|
<aside
|
|
257
533
|
ref={isMobile ? drawerRef : undefined}
|
|
258
534
|
role={isMobile ? "dialog" : undefined}
|
|
259
535
|
aria-modal={isMobile ? true : undefined}
|
|
536
|
+
// Mobile only, which is where it started. Labelling the desktop aside as well looked like
|
|
537
|
+
// an improvement and was not: the `nav` immediately inside it already carries this exact
|
|
538
|
+
// string, so the landmark list gained a "Primary" complementary containing a "Primary"
|
|
539
|
+
// navigation — two nested entries with the same name, which is the disambiguation failure
|
|
540
|
+
// `SplitPane` documents rather than a fix for it. An unnamed complementary wrapping a
|
|
541
|
+
// named navigation is the lesser problem; giving the aside a name of its own is a
|
|
542
|
+
// separate decision with a string to choose, not a side effect of adding a skip link.
|
|
260
543
|
aria-label={isMobile ? strings.primaryNavigationLabel : undefined}
|
|
261
544
|
tabIndex={isMobile ? -1 : undefined}
|
|
262
545
|
onKeyDown={isMobile ? onDrawerKeyDown : undefined}
|
|
@@ -291,27 +574,8 @@ export function AppShell({
|
|
|
291
574
|
</button>
|
|
292
575
|
</div>
|
|
293
576
|
) : brand}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
aria-label={strings.primaryNavigationLabel}
|
|
297
|
-
onClick={isMobile ? closeDrawer : undefined}
|
|
298
|
-
>
|
|
299
|
-
<ul data-terp="appshell-nav-list">
|
|
300
|
-
{nav.map((item) => (
|
|
301
|
-
<li key={item.to} title={railCollapsed ? item.label : undefined}>
|
|
302
|
-
{renderLink(
|
|
303
|
-
item,
|
|
304
|
-
<>
|
|
305
|
-
<NavIcon name={item.icon} label={item.label} />
|
|
306
|
-
<span data-terp="appshell-nav-label">{item.label}</span>
|
|
307
|
-
</>,
|
|
308
|
-
{ collapsed: railCollapsed },
|
|
309
|
-
)}
|
|
310
|
-
</li>
|
|
311
|
-
))}
|
|
312
|
-
</ul>
|
|
313
|
-
</nav>
|
|
314
|
-
{typeof navFooter === "function" ? navFooter(context) : navFooter}
|
|
577
|
+
{navigation}
|
|
578
|
+
{footerSlot}
|
|
315
579
|
{isMobile && (
|
|
316
580
|
<span
|
|
317
581
|
data-terp="drawer-focus-end"
|
|
@@ -323,7 +587,32 @@ export function AppShell({
|
|
|
323
587
|
);
|
|
324
588
|
|
|
325
589
|
return (
|
|
326
|
-
<div
|
|
590
|
+
<div
|
|
591
|
+
data-terp="appshell"
|
|
592
|
+
data-variant={shellVariant}
|
|
593
|
+
data-content-width={contentWidthAttribute}
|
|
594
|
+
data-density={densityAttribute}
|
|
595
|
+
data-nav-placement={navPlacementAttribute}
|
|
596
|
+
>
|
|
597
|
+
{/* First in the DOM, so it is the first thing a keyboard reaches on load — which is the
|
|
598
|
+
whole contract, and why it cannot be placed anywhere more convenient. Visually hidden
|
|
599
|
+
until focused (the sheet shares that with the drawer's focus sentinels) and then
|
|
600
|
+
painted above the sticky header.
|
|
601
|
+
The shell owns this because the shell owns the landmarks: `main` is rendered here, and
|
|
602
|
+
nothing above it knows the id to point at.
|
|
603
|
+
|
|
604
|
+
NOT rendered while the mobile drawer is open, and that is a correctness fix rather
|
|
605
|
+
than tidying. The drawer is role="dialog" aria-modal, and the column below carries
|
|
606
|
+
`inert` — so this link is the one element that contradicts both: it sits outside the
|
|
607
|
+
modal, outside the inert subtree, and points AT the inert subtree. Whether a keyboard
|
|
608
|
+
route to it exists depends on where the browser's sequential-navigation starting point
|
|
609
|
+
happens to be, which is not a thing an accessibility guarantee should rest on. With
|
|
610
|
+
the drawer open there is also nothing to skip to. */}
|
|
611
|
+
{!(isMobile && drawerOpen) && (
|
|
612
|
+
<a data-terp="appshell-skip-link" href={`#${mainId}`}>
|
|
613
|
+
{strings.skipToContent}
|
|
614
|
+
</a>
|
|
615
|
+
)}
|
|
327
616
|
{isMobile ? (
|
|
328
617
|
drawerOpen && (
|
|
329
618
|
<>
|
|
@@ -334,39 +623,71 @@ export function AppShell({
|
|
|
334
623
|
</>
|
|
335
624
|
)
|
|
336
625
|
) : (
|
|
337
|
-
sidebar
|
|
626
|
+
!headerNav && sidebar
|
|
338
627
|
)}
|
|
339
628
|
<div
|
|
340
629
|
data-terp="appshell-column"
|
|
630
|
+
// `inert` is why this package requires React 19, and the requirement is real rather
|
|
631
|
+
// than nominal. Measured against both renderers with renderToStaticMarkup:
|
|
632
|
+
//
|
|
633
|
+
// spelling React 18.3.1 React 19.2.8
|
|
634
|
+
// inert={true} DROPPED (warns) inert=""
|
|
635
|
+
// inert="" inert="" DROPPED (warns: treated as false)
|
|
636
|
+
// inert="true" inert="" inert="" (warns)
|
|
637
|
+
//
|
|
638
|
+
// So on 18.3 this pair degraded to the worst possible half — a subtree announced as
|
|
639
|
+
// hidden to assistive technology while every control in it stayed focusable and
|
|
640
|
+
// clickable, because `aria-hidden` is an aria-* attribute React has always passed
|
|
641
|
+
// through. There is no spelling that is both correct and quiet on the two majors, which
|
|
642
|
+
// is why the fix is the peer range (now ^19) rather than a cast here: the defect was
|
|
643
|
+
// claiming to support a version on which the containment silently did not exist.
|
|
341
644
|
inert={isMobile && drawerOpen ? true : undefined}
|
|
342
645
|
aria-hidden={isMobile && drawerOpen ? true : undefined}
|
|
343
646
|
>
|
|
344
647
|
<header data-terp="appshell-header">
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
648
|
+
{/* No toggle under the header placement, and that is a correctness point rather
|
|
649
|
+
than tidying: the control exists to collapse the sidebar, and there is no
|
|
650
|
+
sidebar. Rendering it anyway would leave an aria-expanded whose target does not
|
|
651
|
+
exist — a button announcing a state about nothing. The brand takes the slot
|
|
652
|
+
instead, which is the other thing the sidebar was carrying. */}
|
|
653
|
+
{headerNav ? (
|
|
654
|
+
brand
|
|
655
|
+
) : (
|
|
656
|
+
<button
|
|
657
|
+
ref={toggleRef}
|
|
658
|
+
type="button"
|
|
659
|
+
data-terp="iconbutton"
|
|
660
|
+
aria-expanded={isMobile ? drawerOpen : !collapsed}
|
|
661
|
+
aria-label={
|
|
662
|
+
isMobile
|
|
663
|
+
? drawerOpen
|
|
664
|
+
? strings.closeNavigation
|
|
665
|
+
: strings.openNavigation
|
|
666
|
+
: collapsed
|
|
667
|
+
? strings.expandSidebar
|
|
668
|
+
: strings.collapseSidebar
|
|
669
|
+
}
|
|
670
|
+
onClick={toggleSidebar}
|
|
671
|
+
>
|
|
672
|
+
<PanelIcon />
|
|
673
|
+
</button>
|
|
674
|
+
)}
|
|
675
|
+
{headerNav && navigation}
|
|
363
676
|
<div data-terp="appshell-header-group">
|
|
364
677
|
{headerActions}
|
|
365
678
|
<ThemeToggle variant="inline" />
|
|
366
679
|
<LanguageSwitcher variant="inline" />
|
|
680
|
+
{headerNav && footerSlot}
|
|
367
681
|
</div>
|
|
368
682
|
</header>
|
|
369
|
-
|
|
683
|
+
{/* tabIndex -1 so the skip link actually MOVES focus. Following a fragment link sets
|
|
684
|
+
the sequential-navigation starting point, but a non-focusable target leaves
|
|
685
|
+
document.activeElement on <body> — so the link would jump the viewport and leave the
|
|
686
|
+
next Tab going back into the chrome it exists to skip. -1 keeps it out of the tab
|
|
687
|
+
order while making it programmatically focusable, which is the whole trick. */}
|
|
688
|
+
<main id={mainId} data-terp="appshell-main" tabIndex={-1}>
|
|
689
|
+
{children}
|
|
690
|
+
</main>
|
|
370
691
|
<footer data-terp="appshell-footer">{footer ?? <small>{resolvedTitle}</small>}</footer>
|
|
371
692
|
</div>
|
|
372
693
|
</div>
|
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
|
});
|