@terpjs/react-core 0.6.1 → 0.8.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 (79) hide show
  1. package/README.md +12 -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/Breadcrumbs.test.tsx +24 -0
  6. package/src/Breadcrumbs.tsx +9 -32
  7. package/src/ConfirmDialog.tsx +13 -44
  8. package/src/EmptyState.tsx +8 -36
  9. package/src/ErrorState.tsx +8 -36
  10. package/src/Field.test.tsx +57 -0
  11. package/src/Field.tsx +46 -22
  12. package/src/HubPage.test.tsx +22 -13
  13. package/src/HubPage.tsx +25 -97
  14. package/src/LoadingState.tsx +3 -24
  15. package/src/ModuleNav.tsx +1 -1
  16. package/src/PageActions.tsx +5 -10
  17. package/src/UserMenu.test.tsx +12 -5
  18. package/src/UserMenu.tsx +33 -62
  19. package/src/dataview/DataView.test.tsx +109 -5
  20. package/src/dataview/DataView.tsx +41 -23
  21. package/src/dataview/DataViewCardList.tsx +14 -60
  22. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  23. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  24. package/src/dataview/DataViewPagination.tsx +2 -32
  25. package/src/dataview/DataViewRowActions.tsx +13 -33
  26. package/src/dataview/DataViewTable.tsx +16 -103
  27. package/src/dataview/DataViewToolbar.tsx +53 -76
  28. package/src/dataview/README.md +6 -0
  29. package/src/dataview/index.ts +1 -0
  30. package/src/dataview/internal.tsx +4 -1
  31. package/src/dataview/types.ts +13 -0
  32. package/src/feedback.test.tsx +26 -0
  33. package/src/files.test.tsx +18 -0
  34. package/src/files.tsx +13 -4
  35. package/src/icons.test.tsx +10 -6
  36. package/src/icons.tsx +33 -37
  37. package/src/index.ts +0 -3
  38. package/src/layout.test.tsx +24 -9
  39. package/src/layout.tsx +24 -21
  40. package/src/layoutContract.test.tsx +95 -0
  41. package/src/locale.tsx +27 -4
  42. package/src/markers.test.ts +468 -0
  43. package/src/raw.d.ts +15 -1
  44. package/src/router.tsx +6 -9
  45. package/src/ssr.test.tsx +1 -3
  46. package/src/styles.test.ts +823 -6
  47. package/src/styles.ts +2699 -153
  48. package/src/theme.test.tsx +39 -0
  49. package/src/theme.themes.test.ts +124 -0
  50. package/src/theme.tsx +62 -14
  51. package/src/toast.tsx +35 -71
  52. package/src/tokens.guard.test.ts +3 -12
  53. package/src/ui/Alert.test.tsx +12 -0
  54. package/src/ui/Alert.tsx +15 -43
  55. package/src/ui/Badge.test.tsx +14 -3
  56. package/src/ui/Badge.tsx +13 -25
  57. package/src/ui/Button.test.tsx +17 -4
  58. package/src/ui/Button.tsx +10 -63
  59. package/src/ui/Card.test.tsx +6 -2
  60. package/src/ui/Card.tsx +11 -39
  61. package/src/ui/Checkbox.tsx +2 -19
  62. package/src/ui/Combobox.test.tsx +22 -0
  63. package/src/ui/Combobox.tsx +31 -80
  64. package/src/ui/DatePicker.test.tsx +131 -4
  65. package/src/ui/DatePicker.tsx +158 -106
  66. package/src/ui/Input.tsx +6 -19
  67. package/src/ui/Markdown.test.tsx +26 -0
  68. package/src/ui/Markdown.tsx +28 -2
  69. package/src/ui/Menu.test.tsx +38 -4
  70. package/src/ui/Menu.tsx +50 -52
  71. package/src/ui/Popover.tsx +53 -19
  72. package/src/ui/Radio.tsx +5 -30
  73. package/src/ui/Select.tsx +7 -30
  74. package/src/ui/Switch.tsx +2 -20
  75. package/src/ui/Tabs.tsx +4 -28
  76. package/src/ui/Textarea.tsx +6 -17
  77. package/src/ui/Tooltip.tsx +9 -21
  78. package/src/uiText.tsx +9 -0
  79. package/src/ui/controlStyles.ts +0 -9
