@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.
- package/README.md +12 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/ModuleNav.tsx +1 -1
- package/src/PageActions.tsx +5 -10
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +13 -4
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +33 -37
- package/src/index.ts +0 -3
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +27 -4
- package/src/markers.test.ts +468 -0
- package/src/raw.d.ts +15 -1
- package/src/router.tsx +6 -9
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +823 -6
- package/src/styles.ts +2699 -153
- package/src/theme.test.tsx +39 -0
- package/src/theme.themes.test.ts +124 -0
- package/src/theme.tsx +62 -14
- package/src/toast.tsx +35 -71
- package/src/tokens.guard.test.ts +3 -12
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +13 -25
- package/src/ui/Button.test.tsx +17 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/uiText.tsx +9 -0
- package/src/ui/controlStyles.ts +0 -9
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
|
137
|
+
{hasExpand && <th data-terp="dataview-expand-cell" aria-hidden />}
|
|
178
138
|
{props.selectionEnabled && (
|
|
179
|
-
<th
|
|
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={{
|
|
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}
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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"
|
|
128
|
+
<div data-terp="dataview-toolbar">
|
|
131
129
|
{props.searchEnabled && (
|
|
132
|
-
<span
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
197
|
+
<>
|
|
210
198
|
{props.pageSize}
|
|
211
199
|
<ChevronDownGlyph size={14} />
|
|
212
|
-
|
|
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
|
|
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
|
-
}
|
package/src/dataview/README.md
CHANGED
|
@@ -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
|
package/src/dataview/index.ts
CHANGED
|
@@ -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
|
);
|
package/src/dataview/types.ts
CHANGED
|
@@ -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). */
|
package/src/feedback.test.tsx
CHANGED
|
@@ -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();
|
package/src/files.test.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
168
|
+
hidden
|
|
160
169
|
onChange={(event) => void onChange(event)}
|
|
161
170
|
aria-hidden="true"
|
|
162
171
|
tabIndex={-1}
|
package/src/icons.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
});
|