@terpjs/react-core 0.8.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.
- package/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- 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
|
+
}
|