@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/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,75 @@ 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
+ ({ to, children, attributes }) => (
465
+ <Link to={to} {...attributes}>
466
+ {children}
467
+ </Link>
468
+ ),
469
+ [],
470
+ );
322
471
  return (
323
472
  // Publish the router's Link so every layout component that renders an in-app link
324
473
  // (Breadcrumbs, HubCard) navigates client-side by default. Forgetting `renderLink`
325
474
  // 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>}>
475
+ <NavLinkContext.Provider value={renderNavLink}>
327
476
  <AppShell
328
477
  title={options.title}
329
- logo={options.logo}
478
+ logo={brandMark(layout.brand?.logo) ?? options.logo}
479
+ logoDark={brandMark(layout.brand?.logoDark) ?? options.logoDark}
480
+ headerActions={options.headerActions}
330
481
  footer={options.footer}
482
+ contentWidth={layout.contentWidth}
483
+ density={layout.density}
484
+ navPlacement={layout.navPlacement}
485
+ activePath={pathname}
331
486
  nav={nav}
487
+ navGroups={layout.navGroups}
332
488
  renderBrandLink={({ to, children }) => (
333
489
  <Link to={to} data-terp="appshell-brand">
334
490
  {children}
335
491
  </Link>
336
492
  )}
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 === "/" }}>
493
+ // No style objects and no activeProps: the shell's stylesheet owns the link geometry
494
+ // and keys the active route on aria-current="page".
495
+ //
496
+ // The shell supplies that attribute now, and `exact: true` is what makes the router
497
+ // agree instead of arguing. Two facts combine. `aria-current` is not among the props
498
+ // useLinkProps destructures, so a value passed here survives into the rendered anchor;
499
+ // and the router's own active props are spread LAST, but only when it considers the link
500
+ // active. With exact matching, "the router considers it active" implies the link's path
501
+ // equals the URL — which is the longest possible match, so it is always the same item
502
+ // the shell picked. The router can therefore only ever agree, never add a second
503
+ // current item.
504
+ //
505
+ // Prefix matching is what broke that: it marked every ancestor active, so `/settings`
506
+ // and `/settings/users` were both current at `/settings/users`. The old
507
+ // `exact: item.to === "/"` was a workaround for the same thing at the root, and it is
508
+ // gone because the shell's predicate matches on segments and `/` claims nothing else.
509
+ renderLink={(item, children, { active }) => (
510
+ <Link
511
+ to={item.to}
512
+ activeOptions={{ exact: true }}
513
+ aria-current={active ? "page" : undefined}
514
+ >
341
515
  {children}
342
516
  </Link>
343
517
  )}
@@ -359,12 +533,21 @@ export function buildAppRouter(
359
533
  function guardedRoute(
360
534
  path: string,
361
535
  View: ComponentType,
362
- role: string | undefined,
536
+ declaration: { role?: string; permission?: string },
363
537
  viewName: string,
364
538
  ): AnyRoute {
365
539
  function RouteComponent() {
366
- const rank = useAuth().currentUser()?.role_rank ?? null;
367
- const allowed = allows(roleRanks, rank, role);
540
+ const user = useAuth().currentUser();
541
+ const rank = user?.role_rank ?? null;
542
+ // The same resolution the sidebar uses, and using it here is what stops `permission` from
543
+ // becoming a cosmetic gate: hiding a link while leaving its route reachable by URL is not
544
+ // a weaker version of authorization, it is the appearance of it. `role` has never had that
545
+ // asymmetry — it is declared on both NavItem and ModuleRoute — so `permission` does not
546
+ // get to introduce one.
547
+ const allowed = isDeclarationVisible(declaration, {
548
+ canSeeRole: (role) => allows(roleRanks, rank, role),
549
+ permissions: user?.permissions ?? [],
550
+ });
368
551
  // The runtime half of the "every routed view is a page archetype" control: Page
369
552
  // (composed by OverviewPage / DetailPage / HubPage) marks the render; a routed view
370
553
  // that mounted without any archetype in its tree is refused, fail closed. The check
@@ -416,14 +599,14 @@ export function buildAppRouter(
416
599
 
417
600
  const childRoutes: AnyRoute[] = manifests.flatMap((manifest) =>
418
601
  manifest.routes.map((route) =>
419
- guardedRoute(route.path, options.views[route.view]!, route.role, route.view),
602
+ guardedRoute(route.path, options.views[route.view]!, route, route.view),
420
603
  ),
421
604
  );
422
605
  const profileClaimed = manifests.some((manifest) =>
423
606
  manifest.routes.some((route) => route.path === PROFILE_PATH),
424
607
  );
425
608
  if (!profileClaimed) {
426
- childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, undefined, "profile"));
609
+ childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, {}, "profile"));
427
610
  }
428
611
 
429
612
  const routeTree = rootRoute.addChildren(childRoutes);