@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.
Files changed (94) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Breadcrumbs.test.tsx +24 -0
  8. package/src/Breadcrumbs.tsx +9 -32
  9. package/src/ConfirmDialog.tsx +13 -44
  10. package/src/EmptyState.tsx +8 -36
  11. package/src/ErrorState.tsx +8 -36
  12. package/src/Field.test.tsx +57 -0
  13. package/src/Field.tsx +46 -22
  14. package/src/HubPage.test.tsx +22 -13
  15. package/src/HubPage.tsx +25 -97
  16. package/src/LoadingState.tsx +3 -24
  17. package/src/LoginView.tsx +22 -75
  18. package/src/ModuleNav.test.tsx +19 -0
  19. package/src/ModuleNav.tsx +10 -35
  20. package/src/Page.test.tsx +9 -6
  21. package/src/Page.tsx +15 -39
  22. package/src/PageActions.tsx +5 -10
  23. package/src/ProfileView.test.tsx +15 -0
  24. package/src/ProfileView.tsx +8 -33
  25. package/src/ResourceList.tsx +13 -24
  26. package/src/UserMenu.test.tsx +12 -5
  27. package/src/UserMenu.tsx +33 -62
  28. package/src/admin/AuditLogAdmin.tsx +1 -10
  29. package/src/admin/GroupCreate.tsx +1 -1
  30. package/src/admin/GroupDetail.tsx +2 -2
  31. package/src/admin/UserCreate.tsx +1 -1
  32. package/src/admin/admin.test.tsx +31 -0
  33. package/src/dataview/DataView.test.tsx +109 -5
  34. package/src/dataview/DataView.tsx +41 -23
  35. package/src/dataview/DataViewCardList.tsx +14 -60
  36. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  37. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  38. package/src/dataview/DataViewPagination.tsx +2 -32
  39. package/src/dataview/DataViewRowActions.tsx +13 -33
  40. package/src/dataview/DataViewTable.tsx +16 -103
  41. package/src/dataview/DataViewToolbar.tsx +53 -76
  42. package/src/dataview/README.md +6 -0
  43. package/src/dataview/index.ts +1 -0
  44. package/src/dataview/internal.tsx +4 -1
  45. package/src/dataview/types.ts +13 -0
  46. package/src/download.test.tsx +153 -0
  47. package/src/download.tsx +132 -0
  48. package/src/feedback.test.tsx +26 -0
  49. package/src/files.test.tsx +18 -0
  50. package/src/files.tsx +15 -15
  51. package/src/icons.test.tsx +10 -6
  52. package/src/icons.tsx +9 -37
  53. package/src/index.ts +7 -4
  54. package/src/layout.test.tsx +24 -9
  55. package/src/layout.tsx +24 -21
  56. package/src/layoutContract.test.tsx +95 -0
  57. package/src/locale.tsx +24 -4
  58. package/src/markers.test.ts +354 -25
  59. package/src/routeSearch.ts +73 -0
  60. package/src/routeTypes.ts +50 -6
  61. package/src/router.test.tsx +191 -1
  62. package/src/router.tsx +81 -18
  63. package/src/sso.test.tsx +6 -3
  64. package/src/ssr.test.tsx +1 -3
  65. package/src/styles.test.ts +855 -6
  66. package/src/styles.ts +3049 -153
  67. package/src/theme.tsx +24 -3
  68. package/src/toast.tsx +35 -71
  69. package/src/ui/Alert.test.tsx +12 -0
  70. package/src/ui/Alert.tsx +15 -43
  71. package/src/ui/Badge.test.tsx +14 -3
  72. package/src/ui/Badge.tsx +9 -28
  73. package/src/ui/Button.test.tsx +19 -4
  74. package/src/ui/Button.tsx +10 -63
  75. package/src/ui/Card.test.tsx +6 -2
  76. package/src/ui/Card.tsx +11 -39
  77. package/src/ui/Checkbox.tsx +2 -19
  78. package/src/ui/Combobox.test.tsx +22 -0
  79. package/src/ui/Combobox.tsx +31 -80
  80. package/src/ui/DatePicker.test.tsx +131 -4
  81. package/src/ui/DatePicker.tsx +158 -106
  82. package/src/ui/Input.tsx +6 -19
  83. package/src/ui/Markdown.test.tsx +26 -0
  84. package/src/ui/Markdown.tsx +28 -2
  85. package/src/ui/Menu.test.tsx +38 -4
  86. package/src/ui/Menu.tsx +50 -52
  87. package/src/ui/Popover.tsx +53 -19
  88. package/src/ui/Radio.tsx +5 -30
  89. package/src/ui/Select.tsx +7 -30
  90. package/src/ui/Switch.tsx +2 -20
  91. package/src/ui/Tabs.tsx +4 -28
  92. package/src/ui/Textarea.tsx +6 -17
  93. package/src/ui/Tooltip.tsx +9 -21
  94. package/src/ui/controlStyles.ts +0 -9
