@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
@@ -1,9 +1,8 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import type { CSSProperties, ReactNode } from "react";
2
+ import type { ReactNode } from "react";
3
3
 
4
4
  import { injectTerpStyles } from "../styles";
5
5
  import type { BadgeTone } from "../ui/Badge";
6
- import { toneSoftColors } from "../ui/Badge";
7
6
  import type { UiText } from "../uiText";
8
7
  import { DataViewExpandToggle, DataViewExpandableRow } from "./DataViewExpandableRow";
9
8
  import { DataViewRowActions } from "./DataViewRowActions";
@@ -46,41 +45,6 @@ export interface DataViewTableProps<T> {
46
45
  rowActionsLayout: DataViewRowActionsLayout;
47
46
  }
48
47
 
49
- const headerCellStyle: CSSProperties = {
50
- position: "relative",
51
- padding: "var(--space-2) var(--space-3)",
52
- textAlign: "left",
53
- fontSize: "var(--font-size-xs)",
54
- fontWeight: "var(--font-weight-semibold)" as never,
55
- color: "var(--color-neutral-500)",
56
- textTransform: "uppercase",
57
- letterSpacing: "0.04em",
58
- borderBottom: "1px solid var(--color-neutral-200)",
59
- whiteSpace: "nowrap",
60
- background: "var(--color-neutral-0)",
61
- };
62
-
63
- const bodyCellStyle: CSSProperties = {
64
- padding: "var(--space-3)",
65
- borderBottom: "1px solid var(--color-neutral-100)",
66
- fontSize: "var(--font-size-sm)",
67
- color: "var(--color-neutral-900)",
68
- overflow: "hidden",
69
- textOverflow: "ellipsis",
70
- };
71
-
72
- const recordButtonStyle: CSSProperties = {
73
- position: "absolute",
74
- width: 1,
75
- height: 1,
76
- padding: 0,
77
- margin: -1,
78
- overflow: "hidden",
79
- clip: "rect(0 0 0 0)",
80
- whiteSpace: "nowrap",
81
- border: 0,
82
- };
83
-
84
48
  /**
85
49
  * The table layout of {@link DataView}: sortable, resizable headers; system columns
86
50
  * (expand → select → user columns → actions); row click, selection, expansion.
@@ -166,17 +130,13 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
166
130
  <table
167
131
  ref={tableRef}
168
132
  data-terp="dataview-table"
169
- style={{
170
- width: "100%",
171
- borderCollapse: "collapse",
172
- tableLayout: liveSizing !== null ? "fixed" : "auto",
173
- }}
133
+ data-resizing={liveSizing !== null || undefined}
174
134
  >
175
135
  <thead>
176
136
  <tr>
177
- {hasExpand && <th style={{ ...headerCellStyle, width: 40 }} aria-hidden />}
137
+ {hasExpand && <th data-terp="dataview-expand-cell" aria-hidden />}
178
138
  {props.selectionEnabled && (
179
- <th style={{ ...headerCellStyle, width: 40 }}>
139
+ <th data-terp="dataview-select-cell">
180
140
  <input
181
141
  ref={selectAllRef}
182
142
  type="checkbox"
@@ -197,27 +157,17 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
197
157
  aria-sort={
198
158
  sort === undefined ? undefined : sort.desc ? "descending" : "ascending"
199
159
  }
200
- style={{ ...headerCellStyle, width }}
160
+ style={width === undefined ? undefined : { width }}
201
161
  >
202
162
  {sortable ? (
203
163
  <button
204
164
  type="button"
165
+ data-terp="dataview-column-sort"
205
166
  onClick={() => props.onToggleSort(column.id)}
206
- style={{
207
- font: "inherit",
208
- color: "inherit",
209
- display: "inline-flex",
210
- alignItems: "center",
211
- gap: "var(--space-1)",
212
- background: "transparent",
213
- border: "none",
214
- padding: 0,
215
- cursor: "pointer",
216
- }}
217
167
  >
218
168
  {resolve(column.header)}
219
169
  {sort === undefined ? (
220
- <SortNoneGlyph size={12} style={{ opacity: 0.5 }} />
170
+ <SortNoneGlyph size={12} />
221
171
  ) : sort.desc ? (
222
172
  <SortDescGlyph size={12} />
223
173
  ) : (
@@ -231,44 +181,18 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
231
181
  role="separator"
232
182
  aria-orientation="vertical"
233
183
  aria-label={`${resolve(strings.resizeColumn)}: ${resolve(column.meta?.label ?? column.header)}`}
184
+ data-terp="dataview-column-resizer"
234
185
  onPointerDown={(event) => {
235
186
  event.preventDefault();
236
187
  startResize(column.id, event.clientX);
237
188
  }}
238
- style={{
239
- position: "absolute",
240
- top: 0,
241
- // Sit flush with the cell's right edge — a negative offset would
242
- // let the last column's handle spill past the table and trip the
243
- // scroll container's overflow-x, adding a spurious scrollbar.
244
- right: 0,
245
- width: 7,
246
- height: "100%",
247
- cursor: "col-resize",
248
- zIndex: 1,
249
- touchAction: "none",
250
- }}
251
189
  />
252
190
  </th>
253
191
  );
254
192
  })}
255
193
  {hasActions && (
256
- <th style={{ ...headerCellStyle, width: 56 }}>
257
- <span
258
- style={{
259
- position: "absolute",
260
- width: 1,
261
- height: 1,
262
- padding: 0,
263
- margin: -1,
264
- overflow: "hidden",
265
- clip: "rect(0 0 0 0)",
266
- whiteSpace: "nowrap",
267
- border: 0,
268
- }}
269
- >
270
- {resolve(strings.actions)}
271
- </span>
194
+ <th data-terp="dataview-actions-cell">
195
+ <span>{resolve(strings.actions)}</span>
272
196
  </th>
273
197
  )}
274
198
  </tr>
@@ -283,23 +207,13 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
283
207
  <RowGroup key={rowId}>
284
208
  <tr
285
209
  onClick={clickable ? () => props.onRowClick?.(row) : undefined}
286
- data-terp={clickable ? "dataview-row" : undefined}
210
+ data-terp="dataview-row"
211
+ data-clickable={clickable || undefined}
287
212
  data-selected={props.isSelected(rowId) || undefined}
288
213
  data-tone={tone ?? undefined}
289
- style={{
290
- cursor: clickable ? "pointer" : undefined,
291
- // A row's own state outranks the selection tint — selection still
292
- // shows via the checkbox and data-selected.
293
- background:
294
- tone !== null
295
- ? toneSoftColors[tone]
296
- : props.isSelected(rowId)
297
- ? "var(--color-neutral-50)"
298
- : undefined,
299
- }}
300
214
  >
301
215
  {hasExpand && (
302
- <td style={bodyCellStyle}>
216
+ <td data-terp="dataview-expand-cell">
303
217
  <DataViewExpandToggle
304
218
  expanded={expanded}
305
219
  onToggle={() => props.onToggleExpanded(rowId)}
@@ -307,7 +221,7 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
307
221
  </td>
308
222
  )}
309
223
  {props.selectionEnabled && (
310
- <td style={bodyCellStyle} onClick={(event) => event.stopPropagation()}>
224
+ <td data-terp="dataview-select-cell" onClick={(event) => event.stopPropagation()}>
311
225
  <input
312
226
  type="checkbox"
313
227
  aria-label={resolve(strings.selectRow)}
@@ -317,7 +231,7 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
317
231
  </td>
318
232
  )}
319
233
  {props.columns.map((column) => (
320
- <td key={column.id} style={bodyCellStyle}>
234
+ <td key={column.id}>
321
235
  {clickable && column === props.columns[0] && (
322
236
  <button
323
237
  type="button"
@@ -329,7 +243,6 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
329
243
  event.stopPropagation();
330
244
  props.onRowClick?.(row);
331
245
  }}
332
- style={recordButtonStyle}
333
246
  />
334
247
  )}
335
248
  {column.cell !== undefined
@@ -338,7 +251,7 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
338
251
  </td>
339
252
  ))}
340
253
  {hasActions && (
341
- <td style={{ ...bodyCellStyle, textAlign: "right" }}>
254
+ <td data-terp="dataview-actions-cell">
342
255
  <DataViewRowActions
343
256
  row={row}
344
257
  actions={props.rowActions?.(row) ?? []}
@@ -49,6 +49,14 @@ export interface DataViewToolbarProps<T> {
49
49
  * the page-size selector, the table/cards toggle and the column-settings menu; when
50
50
  * rows are selected it switches to selection mode ("N selected", batch actions,
51
51
  * select-all-across-pages, clear selection).
52
+ *
53
+ * It renders no inline styles: the band, both its modes and every part inside it take
54
+ * their geometry from the injected react-core sheet, matched on the `data-terp` markers
55
+ * stamped below and on `data-variant="selection"` (ADR 0094). Two of this element's
56
+ * direct children are arbitrary caller slots — `children` and `trailing` — which is why
57
+ * the band's own rule declares no `color` and why the refresh status carries a marker
58
+ * instead of being reached as `[data-terp="dataview-toolbar"] > [role="status"]`: owning
59
+ * one instance of an attribute is not owning every element such a selector reaches.
52
60
  */
