@terpjs/react-core 0.7.0 → 0.9.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 +6 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/LoginView.tsx +22 -75
- package/src/ModuleNav.test.tsx +19 -0
- package/src/ModuleNav.tsx +10 -35
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +15 -39
- package/src/PageActions.tsx +5 -10
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +8 -33
- package/src/ResourceList.tsx +13 -24
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/admin/AuditLogAdmin.tsx +1 -10
- package/src/admin/GroupCreate.tsx +1 -1
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserCreate.tsx +1 -1
- package/src/admin/admin.test.tsx +31 -0
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +15 -15
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +7 -4
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +24 -4
- package/src/markers.test.ts +354 -25
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +191 -1
- package/src/router.tsx +81 -18
- package/src/sso.test.tsx +6 -3
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +855 -6
- package/src/styles.ts +3049 -153
- package/src/theme.tsx +24 -3
- package/src/toast.tsx +35 -71
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +9 -28
- package/src/ui/Button.test.tsx +19 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/ui/controlStyles.ts +0 -9
package/src/Authorized.test.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
|
-
import { Authorized } from "./Authorized";
|
|
6
|
+
import { Authorized, useHasPermission, usePermissions } from "./Authorized";
|
|
7
7
|
import { TerpProvider, useAuth } from "./TerpProvider";
|
|
8
8
|
|
|
9
9
|
function jsonResponse(body: unknown): Response {
|
|
@@ -57,4 +57,66 @@ describe("Authorized", () => {
|
|
|
57
57
|
expect(screen.getByText("no-admin")).toBeInTheDocument();
|
|
58
58
|
expect(screen.queryByText("admin-only")).not.toBeInTheDocument();
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
it("gates on a named grant, not only rank (ADR 0096)", async () => {
|
|
62
|
+
// The hole this closes: rank was all the wire carried, so a screen whose write needs
|
|
63
|
+
// `definitions.publish` hid by rank as a proxy and handled the 403 anyway.
|
|
64
|
+
vi.stubGlobal(
|
|
65
|
+
"fetch",
|
|
66
|
+
vi.fn<typeof fetch>(async (input) => {
|
|
67
|
+
const url = (input as Request).url;
|
|
68
|
+
if (url.endsWith("/api/v1/auth/login")) {
|
|
69
|
+
return jsonResponse({ access_token: "token", token_type: "bearer" });
|
|
70
|
+
}
|
|
71
|
+
return jsonResponse({
|
|
72
|
+
id: "u1",
|
|
73
|
+
email: "editor@example.com",
|
|
74
|
+
role_rank: 20,
|
|
75
|
+
role_name: "editor",
|
|
76
|
+
permissions: ["definitions.manage"],
|
|
77
|
+
});
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
render(
|
|
82
|
+
<TerpProvider baseUrl="https://api.test">
|
|
83
|
+
<LogInOnMount />
|
|
84
|
+
<Authorized action="write" permission="definitions.manage">
|
|
85
|
+
<span>held</span>
|
|
86
|
+
</Authorized>
|
|
87
|
+
<Authorized action="write" permission="definitions.publish">
|
|
88
|
+
<span>not held</span>
|
|
89
|
+
</Authorized>
|
|
90
|
+
{/* Rank alone still fails closed even when the grant is held. */}
|
|
91
|
+
<Authorized action="admin" permission="definitions.manage">
|
|
92
|
+
<span>rank too low</span>
|
|
93
|
+
</Authorized>
|
|
94
|
+
</TerpProvider>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(screen.getByText("held")).toBeInTheDocument());
|
|
98
|
+
expect(screen.queryByText("not held")).toBeNull();
|
|
99
|
+
expect(screen.queryByText("rank too low")).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("reports no permissions when signed out, and none for an app that grants none", async () => {
|
|
103
|
+
vi.stubGlobal(
|
|
104
|
+
"fetch",
|
|
105
|
+
vi.fn<typeof fetch>(async () => new Response("", { status: 401 })),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
function Probe() {
|
|
109
|
+
return (
|
|
110
|
+
<span>{`n=${usePermissions().length} has=${String(useHasPermission("anything"))}`}</span>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
render(
|
|
114
|
+
<TerpProvider baseUrl="https://api.test">
|
|
115
|
+
<Probe />
|
|
116
|
+
</TerpProvider>,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Empty rather than undefined, so a screen can read it without guarding first.
|
|
120
|
+
await waitFor(() => expect(screen.getByText("n=0 has=false")).toBeInTheDocument());
|
|
121
|
+
});
|
|
60
122
|
});
|
package/src/Authorized.tsx
CHANGED
|
@@ -8,14 +8,47 @@ export function useCan(action: Action): boolean {
|
|
|
8
8
|
return useAuth().can(action);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The caller's effective permission names, from `GET /me` (ADR 0096).
|
|
13
|
+
*
|
|
14
|
+
* `useCan` compares role *rank*, which is all the wire used to carry. A screen whose write
|
|
15
|
+
* needs a **named** grant (`definitions.publish`) therefore had nothing to ask: it hid by
|
|
16
|
+
* rank as a proxy and handled the 403 anyway — showing a button it knew might fail, or
|
|
17
|
+
* hiding one the user was entitled to. This is the same set the server's guard enforces,
|
|
18
|
+
* projected for display.
|
|
19
|
+
*
|
|
20
|
+
* Empty when signed out, and empty for an app that mounts no grant capability (it has no
|
|
21
|
+
* named permissions). A *display* input only: the server re-checks every request, and a
|
|
22
|
+
* client that treats this as authority has moved the gate to the wrong side of the wire.
|
|
23
|
+
*/
|
|
24
|
+
export function usePermissions(): readonly string[] {
|
|
25
|
+
return useAuth().currentUser()?.permissions ?? [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether the current user holds the named permission grant (the UI gate). */
|
|
29
|
+
export function useHasPermission(permission: string): boolean {
|
|
30
|
+
return usePermissions().includes(permission);
|
|
31
|
+
}
|
|
32
|
+
|
|
11
33
|
export interface AuthorizedProps {
|
|
12
34
|
action: Action;
|
|
13
35
|
children: ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* Also require this named permission grant.
|
|
38
|
+
*
|
|
39
|
+
* Both must pass, which is deliberately what the server does: a `Policy` carrying a
|
|
40
|
+
* `Permission` enforces the permission's role floor **and** the grant, so a UI that
|
|
41
|
+
* checked only one would disagree with the endpoint in one direction or the other.
|
|
42
|
+
*/
|
|
43
|
+
permission?: string;
|
|
14
44
|
/** Rendered when the user may not perform `action` (default: nothing). */
|
|
15
45
|
fallback?: ReactNode;
|
|
16
46
|
}
|
|
17
47
|
|
|
18
48
|
/** Render `children` only when the current user may perform `action`, else `fallback`. */
|
|
19
|
-
export function Authorized({ action, children, fallback = null }: AuthorizedProps) {
|
|
20
|
-
|
|
49
|
+
export function Authorized({ action, permission, children, fallback = null }: AuthorizedProps) {
|
|
50
|
+
const allowedByRank = useCan(action);
|
|
51
|
+
const permissions = usePermissions();
|
|
52
|
+
const allowed = allowedByRank && (permission === undefined || permissions.includes(permission));
|
|
53
|
+
return <>{allowed ? children : fallback}</>;
|
|
21
54
|
}
|
package/src/Breadcrumbs.test.tsx
CHANGED
|
@@ -42,4 +42,28 @@ describe("Breadcrumbs", () => {
|
|
|
42
42
|
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
|
43
43
|
expect(screen.getByText("Section")).not.toHaveAttribute("aria-current");
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("marks only the final crumb as current, on a marker of its own", () => {
|
|
47
|
+
// The current-crumb styling deliberately does NOT key on aria-current. A router's Link
|
|
48
|
+
// stamps aria-current="page" on every link whose path is a prefix of the current one —
|
|
49
|
+
// which every ancestor crumb is — so borrowing that attribute painted the whole trail as
|
|
50
|
+
// the current page. The marker says what this component means, not what a router infers.
|
|
51
|
+
const { container } = render(
|
|
52
|
+
<Breadcrumbs
|
|
53
|
+
items={[{ label: "Tasks", to: "/tasks" }, { label: "Open", to: "/tasks/open" }, { label: "Here" }]}
|
|
54
|
+
renderLink={(item) => (
|
|
55
|
+
<a href={item.to} aria-current="page">
|
|
56
|
+
{item.label}
|
|
57
|
+
</a>
|
|
58
|
+
)}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const current = container.querySelectorAll('[data-terp="breadcrumbs-current"]');
|
|
63
|
+
expect(current).toHaveLength(1);
|
|
64
|
+
expect(current[0]).toHaveTextContent("Here");
|
|
65
|
+
// Both ancestors claim aria-current here, which is exactly the router behaviour that
|
|
66
|
+
// made the old selector wrong — and none of them may pick up the current styling.
|
|
67
|
+
expect(container.querySelectorAll('[aria-current="page"]')).toHaveLength(3);
|
|
68
|
+
});
|
|
45
69
|
});
|
package/src/Breadcrumbs.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { injectTerpStyles } from "./styles";
|
|
4
4
|
import { useNavLink } from "./navLink";
|
|
@@ -29,32 +29,6 @@ export interface BreadcrumbsProps {
|
|
|
29
29
|
renderLink?: RenderBreadcrumbLink;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const navStyle: CSSProperties = {
|
|
33
|
-
fontSize: "var(--font-size-sm)",
|
|
34
|
-
color: "var(--color-neutral-600)",
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const listStyle: CSSProperties = {
|
|
38
|
-
listStyle: "none",
|
|
39
|
-
margin: 0,
|
|
40
|
-
padding: 0,
|
|
41
|
-
display: "flex",
|
|
42
|
-
flexWrap: "wrap",
|
|
43
|
-
alignItems: "center",
|
|
44
|
-
gap: "var(--space-2)",
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const currentStyle: CSSProperties = {
|
|
48
|
-
color: "var(--color-neutral-900)",
|
|
49
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const separatorStyle: CSSProperties = {
|
|
53
|
-
display: "inline-flex",
|
|
54
|
-
color: "var(--color-neutral-400)",
|
|
55
|
-
lineHeight: 0,
|
|
56
|
-
};
|
|
57
|
-
|
|
58
32
|
function ChevronSeparator() {
|
|
59
33
|
return (
|
|
60
34
|
<svg
|
|
@@ -92,22 +66,25 @@ export function Breadcrumbs({ items, renderLink }: BreadcrumbsProps) {
|
|
|
92
66
|
const strings = useStrings();
|
|
93
67
|
const resolve = useUiText();
|
|
94
68
|
return (
|
|
95
|
-
<nav aria-label={strings.breadcrumbsLabel} data-terp="breadcrumbs"
|
|
96
|
-
<ol
|
|
69
|
+
<nav aria-label={strings.breadcrumbsLabel} data-terp="breadcrumbs">
|
|
70
|
+
<ol>
|
|
97
71
|
{items.map((item, index) => {
|
|
98
72
|
const isLast = index === items.length - 1;
|
|
99
73
|
const label = resolve(item.label);
|
|
100
74
|
return (
|
|
101
|
-
<li key={`${index}-${label}`}
|
|
75
|
+
<li key={`${index}-${label}`}>
|
|
102
76
|
{!isLast && item.to !== undefined ? (
|
|
103
77
|
renderCrumbLink({ label, to: item.to })
|
|
104
78
|
) : (
|
|
105
|
-
<span
|
|
79
|
+
<span
|
|
80
|
+
aria-current={isLast ? "page" : undefined}
|
|
81
|
+
data-terp={isLast ? "breadcrumbs-current" : undefined}
|
|
82
|
+
>
|
|
106
83
|
{label}
|
|
107
84
|
</span>
|
|
108
85
|
)}
|
|
109
86
|
{!isLast && (
|
|
110
|
-
<span aria-hidden="true"
|
|
87
|
+
<span aria-hidden="true" data-terp="breadcrumbs-separator">
|
|
111
88
|
<ChevronSeparator />
|
|
112
89
|
</span>
|
|
113
90
|
)}
|
package/src/ConfirmDialog.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useId, useRef } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { Button } from "./ui/Button";
|
|
5
5
|
import { injectTerpStyles } from "./styles";
|
|
@@ -32,44 +32,6 @@ export interface ConfirmDialogProps {
|
|
|
32
32
|
confirmDisabled?: boolean;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const dialogStyle: CSSProperties = {
|
|
36
|
-
width: "100%",
|
|
37
|
-
maxWidth: "26rem",
|
|
38
|
-
padding: 0,
|
|
39
|
-
border: "1px solid var(--color-neutral-200)",
|
|
40
|
-
borderRadius: "var(--radius-lg)",
|
|
41
|
-
boxShadow: "var(--shadow-lg)",
|
|
42
|
-
background: "var(--color-neutral-0)",
|
|
43
|
-
color: "var(--color-neutral-900)",
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const bodyStyle: CSSProperties = {
|
|
47
|
-
display: "grid",
|
|
48
|
-
gap: "var(--space-3)",
|
|
49
|
-
padding: "var(--space-6)",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const dialogTitleStyle: CSSProperties = {
|
|
53
|
-
margin: 0,
|
|
54
|
-
fontSize: "var(--font-size-lg)",
|
|
55
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
56
|
-
letterSpacing: 0,
|
|
57
|
-
color: "var(--color-neutral-900)",
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const descriptionStyle: CSSProperties = {
|
|
61
|
-
color: "var(--color-neutral-600)",
|
|
62
|
-
fontSize: "var(--font-size-sm)",
|
|
63
|
-
lineHeight: 1.5,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const footerStyle: CSSProperties = {
|
|
67
|
-
display: "flex",
|
|
68
|
-
justifyContent: "flex-end",
|
|
69
|
-
gap: "var(--space-2)",
|
|
70
|
-
marginTop: "var(--space-2)",
|
|
71
|
-
};
|
|
72
|
-
|
|
73
35
|
/**
|
|
74
36
|
* The shared confirmation dialog — replaces `window.confirm()` with an accessible,
|
|
75
37
|
* token-styled modal. Use it before any destructive or irreversible action (delete,
|
|
@@ -99,6 +61,7 @@ export function ConfirmDialog({
|
|
|
99
61
|
const strings = useStrings();
|
|
100
62
|
const resolve = useUiText();
|
|
101
63
|
const titleId = useId();
|
|
64
|
+
const descriptionId = useId();
|
|
102
65
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
103
66
|
|
|
104
67
|
useEffect(() => {
|
|
@@ -130,7 +93,11 @@ export function ConfirmDialog({
|
|
|
130
93
|
ref={dialogRef}
|
|
131
94
|
data-terp="dialog"
|
|
132
95
|
aria-labelledby={titleId}
|
|
133
|
-
|
|
96
|
+
// The consequence is the information a user needs before confirming a destructive action,
|
|
97
|
+
// and a modal announces its name and its description on open — unlinked body copy is
|
|
98
|
+
// reached only by manual exploration. Conditional, so a description-less dialog carries
|
|
99
|
+
// no dangling idref.
|
|
100
|
+
aria-describedby={description !== undefined ? descriptionId : undefined}
|
|
134
101
|
onCancel={(event) => {
|
|
135
102
|
// Escape: stay controlled — never let the platform close the element itself.
|
|
136
103
|
event.preventDefault();
|
|
@@ -145,14 +112,16 @@ export function ConfirmDialog({
|
|
|
145
112
|
}
|
|
146
113
|
}}
|
|
147
114
|
>
|
|
148
|
-
<div
|
|
149
|
-
<h2 id={titleId}
|
|
115
|
+
<div data-terp="dialog-body">
|
|
116
|
+
<h2 id={titleId} data-terp="dialog-title">
|
|
150
117
|
{resolve(title)}
|
|
151
118
|
</h2>
|
|
152
119
|
{description !== undefined && (
|
|
153
|
-
<div
|
|
120
|
+
<div id={descriptionId} data-terp="dialog-description">
|
|
121
|
+
{description}
|
|
122
|
+
</div>
|
|
154
123
|
)}
|
|
155
|
-
<div
|
|
124
|
+
<div data-terp="dialog-actions">
|
|
156
125
|
<Button variant="secondary" disabled={isPending} onClick={() => onOpenChange(false)}>
|
|
157
126
|
{resolve(cancelLabel ?? strings.cancel)}
|
|
158
127
|
</Button>
|
package/src/EmptyState.tsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { Icon } from "./icons";
|
|
4
|
+
import { injectTerpStyles } from "./styles";
|
|
4
5
|
import { useUiText } from "./uiText";
|
|
5
6
|
import type { UiText } from "./uiText";
|
|
6
7
|
|
|
8
|
+
injectTerpStyles();
|
|
9
|
+
|
|
7
10
|
export interface EmptyStateProps {
|
|
8
11
|
/**
|
|
9
12
|
* Optional leading visual (any rendered node — react-core takes no icon dependency).
|
|
@@ -18,37 +21,6 @@ export interface EmptyStateProps {
|
|
|
18
21
|
action?: ReactNode;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
const wrapStyle: CSSProperties = {
|
|
22
|
-
display: "grid",
|
|
23
|
-
justifyItems: "center",
|
|
24
|
-
gap: "var(--space-3)",
|
|
25
|
-
padding: "var(--space-8) var(--space-6)",
|
|
26
|
-
textAlign: "center",
|
|
27
|
-
color: "var(--color-neutral-600)",
|
|
28
|
-
border: "1px dashed var(--color-neutral-300)",
|
|
29
|
-
borderRadius: "var(--radius-lg)",
|
|
30
|
-
background: "var(--color-neutral-0)",
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const iconStyle: CSSProperties = {
|
|
34
|
-
color: "var(--color-neutral-400)",
|
|
35
|
-
display: "inline-flex",
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const titleStyle: CSSProperties = {
|
|
39
|
-
margin: 0,
|
|
40
|
-
color: "var(--color-neutral-900)",
|
|
41
|
-
fontSize: "var(--font-size-base)",
|
|
42
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const descriptionStyle: CSSProperties = {
|
|
46
|
-
color: "var(--color-neutral-600)",
|
|
47
|
-
fontSize: "var(--font-size-sm)",
|
|
48
|
-
lineHeight: 1.5,
|
|
49
|
-
maxWidth: "36ch",
|
|
50
|
-
};
|
|
51
|
-
|
|
52
24
|
/**
|
|
53
25
|
* The standard "nothing here yet" block: use whenever a query legitimately returns zero
|
|
54
26
|
* rows, a module is not wired to data yet, or a feature is gated. One recognisable empty
|
|
@@ -58,15 +30,15 @@ const descriptionStyle: CSSProperties = {
|
|
|
58
30
|
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
|
59
31
|
const resolve = useUiText();
|
|
60
32
|
const leading = icon ?? (
|
|
61
|
-
<span
|
|
33
|
+
<span data-terp="empty-state-icon">
|
|
62
34
|
<Icon name="inbox" size="2rem" />
|
|
63
35
|
</span>
|
|
64
36
|
);
|
|
65
37
|
return (
|
|
66
|
-
<div data-terp="empty-state"
|
|
38
|
+
<div data-terp="empty-state">
|
|
67
39
|
{leading}
|
|
68
|
-
<p
|
|
69
|
-
{description !== undefined && <div
|
|
40
|
+
<p data-terp="empty-state-title">{resolve(title)}</p>
|
|
41
|
+
{description !== undefined && <div data-terp="empty-state-description">{description}</div>}
|
|
70
42
|
{action}
|
|
71
43
|
</div>
|
|
72
44
|
);
|
package/src/ErrorState.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
import { useErrorMessage } from "./errorMessages";
|
|
4
4
|
import { Icon } from "./icons";
|
|
5
|
+
import { injectTerpStyles } from "./styles";
|
|
5
6
|
import { useStrings, useUiText } from "./uiText";
|
|
6
7
|
import type { UiText } from "./uiText";
|
|
7
8
|
|
|
9
|
+
injectTerpStyles();
|
|
10
|
+
|
|
8
11
|
/**
|
|
9
12
|
* Best-effort human-readable message for a caught failure. `unwrap` already throws
|
|
10
13
|
* `Error`s carrying the backend envelope's `detail`, so most callers just surface
|
|
@@ -47,37 +50,6 @@ export interface ErrorStateProps {
|
|
|
47
50
|
action?: ReactNode;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
|
-
const wrapStyle: CSSProperties = {
|
|
51
|
-
display: "grid",
|
|
52
|
-
justifyItems: "center",
|
|
53
|
-
gap: "var(--space-3)",
|
|
54
|
-
padding: "var(--space-6)",
|
|
55
|
-
textAlign: "center",
|
|
56
|
-
color: "var(--color-neutral-700)",
|
|
57
|
-
background: "var(--color-status-danger-soft)",
|
|
58
|
-
border: "1px solid var(--color-status-danger)",
|
|
59
|
-
borderRadius: "var(--radius-lg)",
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const titleStyle: CSSProperties = {
|
|
63
|
-
margin: 0,
|
|
64
|
-
color: "var(--color-status-danger)",
|
|
65
|
-
fontSize: "var(--font-size-base)",
|
|
66
|
-
fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const iconStyle: CSSProperties = {
|
|
70
|
-
color: "var(--color-status-danger)",
|
|
71
|
-
display: "inline-flex",
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const descriptionStyle: CSSProperties = {
|
|
75
|
-
color: "var(--color-neutral-700)",
|
|
76
|
-
fontSize: "var(--font-size-sm)",
|
|
77
|
-
lineHeight: 1.5,
|
|
78
|
-
maxWidth: "48ch",
|
|
79
|
-
};
|
|
80
|
-
|
|
81
53
|
/**
|
|
82
54
|
* The standard "something went wrong" block: use whenever a query fails (404 / 403 /
|
|
83
55
|
* network) and the page frame is already rendered. Announced as an `alert` so assistive
|
|
@@ -93,15 +65,15 @@ export function ErrorState({ icon, title, description, error, action }: ErrorSta
|
|
|
93
65
|
(error !== undefined ? (messageForCode(error) ?? describeError(error)) : null);
|
|
94
66
|
const leading =
|
|
95
67
|
icon ?? (
|
|
96
|
-
<span
|
|
68
|
+
<span data-terp="error-state-icon">
|
|
97
69
|
<Icon name="x" size="1.75rem" />
|
|
98
70
|
</span>
|
|
99
71
|
);
|
|
100
72
|
return (
|
|
101
|
-
<div role="alert" data-terp="error-state"
|
|
73
|
+
<div role="alert" data-terp="error-state">
|
|
102
74
|
{leading}
|
|
103
|
-
<p
|
|
104
|
-
{message !== null && message !== undefined && <div
|
|
75
|
+
<p data-terp="error-state-title">{resolve(title ?? strings.errorTitle)}</p>
|
|
76
|
+
{message !== null && message !== undefined && <div data-terp="error-state-description">{message}</div>}
|
|
105
77
|
{action}
|
|
106
78
|
</div>
|
|
107
79
|
);
|
package/src/Field.test.tsx
CHANGED
|
@@ -30,8 +30,65 @@ describe("Field", () => {
|
|
|
30
30
|
);
|
|
31
31
|
expect(screen.getByLabelText("Name")).toBeInTheDocument();
|
|
32
32
|
});
|
|
33
|
+
it("points the control at its hint and its error, and marks it invalid", () => {
|
|
34
|
+
render(
|
|
35
|
+
<Field label="Email" hint="we never share it" error="required">
|
|
36
|
+
<Input defaultValue="" />
|
|
37
|
+
</Field>,
|
|
38
|
+
);
|
|
39
|
+
const input = screen.getByLabelText("Email");
|
|
40
|
+
// Text beside a control is invisible to a screen reader unless something points at it. The
|
|
41
|
+
// label needs no wiring because the control sits inside it; the hint and the error do.
|
|
42
|
+
const described = (input.getAttribute("aria-describedby") ?? "").split(" ").filter(Boolean);
|
|
43
|
+
expect(described).toHaveLength(2);
|
|
44
|
+
expect(described.map((id) => document.getElementById(id)?.textContent)).toEqual([
|
|
45
|
+
"we never share it",
|
|
46
|
+
"required",
|
|
47
|
+
]);
|
|
48
|
+
// An error also opts the control into the sheet's invalid border, so the field does not
|
|
49
|
+
// depend on every caller remembering to pass aria-invalid alongside its error text.
|
|
50
|
+
expect(input).toHaveAttribute("aria-invalid", "true");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("describes a hint with no error, and does not claim invalid", () => {
|
|
54
|
+
render(
|
|
55
|
+
<Field label="Name" hint="as it appears on the account">
|
|
56
|
+
<Input defaultValue="" />
|
|
57
|
+
</Field>,
|
|
58
|
+
);
|
|
59
|
+
const input = screen.getByLabelText("Name");
|
|
60
|
+
const id = input.getAttribute("aria-describedby");
|
|
61
|
+
expect(document.getElementById(id!)?.textContent).toBe("as it appears on the account");
|
|
62
|
+
expect(input).not.toHaveAttribute("aria-invalid");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("adds no attributes when there is nothing to describe", () => {
|
|
66
|
+
render(
|
|
67
|
+
<Field label="Plain">
|
|
68
|
+
<Input defaultValue="" />
|
|
69
|
+
</Field>,
|
|
70
|
+
);
|
|
71
|
+
const input = screen.getByLabelText("Plain");
|
|
72
|
+
expect(input).not.toHaveAttribute("aria-describedby");
|
|
73
|
+
expect(input).not.toHaveAttribute("aria-invalid");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps a control's own aria-describedby and aria-invalid rather than replacing them", () => {
|
|
77
|
+
render(
|
|
78
|
+
<Field label="Email" hint="we never share it" error="required">
|
|
79
|
+
<Input defaultValue="" aria-describedby="caller-note" aria-invalid={false} />
|
|
80
|
+
</Field>,
|
|
81
|
+
);
|
|
82
|
+
const input = screen.getByLabelText("Email");
|
|
83
|
+
// The field appends; it does not clobber. And a control that deliberately says it is NOT
|
|
84
|
+
// invalid keeps saying so — the field supplies a default, not an override.
|
|
85
|
+
expect(input.getAttribute("aria-describedby")?.split(" ")[0]).toBe("caller-note");
|
|
86
|
+
expect(input.getAttribute("aria-describedby")?.split(" ")).toHaveLength(3);
|
|
87
|
+
expect(input).toHaveAttribute("aria-invalid", "false");
|
|
88
|
+
});
|
|
33
89
|
});
|
|
34
90
|
|
|
91
|
+
|
|
35
92
|
describe("Select / Textarea primitives", () => {
|
|
36
93
|
it("render token-styled controls reachable by their Field label", () => {
|
|
37
94
|
render(
|
package/src/Field.tsx
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { cloneElement, isValidElement, useId } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
2
3
|
|
|
4
|
+
import { injectTerpStyles } from "./styles";
|
|
3
5
|
import { useUiText } from "./uiText";
|
|
4
6
|
import type { UiText } from "./uiText";
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
const labelStyle: CSSProperties = {
|
|
8
|
-
fontWeight: "var(--font-weight-medium)" as never,
|
|
9
|
-
fontSize: "var(--font-size-sm)",
|
|
10
|
-
color: "var(--color-neutral-700)",
|
|
11
|
-
};
|
|
12
|
-
const hintStyle: CSSProperties = {
|
|
13
|
-
color: "var(--color-neutral-500)",
|
|
14
|
-
fontSize: "var(--font-size-xs)",
|
|
15
|
-
};
|
|
16
|
-
const errorStyle: CSSProperties = {
|
|
17
|
-
color: "var(--color-status-danger)",
|
|
18
|
-
fontSize: "var(--font-size-xs)",
|
|
19
|
-
fontWeight: "var(--font-weight-medium)" as never,
|
|
20
|
-
};
|
|
8
|
+
injectTerpStyles();
|
|
21
9
|
|
|
22
10
|
export interface FieldProps {
|
|
23
11
|
/** The field label (also the control's accessible name — the control is wrapped in the `<label>`). */
|
|
@@ -35,17 +23,53 @@ export interface FieldProps {
|
|
|
35
23
|
* id wiring) and renders an optional hint + a field-level error. Compose it with the token-styled
|
|
36
24
|
* `Input` / `Select` / `Textarea` primitives to build a multi-field form — the centralized, accessible
|
|
37
25
|
* way every module authors inputs.
|
|
26
|
+
*
|
|
27
|
+
* The label needs no wiring because the control sits inside it, but the hint and the error do: text
|
|
28
|
+
* beside a control is invisible to a screen reader unless something points at it. So the field
|
|
29
|
+
* gives each one an id and hands the control an `aria-describedby` — and, when there is an error, an
|
|
30
|
+
* `aria-invalid` that also opts the control into the sheet's invalid border. A control that already
|
|
31
|
+
* declares either keeps its own value; the field adds to `aria-describedby` rather than replacing
|
|
32
|
+
* it. `Input`, `Select`, `Textarea` and `Combobox` all spread their props onto the DOM element, so
|
|
33
|
+
* the attributes land where assistive tech reads them.
|
|
38
34
|
*/
|
|
39
35
|
export function Field({ label, children, error, hint }: FieldProps) {
|
|
40
36
|
const resolve = useUiText();
|
|
37
|
+
const baseId = useId();
|
|
38
|
+
const hasError = error !== undefined && error !== null;
|
|
39
|
+
const hintId = hint !== undefined ? `${baseId}-hint` : undefined;
|
|
40
|
+
const errorId = hasError ? `${baseId}-error` : undefined;
|
|
41
|
+
const described = [hintId, errorId].filter((id) => id !== undefined).join(" ");
|
|
42
|
+
|
|
43
|
+
// Only a single element child can be described — which is the documented contract ("the
|
|
44
|
+
// control"). Anything else is passed through untouched rather than guessed at.
|
|
45
|
+
const control = described.length > 0 && isValidElement<{
|
|
46
|
+
"aria-describedby"?: string;
|
|
47
|
+
"aria-invalid"?: boolean | "true" | "false";
|
|
48
|
+
}>(children)
|
|
49
|
+
? cloneElement(children, {
|
|
50
|
+
"aria-describedby": [children.props["aria-describedby"], described]
|
|
51
|
+
.filter((id) => id !== undefined && id !== "")
|
|
52
|
+
.join(" "),
|
|
53
|
+
"aria-invalid": children.props["aria-invalid"] ?? (hasError ? true : undefined),
|
|
54
|
+
})
|
|
55
|
+
: children;
|
|
56
|
+
|
|
41
57
|
return (
|
|
42
|
-
<div
|
|
43
|
-
<label
|
|
44
|
-
<span
|
|
45
|
-
{
|
|
58
|
+
<div data-terp="field">
|
|
59
|
+
<label data-terp="field-label">
|
|
60
|
+
<span data-terp="field-label-text">{resolve(label)}</span>
|
|
61
|
+
{control}
|
|
46
62
|
</label>
|
|
47
|
-
{hint !== undefined &&
|
|
48
|
-
|
|
63
|
+
{hint !== undefined && (
|
|
64
|
+
<span id={hintId} data-terp="field-hint">
|
|
65
|
+
{hint}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
{hasError && (
|
|
69
|
+
<span id={errorId} data-terp="field-error">
|
|
70
|
+
{error}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
49
73
|
</div>
|
|
50
74
|
);
|
|
51
75
|
}
|
package/src/HubPage.test.tsx
CHANGED
|
@@ -18,7 +18,12 @@ describe("HubPage", () => {
|
|
|
18
18
|
expect(screen.getByRole("heading", { level: 1, name: "Administration" })).toBeInTheDocument();
|
|
19
19
|
expect(screen.queryByRole("navigation", { name: "Breadcrumb" })).not.toBeInTheDocument();
|
|
20
20
|
expect(screen.getAllByText("Administration")).toHaveLength(1);
|
|
21
|
-
|
|
21
|
+
// The marker, not the declaration. jsdom does not compute the cascade, so toHaveStyle
|
|
22
|
+
// can only ever see an inline style — asserting gridAutoRows here was asserting that the
|
|
23
|
+
// grid is styled from a style object, which is the thing ADR 0094 removes. What a test
|
|
24
|
+
// should assert is the fact the sheet keys on; the geometry is gated by styles.test.ts
|
|
25
|
+
// (the rule exists) and by the hub-page baselines (it does what it says).
|
|
26
|
+
expect(screen.getByRole("list")).toHaveAttribute("data-terp", "hubpage-grid");
|
|
22
27
|
expect(screen.getByRole("link", { name: /Users/ })).toBeInTheDocument();
|
|
23
28
|
expect(screen.getByRole("link", { name: /Roles/ })).toBeInTheDocument();
|
|
24
29
|
expect(screen.getByText("Manage accounts")).toBeInTheDocument();
|
|
@@ -91,18 +96,22 @@ describe("HubCard", () => {
|
|
|
91
96
|
(title) => title.closest('[data-terp="hubcard-body"]'),
|
|
92
97
|
);
|
|
93
98
|
expect(bodies).toHaveLength(2);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
99
|
+
// What this test can actually establish, and it is the load-bearing half: both rows are
|
|
100
|
+
// PRESENT on the bare card, carrying a placeholder, so the body has three grid children
|
|
101
|
+
// either way. The equal-height claim is the tracks rule plus these placeholders, and the
|
|
102
|
+
// tracks rule is now in the sheet — gated by styles.test.ts for existence and by
|
|
103
|
+
// hub-card-bare, whose whole subject is a bare card sitting flush with a full one.
|
|
100
104
|
const shortBody = bodies[0]!;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
const fullBody = bodies[1]!;
|
|
106
|
+
expect(shortBody.children).toHaveLength(fullBody.children.length);
|
|
107
|
+
// And the attribute the rule reads. Asserting visibility: hidden here asserted an inline
|
|
108
|
+
// style object; asserting data-empty asserts the fact, which is what survives the move.
|
|
109
|
+
for (const part of ["hubcard-description", "hubcard-stat"] as const) {
|
|
110
|
+
expect(shortBody.querySelector(`[data-terp="${part}"]`)).toHaveAttribute(
|
|
111
|
+
"data-empty",
|
|
112
|
+
"true",
|
|
113
|
+
);
|
|
114
|
+
expect(fullBody.querySelector(`[data-terp="${part}"]`)).not.toHaveAttribute("data-empty");
|
|
115
|
+
}
|
|
107
116
|
});
|
|
108
117
|
});
|