@terpjs/react-core 0.5.3 → 0.5.4

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.
package/README.md CHANGED
@@ -70,7 +70,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
70
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
71
  | `OverviewPage` | A module's top-level listing screen (level 2); detail pages crumb back to it. |
72
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). |
73
+ | `Breadcrumbs` | The trail itself (used by the archetypes; rarely composed directly). Ancestor crumbs use the router's `Link` by default — `renderLink` is only for rendering outside a Terp router. |
74
74
  | `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
75
75
  | `PageActions` | Primary action + overflow menu for a page header. |
76
76
 
@@ -131,7 +131,7 @@ marker, counted by the escape-hatch budget.
131
131
  | `Radio`, `RadioGroup` | Labelled radio and accessible grouped radio options with controlled or uncontrolled value. |
132
132
  | `Switch` | Labelled boolean toggle (`role="switch"`) with `checked` / `defaultChecked` and boolean `onChange`. |
133
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). |
134
+ | `Badge` | Small status pill: `<Badge tone="success">Synced</Badge>` (or `label="Synced"`); `tone`: neutral / info / success / warning / danger. |
135
135
  | `Tooltip` | Accessible focus/hover tooltip that describes its trigger with `aria-describedby`. |
136
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
137
  | `Alert` | Inline banner for persistent feedback (`tone`: neutral / info / success / warning / danger); warnings and danger announce as `alert`, others as `status`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "type": "module",
5
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
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@tanstack/react-router": "^1.170.16",
16
- "@terpjs/contract": "^0.5.3"
16
+ "@terpjs/contract": "^0.5.4"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^18.3.0 || ^19.0.0",
@@ -1,6 +1,7 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
2
 
3
3
  import { injectTerpStyles } from "./styles";
4
+ import { useNavLink } from "./navLink";
4
5
  import { useStrings, useUiText } from "./uiText";
5
6
  import type { UiText } from "./uiText";
6
7
 
@@ -20,7 +21,11 @@ export type RenderBreadcrumbLink = (item: { label: string; to: string }) => Reac
20
21
  export interface BreadcrumbsProps {
21
22
  /** The trail, outermost first; the last item is the current page. */
22
23
  items: readonly BreadcrumbItem[];
23
- /** Link renderer for ancestor crumbs (default: a plain `<a href>`); pass the stack's `Link`. */
24
+ /**
25
+ * Link renderer for ancestor crumbs. Defaults to the surrounding router's `Link`
26
+ * (published by `buildAppRouter`), falling back to a plain `<a href>` only outside a
27
+ * Terp router — a crumb inside an app must never full-page-reload.
28
+ */
24
29
  renderLink?: RenderBreadcrumbLink;
25
30
  }
26
31
 
@@ -69,7 +74,7 @@ function ChevronSeparator() {
69
74
  );
70
75
  }
71
76
 
72
- const defaultRenderLink: RenderBreadcrumbLink = (item) => <a href={item.to}>{item.label}</a>;
77
+ const anchorRenderLink: RenderBreadcrumbLink = (item) => <a href={item.to}>{item.label}</a>;
73
78
 
74
79
  /**
75
80
  * The breadcrumb trail every page shows through the remaining layers (shell -> overview ->
@@ -77,7 +82,13 @@ const defaultRenderLink: RenderBreadcrumbLink = (item) => <a href={item.to}>{ite
77
82
  * list, and `aria-current="page"` on the final crumb. Router-agnostic — `renderLink` turns
78
83
  * an ancestor crumb into the active stack's link, exactly like `AppShell`'s `renderLink`.
79
84
  */
80
- export function Breadcrumbs({ items, renderLink = defaultRenderLink }: BreadcrumbsProps) {
85
+ export function Breadcrumbs({ items, renderLink }: BreadcrumbsProps) {
86
+ const navLink = useNavLink();
87
+ const renderCrumbLink =
88
+ renderLink ??
89
+ (navLink === null
90
+ ? anchorRenderLink
91
+ : (item: { label: string; to: string }) => navLink({ to: item.to, children: item.label }));
81
92
  const strings = useStrings();
82
93
  const resolve = useUiText();
83
94
  return (
@@ -89,7 +100,7 @@ export function Breadcrumbs({ items, renderLink = defaultRenderLink }: Breadcrum
89
100
  return (
90
101
  <li key={`${index}-${label}`} style={listStyle}>
91
102
  {!isLast && item.to !== undefined ? (
92
- renderLink({ label, to: item.to })
103
+ renderCrumbLink({ label, to: item.to })
93
104
  ) : (
94
105
  <span aria-current={isLast ? "page" : undefined} style={isLast ? currentStyle : undefined}>
95
106
  {label}
package/src/HubPage.tsx CHANGED
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
4
4
  import { Page } from "./Page";
5
5
  import type { PageProps } from "./Page";
6
6
  import { useLayoutContract, verifySlotChildren } from "./layoutContract";
7
+ import { useNavLink } from "./navLink";
7
8
  import { injectTerpStyles } from "./styles";
8
9
  import { useUiText } from "./uiText";
9
10
  import type { UiText } from "./uiText";
@@ -157,7 +158,7 @@ const linkStyle: CSSProperties = {
157
158
  minHeight: 0,
158
159
  };
159
160
 
160
- const defaultRenderLink: RenderHubCardLink = ({ to, children }) => (
161
+ const anchorRenderLink: RenderHubCardLink = ({ to, children }) => (
161
162
  <a href={to} data-terp="hubcard-link" style={linkStyle}>
162
163
  {children}
163
164
  </a>
@@ -166,7 +167,8 @@ const defaultRenderLink: RenderHubCardLink = ({ to, children }) => (
166
167
  /**
167
168
  * A single navigable card inside a {@link HubPage}: icon + title, a short description of
168
169
  * the area, and an optional live `stat`. The whole card is one link, rendered through
169
- * `renderLink` so the hub stays router-agnostic.
170
+ * `renderLink` so the hub stays router-agnostic — defaulting to the surrounding router's
171
+ * `Link`, and to a plain anchor only outside a Terp router.
170
172
  */
171
173
  export function HubCard({
172
174
  to,
@@ -174,12 +176,22 @@ export function HubCard({
174
176
  description,
175
177
  icon,
176
178
  stat,
177
- renderLink = defaultRenderLink,
179
+ renderLink,
178
180
  }: HubCardProps) {
181
+ const navLink = useNavLink();
182
+ const renderCardLink =
183
+ renderLink ??
184
+ (navLink === null
185
+ ? anchorRenderLink
186
+ : ({ to: href, children }: { to: string; children: ReactNode }) => (
187
+ <span data-terp="hubcard-link" style={linkStyle}>
188
+ {navLink({ to: href, children })}
189
+ </span>
190
+ ));
179
191
  const resolve = useUiText();
180
192
  return (
181
193
  <li data-terp="hubcard" style={cardStyle}>
182
- {renderLink({
194
+ {renderCardLink({
183
195
  to,
184
196
  children: (
185
197
  <span data-terp="hubcard-body" style={cardBodyStyle}>
package/src/Page.tsx CHANGED
@@ -19,7 +19,7 @@ export interface PageProps {
19
19
  title: UiText;
20
20
  /** Ancestor breadcrumb trail, outermost first; the current page's crumb is appended automatically. */
21
21
  breadcrumbs?: readonly BreadcrumbItem[];
22
- /** Link renderer for ancestor crumbs; pass the stack's `Link` (see {@link Breadcrumbs}). */
22
+ /** Link renderer for ancestor crumbs; defaults to the surrounding router's `Link` (see {@link Breadcrumbs}). */
23
23
  renderLink?: RenderBreadcrumbLink;
24
24
  /** Optional page-level actions, rendered on the heading row (e.g. a primary `Button`). */
25
25
  actions?: ReactNode;
@@ -0,0 +1,41 @@
1
+ // Typechecked guide snippets.
2
+ //
3
+ // `terp guide <topic>` is the first thing the golden rules point an author at, so a
4
+ // snippet in it that does not compile is worse than no snippet at all — this file
5
+ // shipped three lines of a DataView API that never existed. The blocks below are the
6
+ // real code the guide prints, compiled by the workspace typecheck and pinned to the
7
+ // guide text by tests/architecture/test_guide_snippets.py: change one and the other
8
+ // fails. Keep the block bodies byte-identical to the guide (modulo indentation).
9
+ import { useMemo } from "react";
10
+
11
+ import { DataView, InMemoryDataViewRepository } from "../dataview";
12
+ import type { DataViewColumn } from "../dataview";
13
+
14
+ interface Row {
15
+ id: string;
16
+ title: string;
17
+ status: string;
18
+ }
19
+
20
+ export function GuideDataViewSnippet({ rows }: { rows: Row[] }) {
21
+ const columns: DataViewColumn<Row>[] = [
22
+ { id: "title", header: "Title", accessor: (r) => r.title },
23
+ { id: "status", header: "Status", accessor: (r) => r.status },
24
+ ];
25
+ // terp-guide-snippet: dataview
26
+ const repo = useMemo(
27
+ () =>
28
+ new InMemoryDataViewRepository(rows, {
29
+ getRowId: (r) => r.id,
30
+ getValue: (r, col) => r[col as keyof Row],
31
+ searchFields: ["title", "status"],
32
+ }),
33
+ [rows],
34
+ );
35
+ // terp-guide-snippet-end
36
+ return (
37
+ // terp-guide-snippet: dataview
38
+ <DataView repository={repo} columns={columns} viewId="notes.list" />
39
+ // terp-guide-snippet-end
40
+ );
41
+ }
@@ -0,0 +1,28 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ /**
5
+ * How the surrounding router renders an in-app link.
6
+ *
7
+ * The layout components (`Breadcrumbs`, `HubCard`) are deliberately router-agnostic and
8
+ * take a `renderLink` prop — but their *default* used to be a raw `<a href>`, which is
9
+ * the one construct the boundary lint refuses in app code, and which does a full page
10
+ * reload instead of a client-side navigation. Forgetting the prop therefore produced a
11
+ * silently degraded app: no error, no lint hit (the anchor lives inside react-core), just
12
+ * a white flash on every crumb click.
13
+ *
14
+ * So `buildAppRouter` publishes the router's own `Link` here, and the components default
15
+ * to it. The anchor remains only for a component rendered outside any Terp router (a
16
+ * standalone story or unit test), where there is no router to navigate with.
17
+ */
18
+ export type NavLinkRenderer = (props: { to: string; children: ReactNode }) => ReactNode;
19
+
20
+ export const NavLinkContext = createContext<NavLinkRenderer | null>(null);
21
+
22
+ /**
23
+ * The ambient link renderer, or `null` outside a Terp router. Callers fall back to a
24
+ * plain anchor — never silently, always as the documented last resort.
25
+ */
26
+ export function useNavLink(): NavLinkRenderer | null {
27
+ return useContext(NavLinkContext);
28
+ }
@@ -85,6 +85,100 @@ describe("buildAppRouter", () => {
85
85
  expect(screen.queryByRole("link", { name: "Users" })).not.toBeInTheDocument();
86
86
  });
87
87
 
88
+ it("mounts a parameterised route in BOTH spellings the contract documents", async () => {
89
+ // The manifest is stack-agnostic and documents `:id`; TanStack wants `$id`. Passing
90
+ // the documented spelling through untranslated built a route that never matched —
91
+ // and nothing caught it: not the lint, not typecheck, not the build. Pin both.
92
+ for (const [path, entry] of [
93
+ ["/things/:thingId", "/things/abc"],
94
+ ["/legacy/$thingId", "/legacy/abc"],
95
+ ] as const) {
96
+ vi.stubGlobal(
97
+ "fetch",
98
+ vi.fn<typeof fetch>(async (input) => {
99
+ const url = (input as Request).url;
100
+ if (url.endsWith("/api/v1/auth/login")) {
101
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
102
+ }
103
+ return jsonResponse({
104
+ id: "1",
105
+ email: "editor@example.com",
106
+ role_rank: 20,
107
+ role_name: "editor",
108
+ });
109
+ }),
110
+ );
111
+ const router = buildAppRouter([{ name: "things", routes: [{ path, view: "Thing" }] }], {
112
+ views: { Thing: () => <Page title="Thing view">thing body</Page> },
113
+ title: "Terp",
114
+ history: createMemoryHistory({ initialEntries: [entry] }),
115
+ });
116
+ render(
117
+ <TerpProvider baseUrl="https://api.test">
118
+ <LogInOnMount />
119
+ <RouterProvider router={router} />
120
+ </TerpProvider>,
121
+ );
122
+ await waitFor(() =>
123
+ expect(screen.getByRole("heading", { name: "Thing view" })).toBeInTheDocument(),
124
+ );
125
+ cleanup();
126
+ }
127
+ });
128
+
129
+ it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
130
+ // A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
131
+ // page reload, silently, with nothing to catch it. Inside a Terp router the default
132
+ // is the router's own Link, so the trail navigates client-side.
133
+ vi.stubGlobal(
134
+ "fetch",
135
+ vi.fn<typeof fetch>(async (input) => {
136
+ const url = (input as Request).url;
137
+ if (url.endsWith("/api/v1/auth/login")) {
138
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
139
+ }
140
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
141
+ }),
142
+ );
143
+ const router = buildAppRouter(
144
+ [
145
+ {
146
+ name: "notes",
147
+ routes: [
148
+ { path: "/notes", view: "NotesList" },
149
+ { path: "/notes/:noteId", view: "NoteDetail" },
150
+ ],
151
+ nav: [],
152
+ },
153
+ ],
154
+ {
155
+ views: {
156
+ NotesList: () => <Page title="Notes view">notes body</Page>,
157
+ NoteDetail: () => (
158
+ <Page title="Note view" breadcrumbs={[{ label: "Notes", to: "/notes" }]}>
159
+ note body
160
+ </Page>
161
+ ),
162
+ },
163
+ title: "Terp",
164
+ history: createMemoryHistory({ initialEntries: ["/notes/1"] }),
165
+ },
166
+ );
167
+
168
+ render(
169
+ <TerpProvider baseUrl="https://api.test">
170
+ <LogInOnMount />
171
+ <RouterProvider router={router} />
172
+ </TerpProvider>,
173
+ );
174
+
175
+ const crumb = await screen.findByRole("link", { name: "Notes" });
176
+ fireEvent.click(crumb);
177
+ await waitFor(() =>
178
+ expect(screen.getByRole("heading", { name: "Notes view" })).toBeInTheDocument(),
179
+ );
180
+ });
181
+
88
182
  it("navigates home through the product brand", async () => {
89
183
  const fetchMock = vi.fn<typeof fetch>(async (input) => {
90
184
  const url = (input as Request).url;
package/src/router.tsx CHANGED
@@ -16,6 +16,7 @@ import { AppShell } from "./AppShell";
16
16
  import { ProfileView } from "./ProfileView";
17
17
  import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
18
18
  import { visibleNav } from "./nav";
19
+ import { NavLinkContext } from "./navLink";
19
20
  import { PageMarkerContext } from "./pageMarker";
20
21
  import { useAuth } from "./TerpProvider";
21
22
  import { UserMenu } from "./UserMenu";
@@ -31,6 +32,21 @@ export const DEFAULT_ROLE_RANKS: Record<string, number> = {
31
32
  /** The built-in profile / settings route (an app manifest claiming the path wins). */
32
33
  export const PROFILE_PATH = "/profile";
33
34
 
35
+ /**
36
+ * Translate a manifest path into TanStack Router's dialect.
37
+ *
38
+ * `ModuleManifest` is stack-agnostic (the same manifest is meant to drive a SvelteKit
39
+ * adapter, where a param is `[id]`), so it spells a parameter the neutral way: `:id`.
40
+ * TanStack wants `$id`. Passing the manifest path through untranslated produced the
41
+ * worst possible failure — a route that simply never matches, caught by nothing: not
42
+ * the boundary lint, not typecheck, not the build, just a 404 at runtime for anyone who
43
+ * followed the documented example. Both spellings are accepted (`$id` is what shipped
44
+ * before this translation existed and what existing apps wrote); `:id` is canonical.
45
+ */
46
+ export function routerPath(path: string): string {
47
+ return path.replace(/(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g, "$1$$$2");
48
+ }
49
+
34
50
  export interface BuildAppRouterOptions {
35
51
  /** Maps a manifest route's `view` id to the component that renders it. */
36
52
  views: Record<string, ComponentType>;
@@ -115,6 +131,10 @@ export function buildAppRouter(
115
131
  const rank = useAuth().currentUser()?.role_rank ?? null;
116
132
  const nav = visibleNav(manifests, (role) => allows(roleRanks, rank, role));
117
133
  return (
134
+ // Publish the router's Link so every layout component that renders an in-app link
135
+ // (Breadcrumbs, HubCard) navigates client-side by default. Forgetting `renderLink`
136
+ // used to degrade the app silently: a raw anchor, a full page reload, no error.
137
+ <NavLinkContext.Provider value={({ to, children }) => <Link to={to}>{children}</Link>}>
118
138
  <AppShell
119
139
  title={options.title}
120
140
  logo={options.logo}
@@ -144,6 +164,7 @@ export function buildAppRouter(
144
164
  >
145
165
  <Outlet />
146
166
  </AppShell>
167
+ </NavLinkContext.Provider>
147
168
  );
148
169
  }
149
170
 
@@ -200,7 +221,7 @@ export function buildAppRouter(
200
221
  }
201
222
  return createRoute({
202
223
  getParentRoute: () => rootRoute,
203
- path,
224
+ path: routerPath(path),
204
225
  component: RouteComponent,
205
226
  });
206
227
  }
@@ -11,4 +11,9 @@ describe("Badge", () => {
11
11
  render(<Badge label="Active" tone="success" />);
12
12
  expect(screen.getByText("Active").style.color).toContain("var(--color-status-success)");
13
13
  });
14
+
15
+ it("takes its text as children too, the way every other component does", () => {
16
+ render(<Badge tone="danger">No drift</Badge>);
17
+ expect(screen.getByText("No drift").style.color).toContain("var(--color-status-danger)");
18
+ });
14
19
  });
package/src/ui/Badge.tsx CHANGED
@@ -5,10 +5,18 @@ import type { UiText } from "../uiText";
5
5
 
6
6
  export type BadgeTone = "neutral" | "info" | "success" | "warning" | "danger";
7
7
 
8
- export interface BadgeProps {
9
- label: UiText;
10
- tone?: BadgeTone;
11
- }
8
+ /**
9
+ * The pill's content, either way round.
10
+ *
11
+ * Every other component in the catalog takes children, so `<Badge tone="success">No
12
+ * drift</Badge>` is the obvious first guess — it used to be a typecheck error, for no
13
+ * reason a caller could see. Both spellings work; `label` stays for call sites that
14
+ * already use it, and either accepts a `UiText` so the string still translates.
15
+ */
16
+ export type BadgeProps = { tone?: BadgeTone } & (
17
+ | { label: UiText; children?: never }
18
+ | { children: UiText; label?: never }
19
+ );
12
20
 
13
21
  const toneColor: Record<BadgeTone, string> = {
14
22
  neutral: "var(--color-neutral-600)",
@@ -41,8 +49,8 @@ const badgeStyle = (tone: BadgeTone): CSSProperties => ({
41
49
  });
42
50
 
43
51
  /** Small token-styled status pill — flat soft tint with a matching text colour. */
44
- export function Badge({ label, tone = "neutral" }: BadgeProps) {
52
+ export function Badge({ label, children, tone = "neutral" }: BadgeProps) {
45
53
  const resolve = useUiText();
46
- return <span style={badgeStyle(tone)}>{resolve(label)}</span>;
54
+ return <span style={badgeStyle(tone)}>{resolve((label ?? children) as UiText)}</span>;
47
55
  }
48
56