package/src/HubPage.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
 
4
4
  import { Page } from "./Page";
@@ -19,22 +19,15 @@ export type HubPageProps = Omit<
19
19
  parents?: PageProps["breadcrumbs"];
20
20
  };
21
21
 
22
- const gridStyle: CSSProperties = {
23
- display: "grid",
24
- gridTemplateColumns: "repeat(auto-fit, minmax(min(16rem, 100%), 1fr))",
25
- gridAutoRows: "1fr",
26
- gap: "var(--space-4)",
27
- alignItems: "stretch",
28
- listStyle: "none",
29
- margin: 0,
30
- padding: 0,
31
- };
32
-
33
22
  /**
34
23
  * The landing / hub page archetype: a `Page` whose body is a responsive grid of
35
24
  * {@link HubCard} links into the sub-areas of a domain. Use it as a module index that
36
25
  * adds discovery value (each card can carry a live `stat`, making the hub a lightweight
37
26
  * dashboard) — never as a mandatory speed-bump in front of a single frequently-used list.
27
+ *
28
+ * It renders no inline styles: the grid and every part of a card take their geometry from
29
+ * the injected react-core sheet, matched on the `data-terp` markers stamped below
30
+ * (ADR 0094).
38
31
  */
39
32
  export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProps) {
40
33
  // The runtime half of the slot-typed layout contract control (ADR 0079) for the hub
@@ -61,7 +54,7 @@ export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProp
61
54
  }
