@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
package/src/Page.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
import { useContext, useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
import { Breadcrumbs } from "./Breadcrumbs";
|
|
@@ -23,6 +23,23 @@ export interface PageProps {
|
|
|
23
23
|
renderLink?: RenderBreadcrumbLink;
|
|
24
24
|
/** Optional page-level actions, rendered on the heading row (e.g. a primary `Button`). */
|
|
25
25
|
actions?: ReactNode;
|
|
26
|
+
/**
|
|
27
|
+
* Cap the whole frame — header included — at a readable measure (default `"full"`).
|
|
28
|
+
*
|
|
29
|
+
* `"narrow"` is the single-column-of-controls shape: a create/edit form, a settings screen.
|
|
30
|
+
* The header is capped WITH the body here, unlike the shell's own content measure, and that
|
|
31
|
+
* asymmetry is the point rather than an inconsistency. A wide page with a narrow column wants
|
|
32
|
+
* its title and actions spanning the full track, because the band is what tells you the page
|
|
33
|
+
* is wider than its text. A form does not: a Save button floating a screen-width away from
|
|
34
|
+
* the field it saves is worse than one sitting over it.
|
|
35
|
+
*
|
|
36
|
+
* `data-measure` is the same attribute name `Text` uses for the same concept, keyed per
|
|
37
|
+
* marker, so there is one vocabulary for "measure" rather than two.
|
|
38
|
+
*
|
|
39
|
+
* `FormPage` and `SettingsPage` default it on; every other archetype leaves it `"full"`,
|
|
40
|
+
* which stamps nothing.
|
|
41
|
+
*/
|
|
42
|
+
measure?: "full" | "narrow";
|
|
26
43
|
/** Show the loading state instead of the body (the header stays for orientation). */
|
|
27
44
|
isLoading?: boolean;
|
|
28
45
|
/** Loading slot; defaults to the standard {@link LoadingState} spinner block. */
|
|
@@ -40,39 +57,6 @@ export interface PageProps {
|
|
|
40
57
|
children: ReactNode;
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
const pageStyle: CSSProperties = {
|
|
44
|
-
display: "grid",
|
|
45
|
-
gridTemplateColumns: "minmax(0, 1fr)",
|
|
46
|
-
gap: "var(--space-4)",
|
|
47
|
-
alignContent: "start",
|
|
48
|
-
minWidth: 0,
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const headerStyle: CSSProperties = { display: "grid", gap: "var(--space-2)" };
|
|
52
|
-
|
|
53
|
-
const breadcrumbRowStyle: CSSProperties = {
|
|
54
|
-
display: "flex",
|
|
55
|
-
alignItems: "center",
|
|
56
|
-
minHeight: "2rem",
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const titleRowStyle: CSSProperties = {
|
|
60
|
-
display: "flex",
|
|
61
|
-
alignItems: "center",
|
|
62
|
-
justifyContent: "space-between",
|
|
63
|
-
gap: "var(--space-3)",
|
|
64
|
-
flexWrap: "wrap",
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const titleStyle: CSSProperties = {
|
|
68
|
-
margin: 0,
|
|
69
|
-
fontSize: "var(--font-size-lg)",
|
|
70
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
71
|
-
letterSpacing: 0,
|
|
72
|
-
color: "var(--color-neutral-900)",
|
|
73
|
-
lineHeight: 1.3,
|
|
74
|
-
};
|
|
75
|
-
|
|
76
60
|
/**
|
|
77
61
|
* The base content-page frame: every routed view is constructed the same way — one
|
|
78
62
|
* header holding the breadcrumb trail (when there is a path back up through the
|
|
@@ -85,12 +69,16 @@ const titleStyle: CSSProperties = {
|
|
|
85
69
|
* The frame also owns the async body states: `error` (which wins, so a failed
|
|
86
70
|
* query never hides behind a spinner) then `isLoading` replace the body while the
|
|
87
71
|
* header stays put, so the user keeps their place in the layers.
|
|
72
|
+
*
|
|
73
|
+
* It renders no inline styles: the frame's geometry and the title's type come from the
|
|
74
|
+
* injected react-core sheet, matched on the `data-terp` markers stamped below (ADR 0094).
|
|
88
75
|
*/
|
|
89
76
|
export function Page({
|
|
90
77
|
title,
|
|
91
78
|
breadcrumbs,
|
|
92
79
|
renderLink,
|
|
93
80
|
actions,
|
|
81
|
+
measure = "full",
|
|
94
82
|
isLoading,
|
|
95
83
|
loadingState,
|
|
96
84
|
error,
|
|
@@ -137,16 +125,26 @@ export function Page({
|
|
|
137
125
|
) : (
|
|
138
126
|
children
|
|
139
127
|
);
|
|
128
|
+
// Hoisted, the density/collapsed idiom: the default stamps nothing, so the expression has a
|
|
129
|
+
// branch, and a conditional written at the attribute is the form the marker scanner reads
|
|
130
|
+
// every string literal out of.
|
|
131
|
+
const measureAttribute = measure === "narrow" ? "narrow" : undefined;
|
|
140
132
|
return (
|
|
141
|
-
<article ref={articleRef}
|
|
142
|
-
<header
|
|
133
|
+
<article ref={articleRef} data-terp="page" data-measure={measureAttribute}>
|
|
134
|
+
{/* A <header> ELEMENT, and it has to stay one. The slot check above drops the header
|
|
135
|
+
from the body set by tagName, so re-rendering this as a marked <div> would put it
|
|
136
|
+
back in and fail every governed OverviewPage / DetailPage closed. The marker is
|
|
137
|
+
additive; the tag is load-bearing. For the same reason the body below takes no
|
|
138
|
+
wrapper of its own — not even a display: contents one, since article.children is a
|
|
139
|
+
DOM traversal and would see it. */}
|
|
140
|
+
<header data-terp="page-header">
|
|
143
141
|
{hasAncestors && (
|
|
144
|
-
<div
|
|
142
|
+
<div data-terp="page-breadcrumbs">
|
|
145
143
|
<Breadcrumbs items={trail} renderLink={renderLink} />
|
|
146
144
|
</div>
|
|
147
145
|
)}
|
|
148
|
-
<div
|
|
149
|
-
<h1
|
|
146
|
+
<div data-terp="page-heading">
|
|
147
|
+
<h1 data-terp="page-title">{resolve(title)}</h1>
|
|
150
148
|
{actions}
|
|
151
149
|
</div>
|
|
152
150
|
</header>
|
package/src/ProfileView.test.tsx
CHANGED
|
@@ -69,6 +69,21 @@ describe("ProfileView", () => {
|
|
|
69
69
|
);
|
|
70
70
|
expect(screen.getAllByText("jane.doe@example.com").length).toBeGreaterThanOrEqual(1);
|
|
71
71
|
expect(screen.getByText("editor (20)")).toBeInTheDocument();
|
|
72
|
+
|
|
73
|
+
// The identity block, which nothing asserted before: the initials tile was entirely
|
|
74
|
+
// unreached by any test, and is aria-hidden so axe skips it too. Markers plus the
|
|
75
|
+
// absence of a style attribute, because the geometry is a sheet rule now (ADR 0094) and
|
|
76
|
+
// jsdom computes no cascade. The address marker matters most of the three — its
|
|
77
|
+
// overflow-wrap has no specimen, since the mock session's address is short, so this is
|
|
78
|
+
// the only gate that the element a long address would need is still the marked one.
|
|
79
|
+
const avatar = document.querySelector('[data-terp="avatar"]');
|
|
80
|
+
expect(avatar).not.toBeNull();
|
|
81
|
+
expect(avatar?.textContent).toBe("JD");
|
|
82
|
+
expect(avatar?.getAttribute("style")).toBeNull();
|
|
83
|
+
const email = document.querySelector('[data-terp="profile-email"]');
|
|
84
|
+
expect(email?.textContent).toBe("jane.doe@example.com");
|
|
85
|
+
expect(email?.getAttribute("style")).toBeNull();
|
|
86
|
+
expect(document.querySelectorAll('[data-terp="profile-card"]')).toHaveLength(2);
|
|
72
87
|
// The stacked preference controls live here (settings surface).
|
|
73
88
|
expect(screen.getByLabelText("Theme")).toBeInTheDocument();
|
|
74
89
|
expect(screen.getByLabelText("Language")).toBeInTheDocument();
|
package/src/ProfileView.tsx
CHANGED
|
@@ -1,46 +1,21 @@
|
|
|
1
|
-
import type { CSSProperties } from "react";
|
|
2
|
-
|
|
3
1
|
import { Page } from "./Page";
|
|
4
2
|
import { useAuth } from "./TerpProvider";
|
|
5
3
|
import { LanguageSwitcher } from "./locale";
|
|
6
4
|
import { Stack, DetailList } from "./layout";
|
|
7
5
|
import { ThemeToggle } from "./theme";
|
|
8
|
-
import {
|
|
6
|
+
import { Avatar } from "./ui/Avatar";
|
|
9
7
|
import { Button } from "./ui/Button";
|
|
10
8
|
import { useStrings } from "./uiText";
|
|
11
9
|
|
|
12
|
-
const avatarStyle: CSSProperties = {
|
|
13
|
-
display: "inline-flex",
|
|
14
|
-
alignItems: "center",
|
|
15
|
-
justifyContent: "center",
|
|
16
|
-
width: "3.5rem",
|
|
17
|
-
height: "3.5rem",
|
|
18
|
-
flexShrink: 0,
|
|
19
|
-
borderRadius: "var(--radius-full)",
|
|
20
|
-
background: "var(--color-brand-primary)",
|
|
21
|
-
color: "var(--color-brand-primary-contrast)",
|
|
22
|
-
fontSize: "var(--font-size-lg)",
|
|
23
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const mutedStyle: CSSProperties = { margin: 0, color: "var(--color-neutral-600)" };
|
|
27
|
-
|
|
28
|
-
const cardStyle: CSSProperties = {
|
|
29
|
-
display: "grid",
|
|
30
|
-
gap: "var(--space-4)",
|
|
31
|
-
padding: "var(--space-4)",
|
|
32
|
-
maxWidth: "32rem",
|
|
33
|
-
background: "var(--color-neutral-0)",
|
|
34
|
-
border: "1px solid var(--color-neutral-200)",
|
|
35
|
-
borderRadius: "var(--radius-lg)",
|
|
36
|
-
};
|
|
37
|
-
|
|
38
10
|
/**
|
|
39
11
|
* The built-in profile / settings page the {@link UserMenu}'s Settings item opens.
|
|
40
12
|
* `buildAppRouter` mounts it at `/profile` in every app (an app manifest claiming
|
|
41
13
|
* that path wins): the signed-in identity (avatar, email, role — the server-validated
|
|
42
14
|
* `/me` session, not token claims), the standard theme + language preferences, and
|
|
43
15
|
* sign-out. A `Page` archetype, so it satisfies the routed-view frame control.
|
|
16
|
+
*
|
|
17
|
+
* It renders no inline styles: both cards, the avatar tile and the two identity lines take
|
|
18
|
+
* their geometry and ink from the injected react-core sheet (ADR 0094).
|
|
44
19
|
*/
|
|
45
20
|
export function ProfileView() {
|
|
46
21
|
const auth = useAuth();
|
|
@@ -52,14 +27,12 @@ export function ProfileView() {
|
|
|
52
27
|
return (
|
|
53
28
|
<Page title={strings.profile}>
|
|
54
29
|
<Stack gap={4}>
|
|
55
|
-
<div
|
|
30
|
+
<div data-terp="profile-card">
|
|
56
31
|
<Stack direction="row" gap={3} align="center">
|
|
57
|
-
<
|
|
58
|
-
{userInitials(user.email)}
|
|
59
|
-
</span>
|
|
32
|
+
<Avatar from={user.email} />
|
|
60
33
|
<Stack gap={0}>
|
|
61
|
-
<strong
|
|
62
|
-
<p
|
|
34
|
+
<strong data-terp="profile-email">{user.email}</strong>
|
|
35
|
+
<p data-terp="profile-role">{user.role_name}</p>
|
|
63
36
|
</Stack>
|
|
64
37
|
</Stack>
|
|
65
38
|
<DetailList
|
|
@@ -69,7 +42,7 @@ export function ProfileView() {
|
|
|
69
42
|
]}
|
|
70
43
|
/>
|
|
71
44
|
</div>
|
|
72
|
-
<div
|
|
45
|
+
<div data-terp="profile-card">
|
|
73
46
|
<ThemeToggle />
|
|
74
47
|
<LanguageSwitcher />
|
|
75
48
|
<div>
|
package/src/ResourceList.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { FormEvent, ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { Authorized } from "./Authorized";
|
|
5
5
|
import { useErrorMessage } from "./errorMessages";
|
|
@@ -9,19 +9,6 @@ import { Button } from "./ui/Button";
|
|
|
9
9
|
import { Input } from "./ui/Input";
|
|
10
10
|
import type { Resource } from "./useResource";
|
|
11
11
|
|
|
12
|
-
const rowStyle: CSSProperties = {
|
|
13
|
-
display: "flex",
|
|
14
|
-
alignItems: "center",
|
|
15
|
-
justifyContent: "space-between",
|
|
16
|
-
gap: "var(--space-3)",
|
|
17
|
-
padding: "var(--space-3)",
|
|
18
|
-
border: "1px solid var(--color-neutral-200)",
|
|
19
|
-
borderRadius: "var(--radius-md)",
|
|
20
|
-
background: "var(--color-neutral-0)",
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const mutedStyle: CSSProperties = { color: "var(--color-neutral-600)" };
|
|
24
|
-
|
|
25
12
|
export interface ResourceListProps<T extends { id: string }> {
|
|
26
13
|
/** Section heading; omit when composed under a `Page` (whose title is the `h1`). */
|
|
27
14
|
title?: UiText;
|
|
@@ -52,6 +39,10 @@ export interface ResourceListProps<T extends { id: string }> {
|
|
|
52
39
|
*
|
|
53
40
|
* It is a composable component, not a hidden CRUD DSL: a screen that needs more just renders its own
|
|
54
41
|
* React and ignores this.
|
|
42
|
+
*
|
|
43
|
+
* It renders no inline styles: the section, the create row, both messages, the list and the
|
|
44
|
+
* rows take their geometry and ink from the injected react-core sheet, matched on the
|
|
45
|
+
* `data-terp` markers stamped below (ADR 0094).
|
|
55
46
|
*/
|
|
56
47
|
export function ResourceList<T extends { id: string }>({
|
|
57
48
|
title,
|
|
@@ -81,37 +72,35 @@ export function ResourceList<T extends { id: string }>({
|
|
|
81
72
|
}
|
|
82
73
|
|
|
83
74
|
return (
|
|
84
|
-
<section
|
|
85
|
-
data-terp="resource-list"
|
|
86
|
-
style={{ display: "grid", gap: "var(--space-4)", maxWidth: "40rem" }}
|
|
87
|
-
>
|
|
75
|
+
<section data-terp="resource-list">
|
|
88
76
|
{title !== undefined && <h1>{resolve(title)}</h1>}
|
|
89
77
|
{renderCreate !== undefined ? (
|
|
90
78
|
<Authorized action="write">{renderCreate()}</Authorized>
|
|
91
79
|
) : createPlaceholder !== undefined ? (
|
|
92
80
|
<Authorized action="write">
|
|
93
|
-
<form onSubmit={onCreate}
|
|
81
|
+
<form onSubmit={onCreate} data-terp="resource-list-create">
|
|
94
82
|
<Input
|
|
95
83
|
placeholder={resolve(createPlaceholder ?? "")}
|
|
96
84
|
value={draft}
|
|
97
85
|
onChange={(event) => setDraft(event.target.value)}
|
|
98
|
-
style={{ flex: 1 }}
|
|
99
86
|
/>
|
|
100
87
|
<Button type="submit">{strings.add}</Button>
|
|
101
88
|
</form>
|
|
102
89
|
</Authorized>
|
|
103
90
|
) : null}
|
|
104
91
|
{resource.error !== null && (
|
|
105
|
-
<p role="alert"
|
|
92
|
+
<p role="alert" data-terp="resource-list-error">
|
|
106
93
|
{messageForCode(resource.cause) ?? resource.error}
|
|
107
94
|
</p>
|
|
108
95
|
)}
|
|
109
96
|
{resource.items.length === 0 ? (
|
|
110
|
-
<p
|
|
97
|
+
<p data-terp="resource-list-empty">
|
|
98
|
+
{resource.loading ? strings.loading : resolve(emptyMessage ?? strings.emptyList)}
|
|
99
|
+
</p>
|
|
111
100
|
) : (
|
|
112
|
-
<ul
|
|
101
|
+
<ul data-terp="resource-list-items">
|
|
113
102
|
{resource.items.map((item) => (
|
|
114
|
-
<li key={item.id}
|
|
103
|
+
<li key={item.id} data-terp="resource-list-row">
|
|
115
104
|
<div>{renderItem(item)}</div>
|
|
116
105
|
{renderActions && <Authorized action="write">{renderActions(item)}</Authorized>}
|
|
117
106
|
</li>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { BreadcrumbItem } from "./Breadcrumbs";
|
|
4
|
+
import { LayoutSlotContext } from "./layoutContract";
|
|
5
|
+
import { Page } from "./Page";
|
|
6
|
+
import type { PageProps } from "./Page";
|
|
7
|
+
|
|
8
|
+
export interface SettingsPageProps extends Omit<PageProps, "breadcrumbs"> {
|
|
9
|
+
/**
|
|
10
|
+
* Optional parent layers, for a settings screen nested under an area rather than reached
|
|
11
|
+
* from the account menu.
|
|
12
|
+
*
|
|
13
|
+
* Optional where `FormPage`'s is required, and the difference is real: a form is always
|
|
14
|
+
* reached from the thing it writes into, while a preferences screen is often a destination in
|
|
15
|
+
* its own right — the built-in profile page has no parent at all.
|
|
16
|
+
*/
|
|
17
|
+
parents?: readonly (BreadcrumbItem & { to: string })[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The settings archetype: preferences and account screens — a stack of titled sections, each
|
|
22
|
+
* owning a few controls.
|
|
23
|
+
*
|
|
24
|
+
* `measure="narrow"` by default, header included, for the reason `FormPage` has it: this is a
|
|
25
|
+
* single column of controls, not a data surface. `ProfileView`'s own card carries exactly this
|
|
26
|
+
* measure today, and 4b already named that card as a page measure wearing a card's clothes — so
|
|
27
|
+
* this is the mechanism it was hand-rolling.
|
|
28
|
+
*
|
|
29
|
+
* **The body is `Card` sections and nothing that holds a collection.** No `DataView`, no
|
|
30
|
+
* `DetailList`, no `Tabs`: a settings screen whose body is a table is an overview, and one with
|
|
31
|
+
* tabs is a hub with the wrong chrome. `Card` is how a titled region is owned here — which is
|
|
32
|
+
* what 4b decided when it refused to ship `Section`, and this slot is the first archetype to
|
|
33
|
+
* depend on that decision rather than merely be compatible with it.
|
|
34
|
+
*
|
|
35
|
+
* Renders no element of its own, for the reason every non-hub archetype does not: a wrapper
|
|
36
|
+
* around the body becomes the sole entry in `article.children` and fails every governed page
|
|
37
|
+
* closed (ADR 0079).
|
|
38
|
+
*/
|
|
39
|
+
export function SettingsPage({
|
|
40
|
+
parents,
|
|
41
|
+
measure = "narrow",
|
|
42
|
+
...page
|
|
43
|
+
}: SettingsPageProps): ReactNode {
|
|
44
|
+
return (
|
|
45
|
+
<LayoutSlotContext.Provider value="SettingsPage">
|
|
46
|
+
{/* Spread first, then the archetype's own props — see `FormPage` for why. */}
|
|
47
|
+
<Page {...page} breadcrumbs={parents} measure={measure} />
|
|
48
|
+
</LayoutSlotContext.Provider>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { useLayoutContract, verifySlotChildren } from "./layoutContract";
|
|
5
|
+
import { Page } from "./Page";
|
|
6
|
+
import type { PageProps } from "./Page";
|
|
7
|
+
import { injectTerpStyles } from "./styles";
|
|
8
|
+
import { useUiText } from "./uiText";
|
|
9
|
+
import type { UiText } from "./uiText";
|
|
10
|
+
|
|
11
|
+
injectTerpStyles();
|
|
12
|
+
|
|
13
|
+
/** How much of the row the list pane takes — a step, not a length (ADR 0097 §4). */
|
|
14
|
+
export type SplitListWidth = "sm" | "md" | "lg";
|
|
15
|
+
|
|
16
|
+
export type SplitPageProps = Omit<PageProps, "children" | "measure"> & {
|
|
17
|
+
/** Parent trail for a split screen nested below a hub; aliases `Page`'s `breadcrumbs`. */
|
|
18
|
+
parents?: PageProps["breadcrumbs"];
|
|
19
|
+
/**
|
|
20
|
+
* The list pane's track (default `"md"`).
|
|
21
|
+
*
|
|
22
|
+
* Enumerable rather than a percentage or a length, which is the same call `Grid`'s
|
|
23
|
+
* `minColumn` made and for the same reason: a CSS length here would be a measured value and
|
|
24
|
+
* therefore an inline style on the panes element — a tenth entry in a ledger that admits only
|
|
25
|
+
* two permanent kinds (ADR 0094 §3). Three steps cover the cases; an app wanting 37% cannot
|
|
26
|
+
* have it, which is the trade `gap` already makes.
|
|
27
|
+
*
|
|
28
|
+
* A **draggable** divider is deliberately not offered: that is a measured value *and* a
|
|
29
|
+
* per-user preference, so it waits for the preference seam rather than arriving as an inline
|
|
30
|
+
* style with nowhere to persist.
|
|
31
|
+
*/
|
|
32
|
+
listWidth?: SplitListWidth;
|
|
33
|
+
/**
|
|
34
|
+
* The two panes, **list first** — a `SplitPane role="list"` then a `SplitPane role="detail"`.
|
|
35
|
+
*
|
|
36
|
+
* Order is load-bearing: the tracks are filled by grid auto-placement, so the first pane takes
|
|
37
|
+
* the narrow one. The contract can see that both children are `SplitPane`s and cannot see
|
|
38
|
+
* which is which, so this is a convention the render makes obvious rather than a rule the
|
|
39
|
+
* runtime enforces.
|
|
40
|
+
*/
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The split archetype: a list beside the record it selects — the master-detail screen.
|
|
46
|
+
*
|
|
47
|
+
* It is structured on `HubPage`, not on `DetailPage`, and that is the decision worth knowing.
|
|
48
|
+
* The obvious shape for two panes is one body slot holding two children, but the layout
|
|
49
|
+
* contract's runtime check takes a single slot owner and reads `article.children`, so two panes
|
|
50
|
+
* would be two entries in one slot with nothing distinguishing them. Teaching the contract two
|
|
51
|
+
* slots per archetype was the invasive option. Instead the panes are the governed thing:
|
|
52
|
+
* `SplitPage` owns the row element and admits `SplitPane` in it and nothing else, exactly as
|
|
53
|
+
* `HubPage` owns its grid and admits `HubCard`. `verifySlotChildren`, the mirrored table's
|
|
54
|
+
* shape and the message builder are all untouched.
|
|
55
|
+
*
|
|
56
|
+
* It therefore provides **no** `LayoutSlotContext`, for the reason `HubPage` provides none: the
|
|
57
|
+
* row element carries a marker that appears in no allow table, so a slot context above it would
|
|
58
|
+
* refuse every split page on its own body.
|
|
59
|
+
*
|
|
60
|
+
* Below the mobile breakpoint the panes stack, in DOM order, list first — so the tab sequence is
|
|
61
|
+
* the reading order in both layouts. That is the property `visual/keyboard.spec.ts` holds.
|
|
62
|
+
*/
|
|
63
|
+
export function SplitPage({
|
|
64
|
+
parents,
|
|
65
|
+
breadcrumbs,
|
|
66
|
+
listWidth = "md",
|
|
67
|
+
children,
|
|
68
|
+
...page
|
|
69
|
+
}: SplitPageProps) {
|
|
70
|
+
// The runtime half of the slot-typed layout contract control (ADR 0079) for the pane row:
|
|
71
|
+
// with a contract active, every rendered child of the row must be a SplitPane (its data-terp
|
|
72
|
+
// marker) — verified one macrotask after mount, refused fail closed. Same shape as HubPage's.
|
|
73
|
+
const contract = useLayoutContract();
|
|
74
|
+
const panesRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const [slotViolation, setSlotViolation] = useState<string | null>(null);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (contract === null) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
const panes = panesRef.current;
|
|
82
|
+
if (panes === null) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
setSlotViolation(verifySlotChildren(contract, "SplitPage", [...panes.children]));
|
|
86
|
+
}, 0);
|
|
87
|
+
return () => clearTimeout(timer);
|
|
88
|
+
});
|
|
89
|
+
if (slotViolation !== null) {
|
|
90
|
+
throw new Error(slotViolation);
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
<Page {...page} breadcrumbs={parents ?? breadcrumbs}>
|
|
94
|
+
<div ref={panesRef} data-terp="splitpage-panes" data-list-width={listWidth}>
|
|
95
|
+
{children}
|
|
96
|
+
</div>
|
|
97
|
+
</Page>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Which half of the split a pane is — the list, or the record it selects. */
|
|
102
|
+
export type SplitPaneRole = "list" | "detail";
|
|
103
|
+
|
|
104
|
+
export interface SplitPaneProps {
|
|
105
|
+
/**
|
|
106
|
+
* Which half this is.
|
|
107
|
+
*
|
|
108
|
+
* It does **not** place the pane. The row's tracks are `minmax(0, <listWidth>) minmax(0, 1fr)`
|
|
109
|
+
* and grid auto-placement fills them in DOM order, so the FIRST pane gets the narrow track
|
|
110
|
+
* whatever its role says — write the list first. A `detail`-first composition renders the
|
|
111
|
+
* record in the narrow column, which is wrong and immediately visible.
|
|
112
|
+
*
|
|
113
|
+
* Placing by role instead was considered and refused: it would render correctly whatever the
|
|
114
|
+
* DOM order, and correct-looking is exactly the wrong failure here. Tab order follows the DOM
|
|
115
|
+
* and CSS cannot change it, so a `detail`-first tree would then read left-to-right and tab
|
|
116
|
+
* right-to-left — the WCAG 1.3.2 / 2.4.3 mismatch, silent. Leaving placement to the DOM makes
|
|
117
|
+
* a mis-ordered split look mis-ordered.
|
|
118
|
+
*
|
|
119
|
+
* What the role does carry: the pane's identity for anything that needs to tell the two apart
|
|
120
|
+
* (`visual/keyboard.spec.ts` asserts the tab order through it), and a hook for a rule should
|
|
121
|
+
* one ever need to distinguish them.
|
|
122
|
+
*/
|
|
123
|
+
role: SplitPaneRole;
|
|
124
|
+
/**
|
|
125
|
+
* The pane's accessible name.
|
|
126
|
+
*
|
|
127
|
+
* Required, because each pane is a `<section>` and therefore a landmark: two unnamed regions
|
|
128
|
+
* side by side are two indistinguishable entries in a screen reader's landmark list, which is
|
|
129
|
+
* worse than one region containing both.
|
|
130
|
+
*/
|
|
131
|
+
label: UiText;
|
|
132
|
+
children: ReactNode;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One half of a {@link SplitPage} — the list, or the detail beside it.
|
|
137
|
+
*
|
|
138
|
+
* A named `<section>`, so the two halves are distinguishable landmarks, and the only component
|
|
139
|
+
* the split's row admits. It renders no inline style, and no style at all beyond a
|
|
140
|
+
* `min-width: 0` floor: which track it takes comes from its POSITION, not from `role` — see the
|
|
141
|
+
* prop for why that is the safer of the two.
|
|
142
|
+
*/
|
|
143
|
+
export function SplitPane({ role, label, children }: SplitPaneProps) {
|
|
144
|
+
const resolve = useUiText();
|
|
145
|
+
return (
|
|
146
|
+
<section data-terp="splitpane" data-role={role} aria-label={resolve(label)}>
|
|
147
|
+
{children}
|
|
148
|
+
</section>
|
|
149
|
+
);
|
|
150
|
+
}
|
package/src/UserMenu.test.tsx
CHANGED
|
@@ -66,7 +66,7 @@ describe("UserMenu", () => {
|
|
|
66
66
|
<UserMenu />
|
|
67
67
|
</TerpProvider>,
|
|
68
68
|
);
|
|
69
|
-
expect(screen.queryByRole("button", { name:
|
|
69
|
+
expect(screen.queryByRole("button", { name: /jane\.doe@example\.com/ })).not.toBeInTheDocument();
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it("shows the avatar initials, email and role, and opens the panel", async () => {
|
|
@@ -81,7 +81,7 @@ describe("UserMenu", () => {
|
|
|
81
81
|
</LocaleProvider>
|
|
82
82
|
</ThemeProvider>,
|
|
83
83
|
);
|
|
84
|
-
const trigger = await screen.findByRole("button", { name:
|
|
84
|
+
const trigger = await screen.findByRole("button", { name: /jane\.doe@example\.com/ });
|
|
85
85
|
expect(screen.getByText("JD")).toBeInTheDocument();
|
|
86
86
|
expect(screen.getByText("jane.doe@example.com")).toBeInTheDocument();
|
|
87
87
|
expect(screen.getByText("editor")).toBeInTheDocument();
|
|
@@ -104,7 +104,7 @@ describe("UserMenu", () => {
|
|
|
104
104
|
<UserMenu onSettings={onSettings} />
|
|
105
105
|
</TerpProvider>,
|
|
106
106
|
);
|
|
107
|
-
fireEvent.click(await screen.findByRole("button", { name:
|
|
107
|
+
fireEvent.click(await screen.findByRole("button", { name: /jane\.doe@example\.com/ }));
|
|
108
108
|
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
|
|
109
109
|
expect(onSettings).toHaveBeenCalledTimes(1);
|
|
110
110
|
expect(screen.queryByRole("menuitem", { name: "Sign out" })).not.toBeInTheDocument();
|
|
@@ -118,6 +118,10 @@ describe("UserMenu", () => {
|
|
|
118
118
|
<UserMenu collapsed />
|
|
119
119
|
</TerpProvider>,
|
|
120
120
|
);
|
|
121
|
+
// Collapsed is the one variant that still carries `triggerLabel`, because its only
|
|
122
|
+
// content is the aria-hidden avatar — without the label the button would have no
|
|
123
|
+
// accessible name at all. Expanded takes its name from the email and role it renders,
|
|
124
|
+
// which is what the other cases in this file now assert.
|
|
121
125
|
const trigger = await screen.findByRole("button", { name: "Account menu" });
|
|
122
126
|
expect(screen.getByText("JD")).toBeInTheDocument();
|
|
123
127
|
expect(screen.queryByText("jane.doe@example.com")).not.toBeInTheDocument();
|
|
@@ -137,6 +141,25 @@ describe("UserMenu", () => {
|
|
|
137
141
|
expect(screen.getByRole("menu").parentElement).toHaveAttribute("data-owner", "user-menu");
|
|
138
142
|
});
|
|
139
143
|
|
|
144
|
+
it("keeps the trigger inside its own root when expanded, which is how the sign-out helper finds it", async () => {
|
|
145
|
+
// `@terpjs/conformance`'s `logout()` signs a user out of any Terp app through this button, and
|
|
146
|
+
// located it by the accessible name "Account menu" until that name stopped existing here —
|
|
147
|
+
// expanded, the button is named after the user's email and role on purpose (WCAG 2.5.3). The
|
|
148
|
+
// helper reaches it by marker path now, so the DESCENDANT relationship is a cross-package
|
|
149
|
+
// contract; the collapsed case above already pins the marker itself, and this pins the shape
|
|
150
|
+
// of the path in the state the sidebar is actually in by default.
|
|
151
|
+
stubAuthFetch();
|
|
152
|
+
const { container } = render(
|
|
153
|
+
<TerpProvider baseUrl="https://api.test">
|
|
154
|
+
<LogInOnMount />
|
|
155
|
+
<UserMenu />
|
|
156
|
+
</TerpProvider>,
|
|
157
|
+
);
|
|
158
|
+
await screen.findByText("jane.doe@example.com");
|
|
159
|
+
const trigger = container.querySelector('[data-terp="user-menu"] [data-terp="menu-trigger"]');
|
|
160
|
+
expect(trigger?.tagName).toBe("BUTTON");
|
|
161
|
+
});
|
|
162
|
+
|
|
140
163
|
it("signs out via the menu (revokes the token server-side)", async () => {
|
|
141
164
|
const fetchMock = stubAuthFetch();
|
|
142
165
|
render(
|
|
@@ -145,7 +168,7 @@ describe("UserMenu", () => {
|
|
|
145
168
|
<UserMenu />
|
|
146
169
|
</TerpProvider>,
|
|
147
170
|
);
|
|
148
|
-
fireEvent.click(await screen.findByRole("button", { name:
|
|
171
|
+
fireEvent.click(await screen.findByRole("button", { name: /jane\.doe@example\.com/ }));
|
|
149
172
|
fireEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
|
|
150
173
|
await waitFor(() =>
|
|
151
174
|
expect(
|
|
@@ -164,7 +187,7 @@ describe("UserMenu", () => {
|
|
|
164
187
|
<UserMenu />
|
|
165
188
|
</TerpProvider>,
|
|
166
189
|
);
|
|
167
|
-
const trigger = await screen.findByRole("button", { name:
|
|
190
|
+
const trigger = await screen.findByRole("button", { name: /jane\.doe@example\.com/ });
|
|
168
191
|
fireEvent.click(trigger);
|
|
169
192
|
expect(screen.getByRole("menuitem", { name: "Sign out" })).toBeInTheDocument();
|
|
170
193
|
fireEvent.keyDown(document, { key: "Escape" });
|
package/src/UserMenu.tsx
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { Icon } from "./icons";
|
|
2
2
|
import { injectTerpStyles } from "./styles";
|
|
3
3
|
import { useAuth } from "./TerpProvider";
|
|
4
|
+
import { Avatar } from "./ui/Avatar";
|
|
4
5
|
import { Menu, MenuItem } from "./ui/Menu";
|
|
5
6
|
import { useStrings } from "./uiText";
|
|
6
7
|
|
|
7
8
|
injectTerpStyles();
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
10
|
+
/**
|
|
11
|
+
* Re-exported from where the tile now lives. It was public under this name before `Avatar`
|
|
12
|
+
* existed, and moving a published export to another module is a breaking change for no
|
|
13
|
+
* reason — the tile is what needed one home, not the name.
|
|
14
|
+
*/
|
|
15
|
+
export { userInitials } from "./ui/Avatar";
|
|
16
16
|
|
|
17
17
|
export interface UserMenuProps {
|
|
18
18
|
/** Icon-rail mode: show only the avatar on the trigger (the shell's collapsed state). */
|
|
@@ -39,7 +39,7 @@ export function UserMenu({ collapsed = false, onSettings, defaultOpen }: UserMen
|
|
|
39
39
|
|
|
40
40
|
const trigger = (
|
|
41
41
|
<>
|
|
42
|
-
<
|
|
42
|
+
<Avatar from={user.email} size="sm" />
|
|
43
43
|
{!collapsed && (
|
|
44
44
|
<span data-terp="user-menu-identity">
|
|
45
45
|
<span data-terp="user-menu-email">{user.email}</span>
|
|
@@ -52,7 +52,13 @@ export function UserMenu({ collapsed = false, onSettings, defaultOpen }: UserMen
|
|
|
52
52
|
return (
|
|
53
53
|
<Menu
|
|
54
54
|
trigger={trigger}
|
|
55
|
-
|
|
55
|
+
// Only in the icon rail, and the asymmetry is the point. `aria-label` REPLACES the
|
|
56
|
+
// subtree text in the accessible name, so in the expanded trigger — which renders the
|
|
57
|
+
// user's email and role as visible text — naming it "Account menu" hid both from anyone
|
|
58
|
+
// relying on the name, and left a voice-control user with no spoken label that matches
|
|
59
|
+
// what they can see (WCAG 2.5.3, Label in Name). Collapsed there is nothing to hide: the
|
|
60
|
+
// avatar initials are aria-hidden, so without this the button would have no name at all.
|
|
61
|
+
triggerLabel={collapsed ? strings.accountMenu : undefined}
|
|
56
62
|
placement="top"
|
|
57
63
|
align="start"
|
|
58
64
|
defaultOpen={defaultOpen}
|