@terpjs/react-core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. package/src/unwrap.ts +118 -32
@@ -0,0 +1,213 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { NavLinkContext } from "./navLink";
6
+ import { Code, Heading, Link, Text } from "./typography";
7
+
8
+ afterEach(cleanup);
9
+
10
+ describe("Heading", () => {
11
+ it("takes its element from the level and its size from a separate default", () => {
12
+ render(
13
+ <>
14
+ <Heading level={2}>two</Heading>
15
+ <Heading level={3}>three</Heading>
16
+ <Heading level={4}>four</Heading>
17
+ </>,
18
+ );
19
+ for (const [level, size] of [
20
+ [2, "lg"],
21
+ [3, "base"],
22
+ [4, "sm"],
23
+ ] as const) {
24
+ const el = screen.getByRole("heading", { level });
25
+ expect(el.tagName).toBe(`H${level}`);
26
+ expect(el).toHaveAttribute("data-size", size);
27
+ expect(el.getAttribute("style")).toBeNull();
28
+ }
29
+ });
30
+
31
+ it("lets a level carry any size, which is the point of separating them", () => {
32
+ // A visually small h2 is a legitimate thing to want, and forcing level and size together
33
+ // is what makes an author pick the wrong element to get the right size — breaking the
34
+ // outline a screen-reader user navigates by, as a styling decision.
35
+ render(
36
+ <Heading level={2} size="sm">
37
+ small but second
38
+ </Heading>,
39
+ );
40
+ const el = screen.getByRole("heading", { level: 2 });
41
+ expect(el.tagName).toBe("H2");
42
+ expect(el).toHaveAttribute("data-size", "sm");
43
+ });
44
+ });
45
+
46
+ describe("Text", () => {
47
+ it("leaves the default tone and step unstamped", () => {
48
+ render(<Text data-testid="copy">body</Text>);
49
+ const el = screen.getByTestId("copy");
50
+ expect(el.tagName).toBe("P");
51
+ expect(el).toHaveAttribute("data-terp", "text");
52
+ expect(el.hasAttribute("data-tone")).toBe(false);
53
+ expect(el.hasAttribute("data-size")).toBe(false);
54
+ expect(el.hasAttribute("data-measure")).toBe(false);
55
+ expect(el.getAttribute("style")).toBeNull();
56
+ });
57
+
58
+ it("names a tone, a step, a measure and its element", () => {
59
+ render(
60
+ <Text data-testid="copy" as="span" tone="muted" size="sm" measure="narrow">
61
+ body
62
+ </Text>,
63
+ );
64
+ const el = screen.getByTestId("copy");
65
+ expect(el.tagName).toBe("SPAN");
66
+ expect(el).toHaveAttribute("data-tone", "muted");
67
+ expect(el).toHaveAttribute("data-size", "sm");
68
+ expect(el).toHaveAttribute("data-measure", "narrow");
69
+ });
70
+ });
71
+
72
+ describe("Code", () => {
73
+ it("is a bare code element when inline", () => {
74
+ render(<Code data-testid="snippet">terp dev</Code>);
75
+ const el = screen.getByTestId("snippet");
76
+ expect(el.tagName).toBe("CODE");
77
+ expect(el.hasAttribute("tabindex")).toBe(false);
78
+ });
79
+
80
+ it("wraps a block in a focusable pre, which is what preserves the whitespace", () => {
81
+ // The `<pre>` is not decoration: a `<code>` alone collapses whitespace, so a multi-line
82
+ // snippet in one runs together. And it scrolls, which is why it is focusable — a scroll
83
+ // container a keyboard cannot reach cannot be scrolled at all (SC 2.1.1).
84
+ render(<Code block>{"a\n b"}</Code>);
85
+ const pre = document.querySelector('[data-terp="code-block"]')!;
86
+ expect(pre.tagName).toBe("PRE");
87
+ expect(pre).toHaveAttribute("tabindex", "0");
88
+ const code = pre.querySelector('[data-terp="code"]')!;
89
+ expect(code.tagName).toBe("CODE");
90
+ expect(code.textContent).toBe("a\n b");
91
+ });
92
+ });
93
+
94
+ describe("Link", () => {
95
+ const renderer = ({
96
+ to,
97
+ children,
98
+ attributes,
99
+ }: {
100
+ to: string;
101
+ children: React.ReactNode;
102
+ attributes?: Record<string, unknown>;
103
+ }) => (
104
+ <a href={`#routed${to}`} data-testid="routed" {...attributes}>
105
+ {children}
106
+ </a>
107
+ );
108
+
109
+ it("routes an in-app path through the ambient renderer, marking a wrapper", () => {
110
+ // The marker lands on a wrapper the component owns, and that is deliberate rather than
111
+ // forced: it could travel to the anchor through the renderer's `attributes`, and must not,
112
+ // because an app supplying its own renderer that ignores them is source-compatible and the
113
+ // failure would be an unstyled link with no error. A styling hook cannot depend on a caller
114
+ // honouring a seam. HubCard's pattern, for a related reason.
115
+ render(
116
+ <NavLinkContext.Provider value={renderer}>
117
+ <Link to="/records">records</Link>
118
+ </NavLinkContext.Provider>,
119
+ );
120
+ const anchor = screen.getByTestId("routed");
121
+ expect(anchor.getAttribute("href")).toBe("#routed/records");
122
+ const wrapper = anchor.parentElement!;
123
+ expect(wrapper.tagName).toBe("SPAN");
124
+ expect(wrapper).toHaveAttribute("data-terp", "link");
125
+ });
126
+
127
+ it("puts the caller's own attributes on the ANCHOR, in both branches", () => {
128
+ // The defect this pins: `rest` used to land on the wrapper for an in-app path and on the
129
+ // anchor for an external one, so the same prop worked or silently did nothing depending on
130
+ // whether the destination started with a slash. An `aria-label` on a `<span>` around a link
131
+ // is ignored — the link keeps its content as its name and the caller's intent disappears
132
+ // with no error, which is the worst available failure.
133
+ const { rerender } = render(
134
+ <NavLinkContext.Provider value={renderer}>
135
+ <Link to="/records" aria-label="All records" id="records-link">
136
+ records
137
+ </Link>
138
+ </NavLinkContext.Provider>,
139
+ );
140
+ const routed = screen.getByRole("link", { name: "All records" });
141
+ expect(routed.tagName).toBe("A");
142
+ expect(routed.getAttribute("id")).toBe("records-link");
143
+
144
+ rerender(
145
+ <Link to="https://example.com" aria-label="The docs">
146
+ docs
147
+ </Link>,
148
+ );
149
+ expect(screen.getByRole("link", { name: "The docs" }).tagName).toBe("A");
150
+ });
151
+
152
+ it("falls back to a plain anchor with no router above it", () => {
153
+ // The story/test/bespoke-shell case, and the same degradation every layout component that
154
+ // renders a link already does. Without it a Link outside a Terp router renders nothing
155
+ // navigable at all.
156
+ render(<Link to="/records">records</Link>);
157
+ const el = screen.getByRole("link", { name: "records" });
158
+ expect(el.tagName).toBe("A");
159
+ expect(el).toHaveAttribute("href", "/records");
160
+ expect(el).toHaveAttribute("data-terp", "link");
161
+ });
162
+
163
+ it("refuses a bare relative destination rather than reloading the page", () => {
164
+ // The silent case this replaces: `to="records"` fell through to the external branch and
165
+ // rendered a relative anchor — a full reload to a URL resolved against wherever the user
166
+ // was, with the router's guard skipped. Every route a manifest declares is absolute, so
167
+ // there is no reading of a bare path that is what the caller wanted.
168
+ expect(() => render(<Link to="records">records</Link>)).toThrow(/leading slash/);
169
+ });
170
+
171
+ it("accepts a same-page fragment and a scheme, which are an anchor's own business", () => {
172
+ render(
173
+ <>
174
+ <Link to="#section">jump</Link>
175
+ <Link to="mailto:ops@example.com">mail</Link>
176
+ </>,
177
+ );
178
+ expect(screen.getByRole("link", { name: "jump" })).toHaveAttribute("href", "#section");
179
+ expect(screen.getByRole("link", { name: "mail" })).toHaveAttribute(
180
+ "href",
181
+ "mailto:ops@example.com",
182
+ );
183
+ });
184
+
185
+ it("gives an external new tab rel=noreferrer, and adds neither otherwise", () => {
186
+ // Without `rel`, the opened page can reach back through `window.opener` — the
187
+ // reverse-tabnabbing shape the boundary lint's own no-unsafe-target-blank rule exists for.
188
+ const { rerender } = render(
189
+ <Link to="https://example.com" newTab>
190
+ out
191
+ </Link>,
192
+ );
193
+ const newTab = screen.getByRole("link", { name: "out" });
194
+ expect(newTab).toHaveAttribute("target", "_blank");
195
+ expect(newTab).toHaveAttribute("rel", "noreferrer");
196
+ rerender(<Link to="https://example.com">out</Link>);
197
+ const sameTab = screen.getByRole("link", { name: "out" });
198
+ expect(sameTab.hasAttribute("target")).toBe(false);
199
+ expect(sameTab.hasAttribute("rel")).toBe(false);
200
+ });
201
+
202
+ it("ignores newTab for an in-app path, which has no external tab to open", () => {
203
+ render(
204
+ <NavLinkContext.Provider value={renderer}>
205
+ <Link to="/records" newTab>
206
+ records
207
+ </Link>
208
+ </NavLinkContext.Provider>,
209
+ );
210
+ const anchor = screen.getByTestId("routed");
211
+ expect(anchor.hasAttribute("target")).toBe(false);
212
+ });
213
+ });
@@ -0,0 +1,255 @@
1
+ import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
2
+
3
+ import { useNavLink } from "./navLink";
4
+ import { injectTerpStyles } from "./styles";
5
+
6
+ injectTerpStyles();
7
+
8
+ /**
9
+ * The prose primitives, and the gap they close is narrower than "we had no typography".
10
+ *
11
+ * A module could always render a `<p>` or a `<code>`. What it could not do is give either any
12
+ * treatment: app modules may not write `style` or `className` (ADR 0059), and a bare element
13
+ * carries no `data-terp`, so no rule in the sheet can reach it. The framework's own generated
14
+ * home page shipped a bare `<p>` and a bare `<code>` for exactly that reason. So these are the
15
+ * marker, and the marker is what makes the ink themeable.
16
+ *
17
+ * ## They are the first readers of the published type scale
18
+ *
19
+ * `--font-line-height-*` and `--font-letter-spacing-*` shipped in 0.7.0 and nothing read them —
20
+ * the same shape as the motion tokens, and resolved differently, because the facts differ.
21
+ * Every motion literal mapped exactly onto a token, so wiring them was inert. Here only 8 of
22
+ * the sheet's 32 literals map: it writes `line-height` values of 1.2, 1.25, 1.3, 1.4 and 1.5,
23
+ * and the published scale offers 1.2, 1.35, 1.5 and 1.7. Converting the eight that match and
24
+ * leaving thirteen that do not would be a half-migration, and converting the rest means
25
+ * *changing rendered line heights* across a dozen components — a deliberate typography pass
26
+ * with its own baselines, not a token wiring done in passing.
27
+ *
28
+ * New components have no such problem: nothing depends on their metrics yet, so they adopt the
29
+ * published scale and give it its first consumers. `tokens.guard.test.ts` tracks the remainder
30
+ * as an exact list, so the reconciliation stays a decision somebody has to make rather than a
31
+ * thing that quietly never happens.
32
+ */
33
+
34
+ /** Semantic heading level. `1` is absent on purpose — see {@link Heading}. */
35
+ export type HeadingLevel = 2 | 3 | 4;
36
+
37
+ /** Type step for a heading, independent of its level. */
38
+ export type HeadingSize = "sm" | "base" | "lg" | "xl";
39
+
40
+ export interface HeadingProps extends Omit<HTMLAttributes<HTMLHeadingElement>, "style"> {
41
+ /**
42
+ * The semantic level, which decides the element.
43
+ *
44
+ * There is no `1`, and the reason is structural rather than stylistic: `Page` renders the
45
+ * single `<h1>` of every routed view, and every routed view is a `Page` (the page-archetype
46
+ * control refuses one that is not). A second `<h1>` in a body would give the document two
47
+ * top-level headings and break the outline a screen-reader user navigates by — so the
48
+ * component cannot express it.
49
+ */
50
+ level: HeadingLevel;
51
+ /**
52
+ * The type step, defaulting per level (`2` → `lg`, `3` → `base`, `4` → `sm`).
53
+ *
54
+ * Decoupled from `level` deliberately: the level is an outline fact and the size is a design
55
+ * one, and forcing them together is what makes authors reach for the wrong element to get the
56
+ * right size. A visually small `<h2>` is a legitimate thing to want.
57
+ */
58
+ size?: HeadingSize;
59
+ children?: ReactNode;
60
+ }
61
+
62
+ const DEFAULT_HEADING_SIZE: Record<HeadingLevel, HeadingSize> = { 2: "lg", 3: "base", 4: "sm" };
63
+
64
+ /**
65
+ * A section heading inside a page body — `h2`–`h4`, with its size a separate choice.
66
+ *
67
+ * `data-size` is stamped for every heading, which breaks the idiom the rest of the package
68
+ * follows — density, Button's `md`, Grid's `auto`, Card's `boxed` and Text's own default all
69
+ * leave the default unstamped because the base rule *is* the default. A heading has no single
70
+ * default to fold into a base rule: the default depends on the level, so the base rule carries
71
+ * the weight and metrics and every size carries its own step. Stamping all three is what keeps
72
+ * the sheet from needing a rule per level as well as per size.
73
+ */
74
+ export function Heading({ level, size, children, ...rest }: HeadingProps) {
75
+ const Component = `h${level}` as "h2" | "h3" | "h4";
76
+ const step = size ?? DEFAULT_HEADING_SIZE[level];
77
+ return (
78
+ <Component {...rest} data-terp="heading" data-size={step}>
79
+ {children}
80
+ </Component>
81
+ );
82
+ }
83
+
84
+ /** Ink weight for body copy. */
85
+ export type TextTone = "default" | "muted" | "subtle";
86
+
87
+ /** Type step for body copy. */
88
+ export type TextSize = "xs" | "sm" | "base" | "lg";
89
+
90
+ export interface TextProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
91
+ /** The rendered element — `"p"` by default; `"span"` for text inside a line. */
92
+ as?: "p" | "span" | "div";
93
+ /** Ink weight (default `"default"`; `"muted"` for secondary copy, `"subtle"` for hints). */
94
+ tone?: TextTone;
95
+ /** Type step (default `"base"`). */
96
+ size?: TextSize;
97
+ /**
98
+ * Cap the line length for readability (default off).
99
+ *
100
+ * A measure is the one typographic control that is about the container rather than the text,
101
+ * and it is enumerable here — `"narrow"` / `"base"` — rather than a length, so there are no
102
+ * arbitrary column widths for the same reason `gap` is a token index.
103
+ *
104
+ * It caps a **block**, so it does nothing on `as="span"` — `max-width` has no effect on an
105
+ * inline box. That combination is a no-op rather than an error, which is worth knowing because
106
+ * the failure is silent; the visible cue is prose that simply never wraps where it was asked
107
+ * to.
108
+ */
109
+ measure?: "narrow" | "base";
110
+ children?: ReactNode;
111
+ }
112
+
113
+ /** Body copy with themeable ink — the thing a bare `<p>` in a module cannot be. */
114
+ export function Text({
115
+ as: Component = "p",
116
+ tone = "default",
117
+ size = "base",
118
+ measure,
119
+ children,
120
+ ...rest
121
+ }: TextProps) {
122
+ return (
123
+ <Component
124
+ {...rest}
125
+ data-terp="text"
126
+ // `default` and `base` are the base rule, so their attributes would describe the default
127
+ // twice — the idiom density, Button's `md`, Grid's `auto` and Card's `boxed` all use.
128
+ data-tone={tone === "default" ? undefined : tone}
129
+ data-size={size === "base" ? undefined : size}
130
+ data-measure={measure}
131
+ >
132
+ {children}
133
+ </Component>
134
+ );
135
+ }
136
+
137
+ export interface CodeProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
138
+ /**
139
+ * Render as a block rather than inline.
140
+ *
141
+ * A block wraps the `<code>` in a `<pre>`, which is what preserves the whitespace — a
142
+ * `<code>` alone collapses it, so a multi-line snippet in one runs together. `tabIndex={0}`
143
+ * goes on the `<pre>` because it scrolls: a scrollable region that cannot be focused cannot
144
+ * be scrolled by keyboard, which is SC 2.1.1.
145
+ */
146
+ block?: boolean;
147
+ children?: ReactNode;
148
+ }
149
+
150
+ /** An identifier or a snippet, in the mono family, with whitespace preserved when `block`. */
151
+ export function Code({ block = false, children, ...rest }: CodeProps) {
152
+ if (!block) {
153
+ return (
154
+ <code {...rest} data-terp="code">
155
+ {children}
156
+ </code>
157
+ );
158
+ }
159
+ return (
160
+ // tabIndex after the spread, not before: a caller removing it would leave a scroll
161
+ // container no keyboard can reach, which is the SC 2.1.1 failure it exists to prevent.
162
+ <pre {...rest} data-terp="code-block" tabIndex={0}>
163
+ <code data-terp="code">{children}</code>
164
+ </pre>
165
+ );
166
+ }
167
+
168
+ export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "style" | "href"> {
169
+ /** Destination. A path (`/records`) routes in-app; anything else is an external anchor. */
170
+ to: string;
171
+ /** Open an external destination in a new tab (ignored for in-app paths). */
172
+ newTab?: boolean;
173
+ children?: ReactNode;
174
+ }
175
+
176
+ /** Whether `to` is an in-app path rather than an external destination. */
177
+ function isInApp(to: string): boolean {
178
+ return to.startsWith("/");
179
+ }
180
+
181
+ /** A scheme (`https:`, `mailto:`, `tel:`) or a same-page fragment — an anchor's own business. */
182
+ function isExternal(to: string): boolean {
183
+ return to.startsWith("#") || /^[a-z][a-z0-9+.\-]*:/i.test(to);
184
+ }
185
+
186
+ /**
187
+ * Refuse a destination that is neither, fail closed and directive.
188
+ *
189
+ * `to="records"` — no leading slash, no scheme — used to fall through to the external branch
190
+ * and render a relative anchor: a full page reload to a URL resolved against wherever the user
191
+ * happened to be, with the role-aware guard skipped. A caller writing that means the route, and
192
+ * every route in a manifest is absolute, so the silent reload is never what was wanted. Loud is
193
+ * strictly better, and it matches how `useRouteParam` and `useTerpNavigate` treat a path they
194
+ * cannot honour.
195
+ */
196
+ function assertRoutable(to: string): void {
197
+ if (!isInApp(to) && !isExternal(to)) {
198
+ throw new Error(
199
+ `Link "to" must be an in-app path ("/records"), an absolute URL ` +
200
+ `("https://example.com"), or a fragment ("#section") — got "${to}". A bare relative ` +
201
+ "path renders an anchor that reloads the page and skips the router's guard; every " +
202
+ "route a manifest declares is absolute, so add the leading slash.",
203
+ );
204
+ }
205
+ }
206
+
207
+ /**
208
+ * A link with themeable ink, routing in-app paths through the surrounding router.
209
+ *
210
+ * Two things it exists for. A bare `<a>` carries no marker, so no rule in the sheet can reach
211
+ * it — which is why the shell's nav links and the breadcrumb trail each needed a selector of
212
+ * their own, and why a link in a module's prose had no treatment at all. And an in-app
213
+ * `<a href="/…">` bypasses the router: a full reload, and the role-aware guard never runs. The
214
+ * boundary lint refuses that anchor in module code; this is the thing to use instead.
215
+ *
216
+ * Outside a Terp router it degrades to a plain anchor, like every other layout component that
217
+ * renders a link, so it still works in a story or a test tree.
218
+ *
219
+ * An external destination opened in a new tab gets `rel="noreferrer"` — without it the opened
220
+ * page can reach back through `window.opener`, which is the reverse-tabnabbing shape the
221
+ * boundary lint's own `no-unsafe-target-blank` rule exists for.
222
+ */
223
+ export function Link({ to, newTab = false, children, ...rest }: LinkProps) {
224
+ const navLink = useNavLink();
225
+ assertRoutable(to);
226
+ if (isInApp(to) && navLink !== null) {
227
+ // Two destinations for two kinds of attribute, and the split is deliberate.
228
+ //
229
+ // The caller's own attributes go to the ANCHOR through the renderer's `attributes`, because
230
+ // that is the only place they mean anything: an `aria-label` on a wrapper is ignored, so it
231
+ // would be silently dropped for an in-app path and honoured for an external one — the same
232
+ // prop behaving differently depending on whether the destination starts with a slash.
233
+ //
234
+ // The MARKER stays on the wrapper, which is `HubCard`'s pattern. It could travel through
235
+ // `attributes` too, and must not: an app supplying its own renderer that destructures only
236
+ // `{ to, children }` is source-compatible and forwards nothing, and the failure would be an
237
+ // unstyled link with no error. A component's own styling hook cannot depend on a caller
238
+ // honouring a seam. The `<span>` is inline, so it adds no box to a line of prose.
239
+ return (
240
+ <span data-terp="link">{navLink({ to, children, attributes: rest })}</span>
241
+ );
242
+ }
243
+ const external = !isInApp(to) && newTab;
244
+ return (
245
+ <a
246
+ {...rest}
247
+ data-terp="link"
248
+ href={to}
249
+ target={external ? "_blank" : undefined}
250
+ rel={external ? "noreferrer" : undefined}
251
+ >
252
+ {children}
253
+ </a>
254
+ );
255
+ }
@@ -0,0 +1,63 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { Avatar, userInitials } from "./Avatar";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("Avatar", () => {
10
+ it("derives initials from an email's local part", () => {
11
+ expect(userInitials("jane.doe@example.com")).toBe("JD");
12
+ expect(userInitials("admin@example.test")).toBe("A");
13
+ expect(userInitials("@example.test")).toBe("?");
14
+ });
15
+
16
+ it("splits a NAME on whitespace, which is the other half of what `from` documents", () => {
17
+ // The prop and the README both say "an email or a name". The split had no whitespace class, so
18
+ // a name produced one letter — a documented input with an undocumented answer.
19
+ expect(userInitials("Jane Doe")).toBe("JD");
20
+ expect(userInitials("ada lovelace king")).toBe("AL");
21
+ // And an email still behaves exactly as before.
22
+ expect(userInitials("jane.doe@example.com")).toBe("JD");
23
+ });
24
+
25
+ it("treats an empty string as absent on both props", () => {
26
+ // `??` only catches null and undefined, so an empty override painted a blank tile — which
27
+ // reads as a loading state rather than as "no initials".
28
+ const { container, rerender } = render(<Avatar from="jane.doe@example.com" initials="" />);
29
+ const tile = () => container.querySelector('[data-terp="avatar"]');
30
+ expect(tile()).toHaveTextContent("JD");
31
+ rerender(<Avatar from="" />);
32
+ expect(tile()).toHaveTextContent("?");
33
+ rerender(<Avatar />);
34
+ expect(tile()).toHaveTextContent("?");
35
+ });
36
+
37
+ it("takes explicit initials over anything it would have derived", () => {
38
+ render(<Avatar from="jane.doe@example.com" initials="ZZ" />);
39
+ expect(screen.getByText("ZZ")).toBeInTheDocument();
40
+ expect(screen.queryByText("JD")).toBeNull();
41
+ });
42
+
43
+ it("stamps a size attribute for sm and none for the default", () => {
44
+ // The same bargain as every other sized component: md IS the base rule, so an attribute for it
45
+ // would leave two places describing the standard tile. The sheet gate asserts the converse —
46
+ // that no `[data-size="md"]` rule exists — and this asserts the component never emits one.
47
+ const { container, rerender } = render(<Avatar from="a.b@example.com" />);
48
+ const tile = () => container.querySelector('[data-terp="avatar"]');
49
+ expect(tile()).not.toBeNull();
50
+ expect(tile()).not.toHaveAttribute("data-size");
51
+ rerender(<Avatar from="a.b@example.com" size="md" />);
52
+ expect(tile()).not.toHaveAttribute("data-size");
53
+ rerender(<Avatar from="a.b@example.com" size="sm" />);
54
+ expect(tile()).toHaveAttribute("data-size", "sm");
55
+ });
56
+
57
+ it("is hidden from assistive tech, because the name it abbreviates is rendered beside it", () => {
58
+ // Announcing "JD" before "jane.doe@example.com" is a puzzle rather than information, and both
59
+ // callers render the full identity next to the tile.
60
+ render(<Avatar from="jane.doe@example.com" />);
61
+ expect(screen.getByText("JD")).toHaveAttribute("aria-hidden", "true");
62
+ });
63
+ });
@@ -0,0 +1,65 @@
1
+ import { injectTerpStyles } from "../styles";
2
+
3
+ injectTerpStyles();
4
+
5
+ /**
6
+ * Two letters from an email's local part — `jane.doe@…` becomes `JD`, `admin@…` becomes `A`.
7
+ *
8
+ * Lives here rather than beside the account menu because it is what an avatar is made of, and both
9
+ * places that render one needed it. Still exported under its own name; it was public before this
10
+ * component existed.
11
+ */
12
+ export function userInitials(email: string): string {
13
+ const local = email.split("@")[0] ?? "";
14
+ // Whitespace is in the split for the same reason the dots are: the prop this feeds documents
15
+ // itself as taking "an email or a name", and a name separates its words with a space. Without
16
+ // it "Jane Doe" yielded "J" — a documented input producing an undocumented answer.
17
+ const words = local.split(/[\s._+-]+/).filter((word) => word.length > 0);
18
+ const initials = words.slice(0, 2).map((word) => word[0]!.toUpperCase());
19
+ return initials.join("") || "?";
20
+ }
21
+
22
+ export interface AvatarProps {
23
+ /** The text initials are derived from when `initials` is omitted — an email or a name. */
24
+ from?: string;
25
+ /** Explicit initials, for a caller with better material than `from`. */
26
+ initials?: string;
27
+ /** `md` = 3.5rem, the profile header. `sm` = 2rem, the account menu. Default `md`. */
28
+ size?: "sm" | "md";
29
+ }
30
+
31
+ /**
32
+ * The initials tile.
33
+ *
34
+ * A merge rather than an addition: the framework already shipped this component twice, as eleven
35
+ * identical declarations under `profile-avatar` and eleven more under `user-menu-avatar`, differing
36
+ * only in a width, a height and a font size. Two markers became one and the inventory went DOWN.
37
+ * That is the opposite of the `Section` / `Surface` decision, and for the opposite reason — those
38
+ * had no consumer, this had two of them wearing different names for the same thing.
39
+ *
40
+ * **No `src`.** `/me` returns `email`, `role_name` and `role_rank` and carries no avatar URL, so an
41
+ * image slot would be a prop with nothing behind it. Additive the day the payload grows one.
42
+ *
43
+ * The tile is `aria-hidden`: initials are a decoration for a name that is always rendered beside
44
+ * them, and announcing "JD" before "jane.doe@example.com" adds a puzzle rather than information.
45
+ * A caller that has no adjacent name has an unlabelled avatar, which is why this takes no label —
46
+ * the fix there is to render the name, not to spell out the letters.
47
+ */
48
+ export function Avatar({ from, initials, size = "md" }: AvatarProps) {
49
+ // An empty string counts as absent on both props, not as an override that renders nothing:
50
+ // `??` alone would let `initials=""` paint a blank tile, and a blank tile reads as loading.
51
+ const explicit = initials !== undefined && initials !== "" ? initials : undefined;
52
+ const derived = from !== undefined && from !== "" ? userInitials(from) : "?";
53
+
54
+ return (
55
+ <span
56
+ aria-hidden="true"
57
+ data-terp="avatar"
58
+ // No attribute for the default, matching every other sized component here: `md` IS the base
59
+ // rule, and stamping it would leave two places describing the standard tile.
60
+ data-size={size === "md" ? undefined : size}
61
+ >
62
+ {explicit ?? derived}
63
+ </span>
64
+ );
65
+ }