62
55
  return (
63
56
  <Page {...page} breadcrumbs={parents ?? breadcrumbs}>
64
- <ul ref={gridRef} style={gridStyle}>
57
+ <ul ref={gridRef} data-terp="hubpage-grid">
65
58
  {children}
66
59
  </ul>
67
60
  </Page>
@@ -89,86 +82,23 @@ export interface HubCardProps {
89
82
  renderLink?: RenderHubCardLink;
90
83
  }
91
84
 
92
- const cardStyle: CSSProperties = {
93
- height: "100%",
94
- minHeight: 0,
95
- };
96
-
97
- const cardBodyStyle: CSSProperties = {
98
- display: "grid",
99
- gridTemplateRows: "auto minmax(3rem, 1fr) auto",
100
- gap: "var(--space-2)",
101
- height: "100%",
102
- minHeight: "10rem",
103
- padding: "var(--space-4)",
104
- border: "1px solid var(--color-neutral-200)",
105
- borderRadius: "var(--radius-lg)",
106
- background: "var(--color-neutral-0)",
107
- color: "var(--color-neutral-900)",
108
- boxSizing: "border-box",
109
- };
110
-
111
- const cardTitleRowStyle: CSSProperties = {
112
- margin: 0,
113
- display: "flex",
114
- alignItems: "center",
115
- gap: "var(--space-3)",
116
- };
117
-
118
- const iconTileStyle: CSSProperties = {
119
- display: "inline-flex",
120
- alignItems: "center",
121
- justifyContent: "center",
122
- width: "2.25rem",
123
- height: "2.25rem",
124
- flexShrink: 0,
125
- borderRadius: "var(--radius-md)",
126
- background: "var(--color-brand-primary-soft)",
127
- color: "var(--color-brand-primary)",
128
- };
129
-
130
- const titleTextStyle: CSSProperties = {
131
- color: "var(--color-neutral-900)",
132
- fontSize: "var(--font-size-base)",
133
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
134
- transition: "color 150ms ease",
135
- };
136
-
137
- const descriptionStyle: CSSProperties = {
138
- margin: 0,
139
- color: "var(--color-neutral-600)",
140
- fontSize: "var(--font-size-sm)",
141
- lineHeight: 1.5,
142
- };
143
-
144
- const emptyDescriptionStyle: CSSProperties = { ...descriptionStyle, visibility: "hidden" };
145
-
146
- const statStyle: CSSProperties = {
147
- color: "var(--color-neutral-900)",
148
- fontSize: "var(--font-size-sm)",
149
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
150
- };
151
- const emptyStatStyle: CSSProperties = { ...statStyle, visibility: "hidden" };
152
-
153
- const linkStyle: CSSProperties = {
154
- textDecoration: "none",
155
- color: "inherit",
156
- display: "block",
157
- height: "100%",
158
- minHeight: 0,
159
- };
160
-
161
85
  const anchorRenderLink: RenderHubCardLink = ({ to, children }) => (
162
- <a href={to} data-terp="hubcard-link" style={linkStyle}>
86
+ <a href={to} data-terp="hubcard-link">
163
87
  {children}
164
88
  </a>
165
89
  );
166
90
 
167
91
  /**
168
- * A single navigable card inside a {@link HubPage}: icon + title, a short description of
92
+ * A single navigable card inside a {@link HubPage}: icon + title, a short explanation of
169
93
  * the area, and an optional live `stat`. The whole card is one link, rendered through
170
94
  * `renderLink` so the hub stays router-agnostic — defaulting to the surrounding router's
171
95
  * `Link`, and to a plain anchor only outside a Terp router.
96
+ *
97
+ * The description and stat rows always render, carrying a non-breaking space when the prop
98
+ * is absent, so a card's three grid rows keep their heights and a bare card stays flush
99
+ * with a full one in the same row. `data-empty` is what hides the placeholder — an
100
+ * attribute rather than a second style object, because "is this row a placeholder" is a
101
+ * fact about the element that a rule can read.
172
102
  */
173
103
  export function HubCard({
174
104
  to,
@@ -184,27 +114,26 @@ export function HubCard({
184
114
  (navLink === null
185
115
  ? anchorRenderLink
186
116
  : ({ to: href, children }: { to: string; children: ReactNode }) => (
187
- <span data-terp="hubcard-link" style={linkStyle}>
188
- {navLink({ to: href, children })}
189
- </span>
117
+ <span data-terp="hubcard-link">{navLink({ to: href, children })}</span>
190
118
  ));
191
119
  const resolve = useUiText();
192
120
  return (
193
- <li data-terp="hubcard" style={cardStyle}>
121
+ <li data-terp="hubcard">
194
122
  {renderCardLink({
195
123
  to,
196
124
  children: (
197
- <span data-terp="hubcard-body" style={cardBodyStyle}>
198
- <span style={cardTitleRowStyle}>
199
- {icon !== undefined && <span style={iconTileStyle}>{icon}</span>}
200
- <strong data-terp="hubcard-title" style={titleTextStyle}>
201
- {resolve(title)}
202
- </strong>
125
+ <span data-terp="hubcard-body">
126
+ <span data-terp="hubcard-heading">
127
+ {icon !== undefined && <span data-terp="hubcard-icon">{icon}</span>}
128
+ <strong data-terp="hubcard-title">{resolve(title)}</strong>
203
129
  </span>
204
- <span data-terp="hubcard-description" style={description === undefined ? emptyDescriptionStyle : descriptionStyle}>
130
+ <span
131
+ data-terp="hubcard-description"
132
+ data-empty={description === undefined ? "true" : undefined}
133
+ >
205
134
  {description === undefined ? " " : resolve(description)}
206
135
  </span>
207
- <span data-terp="hubcard-stat" style={stat === undefined ? emptyStatStyle : statStyle}>
136
+ <span data-terp="hubcard-stat" data-empty={stat === undefined ? "true" : undefined}>
208
137
  {stat ?? " "}
209
138
  </span>
210
139
  </span>
@@ -213,4 +142,3 @@ export function HubCard({
213
142
  </li>
214
143
  );
215
144
  }
216
-
@@ -1,5 +1,3 @@
1
- import type { CSSProperties } from "react";
2
-
3
1
  import { injectTerpStyles } from "./styles";
4
2
  import { useStrings, useUiText } from "./uiText";
5
3
  import type { UiText } from "./uiText";
@@ -23,13 +21,7 @@ export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
23
21
  <span
24
22
  aria-hidden="true"
25
23
  data-terp="spinner-ring"
26
- style={{
27
- display: "inline-block",
28
- verticalAlign: "middle",
29
- width: size,
30
- height: size,
31
- lineHeight: 0,
32
- }}
24
+ style={{ width: size, height: size }}
33
25
  >
34
26
  <svg
35
27
  aria-hidden="true"
@@ -37,7 +29,6 @@ export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
37
29
  height={size}
38
30
  viewBox="0 0 24 24"
39
31
  fill="none"
40
- style={{ display: "block" }}
41
32
  >
42
33
  <circle
43
34
  cx="12"
@@ -63,18 +54,6 @@ export interface LoadingStateProps {
63
54
  label?: UiText;
64
55
  }
65
56
 
66
- const wrapStyle: CSSProperties = {
67
- display: "flex",
68
- alignItems: "center",
69
- justifyContent: "center",
70
- gap: "var(--space-2)",
71
- padding: "var(--space-6)",
72
- color: "var(--color-neutral-500)",
73
- fontSize: "var(--font-size-sm)",
74
- };
75
-
76
- const spinnerColorStyle: CSSProperties = { color: "var(--color-brand-primary)" };
77
-
78
57
  /**
79
58
  * Standard inline loading indicator: a spinner plus a short label, announced
80
59
  * as a `status` live region. Use it when a query is pending and the page shell
@@ -85,8 +64,8 @@ export function LoadingState({ label }: LoadingStateProps) {
85
64
  const strings = useStrings();
86
65
  const resolve = useUiText();
87
66
  return (
88
- <div role="status" data-terp="loading-state" style={wrapStyle}>
89
- <span style={spinnerColorStyle}>
67
+ <div role="status" data-terp="loading-state">
68
+ <span data-terp="loading-state-spinner">
90
69
  <InlineSpinner size={20} />
91
70
  </span>
92
71
  <span>{resolve(label ?? strings.loading)}</span>
package/src/ModuleNav.tsx CHANGED
@@ -41,7 +41,7 @@ const linkStyle: CSSProperties = {
41
41
 
42
42
  const activeLinkStyle: CSSProperties = {
43
43
  color: "var(--color-neutral-900)",
44
- borderBottomColor: "var(--color-brand-primary)",
44
+ borderBottomColor: "var(--color-fg-accent)",
45
45
  };
46
46
 
47
47
  /**
@@ -1,9 +1,12 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
 
3
+ import { injectTerpStyles } from "./styles";
3
4
  import { Menu, MenuItem } from "./ui/Menu";
4
5
  import { useStrings } from "./uiText";
5
6
  import type { UiText } from "./uiText";
6
7
 
8
+ injectTerpStyles();
9
+
7
10
  export interface OverflowAction {
8
11
  /** Display label for the menu item. */
9
12
  label: UiText;
@@ -25,14 +28,6 @@ export interface PageActionsProps {
25
28
  className?: string;
26
29
  }
27
30
 
28
- const clusterStyle: CSSProperties = {
29
- display: "flex",
30
- flexWrap: "wrap",
31
- alignItems: "center",
32
- justifyContent: "flex-end",
33
- gap: "var(--space-2)",
34
- };
35
-
36
31
  /** Standard right-aligned action cluster for page headers. */
37
32
  export function PageActions({ primary, secondary, overflow, className }: PageActionsProps) {
38
33
  const strings = useStrings();
@@ -43,7 +38,7 @@ export function PageActions({ primary, secondary, overflow, className }: PageAct
43
38
  }
44
39
 
45
40
  return (
46
- <div className={className} style={clusterStyle}>
41
+ <div className={className} data-terp="page-actions">
47
42
  {hasOverflow && (
48
43
  <Menu trigger="⋯" triggerLabel={strings.moreActions}>
49
44
  {({ close }) => (
@@ -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
@@ -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
+ });