@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.
Files changed (72) hide show
  1. package/package.json +2 -2
  2. package/src/AppShell.test.tsx +33 -12
  3. package/src/AppShell.tsx +69 -249
  4. package/src/Breadcrumbs.test.tsx +24 -0
  5. package/src/Breadcrumbs.tsx +9 -32
  6. package/src/ConfirmDialog.tsx +13 -44
  7. package/src/EmptyState.tsx +8 -36
  8. package/src/ErrorState.tsx +8 -36
  9. package/src/Field.test.tsx +57 -0
  10. package/src/Field.tsx +46 -22
  11. package/src/HubPage.test.tsx +22 -13
  12. package/src/HubPage.tsx +25 -97
  13. package/src/LoadingState.tsx +3 -24
  14. package/src/PageActions.tsx +5 -10
  15. package/src/UserMenu.test.tsx +12 -5
  16. package/src/UserMenu.tsx +33 -62
  17. package/src/dataview/DataView.test.tsx +109 -5
  18. package/src/dataview/DataView.tsx +41 -23
  19. package/src/dataview/DataViewCardList.tsx +14 -60
  20. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  21. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  22. package/src/dataview/DataViewPagination.tsx +2 -32
  23. package/src/dataview/DataViewRowActions.tsx +13 -33
  24. package/src/dataview/DataViewTable.tsx +16 -103
  25. package/src/dataview/DataViewToolbar.tsx +53 -76
  26. package/src/dataview/README.md +6 -0
  27. package/src/dataview/index.ts +1 -0
  28. package/src/dataview/internal.tsx +4 -1
  29. package/src/dataview/types.ts +13 -0
  30. package/src/feedback.test.tsx +26 -0
  31. package/src/files.test.tsx +18 -0
  32. package/src/files.tsx +13 -4
  33. package/src/icons.test.tsx +10 -6
  34. package/src/icons.tsx +9 -37
  35. package/src/index.ts +0 -3
  36. package/src/layout.test.tsx +24 -9
  37. package/src/layout.tsx +24 -21
  38. package/src/layoutContract.test.tsx +95 -0
  39. package/src/locale.tsx +24 -4
  40. package/src/markers.test.ts +242 -19
  41. package/src/router.tsx +6 -9
  42. package/src/ssr.test.tsx +1 -3
  43. package/src/styles.test.ts +823 -6
  44. package/src/styles.ts +2699 -153
  45. package/src/theme.tsx +24 -3
  46. package/src/toast.tsx +35 -71
  47. package/src/ui/Alert.test.tsx +12 -0
  48. package/src/ui/Alert.tsx +15 -43
  49. package/src/ui/Badge.test.tsx +14 -3
  50. package/src/ui/Badge.tsx +9 -28
  51. package/src/ui/Button.test.tsx +17 -4
  52. package/src/ui/Button.tsx +10 -63
  53. package/src/ui/Card.test.tsx +6 -2
  54. package/src/ui/Card.tsx +11 -39
  55. package/src/ui/Checkbox.tsx +2 -19
  56. package/src/ui/Combobox.test.tsx +22 -0
  57. package/src/ui/Combobox.tsx +31 -80
  58. package/src/ui/DatePicker.test.tsx +131 -4
  59. package/src/ui/DatePicker.tsx +158 -106
  60. package/src/ui/Input.tsx +6 -19
  61. package/src/ui/Markdown.test.tsx +26 -0
  62. package/src/ui/Markdown.tsx +28 -2
  63. package/src/ui/Menu.test.tsx +38 -4
  64. package/src/ui/Menu.tsx +50 -52
  65. package/src/ui/Popover.tsx +53 -19
  66. package/src/ui/Radio.tsx +5 -30
  67. package/src/ui/Select.tsx +7 -30
  68. package/src/ui/Switch.tsx +2 -20
  69. package/src/ui/Tabs.tsx +4 -28
  70. package/src/ui/Textarea.tsx +6 -17
  71. package/src/ui/Tooltip.tsx +9 -21
  72. 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
- <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
  });
package/src/icons.tsx CHANGED
@@ -1,4 +1,8 @@
1
- import type { CSSProperties, ReactNode } from "react";
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" style={navIconStyle}>
452
+ <span aria-hidden="true" data-terp="nav-icon">
474
453
  {glyph ?? (
475
- <span style={fallbackStyle}>
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
- style={{
515
- display: "inline-flex",
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
@@ -30,9 +30,6 @@ export type { RequireAuthProps } from "./RequireAuth";
30
30
  export { visibleNav } from "./nav";
31
31
  export {
32
32
  AppShell,
33
- NAV_LINK_STYLE,
34
- NAV_LINK_COLLAPSED_STYLE,
35
- NAV_LINK_ACTIVE_STYLE,
36
33
  SIDEBAR_STORAGE_KEY,
37
34
  } from "./AppShell";
38
35
  export type {
@@ -7,7 +7,7 @@ import { DetailList, Stack } from "./layout";
7
7
  afterEach(cleanup);
8
8
 
9
9
  describe("Stack", () => {
10
- it("renders a flex column with a token gap by default", () => {
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.style.display).toBe("flex");
20
- expect(el.style.flexDirection).toBe("column");
21
- expect(el.style.gap).toBe("var(--space-2)");
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 with direction, gap, alignment and wrap", () => {
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} align="center" justify="space-between" wrap>
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.style.flexDirection).toBe("row");
33
- expect(el.style.gap).toBe("var(--space-4)");
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.flexWrap).toBe("wrap");
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
- const style: CSSProperties = {
42
- display: "flex",
43
- flexDirection: direction,
44
- gap: `var(--space-${gap})`,
45
- margin: 0,
46
- ...(align !== undefined ? { alignItems: align } : undefined),
47
- ...(justify !== undefined ? { justifyContent: justify } : undefined),
48
- ...(wrap ? { flexWrap: "wrap" } : undefined),
49
- };
50
- return <Component {...rest} data-terp="stack" style={style} />;
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" style={detailListStyle}>
84
+ <dl {...rest} data-terp="detail-list">
82
85
  {items.map((item, index) => (
83
86
  <div key={index}>
84
- <dt style={detailTermStyle}>{text(item.label)}: </dt>
85
- <dd style={detailValueStyle}>{item.value}</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>