@terpjs/react-core 0.9.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.
Files changed (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. 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,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
- /** 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
128
  /** Footer line under the content; default: a muted line with the app title. */
54
129
  footer?: ReactNode;
55
130
  /**
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.
131
+ * The current URL path, so the shell can decide which nav item is current.
58
132
  *
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.
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
- defaultCollapsed?: boolean;
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
- /** Below this width the sidebar becomes an overlay drawer (matches DataView's card cutover). */
73
- const MOBILE_BREAKPOINT = "(max-width: 768px)";
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(MOBILE_BREAKPOINT).matches,
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(MOBILE_BREAKPOINT);
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(false);
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
- // The drawer always shows labels; the desktop rail hides them when collapsed.
228
- const railCollapsed = !isMobile && collapsed;
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
- {logo ?? <TerpMark />}
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
- <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}
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 data-terp="appshell" data-variant={shellVariant}>
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
- <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>
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
- <main data-terp="appshell-main">{children}</main>
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>
@@ -22,6 +22,36 @@ describe("Field", () => {
22
22
  expect(screen.getByText("required")).toBeInTheDocument();
23
23
  });
24
24
 
25
+ it("exposes the error as an alert, and nothing else in the field", () => {
26
+ // `aria-describedby` is read when focus reaches the control. That covers an error which was
27
+ // already there and covers nothing about one that arrives on submit, when focus has left the
28
+ // field and the only thing that changed is a span nobody is pointed at. The two channels fire
29
+ // at different moments, and a submit-time rejection only has the second one.
30
+ //
31
+ // The length assertion is the half with teeth: `role="alert"` on the hint as well would
32
+ // satisfy a bare `getByRole` while training the user to ignore the channel the error needs.
33
+ // Mutation: drop `role="alert"` from the span and the lookup finds nothing.
34
+ render(
35
+ <Field label="Email" hint="we never share it" error="required">
36
+ <Input defaultValue="" />
37
+ </Field>,
38
+ );
39
+ expect(screen.getAllByRole("alert")).toHaveLength(1);
40
+ expect(screen.getByRole("alert")).toHaveTextContent("required");
41
+ });
42
+
43
+ it("raises no alert when there is nothing wrong", () => {
44
+ // An alert that is present on every render is an alert that means nothing. The span is
45
+ // conditional, so it enters the accessibility tree exactly when the error appears, which is
46
+ // the event the role exists to report.
47
+ render(
48
+ <Field label="Email" hint="we never share it">
49
+ <Input defaultValue="" />
50
+ </Field>,
51
+ );
52
+ expect(screen.queryByRole("alert")).toBeNull();
53
+ });
54
+
25
55
  it("renders no error node when error is null", () => {
26
56
  render(
27
57
  <Field label="Name" error={null}>