@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/router.tsx CHANGED
@@ -7,13 +7,14 @@ import {
7
7
  useNavigate,
8
8
  useParams,
9
9
  useRouter,
10
+ useRouterState,
10
11
  useSearch,
11
12
  type AnyRoute,
12
13
  type RouterHistory,
13
14
  } from "@tanstack/react-router";
14
15
  import type { ComponentType, ReactNode } from "react";
15
- import { useEffect, useRef, useState } from "react";
16
- import type { ModuleManifest } from "@terpjs/contract";
16
+ import { useCallback, useEffect, useRef, useState } from "react";
17
+ import type { ModuleManifest, NavGroup } from "@terpjs/contract";
17
18
 
18
19
  import { AppShell } from "./AppShell";
19
20
  import { ProfileView } from "./ProfileView";
@@ -25,8 +26,14 @@ import type {
25
26
  TerpRouteSearch,
26
27
  } from "./routeTypes";
27
28
  import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
28
- import { visibleNav } from "./nav";
29
+ import {
30
+ BRAND_FIELDS,
31
+ resolveLayoutDeclaration,
32
+ type LayoutDeclaration,
33
+ } from "./layoutDeclaration";
34
+ import { isDeclarationVisible, visibleNav } from "./nav";
29
35
  import { NavLinkContext } from "./navLink";
36
+ import type { NavLinkRenderer } from "./navLink";
30
37
  import { PageMarkerContext } from "./pageMarker";
