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