@@ -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 style={cardStyle}>
30
+ <div data-terp="profile-card">
56
31
  <Stack direction="row" gap={3} align="center">
57
- <span aria-hidden="true" style={avatarStyle}>
32
+ <span aria-hidden="true" data-terp="profile-avatar">
58
33
  {userInitials(user.email)}
59
34
  </span>
60
35
  <Stack gap={0}>
61
- <strong style={{ overflowWrap: "anywhere" }}>{user.email}</strong>
62
- <p style={mutedStyle}>{user.role_name}</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 style={cardStyle}>
47
+ <div data-terp="profile-card">
73
48
  <ThemeToggle />
74
49
  <LanguageSwitcher />
75
50
  <div>
@@ -1,5 +1,5 @@
1
1
  import { useState } from "react";
2
- import type { CSSProperties, FormEvent, ReactNode } from "react";
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} style={{ display: "flex", gap: "var(--space-2)" }}>
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" style={{ color: "var(--color-status-danger)" }}>
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 style={mutedStyle}>{resource.loading ? strings.loading : resolve(emptyMessage ?? strings.emptyList)}</p>
97
+ <p data-terp="resource-list-empty">
98
+ {resource.loading ? strings.loading : resolve(emptyMessage ?? strings.emptyList)}
99
+ </p>
111
100
  ) : (
112
- <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: "var(--space-2)" }}>
101
+ <ul data-terp="resource-list-items">
113
102
  {resource.items.map((item) => (
114
- <li key={item.id} style={rowStyle}>
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>
@@ -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
- expect(trigger.style.width).toBe("100%");
125
- expect(trigger.style.boxSizing).toBe("border-box");
126
- expect(trigger.style.justifyContent).toBe("center");
127
- expect(trigger.style.padding).toBe("0px");
128
- // The identity still surfaces inside the opened panel.
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" style={avatarStyle}>{userInitials(user.email)}</span>
42
+ <span aria-hidden="true" data-terp="user-menu-avatar">{userInitials(user.email)}</span>
86
43
  {!collapsed && (
87
- <span style={identityStyle}>
88
- <span style={emailStyle}>{user.email}</span>
89
- <span style={roleStyle}>{user.role_name}</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
- triggerStyle={collapsed ? { ...triggerStyle, ...collapsedTriggerStyle } : triggerStyle}
102
- panelStyle={{ minWidth: "14rem", padding: "var(--space-2)" }}
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
- <div style={panelIdentityStyle}>
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 style={roleStyle}>{user.role_name}</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 style={payloadStyle}>{JSON.stringify(event.payload, null, 2)}</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 style={{ maxWidth: "32rem" }}>
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 style={{ margin: 0, fontSize: "var(--font-size-base)" }}>
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 style={{ margin: 0, fontSize: "var(--font-size-base)" }}>
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}>
@@ -81,7 +81,7 @@ export function UserCreate() {
81
81
  />
82
82
  }
83
83
  >
84
- <div style={{ maxWidth: "32rem" }}>
84
+ <div data-terp="admin-form">
85
85
  <Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
86
86
  <Field label={strings.email}>
87
87
  <Input
@@ -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 menu", async () => {
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
- const menu = screen.getByRole("menu");
374
- fireEvent.click(within(menu).getByRole("checkbox", { name: "Status" }));
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(menu).getByRole("checkbox", { name: "Status" }));
378
- fireEvent.click(within(menu).getByRole("button", { name: "Move up: Status" }));
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
+ });