@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 +2 -2
- package/package.json +2 -2
- package/src/Breadcrumbs.tsx +15 -4
- package/src/HubPage.tsx +16 -4
- package/src/Page.tsx +1 -1
- package/src/guide/guideSnippets.tsx +41 -0
- package/src/navLink.tsx +28 -0
- package/src/router.test.tsx +94 -0
- package/src/router.tsx +22 -1
- package/src/ui/Badge.test.tsx +5 -0
- package/src/ui/Badge.tsx +14 -6
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
|
+
"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.
|
|
16
|
+
"@terpjs/contract": "^0.5.4"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/Breadcrumbs.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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;
|
|
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
|
+
}
|
package/src/navLink.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/router.test.tsx
CHANGED
|
@@ -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
|
}
|
package/src/ui/Badge.test.tsx
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|