@terpjs/react-core 0.7.0 → 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/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/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 +9 -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 +24 -4
- package/src/markers.test.ts +242 -19
- 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.tsx +24 -3
- package/src/toast.tsx +35 -71
- 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 +9 -28
- 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/ui/controlStyles.ts +0 -9
|
@@ -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
|
});
|
package/src/icons.tsx
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { injectTerpStyles } from "./styles";
|
|
4
|
+
|
|
5
|
+
injectTerpStyles();
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* The dependency-free icon layer: a small set of inline SVG glyphs the shell's
|
|
@@ -431,31 +435,6 @@ export const ICON_GLYPHS: Record<string, ReactNode> = {
|
|
|
431
435
|
),
|
|
432
436
|
};
|
|
433
437
|
|
|
434
|
-
const navIconStyle: CSSProperties = {
|
|
435
|
-
display: "inline-flex",
|
|
436
|
-
alignItems: "center",
|
|
437
|
-
justifyContent: "center",
|
|
438
|
-
width: "1.25rem",
|
|
439
|
-
height: "1.25rem",
|
|
440
|
-
flex: "0 0 1.25rem",
|
|
441
|
-
fontSize: "1rem",
|
|
442
|
-
lineHeight: 1,
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
const fallbackStyle: CSSProperties = {
|
|
446
|
-
display: "inline-flex",
|
|
447
|
-
alignItems: "center",
|
|
448
|
-
justifyContent: "center",
|
|
449
|
-
width: "100%",
|
|
450
|
-
height: "100%",
|
|
451
|
-
borderRadius: "var(--radius-sm, 4px)",
|
|
452
|
-
background: "var(--color-brand-primary-soft, var(--color-neutral-200))",
|
|
453
|
-
color: "var(--color-brand-primary, var(--color-neutral-700))",
|
|
454
|
-
fontSize: "0.7em",
|
|
455
|
-
fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
|
|
456
|
-
lineHeight: 1,
|
|
457
|
-
};
|
|
458
|
-
|
|
459
438
|
export interface NavIconProps {
|
|
460
439
|
/** Glyph name (a `NavItem.icon` value); unknown / missing falls back to the initial. */
|
|
461
440
|
name?: string;
|
|
@@ -470,9 +449,9 @@ export interface NavIconProps {
|
|
|
470
449
|
export function NavIcon({ name, label }: NavIconProps) {
|
|
471
450
|
const glyph = name !== undefined ? ICON_GLYPHS[name] : undefined;
|
|
472
451
|
return (
|
|
473
|
-
<span aria-hidden="true" data-terp="nav-icon"
|
|
452
|
+
<span aria-hidden="true" data-terp="nav-icon">
|
|
474
453
|
{glyph ?? (
|
|
475
|
-
<span
|
|
454
|
+
<span data-terp="nav-icon-fallback">
|
|
476
455
|
{(label[0] ?? "?").toUpperCase()}
|
|
477
456
|
</span>
|
|
478
457
|
)}
|
|
@@ -511,15 +490,8 @@ export function Icon({ name, size = "1em", title }: IconProps) {
|
|
|
511
490
|
aria-hidden={labelled ? undefined : true}
|
|
512
491
|
role={labelled ? "img" : undefined}
|
|
513
492
|
aria-label={labelled ? title : undefined}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
alignItems: "center",
|
|
517
|
-
justifyContent: "center",
|
|
518
|
-
width: px,
|
|
519
|
-
height: px,
|
|
520
|
-
lineHeight: 1,
|
|
521
|
-
color: "inherit",
|
|
522
|
-
}}
|
|
493
|
+
data-terp="icon"
|
|
494
|
+
style={{ width: px, height: px }}
|
|
523
495
|
>
|
|
524
496
|
{glyph}
|
|
525
497
|
</span>
|
package/src/index.ts
CHANGED
package/src/layout.test.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { DetailList, Stack } from "./layout";
|
|
|
7
7
|
afterEach(cleanup);
|
|
8
8
|
|
|
9
9
|
describe("Stack", () => {
|
|
10
|
-
it("
|
|
10
|
+
it("names a column at the default gap, with no inline styling", () => {
|
|
11
11
|
render(
|
|
12
12
|
<Stack data-testid="stack">
|
|
13
13
|
<span>a</span>
|
|
@@ -16,24 +16,39 @@ describe("Stack", () => {
|
|
|
16
16
|
);
|
|
17
17
|
const el = screen.getByTestId("stack");
|
|
18
18
|
expect(el.tagName).toBe("DIV");
|
|
19
|
-
expect(el
|
|
20
|
-
expect(el
|
|
21
|
-
expect(el.
|
|
19
|
+
expect(el).toHaveAttribute("data-direction", "column");
|
|
20
|
+
expect(el).toHaveAttribute("data-gap", "2");
|
|
21
|
+
expect(el).not.toHaveAttribute("data-wrap");
|
|
22
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
it("renders the requested element
|
|
25
|
+
it("renders the requested element and names direction, gap and wrap", () => {
|
|
25
26
|
render(
|
|
26
|
-
<Stack data-testid="row" as="section" direction="row" gap={4}
|
|
27
|
+
<Stack data-testid="row" as="section" direction="row" gap={4} wrap>
|
|
27
28
|
<span>a</span>
|
|
28
29
|
</Stack>,
|
|
29
30
|
);
|
|
30
31
|
const el = screen.getByTestId("row");
|
|
31
32
|
expect(el.tagName).toBe("SECTION");
|
|
32
|
-
expect(el
|
|
33
|
-
expect(el
|
|
33
|
+
expect(el).toHaveAttribute("data-direction", "row");
|
|
34
|
+
expect(el).toHaveAttribute("data-gap", "4");
|
|
35
|
+
expect(el).toHaveAttribute("data-wrap", "true");
|
|
36
|
+
expect(el.getAttribute("style")).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps alignment inline, because it is an open set of CSS keywords", () => {
|
|
40
|
+
// align/justify accept any alignment keyword, so they cannot become a rule per value
|
|
41
|
+
// without inventing a vocabulary CSS already has (ADR 0094). They stay inline, and
|
|
42
|
+
// the geometry attributes stay attributes — the two halves of the same element.
|
|
43
|
+
render(
|
|
44
|
+
<Stack data-testid="aligned" align="center" justify="space-between">
|
|
45
|
+
<span>a</span>
|
|
46
|
+
</Stack>,
|
|
47
|
+
);
|
|
48
|
+
const el = screen.getByTestId("aligned");
|
|
34
49
|
expect(el.style.alignItems).toBe("center");
|
|
35
50
|
expect(el.style.justifyContent).toBe("space-between");
|
|
36
|
-
expect(el.style.
|
|
51
|
+
expect(el.style.display).toBe("");
|
|
37
52
|
});
|
|
38
53
|
|
|
39
54
|
it("works as a form (submit handler fires)", () => {
|
package/src/layout.tsx
CHANGED
|
@@ -38,16 +38,27 @@ export function Stack({
|
|
|
38
38
|
wrap = false,
|
|
39
39
|
...rest
|
|
40
40
|
}: StackProps) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
41
|
+
// `direction`, `gap` and `wrap` are closed sets, so they are attributes the sheet keys
|
|
42
|
+
// on. `align` and `justify` take any alignment keyword CSS accepts, so they stay inline
|
|
43
|
+
// rather than turning an open vocabulary into a rule per value (ADR 0094). Undefined on
|
|
44
|
+
// both means no style attribute at all.
|
|
45
|
+
const alignment: CSSProperties | undefined =
|
|
46
|
+
align === undefined && justify === undefined
|
|
47
|
+
? undefined
|
|
48
|
+
: {
|
|
49
|
+
...(align !== undefined ? { alignItems: align } : undefined),
|
|
50
|
+
...(justify !== undefined ? { justifyContent: justify } : undefined),
|
|
51
|
+
};
|
|
52
|
+
return (
|
|
53
|
+
<Component
|
|
54
|
+
{...rest}
|
|
55
|
+
data-terp="stack"
|
|
56
|
+
data-direction={direction}
|
|
57
|
+
data-gap={String(gap)}
|
|
58
|
+
data-wrap={wrap ? "true" : undefined}
|
|
59
|
+
style={alignment}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
export interface DetailItem {
|
|
@@ -62,14 +73,6 @@ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>,
|
|
|
62
73
|
items: readonly DetailItem[];
|
|
63
74
|
}
|
|
64
75
|
|
|
65
|
-
const detailListStyle: CSSProperties = {
|
|
66
|
-
margin: 0,
|
|
67
|
-
display: "grid",
|
|
68
|
-
gap: "var(--space-1)",
|
|
69
|
-
};
|
|
70
|
-
const detailTermStyle: CSSProperties = { display: "inline", fontWeight: "var(--font-weight-medium)" };
|
|
71
|
-
const detailValueStyle: CSSProperties = { display: "inline", margin: 0 };
|
|
72
|
-
|
|
73
76
|
/**
|
|
74
77
|
* Token-styled label/value pairs as a semantic `<dl>` — record metadata on a detail page,
|
|
75
78
|
* an expanded row's summary. Centralizes the "Label: value" pattern so modules never
|
|
@@ -78,11 +81,11 @@ const detailValueStyle: CSSProperties = { display: "inline", margin: 0 };
|
|
|
78
81
|
export function DetailList({ items, ...rest }: DetailListProps) {
|
|
79
82
|
const text = useUiText();
|
|
80
83
|
return (
|
|
81
|
-
<dl {...rest} data-terp="detail-list"
|
|
84
|
+
<dl {...rest} data-terp="detail-list">
|
|
82
85
|
{items.map((item, index) => (
|
|
83
86
|
<div key={index}>
|
|
84
|
-
<dt
|
|
85
|
-
<dd
|
|
87
|
+
<dt data-terp="detail-list-term">{text(item.label)}: </dt>
|
|
88
|
+
<dd data-terp="detail-list-value">{item.value}</dd>
|
|
86
89
|
</div>
|
|
87
90
|
))}
|
|
88
91
|
</dl>
|