@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.
Files changed (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. 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 type AppShellLinkContext = AppShellSlotContext;
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
- export interface AppShellProps {
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
- /** Brand mark at the top of the sidebar; default: the {@link TerpMark} placeholder. */
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
- /** Footer line under the content; default: a muted line with the app title. */
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
- * Start with the desktop sidebar collapsed to its icon rail, when no choice has been
57
- * persisted yet. The user's own toggle still wins and still persists.
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
- * It exists for the same reason `Menu` and both date pickers take `defaultOpen`: the
60
- * rail is internal state read from `localStorage`, so without a way in it can be
61
- * rendered by no specimen and no test, and every rule that only applies to it is
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
- defaultCollapsed?: boolean;
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
- /** Below this width the sidebar becomes an overlay drawer (matches DataView's card cutover). */
73
- const MOBILE_BREAKPOINT = "(max-width: 768px)";
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(MOBILE_BREAKPOINT).matches,
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(MOBILE_BREAKPOINT);
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 a slim `footer` underneath.
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(false);
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
- // The drawer always shows labels; the desktop rail hides them when collapsed.
228
- const railCollapsed = !isMobile && collapsed;
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
- {logo ?? <TerpMark />}
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
- <nav
295
- data-terp="appshell-nav"
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 data-terp="appshell" data-variant={shellVariant}>
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
- <button
346
- ref={toggleRef}
347
- type="button"
348
- data-terp="iconbutton"
349
- aria-expanded={isMobile ? drawerOpen : !collapsed}
350
- aria-label={
351
- isMobile
352
- ? drawerOpen
353
- ? strings.closeNavigation
354
- : strings.openNavigation
355
- : collapsed
356
- ? strings.expandSidebar
357
- : strings.collapseSidebar
358
- }
359
- onClick={toggleSidebar}
360
- >
361
- <PanelIcon />
362
- </button>
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
- <main data-terp="appshell-main">{children}</main>
370
- <footer data-terp="appshell-footer">{footer ?? <small>{resolvedTitle}</small>}</footer>
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
+ });