@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.
- package/README.md +56 -20
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +17 -4
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- 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 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- 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/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- 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 +25 -5
- 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/router.test.tsx +575 -2
- package/src/router.tsx +202 -19
- package/src/styles.test.ts +483 -24
- package/src/styles.ts +956 -85
- 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 +69 -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/Field.tsx
CHANGED
|
@@ -31,6 +31,14 @@ export interface FieldProps {
|
|
|
31
31
|
* declares either keeps its own value; the field adds to `aria-describedby` rather than replacing
|
|
32
32
|
* it. `Input`, `Select`, `Textarea` and `Combobox` all spread their props onto the DOM element, so
|
|
33
33
|
* the attributes land where assistive tech reads them.
|
|
34
|
+
*
|
|
35
|
+
* The error also carries `role="alert"`, and `aria-describedby` is why it has to. A description is
|
|
36
|
+
* read when focus reaches the control, which covers an error that was already there and covers
|
|
37
|
+
* nothing about one that appears on submit — by then focus has left the field, or the button, and
|
|
38
|
+
* the only thing that changed is a span nobody is pointed at. The two are not redundant: they
|
|
39
|
+
* announce at different moments, and a submit-time rejection only has the second one. Because the
|
|
40
|
+
* span is conditional, it enters the accessibility tree exactly when the error appears, which is
|
|
41
|
+
* the event `alert` exists to report.
|
|
34
42
|
*/
|
|
35
43
|
export function Field({ label, children, error, hint }: FieldProps) {
|
|
36
44
|
const resolve = useUiText();
|
|
@@ -39,17 +47,35 @@ export function Field({ label, children, error, hint }: FieldProps) {
|
|
|
39
47
|
const hintId = hint !== undefined ? `${baseId}-hint` : undefined;
|
|
40
48
|
const errorId = hasError ? `${baseId}-error` : undefined;
|
|
41
49
|
const described = [hintId, errorId].filter((id) => id !== undefined).join(" ");
|
|
50
|
+
const labelId = `${baseId}-label`;
|
|
42
51
|
|
|
43
|
-
// Only a single element child can be described — which is the documented contract
|
|
44
|
-
// control"). Anything else is passed through untouched rather than guessed at.
|
|
45
|
-
const control =
|
|
52
|
+
// Only a single element child can be named and described — which is the documented contract
|
|
53
|
+
// ("the control"). Anything else is passed through untouched rather than guessed at.
|
|
54
|
+
const control = isValidElement<{
|
|
46
55
|
"aria-describedby"?: string;
|
|
47
56
|
"aria-invalid"?: boolean | "true" | "false";
|
|
57
|
+
"aria-label"?: string;
|
|
58
|
+
"aria-labelledby"?: string;
|
|
48
59
|
}>(children)
|
|
49
60
|
? cloneElement(children, {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
// The control is named by the label TEXT, not by the label element's subtree, and that
|
|
62
|
+
// distinction is the whole reason this exists. A wrapping label takes its name from
|
|
63
|
+
// everything inside it, so a control that renders an adornment of its own — the password
|
|
64
|
+
// reveal is the first — hands its own button's name to the field: Chromium computes
|
|
65
|
+
// "Password Show password" for that input, which is a WCAG 2.5.3 failure and a sentence
|
|
66
|
+
// no voice-control user can see to say. Pointing at the span makes the name exact.
|
|
67
|
+
//
|
|
68
|
+
// A caller that named the control itself keeps their name; this never overrides one.
|
|
69
|
+
"aria-labelledby":
|
|
70
|
+
children.props["aria-label"] === undefined
|
|
71
|
+
? (children.props["aria-labelledby"] ?? labelId)
|
|
72
|
+
: undefined,
|
|
73
|
+
"aria-describedby":
|
|
74
|
+
described.length > 0
|
|
75
|
+
? [children.props["aria-describedby"], described]
|
|
76
|
+
.filter((id) => id !== undefined && id !== "")
|
|
77
|
+
.join(" ")
|
|
78
|
+
: children.props["aria-describedby"],
|
|
53
79
|
"aria-invalid": children.props["aria-invalid"] ?? (hasError ? true : undefined),
|
|
54
80
|
})
|
|
55
81
|
: children;
|
|
@@ -57,7 +83,9 @@ export function Field({ label, children, error, hint }: FieldProps) {
|
|
|
57
83
|
return (
|
|
58
84
|
<div data-terp="field">
|
|
59
85
|
<label data-terp="field-label">
|
|
60
|
-
<span data-terp="field-label-text">
|
|
86
|
+
<span id={labelId} data-terp="field-label-text">
|
|
87
|
+
{resolve(label)}
|
|
88
|
+
</span>
|
|
61
89
|
{control}
|
|
62
90
|
</label>
|
|
63
91
|
{hint !== undefined && (
|
|
@@ -66,7 +94,7 @@ export function Field({ label, children, error, hint }: FieldProps) {
|
|
|
66
94
|
</span>
|
|
67
95
|
)}
|
|
68
96
|
{hasError && (
|
|
69
|
-
<span id={errorId} data-terp="field-error">
|
|
97
|
+
<span id={errorId} role="alert" data-terp="field-error">
|
|
70
98
|
{error}
|
|
71
99
|
</span>
|
|
72
100
|
)}
|
package/src/FormPage.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { BreadcrumbItem } from "./Breadcrumbs";
|
|
2
|
+
import { LayoutSlotContext } from "./layoutContract";
|
|
3
|
+
import { Page } from "./Page";
|
|
4
|
+
import type { PageProps } from "./Page";
|
|
5
|
+
|
|
6
|
+
export interface FormPageProps extends Omit<PageProps, "breadcrumbs"> {
|
|
7
|
+
/**
|
|
8
|
+
* The layers this form sits under, outermost first — the list it was reached from, and
|
|
9
|
+
* anything above that.
|
|
10
|
+
*
|
|
11
|
+
* Required, and for the reason `DetailPage`'s is: a create or edit screen is always reached
|
|
12
|
+
* from somewhere, and a form with no way back is a dead end with unsaved work in it.
|
|
13
|
+
*/
|
|
14
|
+
parents: readonly (BreadcrumbItem & { to: string })[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The form archetype: one create-or-edit screen, reached from the collection it writes into.
|
|
19
|
+
*
|
|
20
|
+
* It is a `Page` with two things fixed. The frame is `measure="narrow"` by default — header
|
|
21
|
+
* included — because a form is a single column of controls and a Save button a screen-width
|
|
22
|
+
* from its last field is worse than one sitting over it. And the body slot admits the shape a
|
|
23
|
+
* form actually has rather than the shape a record screen has.
|
|
24
|
+
*
|
|
25
|
+
* **The body is a container, so `Field` is deliberately not admitted directly.** The slot takes
|
|
26
|
+
* the form container, plus `Grid` for a two-column arrangement inside a wide form, `Card` for a
|
|
27
|
+
* grouped section, `Divider` and `Text` between them, and the framework states.
|
|
28
|
+
*
|
|
29
|
+
* What that refusal does and does not buy is worth stating exactly, because the obvious claim is
|
|
30
|
+
* too strong. It keeps fields out of the top level, so a form body is always a container — which
|
|
31
|
+
* is the shape the archetype is for. It does **not** guarantee the form can be submitted: both
|
|
32
|
+
* halves of the contract match on `data-terp` markers, and `Stack` renders the same marker
|
|
33
|
+
* whether or not it was given `as="form"`. So `<Stack><Field/></Stack>` passes and is still
|
|
34
|
+
* unsubmittable by Enter. Closing that would mean a second marker for the form case, which is
|
|
35
|
+
* six more names describing the same DOM — the `Section` trade 4b already declined.
|
|
36
|
+
*
|
|
37
|
+
* It renders no element of its own — the slot context and `Page`, nothing between them, because
|
|
38
|
+
* a wrapper around the body would become the sole entry in `article.children` and fail every
|
|
39
|
+
* governed page closed (ADR 0079).
|
|
40
|
+
*/
|
|
41
|
+
export function FormPage({ parents, measure = "narrow", ...page }: FormPageProps) {
|
|
42
|
+
return (
|
|
43
|
+
<LayoutSlotContext.Provider value="FormPage">
|
|
44
|
+
{/* Spread FIRST, then the archetype's own props — `HubPage`'s order, not
|
|
45
|
+
`DetailPage`'s. `Omit<PageProps, "breadcrumbs">` removes the key from the type but not
|
|
46
|
+
from a runtime object, and a JSX spread gets no excess-property check: a wrapper
|
|
47
|
+
forwarding `{...props}` with a present-but-undefined `breadcrumbs` would otherwise
|
|
48
|
+
overwrite `parents` and silently drop the trail this archetype requires.
|
|
49
|
+
(`DetailPage` and `OverviewPage` still spread last. Same latent shape, pre-existing,
|
|
50
|
+
and left for a change that can re-record their baselines rather than this one.) */}
|
|
51
|
+
<Page {...page} breadcrumbs={parents} measure={measure} />
|
|
52
|
+
</LayoutSlotContext.Provider>
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/LoginView.tsx
CHANGED
|
@@ -37,9 +37,16 @@ export interface DevCredentials {
|
|
|
37
37
|
*
|
|
38
38
|
* It renders no inline styles: the full-viewport page, the card, the brand row, both button
|
|
39
39
|
* groups, the separator and the error line take their geometry and ink from the injected
|
|
40
|
-
* react-core sheet (ADR 0094).
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* react-core sheet (ADR 0094).
|
|
41
|
+
*
|
|
42
|
+
* Two things here used to be worked around and are not any more, which is worth recording
|
|
43
|
+
* because the workarounds were both reasonable while they lasted. The buttons filled their
|
|
44
|
+
* group through a rule on the GROUP, because `Button` declares `width: fit-content` — a
|
|
45
|
+
* definite width, so a grid does not stretch them for free — and a prop for one internal
|
|
46
|
+
* caller was API the package did not need. `Button` has `fullWidth` now, so the rule retires
|
|
47
|
+
* and the four call sites say what they mean. And the submit button hand-rolled a busy state
|
|
48
|
+
* out of `disabled` plus a swapped label; `loading` is that state, and it adds the spinner and
|
|
49
|
+
* the `aria-busy` the hand-rolled version never had.
|
|
43
50
|
*/
|
|
44
51
|
export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps = {}) {
|
|
45
52
|
const auth = useAuth();
|
|
@@ -87,6 +94,9 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
87
94
|
<form data-terp="login-form" onSubmit={onSubmit}>
|
|
88
95
|
<Input
|
|
89
96
|
type="email"
|
|
97
|
+
// Neither field declared an autocomplete token, so no password manager offered to
|
|
98
|
+
// fill or save this form — the one place in the framework where that matters most.
|
|
99
|
+
autoComplete="username"
|
|
90
100
|
placeholder={strings.email}
|
|
91
101
|
value={email}
|
|
92
102
|
onChange={(event) => setEmail(event.target.value)}
|
|
@@ -94,18 +104,20 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
94
104
|
/>
|
|
95
105
|
<Input
|
|
96
106
|
type="password"
|
|
107
|
+
autoComplete="current-password"
|
|
97
108
|
placeholder={strings.password}
|
|
98
109
|
value={password}
|
|
99
110
|
onChange={(event) => setPassword(event.target.value)}
|
|
100
111
|
required
|
|
101
112
|
/>
|
|
102
|
-
<Button type="submit"
|
|
113
|
+
<Button type="submit" fullWidth loading={busy}>
|
|
103
114
|
{busy ? strings.signingIn : strings.signIn}
|
|
104
115
|
</Button>
|
|
105
116
|
{devCredentials ? (
|
|
106
117
|
<Button
|
|
107
118
|
type="button"
|
|
108
119
|
variant="secondary"
|
|
120
|
+
fullWidth
|
|
109
121
|
disabled={busy}
|
|
110
122
|
onClick={() => {
|
|
111
123
|
setEmail(devCredentials.email);
|
|
@@ -130,6 +142,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
|
|
|
130
142
|
key={provider.name}
|
|
131
143
|
type="button"
|
|
132
144
|
variant="secondary"
|
|
145
|
+
fullWidth
|
|
133
146
|
disabled={busy}
|
|
134
147
|
onClick={() => void onSso(provider)}
|
|
135
148
|
>
|
package/src/ModuleNav.test.tsx
CHANGED
|
@@ -61,16 +61,16 @@ describe("ModuleNav", () => {
|
|
|
61
61
|
);
|
|
62
62
|
expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current");
|
|
63
63
|
|
|
64
|
-
// The styling key is data-active, and the point is that it is NOT the aria-current
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
64
|
+
// The styling key is data-active, and the point is that it is NOT the aria-current beside
|
|
65
|
+
// it: a router Link merges its own active props last, so that attribute has a second author.
|
|
66
|
+
//
|
|
67
|
+
// The divergence this comment used to describe is fixed (ADR 0097 §6, amended in 4e). Both
|
|
68
|
+
// authors now compute the same thing by construction rather than by coincidence. The
|
|
69
|
+
// component asks `activeNavPath` over the whole strip; the Link is pinned to
|
|
70
|
+
// `exact: true, includeSearch: false`, so the router volunteers `aria-current` only when the
|
|
71
|
+
// path equals the URL — the longest possible match, hence always the tab the predicate
|
|
72
|
+
// picked. It can agree or stay silent; it cannot name a different tab. Which is why the
|
|
73
|
+
// assertion below can now demand exactly one `aria-current` in the strip.
|
|
74
74
|
const active = screen.getByRole("link", { name: "Projects" });
|
|
75
75
|
const inactive = screen.getByRole("link", { name: "Overview" });
|
|
76
76
|
expect(active).toHaveAttribute("data-terp", "module-nav-link");
|
|
@@ -79,6 +79,13 @@ describe("ModuleNav", () => {
|
|
|
79
79
|
expect(inactive).not.toHaveAttribute("data-active");
|
|
80
80
|
expect(active.getAttribute("style")).toBeNull();
|
|
81
81
|
expect(inactive.getAttribute("style")).toBeNull();
|
|
82
|
+
|
|
83
|
+
// Exactly one, over the whole strip. Before 4e the Link was left prefix-matching, so at
|
|
84
|
+
// /tickets/projects the router added its own aria-current to the /tickets tab as well and a
|
|
85
|
+
// screen reader announced two current pages.
|
|
86
|
+
expect(
|
|
87
|
+
document.querySelectorAll('[data-terp="module-nav"] [aria-current="page"]'),
|
|
88
|
+
).toHaveLength(1);
|
|
82
89
|
});
|
|
83
90
|
|
|
84
91
|
it("returns nothing for an empty tab list", () => {
|
package/src/ModuleNav.tsx
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { Link, useRouterState } from "@tanstack/react-router";
|
|
2
2
|
|
|
3
|
+
import { activeNavPath } from "./navActive";
|
|
3
4
|
import { useStrings, useUiText } from "./uiText";
|
|
4
5
|
import type { UiText } from "./uiText";
|
|
5
6
|
|
|
6
7
|
export interface ModuleNavTab {
|
|
7
8
|
/** Display label. */
|
|
8
9
|
label: UiText;
|
|
9
|
-
/** Router path for
|
|
10
|
+
/** Router path for the sub-page route. */
|
|
10
11
|
to: string;
|
|
12
|
+
/**
|
|
13
|
+
* Match this tab's path exactly rather than as a segment-aligned prefix.
|
|
14
|
+
*
|
|
15
|
+
* The default is the prefix, so a tab stays current on the pages beneath it — a detail route
|
|
16
|
+
* under `/records/mapping` keeps "Mapping" lit instead of blanking the strip. Set this on a
|
|
17
|
+
* landing tab that also has siblings deeper in the same strip, where the prefix would keep it
|
|
18
|
+
* lit alongside them; the shared predicate resolves that case by longest match anyway, so this
|
|
19
|
+
* is for the narrower job of a tab owning only itself.
|
|
20
|
+
*/
|
|
21
|
+
exact?: boolean;
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
export interface ModuleNavProps {
|
|
@@ -32,6 +43,12 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
|
32
43
|
const strings = useStrings();
|
|
33
44
|
const resolve = useUiText();
|
|
34
45
|
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
46
|
+
// Resolved over the whole strip, not per tab, and through the same function the sidebar uses
|
|
47
|
+
// (ADR 0097 §6, amended in 4e). Two tabs where one path prefixes the other would otherwise
|
|
48
|
+
// both be current, and this component previously compared `pathname === item.to` raw — which
|
|
49
|
+
// diverged from its own Link in both directions, as the sheet's comment on the active rule
|
|
50
|
+
// says. Longest match wins, so `/records` does not steal from `/records/mapping`.
|
|
51
|
+
const activeTo = activeNavPath(pathname, items);
|
|
35
52
|
|
|
36
53
|
if (items.length === 0) {
|
|
37
54
|
return null;
|
|
@@ -42,12 +59,27 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
|
|
|
42
59
|
<ul data-terp="module-nav-list">
|
|
43
60
|
{items.map((item) => {
|
|
44
61
|
const label = resolve(item.label);
|
|
45
|
-
const isActive =
|
|
62
|
+
const isActive = item.to === activeTo;
|
|
46
63
|
return (
|
|
47
64
|
<li key={item.to}>
|
|
48
65
|
<Link
|
|
49
66
|
to={item.to}
|
|
50
|
-
|
|
67
|
+
// `exact` is ALWAYS true here, whatever the tab asked for, and the asymmetry is
|
|
68
|
+
// the mechanism rather than an oversight. The tab's own `exact` governs the
|
|
69
|
+
// component's predicate above; this governs when the ROUTER volunteers its own
|
|
70
|
+
// `aria-current`, and the router decides per link with no knowledge of siblings.
|
|
71
|
+
// Left non-exact it prefix-matches, so at `/tickets/projects` it would mark the
|
|
72
|
+
// `/tickets` tab current too — a second current item the component never chose.
|
|
73
|
+
// Exact matching means "the router thinks this is active" implies the path equals
|
|
74
|
+
// the URL, which is the longest possible match, which is always the tab the
|
|
75
|
+
// predicate picked. The router can only agree.
|
|
76
|
+
//
|
|
77
|
+
// includeSearch: false for the other half. It defaults to true and on an exact
|
|
78
|
+
// link demands a full query-string match, so at `/tickets/projects?page=2` the
|
|
79
|
+
// router's own `data-status` said inactive while this component said active.
|
|
80
|
+
// Nothing paints from `data-status`, which is exactly why that could sit there
|
|
81
|
+
// unnoticed.
|
|
82
|
+
activeOptions={{ exact: true, includeSearch: false }}
|
|
51
83
|
aria-current={isActive ? "page" : undefined}
|
|
52
84
|
data-terp="module-nav-link"
|
|
53
85
|
data-active={isActive ? "true" : undefined}
|
package/src/Page.tsx
CHANGED
|
@@ -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. */
|
|
@@ -61,6 +78,7 @@ export function Page({
|
|
|
61
78
|
breadcrumbs,
|
|
62
79
|
renderLink,
|
|
63
80
|
actions,
|
|
81
|
+
measure = "full",
|
|
64
82
|
isLoading,
|
|
65
83
|
loadingState,
|
|
66
84
|
error,
|
|
@@ -107,8 +125,12 @@ export function Page({
|
|
|
107
125
|
) : (
|
|
108
126
|
children
|
|
109
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;
|
|
110
132
|
return (
|
|
111
|
-
<article ref={articleRef} data-terp="page">
|
|
133
|
+
<article ref={articleRef} data-terp="page" data-measure={measureAttribute}>
|
|
112
134
|
{/* A <header> ELEMENT, and it has to stay one. The slot check above drops the header
|
|
113
135
|
from the body set by tagName, so re-rendering this as a marked <div> would put it
|
|
114
136
|
back in and fail every governed OverviewPage / DetailPage closed. The marker is
|
package/src/ProfileView.test.tsx
CHANGED
|
@@ -76,7 +76,7 @@ describe("ProfileView", () => {
|
|
|
76
76
|
// jsdom computes no cascade. The address marker matters most of the three — its
|
|
77
77
|
// overflow-wrap has no specimen, since the mock session's address is short, so this is
|
|
78
78
|
// the only gate that the element a long address would need is still the marked one.
|
|
79
|
-
const avatar = document.querySelector('[data-terp="
|
|
79
|
+
const avatar = document.querySelector('[data-terp="avatar"]');
|
|
80
80
|
expect(avatar).not.toBeNull();
|
|
81
81
|
expect(avatar?.textContent).toBe("JD");
|
|
82
82
|
expect(avatar?.getAttribute("style")).toBeNull();
|
package/src/ProfileView.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { useAuth } from "./TerpProvider";
|
|
|
3
3
|
import { LanguageSwitcher } from "./locale";
|
|
4
4
|
import { Stack, DetailList } from "./layout";
|
|
5
5
|
import { ThemeToggle } from "./theme";
|
|
6
|
-
import {
|
|
6
|
+
import { Avatar } from "./ui/Avatar";
|
|
7
7
|
import { Button } from "./ui/Button";
|
|
8
8
|
import { useStrings } from "./uiText";
|
|
9
9
|
|
|
@@ -29,9 +29,7 @@ export function ProfileView() {
|
|
|
29
29
|
<Stack gap={4}>
|
|
30
30
|
<div data-terp="profile-card">
|
|
31
31
|
<Stack direction="row" gap={3} align="center">
|
|
32
|
-
<
|
|
33
|
-
{userInitials(user.email)}
|
|
34
|
-
</span>
|
|
32
|
+
<Avatar from={user.email} />
|
|
35
33
|
<Stack gap={0}>
|
|
36
34
|
<strong data-terp="profile-email">{user.email}</strong>
|
|
37
35
|
<p data-terp="profile-role">{user.role_name}</p>
|
|
@@ -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
|
+
}
|