@terpjs/react-core 0.1.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 (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # @terpjs/react-core
2
+
3
+ Stack A (React) of the Terp frontend contract: the provider/hooks that wire a tree to
4
+ `@terpjs/contract`, the auth session, the app shell + TanStack Router adapter, and the
5
+ **token-styled component surface** every app module composes its screens from.
6
+
7
+ This README is the catalog of that surface. Everything listed here is exported from
8
+ the package root (`import { … } from "@terpjs/react-core"`); each export also carries
9
+ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
10
+ `src/` or `dist/` — the boundary lint (`@terpjs/eslint-boundaries`) refuses it.
11
+
12
+ ## Conventions (the lint enforces these)
13
+
14
+ - **Token-styled primitives only** — raw `<button>` / `<input>` / `<select>` /
15
+ `<textarea>` are refused; use `Button` / `Input` / `Select` / `Textarea` plus the
16
+ higher-level form primitives (`Checkbox`, `RadioGroup`, `Switch`).
17
+ - **Generated client + sanctioned realtime only** — raw `fetch` / `XMLHttpRequest` /
18
+ `WebSocket` / `EventSource` / `navigator.sendBeacon` are refused; use
19
+ `useTerpClient()` (typed from the backend OpenAPI) and `unwrap` for request/response,
20
+ or `useRealtimeChannel()` for typed SSE/WebSocket subscriptions. The hook mints a
21
+ one-use connection ticket through the generated client; bearer tokens never enter URLs.
22
+ - **Design tokens, not inline colours** — style with the CSS variables from
23
+ `@terpjs/contract` (`var(--color-*)`, `var(--space-*)`, `var(--font-*)`).
24
+ - **User-facing text is `UiText`** — every text prop accepts a plain string or an
25
+ `{id, message}` descriptor, so apps can localize via `UiTextProvider` without
26
+ react-core taking an i18n dependency.
27
+ - **Dependency-free UI** — react-core ships no icon/toast/i18n libraries. Glyphs are
28
+ inline SVG; transient feedback goes through `ToastProvider` / `useToast`.
29
+ - **Security defaults** — `dangerouslySetInnerHTML` and the DOM HTML-injection sinks
30
+ (`innerHTML` / `outerHTML` / `insertAdjacentHTML` / `document.write`) are refused
31
+ (render text, or use `Markdown` for rich content); `eval()` / `new Function()` are
32
+ refused; `javascript:` URLs in `href`/`src` are refused; a static `target="_blank"`
33
+ link needs `rel="noopener"`.
34
+
35
+ ## Bootstrap & providers
36
+
37
+ | Export | Use |
38
+ |---|---|
39
+ | `renderTerpApp`, `collectModules`, `withAdminArea` | One-call app bootstrap: glob-import `modules/*/module.tsx`, merge the packaged admin area (opt out with `adminArea: false`, or select sections with `adminArea: { users, groups, audit }`; an app route claiming an admin path overrides that screen), build the router, mount provider + auth gate + shell. Options include `logo` (sidebar brand) and `footer`. |
40
+ | `TerpProvider`, `useAuth`, `useTerpClient` | The context root: session state + the typed API client. Drop to this + `buildAppRouter` when you need full control. |
41
+ | `buildAppRouter`, `DEFAULT_ROLE_RANKS`, `PROFILE_PATH` | TanStack Router adapter: realises stack-agnostic module manifests (routes + nav + roles) into a real router; throws at build time on a route referencing a missing view. Mounts the built-in `ProfileView` at `/profile` unless an app manifest claims that path. |
42
+ | `createAuthClient` | The auth/session contract implementation (login / refresh / currentUser) over the generated client. |
43
+ | `LoginView` | The standard sign-in screen: username/password, plus optional SSO provider buttons via `ssoProviders` and a dev-only credential-fill button via `devCredentials` (gate it on `import.meta.env.DEV`). |
44
+ | `useSso`, `parseSsoCallback`, `fetchSsoAuthorizationUrl`, `completeSsoCallback` | The SSO login seam (ADR 0058): `useSso().begin(provider)` opens an OIDC flow; `TerpProvider` completes the `/auth/callback/{provider}` redirect landing into a normal session on boot. `renderTerpApp({ ssoProviders })` wires the buttons in one line. |
45
+ | `RequireAuth` | Renders children only with a session; pairs with the router so the app mounts only when signed in. |
46
+ | `ThemeProvider`, `ThemeToggle`, `useTheme` | Light/dark/system theming: applies `data-theme` on `<html>` (the token stylesheet carries both palettes) and persists the choice. `renderTerpApp` mounts it for every app; the shell header uses an icon-only, token-themed `variant="inline"` menu. |
47
+ | `LocaleProvider`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`: per-locale string catalogs, a persisted active locale, and an icon-only, token-themed menu in the shell header once an app declares a second locale. English and Dutch catalogs ship complete; `renderTerpApp({ locales })` wires them. |
48
+ | `UserMenu`, `userInitials` | The signed-in user's menu, pinned by `buildAppRouter` to the bottom of the sidebar: an initials avatar trigger opening the identity block, **Settings** (the built-in profile page) and sign-out. Collapses to the avatar in the icon rail. |
49
+ | `ProfileView` | The built-in profile / settings page (`/profile`): the server-validated identity, theme + language preferences, and sign-out. |
50
+
51
+ ## Authorization gates (UI-side; the backend re-checks)
52
+
53
+ | Export | Use |
54
+ |---|---|
55
+ | `Authorized`, `useCan` | Gate UI on `can(module, action)` — write buttons, admin panels. |
56
+ | `canPerform`, `DEFAULT_RANK_THRESHOLDS` | The role-rank predicate behind the gate. |
57
+ | `visibleNav` | Filter nav items to what the current user may see. |
58
+
59
+ ## Page archetypes (the three-level screen pattern)
60
+
61
+ Every routed view **must** render one of the archetypes (`Page`, or `OverviewPage` /
62
+ `DetailPage` / `HubPage`, which compose it) — `buildAppRouter` refuses an unframed view at
63
+ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/error frame.
64
+
65
+ | Export | Use |
66
+ |---|---|
67
+ | `AppShell` | The responsive level-1 frame: a home-linked brand, icon/label nav and account footer. Desktop collapses to a persisted, scrollbar-free rail with one fixed icon slot; mobile becomes a scroll-locking drawer. The sticky header holds the sidebar toggle and icon-only preferences. Router-agnostic link renderers receive framework-owned expanded/collapsed geometry. |
68
+ | `NavIcon`, `Icon`, `TerpMark`, `ICON_GLYPHS` | The dependency-free icon layer: manifest `NavItem.icon` names resolve to bundled inline-SVG glyphs (label-initial fallback), `Icon` renders any glyph by name (`<Icon name="plus" size="1em" />`) — the bundled catalogue covers common UI, action, object, and status glyphs (home, list, folder, users, plus, edit, trash, search, check, x, chevron-{left,right,down}, arrow-left, external, logout, user, bell, key, globe, lock, tag, mail, refresh, filter, download, upload, star, heart, database, code, truck, cart, wallet, map-pin, clock, link, grid, book, briefcase, building, clipboard, layers, send, phone, image, video, music, wrench, zap, …) — and `TerpMark` is the placeholder brand mark until an app passes its own `logo`. |
69
+ | `Page` | The base routed screen: optional breadcrumb row, then one compact `h1` + intrinsic-width actions row (title-first on narrow layouts), then the body with loading/error slots. |
70
+ | `HubPage`, `HubCard` | Responsive `auto-fit` landing grid. Cards share equal outer and internal tracks even when descriptions/stats differ; nested hubs use the ordinary breadcrumb contract via `parents`. |
71
+ | `OverviewPage` | A module's top-level listing screen (level 2); detail pages crumb back to it. |
72
+ | `DetailPage` | One record's screen (level 3); breadcrumb trail = ancestors + record title. |
73
+ | `Breadcrumbs` | The trail itself (used by the archetypes; rarely composed directly). |
74
+ | `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
75
+ | `PageActions` | Primary action + overflow menu for a page header. |
76
+
77
+ ### Slot-typed layout contracts (opt-in, ADR 0079)
78
+
79
+ An app can ratchet the archetype control further with a named **layout contract**:
80
+ `renderTerpApp({ layoutContract: "standard" })` (runtime half) plus a checked-in
81
+ `layout-contract.json` next to the frontend sources (`{ "contract": "standard" }`, the
82
+ `terp/layout-contract` lint half — keep the two in sync; the project template generates
83
+ both). Each governed archetype's body slot then accepts **only** the contract's
84
+ components — `standard`: hub bodies hold `HubCard` only; overview bodies hold
85
+ `DataView` / `ResourceList` / `ModuleNav` / `Stack` plus the framework states
86
+ (`EmptyState` / `ErrorState` / `LoadingState` / `Alert`) and `ConfirmDialog`; detail
87
+ bodies hold `DetailList` / `Stack` / `Tabs` / `ModuleNav` / `DataView` plus the same
88
+ states. The plain `Page` stays unconstrained (the sanctioned home for a bespoke
89
+ screen). Enforcement is two-layer and fail-closed: the lint rule checks static JSX
90
+ children; the archetypes verify the rendered DOM (sanctioned components stamp a
91
+ `data-terp` marker) and refuse a non-conforming view with the **same directive
92
+ message** — contract, slot, what was found, what is allowed, and the fix — so a
93
+ failing check tells the author (human or agent) exactly how to build the screen.
94
+ `LAYOUT_CONTRACTS` exports the table; no config means no checks (fully backwards
95
+ compatible). The one opt-out is a justified `// terp-allow-layout-contract: <reason>`
96
+ marker, counted by the escape-hatch budget.
97
+
98
+ ## Data
99
+
100
+ | Export | Use |
101
+ |---|---|
102
+ | `DataView` + family | **The single sanctioned surface for data collections**: repository-driven table/card view with search, sorting, pagination, column management, selection + batch actions, row actions, expandable rows, persisted view preferences, and pointer/keyboard row activation for overview-to-detail navigation. See [`src/dataview/README.md`](src/dataview/README.md) for the full guide (client-side and server-side recipes). |
103
+ | `InMemoryDataViewRepository`, `HttpDataViewRepository` | Data repositories (client-side / server-side); `useServerDataView` keeps server query state in the URL. |
104
+ | `InMemoryViewStateRepository`, `LocalStorageViewStateRepository` | Preference persistence seam. |
105
+ | `useResource` | An async collection: rows + loading/error + reload + create-then-reload. |
106
+ | `useRealtimeChannel` | The sanctioned typed SSE/WebSocket seam for the optional realtime capability: mints a short-lived one-use ticket via the authenticated generated client, validates every inbound JSON payload with the channel's runtime type guard, and exposes connection state / last message / WebSocket send. App modules never touch raw transports. |
107
+ | `ResourceList` | The standard simple CRUD list screen: titled section, write-gated create form, loading/error/empty states. Composable — screens needing more render their own React. |
108
+ | `unwrap`, `ApiError` | Turn a generated-client result into data-or-throw; `ApiError` carries the envelope's `code` / `status` / `requestId`. |
109
+ | `FileUpload`, `useFileDownload` | The files-capability surface (ADR 0056/0057): a token-styled attachment picker that uploads through the typed client, and an authenticated download helper (a raw `<a href>` would carry no bearer token). |
110
+
111
+ ## Feedback & states
112
+
113
+ | Export | Use |
114
+ |---|---|
115
+ | `LoadingState`, `InlineSpinner` | Full loading block (announces itself) / compact inline glyph. |
116
+ | `EmptyState` | The standard "nothing here yet" block, with an `action` slot for the next step. |
117
+ | `ErrorState`, `describeError` | Human-readable failure block for a caught error. |
118
+ | `ErrorMessagesProvider`, `useErrorMessage`, `DEFAULT_ERROR_MESSAGES` | Map stable backend error codes to copy; falls back to the envelope `detail`. |
119
+ | `ToastProvider`, `useToast` | Transient success/error feedback (no toast library). |
120
+ | `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. |
121
+
122
+ ## Forms & primitives
123
+
124
+ | Export | Use |
125
+ |---|---|
126
+ | `Button` | Token-styled, content-sized command (`variant`: primary / secondary / danger / ghost; optional leading `icon`). Explicit `width: 100%` opts into full width. |
127
+ | `Input`, `Select`, `Textarea` | Token-styled controls with stable framework typography, independent of surrounding display text (raw elements are lint-refused). Numeric inputs suppress unthemeable browser steppers. |
128
+ | `Combobox` | Accessible autocomplete/typeahead single-select: filterable options, controlled or uncontrolled value, loading state, disabled state, and ARIA combobox/listbox keyboard navigation. |
129
+ | `DatePicker`, `DateRangePicker` | Locale-aware calendar popover controls with keyboard-navigable month grids, min/max bounds, and range selection for ERP date filters. |
130
+ | `Checkbox` | Labelled checkbox with `checked` / `defaultChecked` and boolean `onChange`. |
131
+ | `Radio`, `RadioGroup` | Labelled radio and accessible grouped radio options with controlled or uncontrolled value. |
132
+ | `Switch` | Labelled boolean toggle (`role="switch"`) with `checked` / `defaultChecked` and boolean `onChange`. |
133
+ | `Tabs` | In-page (non-routed) tab set with `tablist` / `tab` / `tabpanel` roles, arrow-key navigation, and controlled or uncontrolled value. |
134
+ | `Badge` | Small status pill (`tone`: neutral / info / success / warning / danger). |
135
+ | `Tooltip` | Accessible focus/hover tooltip that describes its trigger with `aria-describedby`. |
136
+ | `Popover`, `Menu`, `MenuItem` | Shared anchored overlay and dropdown-menu primitives: body-portaled, viewport-aware panels that escape scroll/table clipping, with outside-click/Escape close, focus return, selected-item semantics, and roving keyboard navigation. |
137
+ | `Alert` | Inline banner for persistent feedback (`tone`: neutral / info / success / warning / danger); warnings and danger announce as `alert`, others as `status`. |
138
+ | `Markdown` | Safe, dependency-free markdown renderer for headings, paragraphs, bold, italic, inline code, code blocks, lists, and safe links; raw HTML is rendered as text and never passed through. |
139
+ | `Field` | Label + control + hint/error wrapper for one form field. |
140
+
141
+ ## Layout
142
+
143
+ Modules never write `style={}` or CSS — the boundary lint refuses the `style`
144
+ attribute in `src/modules/**`. Layout comes from these primitives (gaps index the
145
+ token spacing scale, so spacing is themed centrally):
146
+
147
+ | Export | Use |
148
+ |---|---|
149
+ | `Stack` | The layout primitive: a flex container with a token gap. Vertical by default (forms, sections); `direction="row"` + `justify` for toolbars; `as="form"` etc. for semantics. |
150
+ | `Card` | A token-styled surface (border + background + padding) grouping one block of a page — the sanctioned visual separation between sections. Optional header row: `title` (semantic `<h3>`), muted `description`, `actions` slot. |
151
+ | `DetailList` | Token-styled label/value pairs as a semantic `<dl>` (record metadata, expanded-row summaries). |
152
+
153
+ ## The packaged admin area
154
+
155
+ Every Terp backend mounts the base-profile admin capabilities (users, groups +
156
+ access grants, audit); react-core ships the UI over them, so every app has a
157
+ working admin area on day one. `renderTerpApp` injects it by default: one
158
+ admin-gated **Admin** sidebar entry opens the `/admin` hub, whose cards lead to
159
+ the overviews; each overview breadcrumbs back to the hub (hub → overview →
160
+ detail, like every screen). Opt out with `adminArea: false`; ship only the
161
+ screens whose capabilities the app mounts with a sections object —
162
+ `adminArea: { groups: false }` is the users + audit profile, first-class (a
163
+ dropped section loses its routes, hub card and stat call) — or override a
164
+ single screen by claiming its path from an app module.
165
+
166
+ | Export | Use |
167
+ |---|---|
168
+ | `adminModule` | The whole area as a `TerpModule` (manifest + views) — spread it manually into an L2 `buildAppRouter` composition. |
169
+ | `AdminHub` | `/admin`: cards into users / groups / audit with live totals. |
170
+ | `UsersAdmin`, `UserCreate`, `UserDetail` | `/admin/users`: clickable account overview; `/new`: dedicated provisioning page; `/$userId`: details with header actions and confirmation-gated role, status and password changes. |
171
+ | `GroupsAdmin`, `GroupCreate`, `GroupDetail` | `/admin/groups`: clickable group overview; `/new`: dedicated creation page; `/$groupId`: details with header deletion, member management and permission grants (destructive changes use confirmation dialogs; deletion cascades memberships + grants, ADR 0074). |
172
+ | `AuditLogAdmin` | `/admin/audit`: the append-only trail, rows expanding to identifiers + payload. |
173
+
174
+ ## Localization
175
+
176
+ | Export | Use |
177
+ |---|---|
178
+ | `UiTextProvider`, `useUiText`, `useStrings`, `resolveUiText`, `DEFAULT_STRINGS` | The `UiText` seam: override built-in strings and plug in a resolver (e.g. an i18n library) at the app root. `LocaleProvider` (above) is the batteries-included layer over it: per-locale catalogs + a persisted switcher. |
179
+
180
+ ## Testing components
181
+
182
+ Component tests run under vitest with `// @vitest-environment jsdom` at the top of the
183
+ file plus an explicit `afterEach(cleanup)` (the default environment is node). Run:
184
+
185
+ ```bash
186
+ npm run -w @terpjs/react-core typecheck && npm run -w @terpjs/react-core test
187
+ ```
188
+
189
+ `vitest.setup.ts` polyfills `HTMLDialogElement.showModal/close` (jsdom lacks them), so
190
+ components may use the native `<dialog>` freely.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@terpjs/react-core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "types": "./src/index.ts",
10
+ "scripts": {
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "vitest run"
13
+ },
14
+ "dependencies": {
15
+ "@tanstack/react-router": "^1.170.16",
16
+ "@terpjs/contract": "^0.1.0"
17
+ },
18
+ "peerDependencies": {
19
+ "react": "^18.3.0 || ^19.0.0",
20
+ "react-dom": "^18.3.0 || ^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@testing-library/jest-dom": "^6.9.1",
24
+ "@testing-library/react": "^16.3.2",
25
+ "@types/react": "^19.2.17",
26
+ "@types/react-dom": "^19.2.3",
27
+ "@vitejs/plugin-react": "^6.0.3",
28
+ "jsdom": "^29.1.1",
29
+ "react": "^19.2.7",
30
+ "react-dom": "^19.2.7",
31
+ "typescript": "^5.9.3",
32
+ "vite": "^8.1.0",
33
+ "vitest": "^4.1.9"
34
+ },
35
+ "license": "Apache-2.0",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/AITT-NL/terp-framework.git",
39
+ "directory": "packages/frontend/react-core"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ }
44
+ }
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { NavItem } from "@terpjs/contract";
5
+
6
+ import { AppShell, SIDEBAR_STORAGE_KEY } from "./AppShell";
7
+
8
+ afterEach(() => {
9
+ cleanup();
10
+ vi.unstubAllGlobals();
11
+ window.localStorage.clear();
12
+ });
13
+
14
+ const nav: NavItem[] = [
15
+ { label: "Notes", to: "/notes", icon: "list" },
16
+ { label: "Users", to: "/users", role: "admin" },
17
+ ];
18
+
19
+ function renderShell(extra?: Partial<Parameters<typeof AppShell>[0]>) {
20
+ return render(
21
+ <AppShell
22
+ title="Terp"
23
+ nav={nav}
24
+ renderLink={(item, children, context) => (
25
+ <a href={item.to} style={context.style}>{children}</a>
26
+ )}
27
+ navFooter={<p>pinned footer</p>}
28
+ {...extra}
29
+ >
30
+ <p>page content</p>
31
+ </AppShell>,
32
+ );
33
+ }
34
+
35
+ /** Make the shell believe it is below the mobile breakpoint. */
36
+ function stubMobileViewport() {
37
+ vi.stubGlobal(
38
+ "matchMedia",
39
+ vi.fn().mockReturnValue({
40
+ matches: true,
41
+ addEventListener: vi.fn(),
42
+ removeEventListener: vi.fn(),
43
+ }),
44
+ );
45
+ }
46
+
47
+ describe("AppShell", () => {
48
+ it("renders the landmarks, brand, footer, and the nav via renderLink", () => {
49
+ renderShell();
50
+
51
+ expect(screen.getByRole("banner")).toBeInTheDocument();
52
+ expect(screen.getByRole("navigation", { name: "Primary" })).toBeInTheDocument();
53
+ expect(screen.getByRole("main")).toBeInTheDocument();
54
+ expect(screen.getByRole("contentinfo")).toBeInTheDocument();
55
+ // The brand is the standard home affordance; the default footer echoes the title.
56
+ expect(screen.getAllByText("Terp").length).toBeGreaterThanOrEqual(2);
57
+ expect(screen.getByRole("link", { name: "Terp" })).toHaveAttribute("href", "/");
58
+ expect(screen.getByRole("link", { name: "Notes" })).toHaveAttribute("href", "/notes");
59
+ expect(screen.getByText("page content")).toBeInTheDocument();
60
+ // The navFooter slot renders inside the sidebar (pinned chrome, e.g. UserMenu).
61
+ expect(screen.getByText("pinned footer")).toBeInTheDocument();
62
+ });
63
+
64
+ it("collapses to an icon rail and persists the choice", () => {
65
+ renderShell({ navFooter: ({ collapsed }) => <p>{collapsed ? "rail" : "full"}</p> });
66
+
67
+ expect(screen.getByText("full")).toBeInTheDocument();
68
+ expect(screen.getByText("Notes")).toBeInTheDocument();
69
+
70
+ const toggle = screen.getByRole("button", { name: "Collapse sidebar" });
71
+ fireEvent.click(toggle);
72
+
73
+ // Labels remain as accessible names; fixed icon slots and the fallback tile remain visible.
74
+ expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
75
+ expect(screen.getByText("U")).toBeInTheDocument(); // Users' fallback initial tile
76
+ const navigation = screen.getByRole("navigation", { name: "Primary" });
77
+ expect(navigation).toHaveAttribute("data-collapsed", "true");
78
+ expect(navigation.querySelectorAll('[data-terp="nav-icon"]')).toHaveLength(2);
79
+ expect(screen.getByRole("link", { name: "Notes" })).toHaveStyle({
80
+ justifyContent: "center",
81
+ width: "100%",
82
+ });
83
+ expect(screen.getByText("rail")).toBeInTheDocument();
84
+ expect(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)).toBe("collapsed");
85
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument();
86
+ });
87
+
88
+ it("restores the collapsed rail from localStorage", () => {
89
+ window.localStorage.setItem(SIDEBAR_STORAGE_KEY, "collapsed");
90
+ renderShell();
91
+ expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
92
+ expect(screen.getByRole("navigation", { name: "Primary" })).toHaveAttribute(
93
+ "data-collapsed",
94
+ "true",
95
+ );
96
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument();
97
+ });
98
+
99
+ it("renders a custom logo and footer in their slots", () => {
100
+ renderShell({ logo: <span>MyMark</span>, footer: <span>v1.2.3</span> });
101
+ expect(screen.getByText("MyMark")).toBeInTheDocument();
102
+ expect(screen.getByRole("contentinfo")).toHaveTextContent("v1.2.3");
103
+ });
104
+
105
+ it("becomes a modal drawer on mobile: contains focus, inerts the page, and closes on nav", async () => {
106
+ stubMobileViewport();
107
+ renderShell();
108
+
109
+ // Closed drawer: no nav in the tree, only the header toggle.
110
+ const toggle = screen.getByRole("button", { name: "Open navigation" });
111
+ expect(screen.queryByRole("navigation", { name: "Primary" })).not.toBeInTheDocument();
112
+
113
+ fireEvent.click(toggle);
114
+ const dialog = screen.getByRole("dialog", { name: "Primary" });
115
+ expect(screen.getByRole("navigation", { name: "Primary" })).toBeInTheDocument();
116
+ const close = screen.getByRole("button", { name: "Close navigation" });
117
+ await waitFor(() => expect(close).toHaveFocus());
118
+ expect(document.body.style.overflow).toBe("hidden");
119
+ const background = screen.getByText("page content").closest("main")?.parentElement;
120
+ expect(background).toHaveAttribute("inert");
121
+ expect(background).toHaveAttribute("aria-hidden", "true");
122
+
123
+ // The end focus sentinel wraps natural forward tabbing to the first drawer link.
124
+ const endGuard = dialog.querySelector('[data-terp="drawer-focus-end"]')!;
125
+ fireEvent.focus(endGuard);
126
+ await waitFor(() => expect(screen.getByRole("link", { name: "Terp" })).toHaveFocus());
127
+ expect(dialog).toContainElement(document.activeElement as HTMLElement);
128
+
129
+ // Choosing a destination closes the drawer.
130
+ fireEvent.click(screen.getByRole("link", { name: "Notes" }));
131
+ expect(screen.queryByRole("navigation", { name: "Primary" })).not.toBeInTheDocument();
132
+ expect(document.body.style.overflow).toBe("");
133
+ await waitFor(() => expect(toggle).toHaveFocus());
134
+ });
135
+
136
+ it("closes the mobile drawer when the product brand navigates home", () => {
137
+ stubMobileViewport();
138
+ renderShell();
139
+ fireEvent.click(screen.getByRole("button", { name: "Open navigation" }));
140
+ fireEvent.click(screen.getByRole("link", { name: "Terp" }));
141
+ expect(screen.queryByRole("dialog", { name: "Primary" })).not.toBeInTheDocument();
142
+ });
143
+
144
+ it("closes the mobile drawer on Escape", () => {
145
+ stubMobileViewport();
146
+ renderShell();
147
+ fireEvent.click(screen.getByRole("button", { name: "Open navigation" }));
148
+ fireEvent.keyDown(screen.getByRole("dialog", { name: "Primary" }), { key: "Escape" });
149
+ expect(screen.queryByRole("navigation", { name: "Primary" })).not.toBeInTheDocument();
150
+ expect(document.body.style.overflow).toBe("");
151
+ });
152
+ });