31
38
  import {
32
39
  RouteSearchContext,
@@ -234,8 +241,51 @@ export interface BuildAppRouterOptions {
234
241
  title: string;
235
242
  /** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
236
243
  logo?: ReactNode;
244
+ /**
245
+ * The dark-theme brand mark ({@link AppShell.logoDark}); the stylesheet picks per appearance.
246
+ *
247
+ * Forwarded because it was not, which made it the third slot to exist on the shell and be
248
+ * unreachable from the entry points every app uses — after `headerActions`, which this ADR's
249
+ * own Context complains about. This one was worse than unreachable: the project template
250
+ * instructs every new app to pass `logoDark` to `renderTerpApp`, so the documented example
251
+ * did not typecheck.
252
+ */
253
+ logoDark?: ReactNode;
254
+ /** Extra header content, rendered before the theme / language controls. */
255
+ headerActions?: ReactNode;
237
256
  /** Footer line under the content; default: a muted line with the app title. */
238
257
  footer?: ReactNode;
258
+ /**
259
+ * Cap routed content at the published measure, with each page's header on the full track
260
+ * ({@link AppShell.contentWidth}); default `"full"`, which changes nothing.
261
+ */
262
+ contentWidth?: "full" | "measured";
263
+ /**
264
+ * App-wide density ({@link AppShell.density}). **No default** — omitting it stamps nothing, so
265
+ * an app's own `data-density` on `<html>` still reaches the tree.
266
+ */
267
+ density?: "comfortable" | "compact";
268
+ /**
269
+ * Where the primary navigation lives on desktop ({@link AppShell.navPlacement}): the
270
+ * full-height `"sidebar"` (default, and what every shell renders today) or `"header"`, a
271
+ * horizontal row in the header with no sidebar at all. Below the mobile breakpoint both are
272
+ * the drawer.
273
+ */
274
+ navPlacement?: "sidebar" | "header";
275
+ /**
276
+ * The app's navigation groups ({@link AppShell.navGroups}), which manifest items reference by
277
+ * `NavItem.group`. Omit for the flat, unlabelled sidebar every app renders today.
278
+ *
279
+ * Prefer {@link BuildAppRouterOptions.layout}: `shell.navGroups` in the app's own
280
+ * `frontend/layout-contract.json` says the same thing in the one document a tool can read and
281
+ * rewrite. Declaring the groups in both places is refused rather than silently resolved.
282
+ *
283
+ * A duplicate id is refused here rather than tolerated: it is an authoring error with no
284
+ * legitimate transient form, and composition time is where it can be reported once instead of
285
+ * on every render. An item naming an *undeclared* group is the opposite case and is not an
286
+ * error at all — modules ship independently of the app, so `groupNav` lets it fall open.
287
+ */
288
+ navGroups?: readonly NavGroup[];
239
289
  /** Role-name -> minimum rank; an unknown role is denied (fail closed). */
240
290
  roleRanks?: Record<string, number>;
241
291
  /** Rendered when the current user may not access a route (default: a simple message). */
@@ -244,10 +294,32 @@ export interface BuildAppRouterOptions {
244
294
  * Opt into a slot-typed layout contract (ADR 0079), e.g. `"standard"`: every routed
245
295
  * archetype's body slot then accepts only the components the contract allows there,
246
296
  * verified at runtime (fail closed) with the same directive message the
247
- * `terp/layout-contract` lint rule phrases. Keep it in sync with the app's checked-in
248
- * `layout-contract.json` (the lint half). Omit for today's archetype-only behavior.
297
+ * `terp/layout-contract` lint rule phrases.
298
+ *
299
+ * Prefer {@link BuildAppRouterOptions.layout}. This option used to carry the instruction
300
+ * "keep it in sync with the app's checked-in `layout-contract.json`", which is a defect
301
+ * written as advice and, since this function began reading that file, worse than stale:
302
+ * doing what the sentence said is now refused as one fact declared twice, even when the two
303
+ * agree. Omit both for today's archetype-only behavior.
249
304
  */
250
305
  layoutContract?: string;
306
+ /**
307
+ * The app's checked-in layout declaration (`frontend/layout-contract.json`, imported).
308
+ *
309
+ * The file the `terp/layout-contract` lint rule already reads, now read by the runtime half
310
+ * too, so `contract` is declared once instead of once per half. It also carries the palette
311
+ * the app opens on and the shell's own shape — `density`, `navPlacement`, `contentWidth` and
312
+ * the `navGroups` a module's `NavItem.group` names by id — all of which shipped as options
313
+ * here and were therefore out of reach of anything that edits files rather than code.
314
+ *
315
+ * The authoritative list is `TOP_LEVEL_KEYS` and `SHELL_KEYS` in
316
+ * {@link ./layoutDeclaration}, and the published `layout.manifest.json` beside them. This
317
+ * sentence is a restatement and has already drifted once.
318
+ *
319
+ * Declaring a key here AND passing the matching option is refused — see
320
+ * {@link resolveLayoutDeclaration}.
321
+ */
322
+ layout?: LayoutDeclaration;
251
323
  /** Router history (e.g. `createMemoryHistory`); omit for the browser history. */
252
324
  history?: RouterHistory;
253
325
  }
@@ -281,13 +353,33 @@ function DefaultUnauthorized() {
281
353
  * built-in {@link ProfileView} mounts at {@link PROFILE_PATH} unless a manifest claims that
282
354
  * path. Wrap the returned router in `<TerpProvider><RouterProvider router={router}/></TerpProvider>`.
283
355
  */
356
+ /**
357
+ * A declared path as the element the shell's slot takes.
358
+ *
359
+ * `alt=""` because the mark is decorative here: the shell renders the app's title beside it,
360
+ * so a name on the image would have a screen reader announce the app twice. That is also what
361
+ * the template's own commented example does, and what an app hand-writing the option should do.
362
+ */
363
+ function brandMark(path: string | undefined) {
364
+ return path === undefined ? undefined : <img src={path} alt="" />;
365
+ }
366
+
284
367
  export function buildAppRouter(
285
368
  manifests: readonly ModuleManifest[],
286
369
  options: BuildAppRouterOptions,
287
370
  ) {
288
371
  const roleRanks = options.roleRanks ?? DEFAULT_ROLE_RANKS;
289
372
  const Unauthorized = options.unauthorized ?? DefaultUnauthorized;
290
- const layoutContract = options.layoutContract ?? null;
373
+ // The declaration and the options collapse into one set before anything reads them, so
374
+ // every consumer below sees a single answer per key and cannot pick a different precedence.
375
+ const layout = resolveLayoutDeclaration(options.layout, {
376
+ contract: options.layoutContract,
377
+ density: options.density,
378
+ navPlacement: options.navPlacement,
379
+ contentWidth: options.contentWidth,
380
+ navGroups: options.navGroups,
381
+ });
382
+ const layoutContract = layout.contract ?? null;
291
383
  if (layoutContract !== null && LAYOUT_CONTRACTS[layoutContract] === undefined) {
292
384
  throw new Error(
293
385
  `Unknown layout contract "${layoutContract}"; known contracts: ` +
@@ -295,6 +387,40 @@ export function buildAppRouter(
295
387
  ".",
296
388
  );
297
389
  }
390
+ // An authoring error with no legitimate transient form, so it is refused here rather than
391
+ // absorbed. `groupNav` itself stays total — first declaration wins — because it runs on every
392
+ // render and a render must not be able to throw; this runs once, when the app is composed.
393
+ // Deliberately NOT symmetrical with an item naming an undeclared group, which is not an error
394
+ // at all: a module ships on its own schedule, so that is the normal state of an app mid-adoption
395
+ // and it falls open.
396
+ // Over the RESOLVED list, so one refusal covers a group declared in the file and a group
397
+ // passed as an option alike — which is also why `resolveLayoutDeclaration` does not restate it.
398
+ // The brand's two slots, each declarable as a checked-in PATH or passed as a rendered
399
+ // element. Refused together for the reason every other doubly-declared key is, and refused
400
+ // HERE rather than in the resolver because a path and an element are not comparable values:
401
+ // the resolver's message names both, and there is nothing to name on the code side but the
402
+ // fact that something was passed.
403
+ const brandConflicts = BRAND_FIELDS.filter(
404
+ (slot) => layout.brand?.[slot] !== undefined && options[slot] !== undefined,
405
+ );
406
+ if (brandConflicts.length > 0) {
407
+ throw new Error(
408
+ `frontend/layout-contract.json declares shell.brand.${brandConflicts.join(" and shell.brand.")}` +
409
+ ` and the bootstrap options pass ${brandConflicts.join(" and ")}. Declare each mark in ` +
410
+ "one place: the file is what a tool can read and rewrite, so prefer it and drop the option.",
411
+ );
412
+ }
413
+
414
+ const duplicateGroups = (layout.navGroups ?? [])
415
+ .map((group) => group.id)
416
+ .filter((id, index, ids) => ids.indexOf(id) !== index);
417
+ if (duplicateGroups.length > 0) {
418
+ throw new Error(
419
+ "Terp navGroups declare duplicate id(s): " +
420
+ [...new Set(duplicateGroups)].join(", ") +
421
+ ". Each group id is referenced by NavItem.group and must be declared once.",
422
+ );
423
+ }
298
424
  const missingViews = manifests.flatMap((manifest) =>
299
425
  manifest.routes
300
426
  .filter((route) => options.views[route.view] === undefined)
@@ -317,27 +443,85 @@ export function buildAppRouter(
317
443
 
318
444
  function Shell() {
319
445
  const router = useRouter();
320
- const rank = useAuth().currentUser()?.role_rank ?? null;
321
- const nav = visibleNav(manifests, (role) => allows(roleRanks, rank, role));
446
+ const user = useAuth().currentUser();
447
+ const rank = user?.role_rank ?? null;
448
+ const nav = visibleNav(manifests, {
449
+ canSeeRole: (role) => allows(roleRanks, rank, role),
450
+ permissions: user?.permissions ?? [],
451
+ });
452
+ // The shell decides which nav item is current and needs the path to do it. Selected, so the
453
+ // subscription re-renders only when the pathname itself changes — the same mechanism
454
+ // ModuleNav already uses.
455
+ const pathname = useRouterState({ select: (state) => state.location.pathname });
456
+ // Memoised, and this is a fix for a regression the line above introduces rather than
457
+ // tidying. `Shell` used to re-render only when `useAuth()` changed; it now re-renders on
458
+ // every navigation. `Outlet` is memoised with no props, so a re-render alone bails out at
459
+ // that boundary and the routed subtree is untouched — but a CONTEXT VALUE punches straight
460
+ // through a memo bailout, and this value is used as a component (Breadcrumbs and HubCard
461
+ // render it through useNavLink), so an unstable identity is worse than a re-render: it
462
+ // remounts every in-app link in the tree on each navigation.
463
+ const renderNavLink = useCallback<NavLinkRenderer>(
464
+ // `activeOptions={{ exact: true }}` for the same reason the shell's `renderLink`
465
+ // below carries it, and it matters MORE here: this renderer is what `Breadcrumbs`
466
+ // and `HubCard` use, so its job is rendering ANCESTORS. Under the router's default
467
+ // prefix matching every ancestor link matched the URL and was marked active, which
468
+ // on any detail route emitted a second `aria-current="page"` — one on the crumb for
469
+ // `/definitions` and one on the current crumb — plus a stray `.active` class and
470
+ // `data-status="active"` that made an ancestor look like the current page. The fix
471
+ // was applied to the nav and missed on the component whose whole purpose is the
472
+ // trail; exact matching means a crumb is only ever current when it IS the URL, and
473
+ // the current crumb is a span rather than a link.
474
+ ({ to, children, attributes }) => (
475
+ <Link to={to} activeOptions={{ exact: true }} {...attributes}>
476
+ {children}
477
+ </Link>
478
+ ),
479
+ [],
480
+ );
322
481
  return (
323
482
  // Publish the router's Link so every layout component that renders an in-app link
324
483
  // (Breadcrumbs, HubCard) navigates client-side by default. Forgetting `renderLink`
325
484
  // used to degrade the app silently: a raw anchor, a full page reload, no error.
326
- <NavLinkContext.Provider value={({ to, children }) => <Link to={to}>{children}</Link>}>
485
+ <NavLinkContext.Provider value={renderNavLink}>
327
486
  <AppShell
328
487
  title={options.title}
329
- logo={options.logo}
488
+ logo={brandMark(layout.brand?.logo) ?? options.logo}
489
+ logoDark={brandMark(layout.brand?.logoDark) ?? options.logoDark}
490
+ headerActions={options.headerActions}
330
491
  footer={options.footer}
492
+ contentWidth={layout.contentWidth}
493
+ density={layout.density}
494
+ navPlacement={layout.navPlacement}
495
+ activePath={pathname}
331
496
  nav={nav}
497
+ navGroups={layout.navGroups}
332
498
  renderBrandLink={({ to, children }) => (
333
499
  <Link to={to} data-terp="appshell-brand">
334
500
  {children}
335
501
  </Link>
336
502
  )}
337
- // No style objects and no activeProps: the shell's stylesheet owns the link
338
- // geometry and keys the active route on aria-current="page", which Link sets.
339
- renderLink={(item, children) => (
340
- <Link to={item.to} activeOptions={{ exact: item.to === "/" }}>
503
+ // No style objects and no activeProps: the shell's stylesheet owns the link geometry
504
+ // and keys the active route on aria-current="page".
505
+ //
506
+ // The shell supplies that attribute now, and `exact: true` is what makes the router
507
+ // agree instead of arguing. Two facts combine. `aria-current` is not among the props
508
+ // useLinkProps destructures, so a value passed here survives into the rendered anchor;
509
+ // and the router's own active props are spread LAST, but only when it considers the link
510
+ // active. With exact matching, "the router considers it active" implies the link's path
511
+ // equals the URL — which is the longest possible match, so it is always the same item
512
+ // the shell picked. The router can therefore only ever agree, never add a second
513
+ // current item.
514
+ //
515
+ // Prefix matching is what broke that: it marked every ancestor active, so `/settings`
516
+ // and `/settings/users` were both current at `/settings/users`. The old
517
+ // `exact: item.to === "/"` was a workaround for the same thing at the root, and it is
518
+ // gone because the shell's predicate matches on segments and `/` claims nothing else.
519
+ renderLink={(item, children, { active }) => (
520
+ <Link
521
+ to={item.to}
522
+ activeOptions={{ exact: true }}
523
+ aria-current={active ? "page" : undefined}
524
+ >
341
525
  {children}
342
526
  </Link>
343
527
  )}
@@ -359,12 +543,21 @@ export function buildAppRouter(
359
543
  function guardedRoute(
360
544
  path: string,
361
545
  View: ComponentType,
362
- role: string | undefined,
546
+ declaration: { role?: string; permission?: string },
363
547
  viewName: string,
364
548
  ): AnyRoute {
365
549
  function RouteComponent() {
366
- const rank = useAuth().currentUser()?.role_rank ?? null;
367
- const allowed = allows(roleRanks, rank, role);
550
+ const user = useAuth().currentUser();
551
+ const rank = user?.role_rank ?? null;
552
+ // The same resolution the sidebar uses, and using it here is what stops `permission` from
553
+ // becoming a cosmetic gate: hiding a link while leaving its route reachable by URL is not
554
+ // a weaker version of authorization, it is the appearance of it. `role` has never had that
555
+ // asymmetry — it is declared on both NavItem and ModuleRoute — so `permission` does not
556
+ // get to introduce one.
557
+ const allowed = isDeclarationVisible(declaration, {
558
+ canSeeRole: (role) => allows(roleRanks, rank, role),
559
+ permissions: user?.permissions ?? [],
560
+ });
368
561
  // The runtime half of the "every routed view is a page archetype" control: Page
369
562
  // (composed by OverviewPage / DetailPage / HubPage) marks the render; a routed view
370
563
  // that mounted without any archetype in its tree is refused, fail closed. The check
@@ -416,14 +609,14 @@ export function buildAppRouter(
416
609
 
417
610
  const childRoutes: AnyRoute[] = manifests.flatMap((manifest) =>
418
611
  manifest.routes.map((route) =>
419
- guardedRoute(route.path, options.views[route.view]!, route.role, route.view),
612
+ guardedRoute(route.path, options.views[route.view]!, route, route.view),
420
613
  ),
421
614
  );
422
615
  const profileClaimed = manifests.some((manifest) =>
423
616
  manifest.routes.some((route) => route.path === PROFILE_PATH),
424
617
  );
425
618
  if (!profileClaimed) {
426
- childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, undefined, "profile"));
619
+ childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, {}, "profile"));
427
620
  }
428
621
 
429
622
  const routeTree = rootRoute.addChildren(childRoutes);