53
61
  export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
54
62
  const { strings, resolve, format } = useDataViewText();
@@ -56,25 +64,16 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
56
64
 
57
65
  const selectionMode = props.selectedCount > 0;
58
66
 
59
- const barStyle = {
60
- display: "flex",
61
- alignItems: "center",
62
- gap: "var(--space-2)",
63
- flexWrap: "wrap",
64
- padding: "var(--space-2) var(--space-3)",
65
- borderBottom: "1px solid var(--color-neutral-200)",
66
- background: selectionMode ? "var(--color-neutral-50)" : "var(--color-neutral-0)",
67
- borderTopLeftRadius: "var(--radius-lg)",
68
- borderTopRightRadius: "var(--radius-lg)",
69
- minHeight: "3rem",
70
- } as const;
71
-
72
67
  if (selectionMode) {
73
68
  const inlineActions = (props.batchActions ?? []).filter((action) => action.inline !== false);
74
69
  const overflowActions = (props.batchActions ?? []).filter((action) => action.inline === false);
75
70
  return (
76
- <div data-terp="dataview-toolbar" style={barStyle}>
77
- <span style={{ fontWeight: "var(--font-weight-medium)" as never }}>
71
+ // data-variant rather than data-selected: in this cluster "selected" already means
72
+ // "this element is selected" (rows, cards, menu items), and the toolbar is not
73
+ // selected — it is showing a different mode. Stamped as a literal in this branch and
74
+ // omitted in the other, which is the cluster's idiom of not naming the default.
75
+ <div data-terp="dataview-toolbar" data-variant="selection">
76
+ <span data-terp="dataview-toolbar-count">
78
77
  {format(strings.selected, { count: props.selectedCount })}
79
78
  </span>
80
79
  {props.onSelectAllAcrossPages !== undefined && !props.selectAllAcrossPages && (
@@ -82,18 +81,17 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
82
81
  {format(strings.selectAllResults, { total: props.totalCount })}
83
82
  </Button>
84
83
  )}
85
- <span style={{ display: "inline-flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
84
+ <span data-terp="dataview-toolbar-actions">
86
85
  {inlineActions.map((action, index) => (
87
86
  <Button
88
87
  key={index}
89
88
  variant={action.variant === "destructive" ? "danger" : "secondary"}
89
+ // Button's own icon slot, rather than a hand-rolled aria-hidden span: it
90
+ // renders [data-terp="button-icon"], which is already ruled and already
91
+ // carries flex-shrink: 0 that the hand-rolled one lacked.
92
+ icon={action.icon}
90
93
  onClick={() => props.onBatchAction(action)}
91
94
  >
92
- {action.icon !== undefined && (
93
- <span aria-hidden style={{ display: "inline-flex", marginRight: "var(--space-1)" }}>
94
- {action.icon}
95
- </span>
96
- )}
97
95
  {resolve(action.label)}
98
96
  </Button>
99
97
  ))}
@@ -118,7 +116,7 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
118
116
  </DataViewMenu>
119
117
  )}
120
118
  </span>
121
- <span style={{ flex: 1 }} />
119
+ <span data-terp="dataview-toolbar-spacer" />
122
120
  <Button variant="secondary" onClick={props.onClearSelection}>
123
121
  {resolve(strings.clearSelection)}
124
122
  </Button>
@@ -127,19 +125,12 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
127
125
  }
128
126
 
129
127
  return (
130
- <div data-terp="dataview-toolbar" style={barStyle}>
128
+ <div data-terp="dataview-toolbar">
131
129
  {props.searchEnabled && (
132
- <span style={{ position: "relative", display: "inline-flex", alignItems: "center" }}>
133
- <span
134
- aria-hidden
135
- style={{
136
- position: "absolute",
137
- left: "var(--space-2)",
138
- display: "inline-flex",
139
- color: "var(--color-neutral-500)",
140
- pointerEvents: "none",
141
- }}
142
- >
130
+ <span data-terp="dataview-toolbar-search">
131
+ {/* Unmarked, and reached as this wrapper's only span child: it is decoration with
132
+ no state, and the wrapper holds no caller slot. */}
133
+ <span aria-hidden>
143
134
  <SearchGlyph size={14} />
144
135
  </span>
145
136
  <Input
@@ -148,38 +139,32 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
148
139
  onChange={(event) => search.setInputValue(event.target.value)}
149
140
  placeholder={resolve(props.searchPlaceholder ?? strings.searchPlaceholder)}
150
141
  aria-label={resolve(props.searchPlaceholder ?? strings.searchPlaceholder)}
151
- style={{
152
- paddingLeft: "var(--space-6)",
153
- paddingRight: "var(--space-6)",
154
- width: "16rem",
155
- maxWidth: "100%",
156
- }}
157
142
  />
158
143
  {search.inputValue !== "" && (
159
144
  <button
160
145
  type="button"
146
+ data-terp="iconbutton"
161
147
  aria-label={resolve(strings.clearSearch)}
162
148
  onClick={search.clear}
163
- style={{
164
- position: "absolute",
165
- right: "var(--space-1)",
166
- display: "inline-flex",
167
- padding: "var(--space-1)",
168
- background: "transparent",
169
- border: "none",
170
- cursor: "pointer",
171
- color: "var(--color-neutral-500)",
172
- }}
173
149
  >
174
150
  <CloseGlyph size={14} />
175
151
  </button>
176
152
  )}
177
153
  </span>
178
154
  )}
155
+ {/* No aria-pressed, and its absence is the decision. This control swaps its LABEL
156
+ between the caller's `label` and `broadenedLabel` — "Search everything" becomes
157
+ "Searching everything" — so the state is already in the accessible name. Adding
158
+ aria-pressed encodes the same fact a second way and announces it twice ("Searching
159
+ everything, toggle button, pressed"), which is the case ARIA's own guidance names:
160
+ when the label changes to describe the new state, the state is the label. The two
161
+ encodings can also disagree, because the labels are caller-supplied and nothing
162
+ checks that broadenedLabel actually reads as the broadened one.
163
+ No specimen or test rendered this branch until the search-scope one existed, which
164
+ is why this shipped for as long as it did: it needs a non-empty search term. */}
179
165
  {props.searchScope !== undefined && props.search.trim() !== "" && (
180
166
  <Button
181
167
  variant="secondary"
182
- aria-pressed={props.searchScope.broadened}
183
168
  onClick={() => props.searchScope?.onBroadenedChange(!props.searchScope.broadened)}
184
169
  >
185
170
  {resolve(
@@ -194,22 +179,25 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
194
179
  </Button>
195
180
  )}
196
181
  {props.isFetching && (
197
- <span
198
- role="status"
199
- style={{ fontSize: "var(--font-size-sm)", color: "var(--color-neutral-500)" }}
200
- >
182
+ <span role="status" data-terp="dataview-toolbar-status">
201
183
  {resolve(strings.refreshing)}
202
184
  </span>
203
185
  )}
204
- <span style={{ flex: 1 }} />
186
+ <span data-terp="dataview-toolbar-spacer" />
205
187
  {props.onPageSizeChange !== undefined && props.pageSize !== undefined && (
206
188
  <DataViewMenu
207
189
  triggerLabel={resolve(strings.pageSize)}
190
+ // A fragment, not a wrapper span: [data-terp="menu-trigger"] already declares
191
+ // display: inline-flex, align-items: center, justify-content: center and
192
+ // gap: var(--space-1) — the deleted span's three declarations verbatim, with the
193
+ // gap applying to exactly this pair. Keeping the span would need a marker of its
194
+ // own, because [data-terp="menu-trigger"] > span would also catch the
195
+ // column-settings trigger's text span in this same toolbar.
208
196
  trigger={
209
- <span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
197
+ <>
210
198
  {props.pageSize}
211
199
  <ChevronDownGlyph size={14} />
212
- </span>
200
+ </>
213
201
  }
214
202
  >
215
203
  {(close) =>
@@ -228,22 +216,26 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
228
216
  </DataViewMenu>
229
217
  )}
230
218
  {props.onLayoutChange !== undefined && (
231
- <span style={{ display: "inline-flex", gap: "var(--space-1)" }}>
219
+ <span data-terp="dataview-toolbar-layout">
220
+ {/* The shared icon-button marker, so these two gain the transition, the focus
221
+ ring and reduced-motion coverage they escaped entirely. Which one is active is
222
+ aria-pressed — the real ARIA state, not a data attribute duplicating it — and
223
+ this component is its sole author, setting it from its own layout prop. */}
232
224
  <button
233
225
  type="button"
226
+ data-terp="iconbutton"
234
227
  aria-label={resolve(strings.tableView)}
235
228
  aria-pressed={props.layout === "table"}
236
229
  onClick={() => props.onLayoutChange?.("table")}
237
- style={layoutToggleStyle(props.layout === "table")}
238
230
  >
239
231
  <TableGlyph />
240
232
  </button>
241
233
  <button
242
234
  type="button"
235
+ data-terp="iconbutton"
243
236
  aria-label={resolve(strings.cardView)}
244
237
  aria-pressed={props.layout === "cards"}
245
238
  onClick={() => props.onLayoutChange?.("cards")}
246
- style={layoutToggleStyle(props.layout === "cards")}
247
239
  >
248
240
  <CardsGlyph />
249
241
  </button>
@@ -254,18 +246,3 @@ export function DataViewToolbar<T>(props: DataViewToolbarProps<T>) {
254
246
  </div>
255
247
  );
256
248
  }
257
-
258
- function layoutToggleStyle(active: boolean) {
259
- return {
260
- display: "inline-flex",
261
- alignItems: "center",
262
- justifyContent: "center",
263
- minHeight: "2rem",
264
- padding: "var(--space-1) var(--space-2)",
265
- background: active ? "var(--color-neutral-100)" : "transparent",
266
- border: "1px solid var(--color-neutral-300)",
267
- borderRadius: "var(--radius-md)",
268
- cursor: "pointer",
269
- color: active ? "var(--color-neutral-900)" : "var(--color-neutral-500)",
270
- } as const;
271
- }
@@ -133,6 +133,12 @@ versioned envelope; corrupt data falls back to defaults) and
133
133
  from `meta.mobileSlot` (`title` / `subtitle` / `status` / `date`), with
134
134
  `renderCard(row)` as a full escape hatch; selection, actions and expansion keep
135
135
  working in card view.
136
+ - **Density**: `density="compact"` stamps `data-density="compact"` on the root, which
137
+ re-scopes the live density tokens for the whole subtree — cell padding here, plus the
138
+ control heights `Button`, `Input` and `Select` already read, so the toolbar tightens
139
+ with the table. `"comfortable"` is the default and stamps no attribute, because
140
+ comfortable is what the token sheet declares on `:root`; the consequence is that
141
+ `density="comfortable"` cannot make one view comfortable inside a compact subtree.
136
142
  - **Variants**: `variant="embedded"` renders a plain compact view (no view toggle, no
137
143
  page-size selector, no pagination footer, all rows) for panels/detail sections.
138
144
  - **i18n**: no hard-coded user-facing strings — every label is a `UiText` routed
@@ -24,6 +24,7 @@ export type {
24
24
  ViewStateRepository,
25
25
  DataViewColumn,
26
26
  DataViewColumnMeta,
27
+ DataViewDensity,
27
28
  DataViewMobileSlot,
28
29
  DataViewRowAction,
29
30
  DataViewBatchAction,
@@ -53,6 +53,7 @@ export function DataViewMenu({
53
53
  trigger,
54
54
  triggerLabel,
55
55
  align = "end",
56
+ defaultOpen,
56
57
  children,
57
58
  }: {
58
59
  /** Trigger content (an icon or a label). */
@@ -60,11 +61,13 @@ export function DataViewMenu({
60
61
  /** Accessible name of the trigger button. */
61
62
  triggerLabel: string;
62
63
  align?: "start" | "end";
64
+ /** Open on mount — threaded to `Menu`, which has carried this since the overlays moved. */
65
+ defaultOpen?: boolean;
63
66
  /** Panel content; render-prop so items can close the menu after acting. */
64
67
  children: (close: () => void) => ReactNode;
65
68
  }) {
66
69
  return (
67
- <Menu trigger={trigger} triggerLabel={triggerLabel} align={align}>
70
+ <Menu trigger={trigger} triggerLabel={triggerLabel} align={align} defaultOpen={defaultOpen}>
68
71
  {({ close }) => children(() => close(false))}
69
72
  </Menu>
70
73
  );
@@ -94,6 +94,19 @@ export function emptyDataViewState(): DataViewState {
94
94
  /** Slot a column occupies in the responsive card layout. */
95
95
  export type DataViewMobileSlot = "title" | "subtitle" | "status" | "date";
96
96
 
97
+ /**
98
+ * How tightly a view packs its cells.
99
+ *
100
+ * Geometry, not colour: the contract publishes the live density tokens at their
101
+ * comfortable values plus explicit compact counterparts, and the react-core sheet
102
+ * re-scopes the live ones under `[data-density="compact"]`. So this is one attribute
103
+ * stamped on a subtree root, and every rule reading a live token follows by
104
+ * custom-property inheritance — including the control heights `Button`, `Input` and
105
+ * `Select` already read, which is why a compact DataView also gets compact controls
106
+ * without either component knowing about the other.
107
+ */
108
+ export type DataViewDensity = "comfortable" | "compact";
109
+
97
110
  /** Typed column meta the DataView-specific features read. */
98
111
  export interface DataViewColumnMeta {
99
112
  /** Human-readable name used in the column-settings menu (falls back to the header). */
@@ -82,6 +82,32 @@ describe("ConfirmDialog", () => {
82
82
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
83
83
  });
84
84
 
85
+ it("links the consequence text to the dialog, and only when there is one", () => {
86
+ const { rerender } = render(
87
+ <ConfirmDialog
88
+ open
89
+ onOpenChange={() => {}}
90
+ onConfirm={() => {}}
91
+ title="Delete this link?"
92
+ description="Its mappings and its run history are removed with it."
93
+ />,
94
+ );
95
+ // A modal announces its name and its description on open. Unlinked body copy is reached
96
+ // only by manual exploration — the wrong ergonomics for the one screen whose whole job is
97
+ // telling someone what a destructive action will do.
98
+ const dialog = screen.getByRole("dialog");
99
+ const describedBy = dialog.getAttribute("aria-describedby");
100
+ expect(describedBy).not.toBeNull();
101
+ expect(document.getElementById(describedBy!)?.textContent).toBe(
102
+ "Its mappings and its run history are removed with it.",
103
+ );
104
+ // No description means no attribute, rather than an idref pointing at nothing.
105
+ rerender(
106
+ <ConfirmDialog open onOpenChange={() => {}} onConfirm={() => {}} title="Delete this link?" />,
107
+ );
108
+ expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-describedby");
109
+ });
110
+
85
111
  it("confirms and cancels through the labelled buttons", () => {
86
112
  const onConfirm = vi.fn();
87
113
  const onOpenChange = vi.fn();
@@ -71,6 +71,24 @@ describe("FileUpload", () => {
71
71
  await waitFor(() => expect(onUploaded).toHaveBeenCalledWith(STORED));
72
72
  });
73
73
 
74
+ it("hides the picker input with the hidden attribute, not an inline style", () => {
75
+ render(
76
+ <TerpProvider baseUrl="https://api.test">
77
+ <FileUpload />
78
+ </TerpProvider>,
79
+ );
80
+ // The visible control is the button; this input exists only so a programmatic click can
81
+ // open the native dialog. `hidden` is the platform's own way to say that, and it keeps the
82
+ // file free of a style object that would read as unmigrated styling.
83
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
84
+ expect(input).not.toBeNull();
85
+ expect(input.hidden).toBe(true);
86
+ expect(input.getAttribute("style")).toBeNull();
87
+ // Belt and braces, kept deliberately: both survive an author rule that resurrects the box.
88
+ expect(input).toHaveAttribute("aria-hidden", "true");
89
+ expect(input.tabIndex).toBe(-1);
90
+ });
91
+
74
92
  it("reports a failed upload through onError", async () => {
75
93
  stubFetch(async (request) => {
76
94
  if (request.url.endsWith("/api/v1/files/") && request.method === "POST") {
package/src/files.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
- import type { ChangeEvent, CSSProperties } from "react";
2
+ import type { ChangeEvent } from "react";
3
3
 
4
4
  import { useTerpClient } from "./TerpProvider";
5
5
  import { Button } from "./ui/Button";
@@ -107,8 +107,6 @@ export function useFileDownload(): (file: Pick<FileMeta, "id" | "filename">) =>
107
107
  );
108
108
  }
109
109
 
110
- const hiddenInputStyle: CSSProperties = { display: "none" };
111
-
112
110
  export interface FileUploadProps {
113
111
  /** Called with the stored metadata after a successful upload. */
114
112
  onUploaded?: (file: FileMeta) => void;
@@ -152,11 +150,22 @@ export function FileUpload({ onUploaded, onError, label, accept, disabled }: Fil
152
150
 
153
151
  return (
154
152
  <>
153
+ {/* The platform attribute, not an inline display:none.
154
+
155
+ This input is the file picker's plumbing: the visible control is the Button below,
156
+ and this element exists only so `.click()` can open the native dialog. So it is not
157
+ a styled surface at all, and the alternatives both misdescribe it — giving it a
158
+ marker and a `display: none` rule would put a component with no visual design into
159
+ the sheet, and keeping the inline object would leave the one declaration in the file
160
+ looking like styling that had not been migrated yet. `hidden` is what HTML provides
161
+ for "not relevant", and a programmatic click still opens the dialog. The
162
+ `aria-hidden` and `tabIndex` become redundant beside it and are kept anyway: they
163
+ cost nothing and they survive an author rule that resurrects the box. */}
155
164
  <input
156
165
  ref={inputRef}
157
166
  type="file"
158
167
  accept={accept}
159
- style={hiddenInputStyle}
168
+ hidden
160
169
  onChange={(event) => void onChange(event)}
161
170
  aria-hidden="true"
162
171
  tabIndex={-1}
@@ -11,7 +11,10 @@ describe("NavIcon", () => {
11
11
  const { container } = render(<NavIcon name="users" label="Users" />);
12
12
  const slot = container.querySelector('[data-terp="nav-icon"]');
13
13
  const svg = container.querySelector("svg");
14
- expect(slot).toHaveStyle({ width: "1.25rem", height: "1.25rem", flex: "0 0 1.25rem" });
14
+ // The rail slot's fixed 1.25rem box is a sheet rule now (ADR 0094); what this test
15
+ // owns is that the named glyph resolved and stayed decorative.
16
+ expect(slot).not.toBeNull();
17
+ expect(slot?.getAttribute("style")).toBeNull();
15
18
  expect(svg).not.toBeNull();
16
19
  expect(svg).toHaveAttribute("aria-hidden", "true");
17
20
  });
@@ -19,11 +22,12 @@ describe("NavIcon", () => {
19
22
  it("falls back to the label's initial for an unknown or missing name", () => {
20
23
  const { container } = render(<NavIcon name="no-such-glyph" label="widgets" />);
21
24
  expect(screen.getByText("W")).toBeInTheDocument();
22
- expect(container.querySelector('[data-terp="nav-icon"]')).toHaveStyle({
23
- width: "1.25rem",
24
- height: "1.25rem",
25
- flex: "0 0 1.25rem",
26
- });
25
+ // The fallback tile is its own marker, so the rail can style "an initial in a tile"
26
+ // separately from "a glyph" — which is the distinction a theme cares about.
27
+ expect(
28
+ container.querySelector('[data-terp="nav-icon-fallback"]'),
29
+ "the initial renders in the fallback tile",
30
+ ).not.toBeNull();
27
31
  render(<NavIcon label="records" />);
28
32
  expect(screen.getByText("R")).toBeInTheDocument();
29
33
  });