@terpjs/react-core 0.13.1 → 0.14.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.
- package/README.md +4 -4
- package/package.json +2 -2
- package/src/AppShell.tsx +9 -1
- package/src/Breadcrumbs.test.tsx +30 -0
- package/src/Breadcrumbs.tsx +34 -6
- package/src/HubPage.test.tsx +14 -3
- package/src/Page.test.tsx +80 -12
- package/src/Page.tsx +89 -18
- package/src/layout.test.tsx +36 -0
- package/src/layout.tsx +39 -0
- package/src/markers.test.ts +2 -1
- package/src/styles.test.ts +350 -3
- package/src/styles.ts +398 -68
- package/src/tokens.guard.test.ts +53 -13
- package/src/ui/Card.test.tsx +39 -1
- package/src/ui/Card.tsx +17 -5
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
|
|
|
77
77
|
| Export | Use |
|
|
78
78
|
|---|---|
|
|
79
79
|
| `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. |
|
|
80
|
-
| Shell geometry (tokens, not props) | `--shell-sidebar-width-expanded` / `-collapsed`, `--shell-header-height`, `--shell-content-max-width
|
|
80
|
+
| Shell geometry (tokens, not props) | `--shell-sidebar-width-expanded` / `-collapsed`, `--shell-header-height`, `--shell-content-max-width`, `--shell-brand-size` and `--shell-gutter` (the content column's inline gutter, read by the app header, `main`, the footer and the page band, and tightened one step below the mobile breakpoint) are published contract tokens: an app moves them from its own unlayered `theme.css` with no prop at all, and the Studio gets them from the manifest. |
|
|
81
81
|
| `logo` / `logoDark` (shell) | The brand mark, rendered in a box of `--shell-brand-size` so an asset larger than the 4rem icon rail is scaled rather than clipped — which is why there is no separate collapsed-mark slot: `logo` is the mark, `title` is the wordmark, and the rail already hides the second. `logoDark` is for a fixed-colour mark that cannot survive a dark theme; both render and the stylesheet shows one, switched on `--appearance-show-light` / `--appearance-show-dark`, which the token build emits from each theme's declared `appearance`. Resolving it in React would be wrong: the theme is `<html data-theme>` and an app may set it with no provider mounted. |
|
|
82
82
|
| `density` (shell) | App-wide `"comfortable"` or `"compact"`, stamped on the shell root; every control height and cell padding follows by token inheritance. **No default** — omitting it stamps nothing, so an app's own `data-density` on `<html>` still wins. A subtree may override it: a `DataView density="comfortable"` inside a compact shell really is comfortable, because comfortable now has named tokens and a rule rather than being the absence of an attribute. |
|
|
83
83
|
| Skip to content | The shell renders the skip link and owns the landmarks it skips: `main` carries `tabIndex={-1}` and a per-instance id, so activating the link *moves focus* rather than only scrolling and two shells on one page get two distinct targets. It is not rendered while the mobile drawer is open — the drawer is `aria-modal` and its target sits inside the `inert` column. |
|
|
@@ -89,7 +89,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
|
|
|
89
89
|
| `navPlacement` (shell) | Where the primary navigation lives on desktop: `"sidebar"` (default, stamps nothing) or `"header"` — a horizontal row in the header with no sidebar at all, for an app whose destinations are few enough that 15rem of permanent chrome is a tax. The header then *becomes* the sidebar surface, so every `--color-sidebar-*` an app themes carries over and no property is overridden. Below the mobile breakpoint both placements are the drawer. `defaultCollapsed` is `never` under `"header"`: with no sidebar there is nothing to collapse. Available on `AppShell`, `buildAppRouter` and `renderTerpApp`. |
|
|
90
90
|
| `contentWidth` (shell) | `"measured"` caps routed content at `--shell-content-max-width` while each page's own header keeps the full track — the subheader band. Default `"full"` stamps no attribute, so nothing moves until an app asks. Available on `AppShell`, `buildAppRouter` and `renderTerpApp`. |
|
|
91
91
|
| `NavIcon`, `Icon`, `TerpMark`, `ICON_GLYPHS`, `ICON_NAMES` | The dependency-free icon layer. `ICON_NAMES` is published by `@terpjs/contract` as data and `IconName` is derived from it, so `NavItem.icon` and `Icon`'s `name` are **checked names** — a typo is a typecheck error, not a picture of nothing. `NavIcon`'s `name` stays a plain `string` on purpose: its unknown-name behaviour is a designed, visible fallback (the label's initial in a tile), while `Icon` rendered an empty box. The glyph table is held to the name set exhaustively in both directions by `satisfies`, so a glyph without a name or a name without a glyph fails to compile. 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, …). `TerpMark` is the placeholder brand mark until an app passes its own `logo`. |
|
|
92
|
-
| `Page` | The base routed screen:
|
|
92
|
+
| `Page` | The base routed screen: one band carrying the breadcrumb trail — whose leaf **is** the view's single `h1` — plus `badges`, a truncated `description` lead line and the `actions` cluster, then the body with loading/error slots. Every page renders a trail, even one of a single crumb, so an overview's title sits where a detail's does. Inside a shell the band bleeds to the content column's edge and takes `--shell-header-height`; a `measure="narrow"` frame keeps the row and drops the chrome. |
|
|
93
93
|
| `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`. |
|
|
94
94
|
| `OverviewPage` | A module's top-level listing screen (level 2); detail pages crumb back to it. |
|
|
95
95
|
| `DetailPage` | One record's screen (level 3); breadcrumb trail = ancestors + record title. |
|
|
@@ -234,9 +234,9 @@ token spacing scale, so spacing is themed centrally):
|
|
|
234
234
|
|---|---|
|
|
235
235
|
| `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. `padding` insets on the same token scale; `direction` and `gap` also take a `{ narrow, wide }` pair, which changes over at the one viewport cutover the shell and the DataView already use. |
|
|
236
236
|
| `Grid` | The two-dimensional primitive, and the one that lifts a real ceiling — a two-column form could not be expressed at all before it. `columns` takes a fixed 1–4 or `"auto"` (the default), which reflows to whatever the **container** can hold with no breakpoint anywhere; `minColumn` is the track floor for `auto`; `gap` indexes the spacing scale; `align` is a closed four. Renders no inline style. No `span`, and therefore no twelve-column option — a span system needs a child component to carry it. |
|
|
237
|
-
| `Card` | A token-styled
|
|
237
|
+
| `Card` | A token-styled frame (a border, a radius and padding — no fill, so the page's canvas shows through) grouping one block of a page — the sanctioned visual separation between sections. Optional header row: `title` (semantic `<h3>`), muted `description`, `actions` slot — and the slot stays on the title's line whether or not there is a description, which took a rule: a content-sized heading is as wide as its longest line, and flex breaks lines on that width before it shrinks anything. `variant="plain"` keeps the heading and drops the box, for a titled region inside something that is already a surface — a section whose body is a `DataView` gets a border inside a border otherwise. There is no separate `Section` or `Surface`: both are this element with declarations removed. |
|
|
238
238
|
| `Divider` | A rule between groups, as a semantic `<hr>` so the separation reaches the accessibility tree — `Separator` under its other name, shipped once. `orientation="vertical"` takes its height from its flex or grid line rather than inventing one, so it works between the items of a row `Stack` and is zero-height in a block parent. |
|
|
239
|
-
| `DetailList` | Token-styled label/value pairs as a semantic `<dl>` (record metadata, expanded-row summaries). A real grid: `layout="aligned"` puts every label in a shared column so the values line up, `"stacked"` puts the label above its value, and `columns` takes two pairs per row. Tracks are floored at zero and long values wrap, so a 64-character digest no longer pushes the list past its container. |
|
|
239
|
+
| `DetailList` | Token-styled label/value pairs as a semantic `<dl>` (record metadata, expanded-row summaries). A real grid: `layout="aligned"` puts every label in a shared column so the values line up, `"stacked"` puts the label above its value, and `columns` takes two pairs per row. Tracks are floored at zero and long values wrap, so a 64-character digest no longer pushes the list past its container. Both non-inline layouts mute their label to the small, regular step — at the value's own size and weight a card of labelled values is a wall of bold text — and space their rows so a pair groups; `gap` moves that distance on the spacing scale, and it is the **row** gap, since the column gap is the label-to-value distance. Below the framework's viewport cutover it reflows to one column, where `Grid` deliberately does not for a fixed count: `Grid` publishes `columns="auto"` as its responsive answer and this component's closed one-or-two has none, so the reflow has to be its own. |
|
|
240
240
|
| `Heading` | A section heading inside a page body — `h2`–`h4`, with `size` a **separate** choice from `level` so a visually small `h2` is expressible without picking the wrong element. No level 1: `Page` renders the single `h1` of every routed view. |
|
|
241
241
|
| `Text` | Body copy with themeable ink — `tone` (default / muted / subtle), `size`, and an enumerable `measure` that caps the line length in `ch`. What a bare `<p>` in a module cannot be, since a bare element carries no marker for a rule to reach. |
|
|
242
242
|
| `Code` | An identifier or a snippet in the mono family. `block` wraps it in a focusable `<pre>`, which is what preserves the whitespace and what makes a long line scrollable by keyboard. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/react-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@tanstack/react-router": "^1.170.16",
|
|
17
|
-
"@terpjs/contract": "^0.
|
|
17
|
+
"@terpjs/contract": "^0.14.0"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"react": "^19.0.0",
|
package/src/AppShell.tsx
CHANGED
|
@@ -104,7 +104,15 @@ interface AppShellBaseProps {
|
|
|
104
104
|
*
|
|
105
105
|
* "Full width" means the full width of the article's own track. `appshell-main`'s padding
|
|
106
106
|
* sits outside it, so this is a measure within the content column rather than a bleed to the
|
|
107
|
-
* window edge
|
|
107
|
+
* window edge.
|
|
108
|
+
*
|
|
109
|
+
* The page's header does reach past that padding, to the content column's edge, and it does
|
|
110
|
+
* it with a negative margin of one `--shell-gutter` per side — so a band and the app
|
|
111
|
+
* header above it read as one piece of chrome. This paragraph used to say such a bleed
|
|
112
|
+
* "would need a negative margin, and therefore an inline site"; the margin is right and the
|
|
113
|
+
* inline site is not, because the sheet knows both viewport variants and the gutter is a
|
|
114
|
+
* published token. ADR 0097 §2 carries the amendment. Nothing here needs the app's help:
|
|
115
|
+
* the band is `Page`'s own header, not something `contentWidth` switches on.
|
|
108
116
|
*/
|
|
109
117
|
contentWidth?: "full" | "measured";
|
|
110
118
|
/**
|
package/src/Breadcrumbs.test.tsx
CHANGED
|
@@ -43,6 +43,36 @@ describe("Breadcrumbs", () => {
|
|
|
43
43
|
expect(screen.getByText("Section")).not.toHaveAttribute("aria-current");
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it("renders the current crumb as the page heading when asked, and only then", () => {
|
|
47
|
+
// The page band's case: the trail IS the title, so its leaf is the view's single h1
|
|
48
|
+
// rather than a second copy of the same string sitting under the trail. The element
|
|
49
|
+
// changes and the accessible current-ness does not.
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<Breadcrumbs
|
|
52
|
+
items={[{ label: "Tasks", to: "/tasks" }, { label: "Fix the door" }]}
|
|
53
|
+
currentAs="h1"
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const heading = screen.getByRole("heading", { level: 1, name: "Fix the door" });
|
|
58
|
+
expect(heading).toHaveAttribute("aria-current", "page");
|
|
59
|
+
// The marker moves with the element, because the two mean different things to the sheet:
|
|
60
|
+
// a trail's end, versus a heading that happens to sit at the trail's end.
|
|
61
|
+
expect(heading).toHaveAttribute("data-terp", "page-title");
|
|
62
|
+
expect(container.querySelector('[data-terp="breadcrumbs-current"]')).toBeNull();
|
|
63
|
+
// The ancestor is untouched: currentAs describes the LEAF only.
|
|
64
|
+
expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("defaults to a span, so a standalone trail mints no heading", () => {
|
|
68
|
+
// The default matters as much as the option. A wayfinding trail rendered anywhere on a
|
|
69
|
+
// page must not introduce an h1 competing with that page's own.
|
|
70
|
+
render(<Breadcrumbs items={[{ label: "Tasks", to: "/tasks" }, { label: "Here" }]} />);
|
|
71
|
+
|
|
72
|
+
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
|
|
73
|
+
expect(screen.getByText("Here")).toHaveAttribute("data-terp", "breadcrumbs-current");
|
|
74
|
+
});
|
|
75
|
+
|
|
46
76
|
it("marks only the final crumb as current, on a marker of its own", () => {
|
|
47
77
|
// The current-crumb styling deliberately does NOT key on aria-current. A router's Link
|
|
48
78
|
// stamps aria-current="page" on every link whose path is a prefix of the current one —
|
package/src/Breadcrumbs.tsx
CHANGED
|
@@ -21,6 +21,21 @@ export type RenderBreadcrumbLink = (item: { label: string; to: string }) => Reac
|
|
|
21
21
|
export interface BreadcrumbsProps {
|
|
22
22
|
/** The trail, outermost first; the last item is the current page. */
|
|
23
23
|
items: readonly BreadcrumbItem[];
|
|
24
|
+
/**
|
|
25
|
+
* What element the current (last) crumb renders as. Default `"span"`.
|
|
26
|
+
*
|
|
27
|
+
* `"h1"` is the page band's case (see `Page`): there the trail IS the page title, so its
|
|
28
|
+
* leaf is the view's single heading rather than a second copy of the same string sitting
|
|
29
|
+
* under the trail. It carries `data-terp="page-title"` instead of `breadcrumbs-current`,
|
|
30
|
+
* because the two mean different things to the sheet: one is a trail's end, the other is a
|
|
31
|
+
* heading that happens to sit at the trail's end and is styled as such.
|
|
32
|
+
*
|
|
33
|
+
* The heading lands inside the list item, which is deliberate rather than convenient. `li`
|
|
34
|
+
* takes flow content, so it is valid; it keeps the separator logic in one place instead of
|
|
35
|
+
* asking the caller to draw a chevron; and it leaves `aria-current="page"` on the same node
|
|
36
|
+
* a screen reader reads as the heading.
|
|
37
|
+
*/
|
|
38
|
+
currentAs?: "span" | "h1";
|
|
24
39
|
/**
|
|
25
40
|
* Link renderer for ancestor crumbs. Defaults to the surrounding router's `Link`
|
|
26
41
|
* (published by `buildAppRouter`), falling back to a plain `<a href>` only outside a
|
|
@@ -56,7 +71,7 @@ const anchorRenderLink: RenderBreadcrumbLink = (item) => <a href={item.to}>{item
|
|
|
56
71
|
* list, and `aria-current="page"` on the final crumb. Router-agnostic — `renderLink` turns
|
|
57
72
|
* an ancestor crumb into the active stack's link, exactly like `AppShell`'s `renderLink`.
|
|
58
73
|
*/
|
|
59
|
-
export function Breadcrumbs({ items, renderLink }: BreadcrumbsProps) {
|
|
74
|
+
export function Breadcrumbs({ items, renderLink, currentAs = "span" }: BreadcrumbsProps) {
|
|
60
75
|
const navLink = useNavLink();
|
|
61
76
|
const renderCrumbLink =
|
|
62
77
|
renderLink ??
|
|
@@ -75,13 +90,26 @@ export function Breadcrumbs({ items, renderLink }: BreadcrumbsProps) {
|
|
|
75
90
|
<li key={`${index}-${label}`}>
|
|
76
91
|
{!isLast && item.to !== undefined ? (
|
|
77
92
|
renderCrumbLink({ label, to: item.to })
|
|
78
|
-
) : (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
) : isLast && currentAs === "h1" ? (
|
|
94
|
+
// Two spelled-out branches rather than one element with a computed tag and a
|
|
95
|
+
// computed marker, and the marker scanner is the reason rather than taste: it
|
|
96
|
+
// reads string literals out of a data-terp attribute, so a ternary there
|
|
97
|
+
// published "h1" as a marker and hid breadcrumbs-current from the inventory.
|
|
98
|
+
// The sheet records the same idiom for the same scanner ("a conditional
|
|
99
|
+
// written at the attribute is the form the marker scanner reads every string
|
|
100
|
+
// literal out of"); spelling both out keeps one literal per element.
|
|
101
|
+
<h1 aria-current="page" data-terp="page-title">
|
|
102
|
+
{label}
|
|
103
|
+
</h1>
|
|
104
|
+
) : isLast ? (
|
|
105
|
+
<span aria-current="page" data-terp="breadcrumbs-current">
|
|
83
106
|
{label}
|
|
84
107
|
</span>
|
|
108
|
+
) : (
|
|
109
|
+
// An ancestor with no `to`: plain text, never a dead link, and never the
|
|
110
|
+
// heading either — `currentAs` describes the LEAF, and an intermediate layer
|
|
111
|
+
// that happens to be unlinked is not the page.
|
|
112
|
+
<span>{label}</span>
|
|
85
113
|
)}
|
|
86
114
|
{!isLast && (
|
|
87
115
|
<span aria-hidden="true" data-terp="breadcrumbs-separator">
|
package/src/HubPage.test.tsx
CHANGED
|
@@ -15,15 +15,26 @@ describe("HubPage", () => {
|
|
|
15
15
|
</HubPage>,
|
|
16
16
|
);
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
expect(
|
|
18
|
+
const heading = screen.getByRole("heading", { level: 1, name: "Administration" });
|
|
19
|
+
expect(heading).toBeInTheDocument();
|
|
20
|
+
// A parentless hub is a trail of ONE, not a bare heading, so its title sits exactly where
|
|
21
|
+
// an overview's and a detail's do and does not move when you descend. The trail is
|
|
22
|
+
// therefore present and has nothing to link to yet.
|
|
23
|
+
expect(heading).toHaveAttribute("data-terp", "page-title");
|
|
24
|
+
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
|
|
20
25
|
expect(screen.getAllByText("Administration")).toHaveLength(1);
|
|
21
26
|
// The marker, not the declaration. jsdom does not compute the cascade, so toHaveStyle
|
|
22
27
|
// can only ever see an inline style — asserting gridAutoRows here was asserting that the
|
|
23
28
|
// grid is styled from a style object, which is the thing ADR 0094 removes. What a test
|
|
24
29
|
// should assert is the fact the sheet keys on; the geometry is gated by styles.test.ts
|
|
25
30
|
// (the rule exists) and by the hub-page baselines (it does what it says).
|
|
26
|
-
|
|
31
|
+
// getByRole("list") would be ambiguous now that the trail renders an <ol> of its own, and
|
|
32
|
+
// the ambiguity is the assertion's own fault rather than the trail's: what it means is
|
|
33
|
+
// "the grid is the marked element", so it asks for the grid.
|
|
34
|
+
expect(
|
|
35
|
+
screen.getAllByRole("list").find((list) => list.dataset.terp === "hubpage-grid"),
|
|
36
|
+
"the card grid must be the marked list",
|
|
37
|
+
).toBeDefined();
|
|
27
38
|
expect(screen.getByRole("link", { name: /Users/ })).toBeInTheDocument();
|
|
28
39
|
expect(screen.getByRole("link", { name: /Roles/ })).toBeInTheDocument();
|
|
29
40
|
expect(screen.getByText("Manage accounts")).toBeInTheDocument();
|
package/src/Page.test.tsx
CHANGED
|
@@ -33,11 +33,37 @@ describe("Page", () => {
|
|
|
33
33
|
expect(screen.getByText("body")).toBeInTheDocument();
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
it("renders a parentless page as a trail of one, in the same boxes as a deeper page", () => {
|
|
37
|
+
// The overview and the detail have to put the title in the SAME place, or the name moves
|
|
38
|
+
// the moment you open a record. So a parentless page is a trail of one rather than a bare
|
|
39
|
+
// heading: same nav, same list, same leaf, no ancestors in front of it yet.
|
|
40
|
+
const root = render(<Page title="Tasks">x</Page>);
|
|
41
|
+
const rootLeaf = screen.getByRole("heading", { level: 1, name: "Tasks" });
|
|
42
|
+
expect(rootLeaf).toHaveAttribute("data-terp", "page-title");
|
|
43
|
+
// Still exactly once: the leaf IS the heading, so there is nothing to repeat.
|
|
40
44
|
expect(screen.getAllByText("Tasks")).toHaveLength(1);
|
|
45
|
+
// The ancestry of the heading is the shape that must not differ between the two.
|
|
46
|
+
const chain = (node: HTMLElement) => {
|
|
47
|
+
const tags: string[] = [];
|
|
48
|
+
for (let at = node.parentElement; at !== null; at = at.parentElement) {
|
|
49
|
+
tags.push(at.tagName);
|
|
50
|
+
if (at.dataset.terp === "page-heading") break;
|
|
51
|
+
}
|
|
52
|
+
return tags;
|
|
53
|
+
};
|
|
54
|
+
const rootChain = chain(rootLeaf);
|
|
55
|
+
expect(rootChain).toEqual(["LI", "OL", "NAV", "DIV"]);
|
|
56
|
+
root.unmount();
|
|
57
|
+
|
|
58
|
+
const deep = render(
|
|
59
|
+
<Page title="Fix the door" breadcrumbs={[{ label: "Tasks", to: "/tasks" }]}>
|
|
60
|
+
x
|
|
61
|
+
</Page>,
|
|
62
|
+
);
|
|
63
|
+
expect(chain(screen.getByRole("heading", { level: 1, name: "Fix the door" }))).toEqual(
|
|
64
|
+
rootChain,
|
|
65
|
+
);
|
|
66
|
+
deep.unmount();
|
|
41
67
|
});
|
|
42
68
|
|
|
43
69
|
it("appends its own crumb to the supplied trail", () => {
|
|
@@ -49,10 +75,47 @@ describe("Page", () => {
|
|
|
49
75
|
|
|
50
76
|
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
|
|
51
77
|
expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
78
|
+
// The leaf of the trail IS the h1 now, so the page's name appears ONCE. It used to
|
|
79
|
+
// appear twice: Page built its trail as [...breadcrumbs, { label: title }] and then
|
|
80
|
+
// rendered <h1>{title}</h1>, so every DetailPage printed its own name as the leaf crumb
|
|
81
|
+
// and again a couple of dozen pixels below it. That duplication was the whole reason the
|
|
82
|
+
// header collapsed into one band, and this is the assertion that holds it.
|
|
83
|
+
const heading = screen.getByRole("heading", { level: 1, name: "Fix the door" });
|
|
84
|
+
expect(heading).toHaveAttribute("aria-current", "page");
|
|
85
|
+
expect(heading).toHaveAttribute("data-terp", "page-title");
|
|
86
|
+
expect(screen.getAllByText("Fix the door")).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("renders badges and a lead line on the band, and neither when absent", () => {
|
|
90
|
+
const { container, unmount } = render(
|
|
91
|
+
<Page title="Fix the door" badges={<span>Open</span>} description="Reported yesterday">
|
|
92
|
+
x
|
|
93
|
+
</Page>,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(screen.getByText("Open")).toBeInTheDocument();
|
|
97
|
+
expect(screen.getByText("Reported yesterday")).toBeInTheDocument();
|
|
98
|
+
expect(container.querySelector('[data-terp="page-badges"]')).not.toBeNull();
|
|
99
|
+
expect(container.querySelector('[data-terp="page-description"]')).not.toBeNull();
|
|
100
|
+
unmount();
|
|
101
|
+
|
|
102
|
+
// Absent is not an empty row: a band that reserved space for slots no page filled would
|
|
103
|
+
// put a gap next to every title in every app that passes neither.
|
|
104
|
+
const bare = render(<Page title="Fix the door">x</Page>);
|
|
105
|
+
expect(bare.container.querySelector('[data-terp="page-badges"]')).toBeNull();
|
|
106
|
+
expect(bare.container.querySelector('[data-terp="page-description"]')).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("takes several badges as one row, not one row each", () => {
|
|
110
|
+
const { container } = render(
|
|
111
|
+
<Page title="Fix the door" badges={[<span key="a">Open</span>, <span key="b">Urgent</span>]}>
|
|
112
|
+
x
|
|
113
|
+
</Page>,
|
|
55
114
|
);
|
|
115
|
+
|
|
116
|
+
expect(container.querySelectorAll('[data-terp="page-badges"]')).toHaveLength(1);
|
|
117
|
+
expect(screen.getByText("Open")).toBeInTheDocument();
|
|
118
|
+
expect(screen.getByText("Urgent")).toBeInTheDocument();
|
|
56
119
|
});
|
|
57
120
|
|
|
58
121
|
it("replaces the body with the loading state while keeping the header", () => {
|
|
@@ -116,11 +179,15 @@ describe("Page", () => {
|
|
|
116
179
|
});
|
|
117
180
|
|
|
118
181
|
describe("OverviewPage", () => {
|
|
119
|
-
it("
|
|
182
|
+
it("puts its title where a detail page puts its own: the trail's leaf", () => {
|
|
120
183
|
render(<OverviewPage title="Tasks">list</OverviewPage>);
|
|
121
184
|
|
|
122
|
-
|
|
123
|
-
expect(
|
|
185
|
+
const leaf = screen.getByRole("heading", { level: 1, name: "Tasks" });
|
|
186
|
+
expect(leaf).toHaveAttribute("data-terp", "page-title");
|
|
187
|
+
expect(leaf).toHaveAttribute("aria-current", "page");
|
|
188
|
+
// A trail of one has no ancestors to link to, which is the whole difference between an
|
|
189
|
+
// overview and the detail beneath it.
|
|
190
|
+
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
124
191
|
expect(screen.getAllByText("Tasks")).toHaveLength(1);
|
|
125
192
|
expect(screen.getByText("list")).toBeInTheDocument();
|
|
126
193
|
});
|
|
@@ -140,8 +207,9 @@ describe("DetailPage", () => {
|
|
|
140
207
|
|
|
141
208
|
expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
|
|
142
209
|
expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
|
|
143
|
-
|
|
144
|
-
expect(screen.
|
|
210
|
+
const leaf = screen.getByRole("heading", { level: 1, name: "Fix the door" });
|
|
211
|
+
expect(screen.getAllByText("Fix the door")).toHaveLength(1);
|
|
212
|
+
expect(leaf).toHaveAttribute(
|
|
145
213
|
"aria-current",
|
|
146
214
|
"page",
|
|
147
215
|
);
|
package/src/Page.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
import { useContext, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Fragment, useContext, useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
import { Breadcrumbs } from "./Breadcrumbs";
|
|
5
5
|
import type { BreadcrumbItem, RenderBreadcrumbLink } from "./Breadcrumbs";
|
|
@@ -11,8 +11,8 @@ import {
|
|
|
11
11
|
} from "./layoutContract";
|
|
12
12
|
import { LoadingState } from "./LoadingState";
|
|
13
13
|
import { usePageMarker } from "./pageMarker";
|
|
14
|
-
import { useUiText } from "./uiText";
|
|
15
|
-
import type { UiText } from "./uiText";
|
|
14
|
+
import { resolveUiTextNode, useUiText } from "./uiText";
|
|
15
|
+
import type { UiText, UiTextNode } from "./uiText";
|
|
16
16
|
|
|
17
17
|
export interface PageProps {
|
|
18
18
|
/** The page heading (rendered as the single `h1`). */
|
|
@@ -21,8 +21,31 @@ export interface PageProps {
|
|
|
21
21
|
breadcrumbs?: readonly BreadcrumbItem[];
|
|
22
22
|
/** Link renderer for ancestor crumbs; defaults to the surrounding router's `Link` (see {@link Breadcrumbs}). */
|
|
23
23
|
renderLink?: RenderBreadcrumbLink;
|
|
24
|
-
/** Optional page-level actions, rendered
|
|
24
|
+
/** Optional page-level actions, rendered at the band's right edge (e.g. a primary `Button`). */
|
|
25
25
|
actions?: ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* Status pill(s) for the page's subject, shown next to the title — an entity's state, a
|
|
28
|
+
* visibility marker. Pass a `Badge` (or several) so the tone is the caller's choice; a
|
|
29
|
+
* bare string is not accepted, because "which tone" is a decision this frame cannot make.
|
|
30
|
+
*
|
|
31
|
+
* Keep it to a handful of short labels. This is a badge row on a bounded band, not a place
|
|
32
|
+
* for prose — that is what `description` is, and neither is a place for a paragraph.
|
|
33
|
+
*/
|
|
34
|
+
badges?: ReactNode | readonly ReactNode[];
|
|
35
|
+
/**
|
|
36
|
+
* One short line about the page, shown after the badges and truncated to a single line.
|
|
37
|
+
*
|
|
38
|
+
* It is chrome, not content: the band has a height, so a lead line that would wrap is
|
|
39
|
+
* clipped rather than allowed to set it. An explanation that does not fit is describing the
|
|
40
|
+
* body and belongs in the body.
|
|
41
|
+
*
|
|
42
|
+
* `UiTextNode`, like `EmptyState`'s own description, rather than a bare `ReactNode`: this is
|
|
43
|
+
* user-facing prose, so it goes through the localization seam. Typed as `ReactNode` it took
|
|
44
|
+
* a plain string that never reached the resolver, which is the failure that does not
|
|
45
|
+
* announce itself — every other string on the band translates and the lead line stays in the
|
|
46
|
+
* source language.
|
|
47
|
+
*/
|
|
48
|
+
description?: UiTextNode;
|
|
26
49
|
/**
|
|
27
50
|
* Cap the whole frame — header included — at a readable measure (default `"full"`).
|
|
28
51
|
*
|
|
@@ -58,11 +81,27 @@ export interface PageProps {
|
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
/**
|
|
61
|
-
* The base content-page frame: every routed view is constructed the same way — one
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
84
|
+
* The base content-page frame: every routed view is constructed the same way — one band
|
|
85
|
+
* carrying the page's identity and its actions, then the body.
|
|
86
|
+
*
|
|
87
|
+
* **The band is one row, the row is the title, and the title is the trail's leaf.** Every
|
|
88
|
+
* page renders a trail: its ancestors link up through the layers and its leaf is the view's
|
|
89
|
+
* single `h1` (see `Breadcrumbs`' `currentAs`). An overview with no parents is a trail of
|
|
90
|
+
* one, which is deliberately the same node in the same boxes as a detail's leaf — so
|
|
91
|
+
* opening a record slides the name right behind its new parents instead of moving it between
|
|
92
|
+
* two different layouts.
|
|
93
|
+
*
|
|
94
|
+
* The page's name therefore appears exactly ONCE, which is the defect this shape fixed: the
|
|
95
|
+
* frame used to append `title` to the trail *and* render it as an `h1`, so every `DetailPage`
|
|
96
|
+
* printed its own name twice, a couple of dozen pixels apart.
|
|
97
|
+
*
|
|
98
|
+
* `badges` and `description` sit after the title and `actions` at the right edge, all on that
|
|
99
|
+
* one line. Inside a shell the band bleeds to the content column's edge and takes the app
|
|
100
|
+
* header's own height and a bottom border, so the two read as one piece of chrome; standalone
|
|
101
|
+
* — the workbench, the unit tests — it is the same row without the bleed. A `measure="narrow"`
|
|
102
|
+
* frame (`FormPage`, `SettingsPage`) keeps the row and drops the chrome, because a form is
|
|
103
|
+
* capped with its header (ADR 0098 §3).
|
|
104
|
+
*
|
|
66
105
|
* `OverviewPage` and `DetailPage` specialise it for the standard overview -> detail
|
|
67
106
|
* layering; a bespoke screen composes `Page` directly.
|
|
68
107
|
*
|
|
@@ -78,6 +117,8 @@ export function Page({
|
|
|
78
117
|
breadcrumbs,
|
|
79
118
|
renderLink,
|
|
80
119
|
actions,
|
|
120
|
+
badges,
|
|
121
|
+
description,
|
|
81
122
|
measure = "full",
|
|
82
123
|
isLoading,
|
|
83
124
|
loadingState,
|
|
@@ -115,8 +156,30 @@ export function Page({
|
|
|
115
156
|
if (slotViolation !== null) {
|
|
116
157
|
throw new Error(slotViolation);
|
|
117
158
|
}
|
|
118
|
-
|
|
119
|
-
|
|
159
|
+
// ALWAYS a trail, even of one, and that is the point rather than a simplification.
|
|
160
|
+
// An overview's title has to be the same node in the same boxes as a detail's, or the name
|
|
161
|
+
// moves the moment you open a record: one path rendered a bare h1 and the other rendered
|
|
162
|
+
// the h1 inside nav > ol > li, so the text sat at two slightly different places and the
|
|
163
|
+
// transition showed it. A trail of one is the overview's title; append an ancestor and the
|
|
164
|
+
// same leaf slides right behind its parents, which is the only motion there should be.
|
|
165
|
+
const trail: BreadcrumbItem[] = [...(breadcrumbs ?? []), { label: title }];
|
|
166
|
+
// Normalised rather than branched at the site, so one badge and several take the same path
|
|
167
|
+
// and the row exists or does not exist for one reason.
|
|
168
|
+
//
|
|
169
|
+
// Filtered on RENDERABILITY, not on undefined, and the difference is the whole bug it fixes:
|
|
170
|
+
// the idiomatic call is `badges={isPublished && <Badge/>}`, which hands this `false` when the
|
|
171
|
+
// condition fails. Testing `!== undefined` let `[false]` through, so the row existed, was
|
|
172
|
+
// empty, and put a gap beside the title — the exact "absent is not an empty row"
|
|
173
|
+
// invariant the band promises. `false`, `null`, `undefined` and `""` all mean absent here.
|
|
174
|
+
const badgeList: readonly ReactNode[] = ([] as ReactNode[])
|
|
175
|
+
.concat(badges ?? [])
|
|
176
|
+
.filter((badge) => badge !== false && badge !== null && badge !== undefined && badge !== "");
|
|
177
|
+
// Same test for the lead line, for the same reason: `description={error && error.message}`.
|
|
178
|
+
const hasDescription =
|
|
179
|
+
description !== undefined &&
|
|
180
|
+
description !== null &&
|
|
181
|
+
description !== false &&
|
|
182
|
+
description !== "";
|
|
120
183
|
const body =
|
|
121
184
|
error !== null && error !== undefined ? (
|
|
122
185
|
(errorState ?? <ErrorState error={error} />)
|
|
@@ -138,15 +201,23 @@ export function Page({
|
|
|
138
201
|
wrapper of its own — not even a display: contents one, since article.children is a
|
|
139
202
|
DOM traversal and would see it. */}
|
|
140
203
|
<header data-terp="page-header">
|
|
141
|
-
{hasAncestors && (
|
|
142
|
-
<div data-terp="page-breadcrumbs">
|
|
143
|
-
<Breadcrumbs items={trail} renderLink={renderLink} />
|
|
144
|
-
</div>
|
|
145
|
-
)}
|
|
146
204
|
<div data-terp="page-heading">
|
|
147
|
-
|
|
148
|
-
|
|
205
|
+
{/* The trail carries the h1 as its leaf. No wrapper of its own any more: the crumb
|
|
206
|
+
row it used to sit in existed to hold a 2rem floor above the title row, and
|
|
207
|
+
there is no title row to be above. */}
|
|
208
|
+
<Breadcrumbs items={trail} renderLink={renderLink} currentAs="h1" />
|
|
209
|
+
{badgeList.length > 0 && (
|
|
210
|
+
<div data-terp="page-badges">
|
|
211
|
+
{badgeList.map((badge, index) => (
|
|
212
|
+
<Fragment key={index}>{badge}</Fragment>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
{hasDescription && (
|
|
217
|
+
<p data-terp="page-description">{resolveUiTextNode(description, resolve)}</p>
|
|
218
|
+
)}
|
|
149
219
|
</div>
|
|
220
|
+
{actions}
|
|
150
221
|
</header>
|
|
151
222
|
{/* Reset the slot for the body's own subtree, so nested content is never judged
|
|
152
223
|
by an ancestor archetype's slot. */}
|
package/src/layout.test.tsx
CHANGED
|
@@ -259,6 +259,42 @@ describe("DetailList", () => {
|
|
|
259
259
|
expect(el).toHaveAttribute("data-columns", "2");
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
it("stamps a gap only when one was asked for, because the default is the layout's", () => {
|
|
263
|
+
// Unlike Stack's and Grid's, this attribute is conditional, and the condition is the point:
|
|
264
|
+
// the default row gap differs per layout — --space-3 for aligned and stacked, --space-1 for
|
|
265
|
+
// inline, where a pair is one line of a paragraph rather than a block. There is no single
|
|
266
|
+
// default to compare against, so an unset gap must stamp NOTHING and leave the layout rule
|
|
267
|
+
// standing. Stamping a default here would silently flatten all three back to one value,
|
|
268
|
+
// which is the grouping defect the layouts' own gap was added to fix.
|
|
269
|
+
const { rerender } = render(
|
|
270
|
+
<DetailList data-testid="dl" layout="aligned" items={[{ label: "Owner", value: "Ada" }]} />,
|
|
271
|
+
);
|
|
272
|
+
expect(screen.getByTestId("dl").hasAttribute("data-gap")).toBe(false);
|
|
273
|
+
rerender(
|
|
274
|
+
<DetailList
|
|
275
|
+
data-testid="dl"
|
|
276
|
+
layout="aligned"
|
|
277
|
+
gap={6}
|
|
278
|
+
items={[{ label: "Owner", value: "Ada" }]}
|
|
279
|
+
/>,
|
|
280
|
+
);
|
|
281
|
+
expect(screen.getByTestId("dl")).toHaveAttribute("data-gap", "6");
|
|
282
|
+
// Step 0 is a real step and must survive the conditional — a falsy-check rather than an
|
|
283
|
+
// undefined-check would drop it, and "no gap between pairs" would silently render the
|
|
284
|
+
// layout's default instead.
|
|
285
|
+
rerender(
|
|
286
|
+
<DetailList
|
|
287
|
+
data-testid="dl"
|
|
288
|
+
layout="aligned"
|
|
289
|
+
gap={0}
|
|
290
|
+
items={[{ label: "Owner", value: "Ada" }]}
|
|
291
|
+
/>,
|
|
292
|
+
);
|
|
293
|
+
expect(screen.getByTestId("dl")).toHaveAttribute("data-gap", "0");
|
|
294
|
+
// And still no inline style: every step is a rule the sheet keys on (ADR 0097).
|
|
295
|
+
expect(screen.getByTestId("dl").getAttribute("style")).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
262
298
|
it("puts no colon in the markup, because two layouts must not have one", () => {
|
|
263
299
|
// The colon is a rule on the inline layout, not a text node — `aligned` and `stacked` must
|
|
264
300
|
// not carry one, and no rule can withdraw a text node. It is decorative either way: the
|
package/src/layout.tsx
CHANGED
|
@@ -241,6 +241,22 @@ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>,
|
|
|
241
241
|
layout?: DetailListLayout;
|
|
242
242
|
/** Pairs per row (default `1`). */
|
|
243
243
|
columns?: 1 | 2;
|
|
244
|
+
/**
|
|
245
|
+
* Distance **between pairs**, as a step on the token spacing scale.
|
|
246
|
+
*
|
|
247
|
+
* Defaults to the layout's own: `--space-3` for `aligned` and `stacked`, `--space-1` for
|
|
248
|
+
* `inline`, where a pair is one line of a paragraph rather than a block of its own.
|
|
249
|
+
*
|
|
250
|
+
* It sets the ROW gap only, and that is a guarantee rather than an implementation detail. The
|
|
251
|
+
* column gap is the label-to-value distance in `aligned` and the space between pair groups at
|
|
252
|
+
* `columns={2}`, so a caller who could set it would be reaching past the layout into its
|
|
253
|
+
* internals; a `gap` shorthand from here would reset both.
|
|
254
|
+
*
|
|
255
|
+
* The prop exists because its absence was the reason the value was unreachable: `Stack`,
|
|
256
|
+
* `Grid` and `Card` all take a `gap` on this scale, and app modules may write neither `style`
|
|
257
|
+
* nor `className` (ADR 0059), so a detail list needing looser rows had nowhere to say so.
|
|
258
|
+
*/
|
|
259
|
+
gap?: SpaceToken;
|
|
244
260
|
}
|
|
245
261
|
|
|
246
262
|
/**
|
|
@@ -263,11 +279,30 @@ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>,
|
|
|
263
279
|
* layout alone — `aligned` and `stacked` must not have one, and a text node cannot be
|
|
264
280
|
* withdrawn by a rule. It is decorative either way: the `<dt>` / `<dd>` pairing is what carries
|
|
265
281
|
* the relationship to assistive tech.
|
|
282
|
+
*
|
|
283
|
+
* Three later corrections are worth knowing, because each was measured rather than reasoned:
|
|
284
|
+
*
|
|
285
|
+
* - **The label is muted, and `aligned` shares that rule with `stacked`.** It did not, and the
|
|
286
|
+
* gap was the whole legibility complaint: an aligned `<dt>` rendered at the value's own size,
|
|
287
|
+
* weight and ink, so a card of five labelled values was a wall of bold text with nothing
|
|
288
|
+
* saying which half to read first. `inline` keeps its plain term deliberately — there the
|
|
289
|
+
* label is half a sentence.
|
|
290
|
+
* - **Rows are `--space-3` apart in both non-inline layouts.** At the old `--space-1` the
|
|
291
|
+
* distance within a pair equalled the distance between pairs, so nothing grouped. {@link
|
|
292
|
+
* DetailListProps.gap} makes it a caller's choice on the same scale as every other primitive.
|
|
293
|
+
* - **It reflows to one column below the framework's viewport cutover**, where `Grid`
|
|
294
|
+
* deliberately does not for a fixed `columns` count. The asymmetry is a decision: `Grid`
|
|
295
|
+
* publishes `columns="auto"` as its responsive answer and this component's closed `1 | 2` has
|
|
296
|
+
* no such escape, so the reflow has to be its own. Above the cutover the shape is what it
|
|
297
|
+
* was, bar a label column now capped at `max-content` rather than floored at min-content;
|
|
298
|
+
* below it, four tracks in a phone's width met `overflow-wrap: anywhere` — correct for an
|
|
299
|
+
* unbreakable digest, wrong as a way to fit a label — and broke ordinary values mid-word.
|
|
266
300
|
*/
|
|
267
301
|
export function DetailList({
|
|
268
302
|
items,
|
|
269
303
|
layout = "inline",
|
|
270
304
|
columns = 1,
|
|
305
|
+
gap,
|
|
271
306
|
...rest
|
|
272
307
|
}: DetailListProps) {
|
|
273
308
|
const text = useUiText();
|
|
@@ -278,6 +313,10 @@ export function DetailList({
|
|
|
278
313
|
// `inline` and one column are the base rule, so neither stamps an attribute.
|
|
279
314
|
data-layout={layout === "inline" ? undefined : layout}
|
|
280
315
|
data-columns={columns === 1 ? undefined : String(columns)}
|
|
316
|
+
// No default to compare against: the default row gap is the LAYOUT's, so an unset gap
|
|
317
|
+
// must stamp no attribute at all and leave the layout rule standing. Stack's and Grid's
|
|
318
|
+
// gaps stamp unconditionally because their defaults are one value, not three.
|
|
319
|
+
data-gap={gap === undefined ? undefined : String(gap)}
|
|
281
320
|
>
|
|
282
321
|
{items.map((item, index) => (
|
|
283
322
|
<div key={index} data-terp="detail-list-row">
|