@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
@@ -17,6 +17,7 @@ import { DataViewTextProvider, useDataViewText } from "./internal";
17
17
  import type {
18
18
  DataViewBatchAction,
19
19
  DataViewColumn,
20
+ DataViewDensity,
20
21
  DataViewQuery,
21
22
  DataViewRepository,
22
23
  DataViewRowAction,
@@ -39,6 +40,21 @@ interface DataViewBaseProps<T> {
39
40
  columns: DataViewColumn<T>[];
40
41
  /** "embedded" renders a plain compact view: no view toggle, no page-size selector, no footer. */
41
42
  variant?: "full" | "embedded";
43
+ /**
44
+ * How tightly this view packs its cells and controls (default `"comfortable"`).
45
+ *
46
+ * `"compact"` stamps `data-density="compact"` on the root, which re-scopes the live
47
+ * density tokens for the whole subtree — cell padding here, and the control heights
48
+ * `Button`, `Input` and `Select` already read, so the toolbar tightens with the table.
49
+ *
50
+ * `"comfortable"` stamps NO attribute, because comfortable IS the token sheet's
51
+ * `:root` value and an attribute for it would match no rule. The consequence worth
52
+ * knowing: inside an already-compact subtree, `density="comfortable"` does not make
53
+ * this view comfortable again. Expressing that needs a named comfortable copy of
54
+ * each live token, which ADR 0094 defers until something asks — nothing can ask
55
+ * until the shell takes a density of its own.
56
+ */
57
+ density?: DataViewDensity;
42
58
  enableSelection?: boolean;
43
59
  batchActions?: DataViewBatchAction<T>[];
44
60
  rowActions?: (row: T) => DataViewRowAction<T>[];
@@ -130,6 +146,13 @@ function useIsMobile(): boolean {
130
146
  function DataViewInner<T>(props: DataViewProps<T>) {
131
147
  const { strings, resolve } = useDataViewText();
132
148
  const embedded = props.variant === "embedded";
149
+ // Only the compact value is expressible as an attribute; see the prop's doc comment.
150
+ const densityAttribute = props.density === "compact" ? "compact" : undefined;
151
+ // Hoisted rather than written inline at the two roots, which is what the density
152
+ // attribute above already does and what keeps the marker scanner out of a trap: it
153
+ // reads a whole expression container, so every string literal inside one counts as a
154
+ // marker name and a conditional written at the attribute would report phantoms.
155
+ const variantAttribute = embedded ? "embedded" : "full";
133
156
  const serverSide = props.repository.capabilities.serverSide;
134
157
  const initialPageSize = embedded
135
158
  ? EMBEDDED_PAGE_SIZE
@@ -296,7 +319,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
296
319
  return props.renderError !== undefined ? (
297
320
  <>{props.renderError(error)}</>
298
321
  ) : (
299
- <div style={{ padding: "var(--space-4)" }}>
322
+ <div data-terp="dataview-error">
300
323
  <ErrorState title={strings.errorTitle} error={error} />
301
324
  </div>
302
325
  );
@@ -325,7 +348,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
325
348
  );
326
349
  }
327
350
  return (
328
- <div style={{ overflowX: "auto" }}>
351
+ <div data-terp="dataview-scroll">
329
352
  <DataViewTable
330
353
  rows={rows}
331
354
  columns={state.visibleColumns}
@@ -366,7 +389,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
366
389
  props.toolbarContent !== undefined ||
367
390
  props.toolbarTrailing !== undefined;
368
391
  return (
369
- <div data-terp="dataview" style={{ display: "grid" }}>
392
+ <div data-terp="dataview" data-variant={variantAttribute} data-density={densityAttribute}>
370
393
  {showsEmbeddedToolbar && (
371
394
  <DataViewToolbar<T>
372
395
  searchEnabled={props.repository.capabilities.search}
@@ -401,15 +424,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
401
424
  }
402
425
 
403
426
  return (
404
- <div
405
- data-terp="dataview"
406
- style={{
407
- display: "grid",
408
- background: "var(--color-neutral-0)",
409
- border: "1px solid var(--color-neutral-200)",
410
- borderRadius: "var(--radius-lg)",
411
- }}
412
- >
427
+ <div data-terp="dataview" data-variant={variantAttribute} data-density={densityAttribute}>
413
428
  <DataViewToolbar<T>
414
429
  searchEnabled={props.repository.capabilities.search}
415
430
  search={state.search}
@@ -458,20 +473,23 @@ function DataViewInner<T>(props: DataViewProps<T>) {
458
473
  );
459
474
  }
460
475
 
461
- /** Fixed-height placeholder rows so the initial load never causes a layout jump. */
476
+ /**
477
+ * Fixed-height placeholder rows so the initial load never causes a layout jump.
478
+ *
479
+ * One consequence of taking those heights from the sheet is worth stating where it
480
+ * bites, because it is the sharpest instance of something the whole migration accepted
481
+ * rather than anything peculiar to this component: the style injector is guarded on
482
+ * `document`, so no migrated rule reaches server-rendered HTML. A server-rendered
483
+ * DataView emits its toolbar and this skeleton and nothing else, which makes this the
484
+ * one place where "no layout jump" is the entire reason the markup exists. Until
485
+ * hydration these five divs are now zero-height. `ssr.test.tsx` asserts the marker
486
+ * string only, so nothing fails — it is a trade, recorded, not an oversight.
487
+ */
462
488
  function DataViewSkeleton({ label }: { label: string }) {
463
489
  return (
464
- <div role="status" aria-label={label} style={{ display: "grid", gap: "var(--space-2)", padding: "var(--space-3)" }}>
490
+ <div role="status" aria-label={label} data-terp="dataview-skeleton">
465
491
  {Array.from({ length: 5 }, (_, index) => (
466
- <div
467
- key={index}
468
- aria-hidden
469
- style={{
470
- height: "2.75rem",
471
- background: "var(--color-neutral-100)",
472
- borderRadius: "var(--radius-md)",
473
- }}
474
- />
492
+ <div key={index} aria-hidden />
475
493
  ))}
476
494
  </div>
477
495
  );
@@ -1,7 +1,6 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
 
3
3
  import type { BadgeTone } from "../ui/Badge";
4
- import { toneSoftColors } from "../ui/Badge";
5
4
  import type { UiText } from "../uiText";
6
5
 
7
6
  import { DataViewExpandToggle } from "./DataViewExpandableRow";
@@ -30,18 +29,6 @@ export interface DataViewCardListProps<T> {
30
29
  rowActions?: (row: T) => DataViewRowAction<T>[];
31
30
  }
32
31
 
33
- const recordButtonStyle: CSSProperties = {
34
- position: "absolute",
35
- width: 1,
36
- height: 1,
37
- padding: 0,
38
- margin: -1,
39
- overflow: "hidden",
40
- clip: "rect(0 0 0 0)",
41
- whiteSpace: "nowrap",
42
- border: 0,
43
- };
44
-
45
32
  function slotValue<T>(
46
33
  columns: DataViewColumn<T>[],
47
34
  row: T,
@@ -68,7 +55,7 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
68
55
  const { strings, resolve, format } = useDataViewText();
69
56
 
70
57
  return (
71
- <ul style={{ listStyle: "none", margin: 0, padding: "var(--space-2)", display: "grid", gap: "var(--space-2)" }}>
58
+ <ul data-terp="dataview-card-list">
72
59
  {props.rows.map((row) => {
73
60
  const rowId = props.getRowId(row);
74
61
  const expanded = props.isExpanded(rowId);
@@ -78,18 +65,9 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
78
65
  <li key={rowId}>
79
66
  <div
80
67
  onClick={clickable ? () => props.onRowClick?.(row) : undefined}
81
- data-terp={clickable ? "dataview-card" : undefined}
68
+ data-terp="dataview-card"
69
+ data-clickable={clickable || undefined}
82
70
  data-tone={tone ?? undefined}
83
- style={{
84
- display: "grid",
85
- gap: "var(--space-2)",
86
- padding: "var(--space-3)",
87
- background: tone !== null ? toneSoftColors[tone] : "var(--color-neutral-0)",
88
- border: "1px solid var(--color-neutral-200)",
89
- borderRadius: "var(--radius-lg)",
90
- boxShadow: "var(--shadow-sm)",
91
- cursor: clickable ? "pointer" : undefined,
92
- }}
93
71
  >
94
72
  {clickable && (
95
73
  <button
@@ -102,10 +80,9 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
102
80
  event.stopPropagation();
103
81
  props.onRowClick?.(row);
104
82
  }}
105
- style={recordButtonStyle}
106
83
  />
107
84
  )}
108
- <div style={{ display: "flex", alignItems: "flex-start", gap: "var(--space-2)" }}>
85
+ <div data-terp="dataview-card-main">
109
86
  {props.renderExpanded !== undefined && (
110
87
  <DataViewExpandToggle
111
88
  expanded={expanded}
@@ -113,7 +90,7 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
113
90
  />
114
91
  )}
115
92
  {props.selectionEnabled && (
116
- <span onClick={(event) => event.stopPropagation()} style={{ display: "inline-flex" }}>
93
+ <span onClick={(event) => event.stopPropagation()}>
117
94
  <input
118
95
  type="checkbox"
119
96
  aria-label={resolve(strings.selectRow)}
@@ -122,7 +99,7 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
122
99
  />
123
100
  </span>
124
101
  )}
125
- <div style={{ flex: 1, minWidth: 0 }}>
102
+ <div data-terp="dataview-card-body">
126
103
  {props.renderCard !== undefined ? (
127
104
  props.renderCard(row)
128
105
  ) : (
@@ -139,12 +116,7 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
139
116
  )}
140
117
  </div>
141
118
  {expanded && props.renderExpanded !== undefined && (
142
- <div
143
- style={{
144
- borderTop: "1px solid var(--color-neutral-200)",
145
- paddingTop: "var(--space-2)",
146
- }}
147
- >
119
+ <div data-terp="dataview-card-expanded">
148
120
  {props.renderExpanded(row)}
149
121
  </div>
150
122
  )}
@@ -162,33 +134,15 @@ function DefaultCardBody<T>({ row, columns }: { row: T; columns: DataViewColumn<
162
134
  const status = slotValue(columns, row, "status");
163
135
  const date = slotValue(columns, row, "date");
164
136
  return (
165
- <div style={{ display: "grid", gap: "var(--space-1)" }}>
166
- <div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flexWrap: "wrap" }}>
167
- <span style={{ fontWeight: "var(--font-weight-medium)" as never }}>{title}</span>
137
+ <div data-terp="dataview-card-fields">
138
+ <div data-terp="dataview-card-heading">
139
+ <span data-terp="dataview-card-title">{title}</span>
168
140
  {status !== null && (
169
- <span
170
- style={{
171
- fontSize: "var(--font-size-sm)",
172
- padding: "0 var(--space-2)",
173
- background: "var(--color-neutral-100)",
174
- borderRadius: "var(--radius-full)",
175
- color: "var(--color-neutral-700)",
176
- }}
177
- >
178
- {status}
179
- </span>
141
+ <span data-terp="dataview-card-status">{status}</span>
180
142
  )}
181
143
  </div>
182
- {subtitle !== null && (
183
- <span style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}>
184
- {subtitle}
185
- </span>
186
- )}
187
- {date !== null && (
188
- <span style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}>
189
- {date}
190
- </span>
191
- )}
144
+ {subtitle !== null && <span data-terp="dataview-card-meta">{subtitle}</span>}
145
+ {date !== null && <span data-terp="dataview-card-meta">{date}</span>}
192
146
  </div>
193
147
  );
194
148
  }
@@ -1,5 +1,8 @@
1
+ import { useId } from "react";
2
+
3
+ import { Popover } from "../ui/Popover";
1
4
  import { ArrowDownGlyph, ArrowUpGlyph, ColumnsGlyph } from "./glyphs";
2
- import { DataViewMenu, useDataViewText } from "./internal";
5
+ import { useDataViewText } from "./internal";
3
6
  import type { DataViewColumn } from "./types";
4
7
 
5
8
  export interface DataViewColumnSettingsProps<T> {
@@ -8,68 +11,72 @@ export interface DataViewColumnSettingsProps<T> {
8
11
  columnVisibility: Record<string, boolean>;
9
12
  onColumnVisibleChange: (columnId: string, visible: boolean) => void;
10
13
  onMoveColumn: (columnId: string, direction: -1 | 1) => void;
14
+ /**
15
+ * Render the panel open on mount — the same escape the menu primitive already exposes,
16
+ * and the only way a portalled panel reaches a per-specimen visual lane.
17
+ */
18
+ defaultOpen?: boolean;
11
19
  }
12
20
 
13
21
  /**
14
- * The "view options" menu: per-column show/hide checkboxes and up/down reordering.
22
+ * The "view options" panel: per-column show/hide checkboxes and up/down reordering.
15
23
  * Only user columns appear — the pinned system columns (select/expand/actions) are
16
24
  * never hideable or reorderable, and are skipped when computing reorder targets
17
25
  * because this list simply does not contain them.
26
+ *
27
+ * A `Popover` rather than a `Menu`, and the distinction is a correctness one rather than
28
+ * a preference. `Menu` stamps `role="menu"`, which ARIA requires to own only
29
+ * `menuitem` / `menuitemradio` / `menuitemcheckbox` / `group` children — and this panel's
30
+ * content is a heading, labelled checkboxes and paired reorder buttons, which is a small
31
+ * FORM. Feeding it to a menu produced a critical `aria-required-children` violation in
32
+ * every theme, and there is no arrangement that fixes it while keeping the interaction:
33
+ * reorder buttons inside a `menuitemcheckbox` would be interactive descendants of a menu
34
+ * item, which ARIA forbids in turn. The panel is therefore a group labelled by its own
35
+ * heading, navigated with Tab the way a form is, and `Popover` supplies the Escape,
36
+ * outside-click and focus-return behaviour `Menu` was being used for.
37
+ *
38
+ * Nothing had rendered this panel open before, which is why a shipped critical defect was
39
+ * invisible: the screenshot lane clips to the specimen and the panel portals to the body,
40
+ * and no specimen opened it at all.
18
41
  */
19
42
  export function DataViewColumnSettings<T>({
20
43
  columns,
21
44
  columnVisibility,
22
45
  onColumnVisibleChange,
23
46
  onMoveColumn,
47
+ defaultOpen,
24
48
  }: DataViewColumnSettingsProps<T>) {
25
49
  const { strings, resolve } = useDataViewText();
50
+ const headingId = useId();
26
51
 
27
52
  return (
28
- <DataViewMenu
53
+ <Popover
54
+ defaultOpen={defaultOpen}
55
+ focusOnOpen
56
+ data-owner="dataview-column-settings"
29
57
  trigger={
30
- <>
58
+ // The trigger keeps the menu-trigger marker, which is styling vocabulary rather than
59
+ // a semantic claim — the sheet describes it as the package's outlined control, and
60
+ // reusing it is what keeps this control's appearance identical. What it does NOT keep
61
+ // is aria-haspopup="menu", because the panel is no longer a menu; Popover supplies
62
+ // aria-expanded and aria-controls, which is the whole contract for a disclosure.
63
+ <button type="button" data-terp="menu-trigger">
31
64
  <ColumnsGlyph />
32
- <span style={{ fontSize: "var(--font-size-sm)" }}>{resolve(strings.viewOptions)}</span>
33
- </>
65
+ <span>{resolve(strings.viewOptions)}</span>
66
+ </button>
34
67
  }
35
- triggerLabel={resolve(strings.viewOptions)}
36
68
  >
37
69
  {() => (
38
- <div style={{ display: "grid", gap: "var(--space-1)" }}>
39
- <div
40
- style={{
41
- padding: "var(--space-1) var(--space-2)",
42
- fontSize: "var(--font-size-sm)",
43
- fontWeight: "var(--font-weight-medium)" as never,
44
- color: "var(--color-neutral-500)",
45
- }}
46
- >
70
+ <div data-terp="dataview-column-settings" role="group" aria-labelledby={headingId}>
71
+ <div id={headingId} data-terp="dataview-column-settings-title">
47
72
  {resolve(strings.columns)}
48
73
  </div>
49
74
  {columns.map((column, index) => {
50
75
  const label = resolve(column.meta?.label ?? column.header);
51
76
  const visible = columnVisibility[column.id] !== false;
52
77
  return (
53
- <div
54
- key={column.id}
55
- style={{
56
- display: "flex",
57
- alignItems: "center",
58
- gap: "var(--space-2)",
59
- padding: "var(--space-1) var(--space-2)",
60
- borderRadius: "var(--radius-sm)",
61
- }}
62
- >
63
- <label
64
- style={{
65
- display: "flex",
66
- alignItems: "center",
67
- gap: "var(--space-2)",
68
- flex: 1,
69
- cursor: "pointer",
70
- fontSize: "var(--font-size-sm)",
71
- }}
72
- >
78
+ <div key={column.id} data-terp="dataview-column-option">
79
+ <label>
73
80
  <input
74
81
  type="checkbox"
75
82
  checked={visible}
@@ -80,19 +87,19 @@ export function DataViewColumnSettings<T>({
80
87
  </label>
81
88
  <button
82
89
  type="button"
90
+ data-terp="iconbutton"
83
91
  aria-label={`${resolve(strings.moveUp)}: ${label}`}
84
92
  disabled={index === 0}
85
93
  onClick={() => onMoveColumn(column.id, -1)}
86
- style={reorderButtonStyle(index === 0)}
87
94
  >
88
95
  <ArrowUpGlyph size={14} />
89
96
  </button>
90
97
  <button
91
98
  type="button"
99
+ data-terp="iconbutton"
92
100
  aria-label={`${resolve(strings.moveDown)}: ${label}`}
93
101
  disabled={index === columns.length - 1}
94
102
  onClick={() => onMoveColumn(column.id, 1)}
95
- style={reorderButtonStyle(index === columns.length - 1)}
96
103
  >
97
104
  <ArrowDownGlyph size={14} />
98
105
  </button>
@@ -101,18 +108,6 @@ export function DataViewColumnSettings<T>({
101
108
  })}
102
109
  </div>
103
110
  )}
104
- </DataViewMenu>
111
+ </Popover>
105
112
  );
106
113
  }
107
-
108
- function reorderButtonStyle(disabled: boolean) {
109
- return {
110
- display: "inline-flex",
111
- padding: "var(--space-1)",
112
- background: "transparent",
113
- border: "none",
114
- borderRadius: "var(--radius-sm)",
115
- cursor: disabled ? "not-allowed" : "pointer",
116
- color: disabled ? "var(--color-neutral-300)" : "var(--color-neutral-700)",
117
- } as const;
118
- }
@@ -20,19 +20,11 @@ export function DataViewExpandToggle({
20
20
  type="button"
21
21
  aria-label={resolve(expanded ? strings.collapseRow : strings.expandRow)}
22
22
  aria-expanded={expanded}
23
+ data-terp="iconbutton"
23
24
  onClick={(event) => {
24
25
  event.stopPropagation();
25
26
  onToggle();
26
27
  }}
27
- style={{
28
- display: "inline-flex",
29
- padding: "var(--space-1)",
30
- background: "transparent",
31
- border: "none",
32
- borderRadius: "var(--radius-sm)",
33
- cursor: "pointer",
34
- color: "var(--color-neutral-500)",
35
- }}
36
28
  >
37
29
  {expanded ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
38
30
  </button>
@@ -52,14 +44,7 @@ export function DataViewExpandableRow({
52
44
  }) {
53
45
  return (
54
46
  <tr>
55
- <td
56
- colSpan={colSpan}
57
- style={{
58
- padding: "var(--space-3) var(--space-4)",
59
- background: "var(--color-neutral-50)",
60
- borderBottom: "1px solid var(--color-neutral-200)",
61
- }}
62
- >
47
+ <td colSpan={colSpan} data-terp="dataview-expanded-cell">
63
48
  {children}
64
49
  </td>
65
50
  </tr>
@@ -1,5 +1,3 @@
1
- import type { CSSProperties } from "react";
2
-
3
1
  import { useDataViewText } from "./internal";
4
2
  import {
5
3
  PageFirstGlyph,
@@ -15,18 +13,6 @@ export interface DataViewPaginationProps {
15
13
  onPaginationChange: (pagination: DataViewPaginationState) => void;
16
14
  }
17
15
 
18
- const pagerButtonStyle = (disabled: boolean): CSSProperties => ({
19
- display: "inline-flex",
20
- alignItems: "center",
21
- minHeight: "2rem",
22
- padding: "var(--space-1) var(--space-2)",
23
- background: "var(--color-neutral-0)",
24
- border: "1px solid var(--color-neutral-300)",
25
- borderRadius: "var(--radius-md)",
26
- cursor: disabled ? "not-allowed" : "pointer",
27
- color: disabled ? "var(--color-neutral-300)" : "var(--color-neutral-700)",
28
- });
29
-
30
16
  /**
31
17
  * The footer pagination bar: "X–Y of Z results", the current page / page count, and
32
18
  * first / prev / next / last controls (disabled at bounds; page controls hidden when
@@ -49,22 +35,10 @@ export function DataViewPagination({
49
35
  const atLast = pageIndex >= pageCount - 1;
50
36
 
51
37
  return (
52
- <div
53
- style={{
54
- display: "flex",
55
- alignItems: "center",
56
- justifyContent: "space-between",
57
- gap: "var(--space-3)",
58
- flexWrap: "wrap",
59
- padding: "var(--space-2) var(--space-3)",
60
- borderTop: "1px solid var(--color-neutral-200)",
61
- fontSize: "var(--font-size-sm)",
62
- color: "var(--color-neutral-500)",
63
- }}
64
- >
38
+ <div data-terp="dataview-pagination">
65
39
  <span>{format(strings.resultsRange, { from, to, total: totalCount })}</span>
66
40
  {pageCount > 1 && (
67
- <span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-2)" }}>
41
+ <span data-terp="dataview-pager">
68
42
  <span>{format(strings.pageOf, { page: pageIndex + 1, pages: pageCount })}</span>
69
43
  <button
70
44
  type="button"
@@ -72,7 +46,6 @@ export function DataViewPagination({
72
46
  disabled={atFirst}
73
47
  onClick={() => goTo(0)}
74
48
  data-terp="iconbutton"
75
- style={pagerButtonStyle(atFirst)}
76
49
  >
77
50
  <PageFirstGlyph />
78
51
  </button>
@@ -82,7 +55,6 @@ export function DataViewPagination({
82
55
  disabled={atFirst}
83
56
  onClick={() => goTo(pageIndex - 1)}
84
57
  data-terp="iconbutton"
85
- style={pagerButtonStyle(atFirst)}
86
58
  >
87
59
  <PagePrevGlyph />
88
60
  </button>
@@ -92,7 +64,6 @@ export function DataViewPagination({
92
64
  disabled={atLast}
93
65
  onClick={() => goTo(pageIndex + 1)}
94
66
  data-terp="iconbutton"
95
- style={pagerButtonStyle(atLast)}
96
67
  >
97
68
  <PageNextGlyph />
98
69
  </button>
@@ -102,7 +73,6 @@ export function DataViewPagination({
102
73
  disabled={atLast}
103
74
  onClick={() => goTo(pageCount - 1)}
104
75
  data-terp="iconbutton"
105
- style={pagerButtonStyle(atLast)}
106
76
  >
107
77
  <PageLastGlyph />
108
78
  </button>
@@ -14,6 +14,12 @@ export interface DataViewRowActionsProps<T> {
14
14
  layout: DataViewRowActionsLayout;
15
15
  /** On mobile all standard actions collapse into the menu regardless of flags. */
16
16
  isMobile: boolean;
17
+ /**
18
+ * Render the overflow menu open on mount — the same escape `Menu`, `Popover` and both
19
+ * date pickers already expose, and for the same reason: a portalled panel is invisible
20
+ * to a per-specimen visual lane unless something can name its open state.
21
+ */
22
+ defaultOpen?: boolean;
17
23
  }
18
24
 
19
25
  function InlineActionButton<T>({ action, row }: { action: DataViewRowAction<T>; row: T }) {
@@ -23,30 +29,12 @@ function InlineActionButton<T>({ action, row }: { action: DataViewRowAction<T>;
23
29
  return (
24
30
  <button
25
31
  type="button"
32
+ data-terp="dataview-inline-action"
33
+ data-destructive={destructive || undefined}
26
34
  disabled={disabled}
27
35
  onClick={() => action.onClick?.(row)}
28
- style={{
29
- font: "inherit",
30
- fontSize: "var(--font-size-sm)",
31
- display: "inline-flex",
32
- alignItems: "center",
33
- minHeight: "2rem",
34
- gap: "var(--space-1)",
35
- padding: "var(--space-1) var(--space-2)",
36
- background: "transparent",
37
- border: "1px solid var(--color-neutral-300)",
38
- borderRadius: "var(--radius-md)",
39
- cursor: disabled ? "not-allowed" : "pointer",
40
- color: disabled
41
- ? "var(--color-neutral-300)"
42
- : destructive
43
- ? "var(--color-status-danger)"
44
- : "var(--color-neutral-700)",
45
- }}
46
36
  >
47
- {action.icon !== undefined && (
48
- <span aria-hidden style={{ display: "inline-flex" }}>{action.icon}</span>
49
- )}
37
+ {action.icon !== undefined && <span aria-hidden>{action.icon}</span>}
50
38
  {resolve(action.label)}
51
39
  </button>
52
40
  );
@@ -62,6 +50,7 @@ export function DataViewRowActions<T>({
62
50
  actions,
63
51
  layout,
64
52
  isMobile,
53
+ defaultOpen,
65
54
  }: DataViewRowActionsProps<T>) {
66
55
  const { strings, resolve } = useDataViewText();
67
56
 
@@ -83,21 +72,11 @@ export function DataViewRowActions<T>({
83
72
  const stop = (event: MouseEvent) => event.stopPropagation();
84
73
 
85
74
  const renderCustom = (action: DataViewRowAction<T>, index: number): ReactNode => (
86
- <span key={`custom-${index}`} style={{ display: "inline-flex" }}>
87
- {action.render?.(row)}
88
- </span>
75
+ <span key={`custom-${index}`}>{action.render?.(row)}</span>
89
76
  );
90
77
 
91
78
  return (
92
- <span
93
- onClick={stop}
94
- style={{
95
- display: "inline-flex",
96
- alignItems: "center",
97
- justifyContent: "flex-end",
98
- gap: "var(--space-1)",
99
- }}
100
- >
79
+ <span data-terp="dataview-row-actions" onClick={stop}>
101
80
  {custom.map(renderCustom)}
102
81
  {inline.map((action, index) => (
103
82
  <InlineActionButton key={`inline-${index}`} action={action} row={row} />
@@ -106,6 +85,7 @@ export function DataViewRowActions<T>({
106
85
  <DataViewMenu
107
86
  trigger={<EllipsisGlyph />}
108
87
  triggerLabel={resolve(strings.moreActions)}
88
+ defaultOpen={defaultOpen}
109
89
  >
110
90
  {(close) => (
111
91
  <>