@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/ProfileView.tsx
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
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";
|
|
@@ -9,38 +7,15 @@ import { userInitials } from "./UserMenu";
|
|
|
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,14 @@ 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
|
-
<span aria-hidden="true"
|
|
32
|
+
<span aria-hidden="true" data-terp="profile-avatar">
|
|
58
33
|
{userInitials(user.email)}
|
|
59
34
|
</span>
|
|
60
35
|
<Stack gap={0}>
|
|
61
|
-
<strong
|
|
62
|
-
<p
|
|
36
|
+
<strong data-terp="profile-email">{user.email}</strong>
|
|
37
|
+
<p data-terp="profile-role">{user.role_name}</p>
|
|
63
38
|
</Stack>
|
|
64
39
|
</Stack>
|
|
65
40
|
<DetailList
|
|
@@ -69,7 +44,7 @@ export function ProfileView() {
|
|
|
69
44
|
]}
|
|
70
45
|
/>
|
|
71
46
|
</div>
|
|
72
|
-
<div
|
|
47
|
+
<div data-terp="profile-card">
|
|
73
48
|
<ThemeToggle />
|
|
74
49
|
<LanguageSwitcher />
|
|
75
50
|
<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>
|
package/src/UserMenu.test.tsx
CHANGED
|
@@ -121,13 +121,20 @@ describe("UserMenu", () => {
|
|
|
121
121
|
const trigger = await screen.findByRole("button", { name: "Account menu" });
|
|
122
122
|
expect(screen.getByText("JD")).toBeInTheDocument();
|
|
123
123
|
expect(screen.queryByText("jane.doe@example.com")).not.toBeInTheDocument();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
// The collapsed geometry is a sheet rule now (ADR 0094), so what this asserts is the
|
|
125
|
+
// attribute the rule keys on and the absence of the inline object it replaced. The
|
|
126
|
+
// variant sits on the component's ROOT rather than on the trigger, because the root is
|
|
127
|
+
// what UserMenu names — the trigger is reached by descending from it.
|
|
128
|
+
const root = trigger.closest('[data-terp="user-menu"]');
|
|
129
|
+
expect(root).not.toBeNull();
|
|
130
|
+
expect(root).toHaveAttribute("data-variant", "collapsed");
|
|
131
|
+
expect(trigger).toHaveAttribute("data-terp", "menu-trigger");
|
|
132
|
+
expect(trigger.getAttribute("style")).toBeNull();
|
|
133
|
+
// The identity still surfaces inside the opened panel, whose geometry is keyed on the
|
|
134
|
+
// owner attribute — the panel is portalled out, so nothing else could reach it.
|
|
129
135
|
fireEvent.click(trigger);
|
|
130
136
|
expect(screen.getByText("jane.doe@example.com")).toBeInTheDocument();
|
|
137
|
+
expect(screen.getByRole("menu").parentElement).toHaveAttribute("data-owner", "user-menu");
|
|
131
138
|
});
|
|
132
139
|
|
|
133
140
|
it("signs out via the menu (revokes the token server-side)", async () => {
|
package/src/UserMenu.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { CSSProperties } from "react";
|
|
2
|
-
|
|
3
1
|
import { Icon } from "./icons";
|
|
2
|
+
import { injectTerpStyles } from "./styles";
|
|
4
3
|
import { useAuth } from "./TerpProvider";
|
|
5
4
|
import { Menu, MenuItem } from "./ui/Menu";
|
|
6
|
-
import { CONTROL_TEXT_STYLE } from "./ui/controlStyles";
|
|
7
5
|
import { useStrings } from "./uiText";
|
|
8
6
|
|
|
7
|
+
injectTerpStyles();
|
|
8
|
+
|
|
9
9
|
/** Initials for the avatar: the first letters of the email's local-part words. */
|
|
10
10
|
export function userInitials(email: string): string {
|
|
11
11
|
const local = email.split("@")[0] ?? "";
|
|
@@ -14,65 +14,22 @@ export function userInitials(email: string): string {
|
|
|
14
14
|
return initials.join("") || "?";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const triggerStyle: CSSProperties = {
|
|
18
|
-
...CONTROL_TEXT_STYLE,
|
|
19
|
-
display: "flex",
|
|
20
|
-
alignItems: "center",
|
|
21
|
-
justifyContent: "flex-start",
|
|
22
|
-
gap: "var(--space-2)",
|
|
23
|
-
width: "100%",
|
|
24
|
-
boxSizing: "border-box",
|
|
25
|
-
padding: "var(--space-2)",
|
|
26
|
-
textAlign: "left",
|
|
27
|
-
color: "var(--color-neutral-900)",
|
|
28
|
-
background: "transparent",
|
|
29
|
-
border: "1px solid transparent",
|
|
30
|
-
borderRadius: "var(--radius-md)",
|
|
31
|
-
cursor: "pointer",
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const collapsedTriggerStyle: CSSProperties = {
|
|
35
|
-
justifyContent: "center",
|
|
36
|
-
gap: 0,
|
|
37
|
-
padding: 0,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const avatarStyle: CSSProperties = {
|
|
41
|
-
display: "inline-flex",
|
|
42
|
-
alignItems: "center",
|
|
43
|
-
justifyContent: "center",
|
|
44
|
-
width: "2rem",
|
|
45
|
-
height: "2rem",
|
|
46
|
-
flexShrink: 0,
|
|
47
|
-
borderRadius: "var(--radius-full)",
|
|
48
|
-
background: "var(--color-brand-primary)",
|
|
49
|
-
color: "var(--color-brand-primary-contrast)",
|
|
50
|
-
fontSize: "var(--font-size-sm)",
|
|
51
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const identityStyle: CSSProperties = { display: "grid", minWidth: 0, fontSize: "var(--font-size-sm)" };
|
|
55
|
-
const emailStyle: CSSProperties = { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
|
|
56
|
-
const roleStyle: CSSProperties = { color: "var(--color-neutral-600)" };
|
|
57
|
-
const panelIdentityStyle: CSSProperties = {
|
|
58
|
-
display: "grid",
|
|
59
|
-
gap: "var(--space-1)",
|
|
60
|
-
padding: "var(--space-2)",
|
|
61
|
-
marginBottom: "var(--space-1)",
|
|
62
|
-
borderBottom: "1px solid var(--color-neutral-200)",
|
|
63
|
-
fontSize: "var(--font-size-sm)",
|
|
64
|
-
overflowWrap: "anywhere",
|
|
65
|
-
};
|
|
66
|
-
|
|
67
17
|
export interface UserMenuProps {
|
|
68
18
|
/** Icon-rail mode: show only the avatar on the trigger (the shell's collapsed state). */
|
|
69
19
|
collapsed?: boolean;
|
|
70
20
|
/** Opens the settings / profile page; rendered as the menu's first item when provided. */
|
|
71
21
|
onSettings?: () => void;
|
|
22
|
+
/**
|
|
23
|
+
* Open the panel on mount (uncontrolled), the shape every other disclosure in the package
|
|
24
|
+
* takes. It is also the only way to render the panel deterministically, and the panel is
|
|
25
|
+
* where this component's own geometry lives — so without it those rules ship unpainted by
|
|
26
|
+
* either visual lane, which is the state the calendar was in for two stages.
|
|
27
|
+
*/
|
|
28
|
+
defaultOpen?: boolean;
|
|
72
29
|
}
|
|
73
30
|
|
|
74
31
|
/** The signed-in user's account menu. */
|
|
75
|
-
export function UserMenu({ collapsed = false, onSettings }: UserMenuProps = {}) {
|
|
32
|
+
export function UserMenu({ collapsed = false, onSettings, defaultOpen }: UserMenuProps = {}) {
|
|
76
33
|
const auth = useAuth();
|
|
77
34
|
const strings = useStrings();
|
|
78
35
|
const user = auth.currentUser();
|
|
@@ -82,11 +39,11 @@ export function UserMenu({ collapsed = false, onSettings }: UserMenuProps = {})
|
|
|
82
39
|
|
|
83
40
|
const trigger = (
|
|
84
41
|
<>
|
|
85
|
-
<span aria-hidden="true"
|
|
42
|
+
<span aria-hidden="true" data-terp="user-menu-avatar">{userInitials(user.email)}</span>
|
|
86
43
|
{!collapsed && (
|
|
87
|
-
<span
|
|
88
|
-
<span
|
|
89
|
-
<span
|
|
44
|
+
<span data-terp="user-menu-identity">
|
|
45
|
+
<span data-terp="user-menu-email">{user.email}</span>
|
|
46
|
+
<span data-terp="user-menu-role">{user.role_name}</span>
|
|
90
47
|
</span>
|
|
91
48
|
)}
|
|
92
49
|
</>
|
|
@@ -98,14 +55,28 @@ export function UserMenu({ collapsed = false, onSettings }: UserMenuProps = {})
|
|
|
98
55
|
triggerLabel={strings.accountMenu}
|
|
99
56
|
placement="top"
|
|
100
57
|
align="start"
|
|
101
|
-
|
|
102
|
-
|
|
58
|
+
defaultOpen={defaultOpen}
|
|
59
|
+
// This component's rendered root is Menu's popover wrapper — it adds no element of its
|
|
60
|
+
// own — so it names that root through Menu. The icon-rail mode is a variant of the same
|
|
61
|
+
// component rather than a different one, so it is data-variant on the same marker; and
|
|
62
|
+
// the trigger and the panel are then reachable from the sheet, which is what let both
|
|
63
|
+
// `triggerStyle` and `panelStyle` be deleted. The panel needs the owner attribute
|
|
64
|
+
// Popover stamps for it: it is portalled to document.body, so no descendant selector
|
|
65
|
+
// from this side of the tree could ever reach it.
|
|
66
|
+
data-terp="user-menu"
|
|
67
|
+
data-variant={collapsed ? "collapsed" : undefined}
|
|
68
|
+
data-owner="user-menu"
|
|
103
69
|
>
|
|
104
70
|
{({ close }) => (
|
|
105
71
|
<>
|
|
106
|
-
|
|
72
|
+
{/* role="group": a role="menu" may own only menuitem / menuitemradio /
|
|
73
|
+
menuitemcheckbox / group / separator, and this identity block is a plain div —
|
|
74
|
+
so in menu mode assistive tech had no valid reason to reach it. A group needs no
|
|
75
|
+
accessible name to be valid and has no default presentation, so this is an ARIA
|
|
76
|
+
correction with no visual and no new UI string. */}
|
|
77
|
+
<div role="group" data-terp="user-menu-header">
|
|
107
78
|
<span>{user.email}</span>
|
|
108
|
-
<span
|
|
79
|
+
<span data-terp="user-menu-role">{user.role_name}</span>
|
|
109
80
|
</div>
|
|
110
81
|
{onSettings !== undefined && (
|
|
111
82
|
<MenuItem
|
|
@@ -46,15 +46,6 @@ function buildColumns(strings: TerpStrings): DataViewColumn<AuditEventRead>[] {
|
|
|
46
46
|
];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const payloadStyle = {
|
|
50
|
-
margin: 0,
|
|
51
|
-
padding: "var(--space-3)",
|
|
52
|
-
background: "var(--color-neutral-100)",
|
|
53
|
-
borderRadius: "var(--radius-md)",
|
|
54
|
-
fontSize: "var(--font-size-sm, 0.875rem)",
|
|
55
|
-
overflowX: "auto" as const,
|
|
56
|
-
};
|
|
57
|
-
|
|
58
49
|
/**
|
|
59
50
|
* The packaged audit-log screen (`/admin/audit`): the append-only trail every
|
|
60
51
|
* mutation lands in (ADR 0007), newest first as served, read-only by design.
|
|
@@ -106,7 +97,7 @@ export function AuditLogAdmin() {
|
|
|
106
97
|
]}
|
|
107
98
|
/>
|
|
108
99
|
{event.payload !== null && (
|
|
109
|
-
<pre
|
|
100
|
+
<pre data-terp="admin-payload">{JSON.stringify(event.payload, null, 2)}</pre>
|
|
110
101
|
)}
|
|
111
102
|
</div>
|
|
112
103
|
)}
|
|
@@ -75,7 +75,7 @@ export function GroupCreate() {
|
|
|
75
75
|
/>
|
|
76
76
|
}
|
|
77
77
|
>
|
|
78
|
-
<div
|
|
78
|
+
<div data-terp="admin-form">
|
|
79
79
|
<Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
|
|
80
80
|
<Field label={strings.groupName}>
|
|
81
81
|
<Input value={name} onChange={(event) => setName(event.target.value)} required />
|
|
@@ -339,7 +339,7 @@ export function GroupDetail() {
|
|
|
339
339
|
/>
|
|
340
340
|
)}
|
|
341
341
|
<Stack gap={3}>
|
|
342
|
-
<h2
|
|
342
|
+
<h2 data-terp="admin-section-title">
|
|
343
343
|
{strings.members}
|
|
344
344
|
</h2>
|
|
345
345
|
<Stack as="form" direction="row" gap={2} align="end" wrap onSubmit={onAddMember}>
|
|
@@ -376,7 +376,7 @@ export function GroupDetail() {
|
|
|
376
376
|
/>
|
|
377
377
|
</Stack>
|
|
378
378
|
<Stack gap={3}>
|
|
379
|
-
<h2
|
|
379
|
+
<h2 data-terp="admin-section-title">
|
|
380
380
|
{strings.permissions}
|
|
381
381
|
</h2>
|
|
382
382
|
<Stack as="form" direction="row" gap={2} align="end" wrap onSubmit={onGrant}>
|
package/src/admin/UserCreate.tsx
CHANGED
package/src/admin/admin.test.tsx
CHANGED
|
@@ -418,6 +418,15 @@ describe("the packaged admin area", () => {
|
|
|
418
418
|
fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
|
|
419
419
|
await screen.findByRole("heading", { level: 1, name: "Provision user" });
|
|
420
420
|
|
|
421
|
+
// The form's measure is a sheet rule now (ADR 0094), and this is the only gate on it:
|
|
422
|
+
// no admin screen has a specimen, so nothing pictures these three surfaces. What a test
|
|
423
|
+
// can hold is that the marked element is there and styles nothing itself; that the rule
|
|
424
|
+
// exists is held by styles.test.ts, and its values are a verbatim copy of the object
|
|
425
|
+
// this replaced.
|
|
426
|
+
const form = document.querySelector('[data-terp="admin-form"]');
|
|
427
|
+
expect(form).not.toBeNull();
|
|
428
|
+
expect(form?.getAttribute("style")).toBeNull();
|
|
429
|
+
|
|
421
430
|
fireEvent.change(screen.getByLabelText("Email"), {
|
|
422
431
|
target: { value: "new.account@example.com" },
|
|
423
432
|
});
|
|
@@ -510,6 +519,28 @@ describe("the packaged admin area", () => {
|
|
|
510
519
|
);
|
|
511
520
|
});
|
|
512
521
|
|
|
522
|
+
it("marks the detail sections it no longer styles inline", async () => {
|
|
523
|
+
// The second of the three surfaces the admin views used to style at the call site. Same
|
|
524
|
+
// reasoning as the create form above: markers plus the absence of a style attribute,
|
|
525
|
+
// because no admin screen has a specimen.
|
|
526
|
+
//
|
|
527
|
+
// The third, the audit payload, has no assertion here and it is worth saying why rather
|
|
528
|
+
// than quietly having none: the audit fixture serves an empty page, so no row exists to
|
|
529
|
+
// expand and the <pre> never renders in any test. Giving it one means adding rows to a
|
|
530
|
+
// fixture several tests share, which is not a change a styling migration should make.
|
|
531
|
+
// It is held by styles.test.ts (the rule exists) and by the values being a verbatim copy.
|
|
532
|
+
renderAdminApp("/admin/groups/g1");
|
|
533
|
+
const headings = await waitFor(() => {
|
|
534
|
+
const found = document.querySelectorAll('[data-terp="admin-section-title"]');
|
|
535
|
+
expect(found.length).toBe(2);
|
|
536
|
+
return found;
|
|
537
|
+
});
|
|
538
|
+
for (const heading of headings) {
|
|
539
|
+
expect(heading.tagName).toBe("H2");
|
|
540
|
+
expect(heading.getAttribute("style")).toBeNull();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
513
544
|
it("clears group destructive state when navigating between detail records in place", async () => {
|
|
514
545
|
const { router } = renderAdminApp("/admin/groups/g1");
|
|
515
546
|
await screen.findByRole("heading", { level: 1, name: "Finance" });
|
|
@@ -83,6 +83,32 @@ describe("DataView states", () => {
|
|
|
83
83
|
const untinted = screen.getByText("Broken printer").closest("tr");
|
|
84
84
|
expect(untinted).not.toHaveAttribute("data-tone");
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
it("claims data-clickable only when a row click is wired up", async () => {
|
|
88
|
+
// The other half of a sheet rule no visual lane can see. The row marker is stamped
|
|
89
|
+
// unconditionally — it has to be, or a toned row that is not clickable carries data-tone on
|
|
90
|
+
// an element no selector reaches — so what separates a row Enter will open from a row that
|
|
91
|
+
// merely contains a focusable checkbox is this attribute alone. The sheet's focus-within
|
|
92
|
+
// tint is keyed on it, and styles.test.ts pins that end.
|
|
93
|
+
const { unmount } = render(
|
|
94
|
+
<DataView repository={inMemoryRepo()} columns={COLUMNS} enableSelection />,
|
|
95
|
+
);
|
|
96
|
+
const inert = (await screen.findByText("VPN access")).closest("tr");
|
|
97
|
+
expect(inert).toHaveAttribute("data-terp", "dataview-row");
|
|
98
|
+
expect(inert).not.toHaveAttribute("data-clickable");
|
|
99
|
+
unmount();
|
|
100
|
+
|
|
101
|
+
render(
|
|
102
|
+
<DataView
|
|
103
|
+
repository={inMemoryRepo()}
|
|
104
|
+
columns={COLUMNS}
|
|
105
|
+
getRowLabel={(t) => t.title}
|
|
106
|
+
onRowClick={() => {}}
|
|
107
|
+
/>,
|
|
108
|
+
);
|
|
109
|
+
const clickable = (await screen.findByText("VPN access")).closest("tr");
|
|
110
|
+
expect(clickable).toHaveAttribute("data-clickable", "true");
|
|
111
|
+
});
|
|
86
112
|
});
|
|
87
113
|
|
|
88
114
|
describe("DataView server-side mode", () => {
|
|
@@ -365,20 +391,40 @@ describe("DataView search and view options", () => {
|
|
|
365
391
|
expect(await screen.findByText("Broken printer")).toBeInTheDocument();
|
|
366
392
|
});
|
|
367
393
|
|
|
368
|
-
it("hides and reorders columns from the view-options
|
|
394
|
+
it("hides and reorders columns from the view-options panel", async () => {
|
|
369
395
|
render(<DataView repository={inMemoryRepo()} columns={COLUMNS} />);
|
|
370
396
|
await screen.findByText("Broken printer");
|
|
371
397
|
|
|
372
398
|
fireEvent.click(screen.getByRole("button", { name: "View options" }));
|
|
373
|
-
|
|
374
|
-
|
|
399
|
+
// A group labelled by its own heading, not a menu. That is the fix for a critical
|
|
400
|
+
// aria-required-children violation: role="menu" may own only menuitem-family children,
|
|
401
|
+
// and this panel's content is a heading plus labelled checkboxes plus paired reorder
|
|
402
|
+
// buttons — a form. The guard below is the regression test for it, because the violation
|
|
403
|
+
// was invisible for as long as nothing rendered the panel open.
|
|
404
|
+
const panel = screen.getByRole("group", { name: "Columns" });
|
|
405
|
+
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
|
406
|
+
fireEvent.click(within(panel).getByRole("checkbox", { name: "Status" }));
|
|
375
407
|
expect(screen.queryByRole("columnheader", { name: /Status/ })).not.toBeInTheDocument();
|
|
376
408
|
|
|
377
|
-
fireEvent.click(within(
|
|
378
|
-
fireEvent.click(within(
|
|
409
|
+
fireEvent.click(within(panel).getByRole("checkbox", { name: "Status" }));
|
|
410
|
+
fireEvent.click(within(panel).getByRole("button", { name: "Move up: Status" }));
|
|
379
411
|
const headers = screen.getAllByRole("columnheader").map((th) => th.textContent);
|
|
380
412
|
expect(headers[0]).toContain("Status");
|
|
381
413
|
});
|
|
414
|
+
|
|
415
|
+
it("names the view-options trigger from its visible label, not an override", async () => {
|
|
416
|
+
// The trigger used to take its accessible name from an aria-label the menu primitive
|
|
417
|
+
// applied, which is the shape that hides a visible label from assistive tech when the two
|
|
418
|
+
// drift. It is now named by its own content, so the announced name IS the rendered one.
|
|
419
|
+
render(<DataView repository={inMemoryRepo()} columns={COLUMNS} />);
|
|
420
|
+
await screen.findByText("Broken printer");
|
|
421
|
+
const trigger = screen.getByRole("button", { name: "View options" });
|
|
422
|
+
expect(trigger).not.toHaveAttribute("aria-label");
|
|
423
|
+
expect(trigger).toHaveTextContent("View options");
|
|
424
|
+
// A disclosure, so this is the whole contract: expanded state plus the panel it controls.
|
|
425
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
426
|
+
expect(trigger).not.toHaveAttribute("aria-haspopup");
|
|
427
|
+
});
|
|
382
428
|
});
|
|
383
429
|
|
|
384
430
|
describe("DataView embedded variant", () => {
|
|
@@ -404,3 +450,61 @@ describe("DataView embedded variant", () => {
|
|
|
404
450
|
expect(document.querySelector('[data-terp="dataview-toolbar"]')).not.toBeInTheDocument();
|
|
405
451
|
});
|
|
406
452
|
});
|
|
453
|
+
|
|
454
|
+
describe("DataView search scope", () => {
|
|
455
|
+
// Nothing rendered this branch before the search-scope specimen existed: the control is
|
|
456
|
+
// behind `search.trim() !== ""`, and every other test and specimen starts with an empty box.
|
|
457
|
+
// That is how a control shipped announcing itself as a toggle button whose state is also its
|
|
458
|
+
// label.
|
|
459
|
+
const scopedRepo = (): DataViewRepository<Ticket> => ({
|
|
460
|
+
query: async () => ({ rows: TICKETS, totalCount: TICKETS.length }),
|
|
461
|
+
getRowId: (ticket) => ticket.id,
|
|
462
|
+
capabilities: { serverSide: true, search: true, searchScope: true },
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("swaps the label rather than claiming to be a toggle button", async () => {
|
|
466
|
+
const onBroadenedChange = vi.fn();
|
|
467
|
+
const { rerender } = render(
|
|
468
|
+
<DataView
|
|
469
|
+
repository={scopedRepo()}
|
|
470
|
+
columns={COLUMNS}
|
|
471
|
+
searchScope={{
|
|
472
|
+
broadened: false,
|
|
473
|
+
onBroadenedChange,
|
|
474
|
+
label: "Search everything",
|
|
475
|
+
broadenedLabel: "Searching everything",
|
|
476
|
+
}}
|
|
477
|
+
/>,
|
|
478
|
+
);
|
|
479
|
+
await screen.findByText("Broken printer");
|
|
480
|
+
// The control needs a search term, so type one.
|
|
481
|
+
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "printer" } });
|
|
482
|
+
const narrow = await screen.findByRole("button", { name: "Search everything" });
|
|
483
|
+
// The state is the label. Encoding it a second time in aria-pressed announces it twice
|
|
484
|
+
// and lets the two disagree, because both labels are caller-supplied.
|
|
485
|
+
expect(narrow).not.toHaveAttribute("aria-pressed");
|
|
486
|
+
fireEvent.click(narrow);
|
|
487
|
+
expect(onBroadenedChange).toHaveBeenCalledWith(true);
|
|
488
|
+
|
|
489
|
+
rerender(
|
|
490
|
+
<DataView
|
|
491
|
+
repository={scopedRepo()}
|
|
492
|
+
columns={COLUMNS}
|
|
493
|
+
searchScope={{
|
|
494
|
+
broadened: true,
|
|
495
|
+
onBroadenedChange,
|
|
496
|
+
label: "Search everything",
|
|
497
|
+
broadenedLabel: "Searching everything",
|
|
498
|
+
}}
|
|
499
|
+
/>,
|
|
500
|
+
);
|
|
501
|
+
const broad = await screen.findByRole("button", { name: "Searching everything" });
|
|
502
|
+
expect(broad).not.toHaveAttribute("aria-pressed");
|
|
503
|
+
// And the attribute is now this component's alone on the two layout toggles, which is the
|
|
504
|
+
// enumeration the shared icon-button hover guard is safe by.
|
|
505
|
+
expect(document.querySelectorAll("[aria-pressed]")).toHaveLength(2);
|
|
506
|
+
for (const toggle of document.querySelectorAll("[aria-pressed]")) {
|
|
507
|
+
expect(toggle.getAttribute("data-terp")).toBe("iconbutton");
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
});
|