@terpjs/react-core 0.8.0 → 0.10.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 (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. package/src/unwrap.ts +118 -32
@@ -2,7 +2,7 @@
2
2
  import { cleanup, render, screen } from "@testing-library/react";
3
3
  import { afterEach, describe, expect, it } from "vitest";
4
4
 
5
- import { DetailList, Stack } from "./layout";
5
+ import { DetailList, Divider, Grid, Stack } from "./layout";
6
6
 
7
7
  afterEach(cleanup);
8
8
 
@@ -51,6 +51,77 @@ describe("Stack", () => {
51
51
  expect(el.style.display).toBe("");
52
52
  });
53
53
 
54
+ it("splits a responsive prop into a narrow attribute and a wide one", () => {
55
+ // jsdom evaluates no media query, so the unit-level claim is the attributes and nothing
56
+ // more — which is the honest boundary. That the rules then apply at the right width is a
57
+ // browser fact, held by the `stack-responsive-*` specimen pair at 420 and 900.
58
+ render(
59
+ <Stack
60
+ data-testid="toolbar"
61
+ direction={{ narrow: "column", wide: "row" }}
62
+ gap={{ narrow: 2, wide: 4 }}
63
+ >
64
+ <span>a</span>
65
+ </Stack>,
66
+ );
67
+ const el = screen.getByTestId("toolbar");
68
+ expect(el).toHaveAttribute("data-direction", "column");
69
+ expect(el).toHaveAttribute("data-direction-wide", "row");
70
+ expect(el).toHaveAttribute("data-gap", "2");
71
+ expect(el).toHaveAttribute("data-gap-wide", "4");
72
+ expect(el.getAttribute("style")).toBeNull();
73
+ });
74
+
75
+ it("stamps no wide attribute when the prop is not responsive", () => {
76
+ // The back-compatibility claim, asserted as absence: every Stack in every consuming app
77
+ // passes scalars, so it must render exactly the attributes it always did — which is why
78
+ // adding the feature moved no baseline anywhere.
79
+ render(
80
+ <Stack data-testid="plain" direction="row" gap={4}>
81
+ <span>a</span>
82
+ </Stack>,
83
+ );
84
+ const el = screen.getByTestId("plain");
85
+ expect(el).toHaveAttribute("data-direction", "row");
86
+ expect(el).toHaveAttribute("data-gap", "4");
87
+ expect(el.hasAttribute("data-direction-wide")).toBe(false);
88
+ expect(el.hasAttribute("data-gap-wide")).toBe(false);
89
+ });
90
+
91
+ it("accepts a responsive gap of 0, which a falsy check would drop", () => {
92
+ // `gap: 0` is a legal step and the only one that is falsy, so a `value.wide || undefined`
93
+ // anywhere in the split would silently render the narrow gap at every width.
94
+ render(
95
+ <Stack data-testid="zero" gap={{ narrow: 4, wide: 0 }}>
96
+ <span>a</span>
97
+ </Stack>,
98
+ );
99
+ expect(screen.getByTestId("zero")).toHaveAttribute("data-gap-wide", "0");
100
+ });
101
+
102
+ it("names padding on the token scale, including the falsy zero step", () => {
103
+ // `padding={0}` is a legal step and the only falsy one, so a truthiness check anywhere in
104
+ // the stamp would silently drop it and inherit the container's inset instead.
105
+ const { rerender } = render(
106
+ <Stack data-testid="padded" padding={4}>
107
+ <span>a</span>
108
+ </Stack>,
109
+ );
110
+ expect(screen.getByTestId("padded")).toHaveAttribute("data-padding", "4");
111
+ rerender(
112
+ <Stack data-testid="padded" padding={0}>
113
+ <span>a</span>
114
+ </Stack>,
115
+ );
116
+ expect(screen.getByTestId("padded")).toHaveAttribute("data-padding", "0");
117
+ rerender(
118
+ <Stack data-testid="padded">
119
+ <span>a</span>
120
+ </Stack>,
121
+ );
122
+ expect(screen.getByTestId("padded").hasAttribute("data-padding")).toBe(false);
123
+ });
124
+
54
125
  it("works as a form (submit handler fires)", () => {
55
126
  let submitted = false;
56
127
  render(
@@ -70,7 +141,133 @@ describe("Stack", () => {
70
141
  });
71
142
  });
72
143
 
144
+ describe("Grid", () => {
145
+ it("leaves every default unstamped, because the defaults ARE the base rule", () => {
146
+ // auto columns, the sm track floor and stretched cells are what the base rule declares, so
147
+ // their attributes would describe the standard shape a second time. Same idiom as density's
148
+ // "comfortable" and Button's md — and asserted as absence, deliberately, because a later
149
+ // change that starts stamping them would leave the sheet with two accounts of one default.
150
+ render(
151
+ <Grid data-testid="grid">
152
+ <span>a</span>
153
+ </Grid>,
154
+ );
155
+ const el = screen.getByTestId("grid");
156
+ expect(el.tagName).toBe("DIV");
157
+ expect(el).toHaveAttribute("data-terp", "grid");
158
+ expect(el).toHaveAttribute("data-gap", "4");
159
+ expect(el.hasAttribute("data-columns")).toBe(false);
160
+ expect(el.hasAttribute("data-min-column")).toBe(false);
161
+ expect(el.hasAttribute("data-align")).toBe(false);
162
+ expect(el.getAttribute("style")).toBeNull();
163
+ });
164
+
165
+ it("names a fixed count, the element and the gap", () => {
166
+ render(
167
+ <Grid data-testid="grid" as="ul" columns={3} gap={2}>
168
+ <li>a</li>
169
+ </Grid>,
170
+ );
171
+ const el = screen.getByTestId("grid");
172
+ expect(el.tagName).toBe("UL");
173
+ // A number prop, a text attribute: `columns={3}` has to produce "3" or the rule misses.
174
+ expect(el).toHaveAttribute("data-columns", "3");
175
+ expect(el).toHaveAttribute("data-gap", "2");
176
+ expect(el.getAttribute("style")).toBeNull();
177
+ });
178
+
179
+ it("names a non-default track floor, and only while the columns are auto", () => {
180
+ // `minColumn` is meaningless at a fixed count — the tracks are counted, not floored — so
181
+ // stamping it there would put an attribute on the element that no rule reads and that a
182
+ // reader would take for an active choice.
183
+ const { rerender } = render(
184
+ <Grid data-testid="grid" minColumn="lg">
185
+ <span>a</span>
186
+ </Grid>,
187
+ );
188
+ expect(screen.getByTestId("grid")).toHaveAttribute("data-min-column", "lg");
189
+ rerender(
190
+ <Grid data-testid="grid" columns={2} minColumn="lg">
191
+ <span>a</span>
192
+ </Grid>,
193
+ );
194
+ const fixed = screen.getByTestId("grid");
195
+ expect(fixed).toHaveAttribute("data-columns", "2");
196
+ expect(fixed.hasAttribute("data-min-column")).toBe(false);
197
+ });
198
+
199
+ it("keeps alignment an attribute, unlike Stack's, and takes no style", () => {
200
+ // The deliberate divergence: Stack's align is an open set of CSS keywords and stays inline
201
+ // (ADR 0094 §3); Grid's is a closed four, so it is an attribute and Grid renders no inline
202
+ // style at all. That is what keeps a new primitive out of the inline-style ledger.
203
+ render(
204
+ <Grid data-testid="grid" align="center">
205
+ <span>a</span>
206
+ </Grid>,
207
+ );
208
+ const el = screen.getByTestId("grid");
209
+ expect(el).toHaveAttribute("data-align", "center");
210
+ expect(el.getAttribute("style")).toBeNull();
211
+ });
212
+ });
213
+
214
+ describe("Divider", () => {
215
+ it("is an hr, so the separation reaches the accessibility tree", () => {
216
+ // A bordered div is what a module reaches for without a primitive, and it says nothing to
217
+ // a screen reader. The element is the point of the component.
218
+ render(<Divider data-testid="rule" />);
219
+ const el = screen.getByTestId("rule");
220
+ expect(el.tagName).toBe("HR");
221
+ expect(el).toHaveAttribute("data-terp", "divider");
222
+ expect(el.hasAttribute("data-orientation")).toBe(false);
223
+ expect(el.getAttribute("style")).toBeNull();
224
+ });
225
+
226
+ it("announces a vertical rule as one, which hr does not imply", () => {
227
+ render(<Divider data-testid="rule" orientation="vertical" />);
228
+ const el = screen.getByTestId("rule");
229
+ expect(el).toHaveAttribute("data-orientation", "vertical");
230
+ expect(el).toHaveAttribute("aria-orientation", "vertical");
231
+ });
232
+ });
233
+
73
234
  describe("DetailList", () => {
235
+ it("leaves the inline default unstamped, and marks each row", () => {
236
+ // `inline` and one column are the base rule. The row marker is new and load-bearing: the
237
+ // aligned layout turns the wrapper into `display: contents` so the dt and dd become grid
238
+ // items of the dl itself, which is the only way to align labels across rows without
239
+ // changing the DOM — and a rule cannot reach an unmarked wrapper.
240
+ render(<DetailList data-testid="dl" items={[{ label: "Owner", value: "Ada" }]} />);
241
+ const el = screen.getByTestId("dl");
242
+ expect(el.hasAttribute("data-layout")).toBe(false);
243
+ expect(el.hasAttribute("data-columns")).toBe(false);
244
+ expect(el.querySelector('[data-terp="detail-list-row"]')).not.toBeNull();
245
+ expect(el.getAttribute("style")).toBeNull();
246
+ });
247
+
248
+ it("names a layout and a column count", () => {
249
+ render(
250
+ <DetailList
251
+ data-testid="dl"
252
+ layout="aligned"
253
+ columns={2}
254
+ items={[{ label: "Owner", value: "Ada" }]}
255
+ />,
256
+ );
257
+ const el = screen.getByTestId("dl");
258
+ expect(el).toHaveAttribute("data-layout", "aligned");
259
+ expect(el).toHaveAttribute("data-columns", "2");
260
+ });
261
+
262
+ it("puts no colon in the markup, because two layouts must not have one", () => {
263
+ // The colon is a rule on the inline layout, not a text node — `aligned` and `stacked` must
264
+ // not carry one, and no rule can withdraw a text node. It is decorative either way: the
265
+ // dt/dd pairing is what carries the relationship to assistive tech, so moving it out of the
266
+ // markup also stops it being read aloud.
267
+ render(<DetailList items={[{ label: "Owner", value: "Ada" }]} />);
268
+ expect(screen.getByText("Owner").textContent).toBe("Owner");
269
+ });
270
+
74
271
  it("renders label/value pairs as a definition list", () => {
75
272
  render(
76
273
  <DetailList
package/src/layout.tsx CHANGED
@@ -6,13 +6,45 @@ import type { UiText } from "./uiText";
6
6
  /** The spacing scale — indexes into the `--space-*` design tokens (no arbitrary pixel gaps). */
7
7
  export type SpaceToken = 0 | 1 | 2 | 3 | 4 | 6 | 8;
8
8
 
9
+ /**
10
+ * A value, or one value below the framework's viewport cutover and another above it.
11
+ *
12
+ * There is one cutover and not a scale of them, deliberately: `wide` is the complement of the
13
+ * width at which the shell becomes a drawer and the DataView becomes cards, so a responsive
14
+ * `Stack` changes over at exactly the moment the chrome around it does. A second breakpoint is
15
+ * a rule per value per breakpoint in the sheet, and nothing has asked for one — the same reason
16
+ * the density scale has two steps rather than five.
17
+ */
18
+ export type Responsive<T> = T | { narrow: T; wide: T };
19
+
20
+ /** Split a possibly-responsive prop into its two halves. */
21
+ function responsive<T>(value: Responsive<T>): { narrow: T; wide: T | undefined } {
22
+ return value !== null && typeof value === "object" && "narrow" in value
23
+ ? { narrow: value.narrow, wide: value.wide }
24
+ : { narrow: value as T, wide: undefined };
25
+ }
26
+
9
27
  export interface StackProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
10
28
  /** The rendered element (`"div"` by default; use `"form"`, `"section"`, `"ul"`, …). */
11
29
  as?: ElementType;
12
- /** Main axis: `"column"` (default) stacks, `"row"` lines up. */
13
- direction?: "column" | "row";
30
+ /**
31
+ * Main axis: `"column"` (default) stacks, `"row"` lines up.
32
+ *
33
+ * Takes a {@link Responsive} pair for the toolbar case — `{ narrow: "column", wide: "row" }`
34
+ * is a row of controls that stacks below the cutover, which was previously inexpressible
35
+ * without a `style` an app module may not write.
36
+ */
37
+ direction?: Responsive<"column" | "row">;
14
38
  /** Gap between children, as a step on the token spacing scale (default `2`). */
15
- gap?: SpaceToken;
39
+ gap?: Responsive<SpaceToken>;
40
+ /**
41
+ * Inset around the children, as a step on the token spacing scale (default none).
42
+ *
43
+ * The dimension the diagnosis named first — "no padding" — and the one that made a padded
44
+ * region reachable only through a `Card`, whose border and background came with it whether
45
+ * they were wanted or not.
46
+ */
47
+ padding?: SpaceToken;
16
48
  /** Cross-axis alignment (e.g. `"center"`, `"start"`, `"end"`, `"stretch"`). */
17
49
  align?: CSSProperties["alignItems"];
18
50
  /** Main-axis distribution (e.g. `"space-between"`, `"center"`, `"end"`). */
@@ -33,6 +65,7 @@ export function Stack({
33
65
  as: Component = "div",
34
66
  direction = "column",
35
67
  gap = 2,
68
+ padding,
36
69
  align,
37
70
  justify,
38
71
  wrap = false,
@@ -49,18 +82,140 @@ export function Stack({
49
82
  ...(align !== undefined ? { alignItems: align } : undefined),
50
83
  ...(justify !== undefined ? { justifyContent: justify } : undefined),
51
84
  };
85
+ // A responsive prop is two attributes, and the wide half is stamped only when it was asked
86
+ // for — so a non-responsive Stack renders exactly the attributes it always did and every
87
+ // existing baseline is untouched by construction.
88
+ const directions = responsive(direction);
89
+ const gaps = responsive(gap);
52
90
  return (
53
91
  <Component
54
92
  {...rest}
55
93
  data-terp="stack"
56
- data-direction={direction}
57
- data-gap={String(gap)}
94
+ data-direction={directions.narrow}
95
+ data-direction-wide={directions.wide}
96
+ data-gap={String(gaps.narrow)}
97
+ data-gap-wide={gaps.wide === undefined ? undefined : String(gaps.wide)}
98
+ data-padding={padding === undefined ? undefined : String(padding)}
58
99
  data-wrap={wrap ? "true" : undefined}
59
100
  style={alignment}
60
101
  />
61
102
  );
62
103
  }
63
104
 
105
+ /** Fixed column count, or `"auto"` — as many columns as fit above `minColumn`. */
106
+ export type GridColumns = 1 | 2 | 3 | 4 | "auto";
107
+
108
+ /** Track floor for an `"auto"` grid: the width below which a column stops being one. */
109
+ export type GridMinColumn = "xs" | "sm" | "md" | "lg";
110
+
111
+ export interface GridProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
112
+ /** The rendered element (`"div"` by default; use `"ul"`, `"section"`, `"dl"`, …). */
113
+ as?: ElementType;
114
+ /**
115
+ * Columns: a fixed count, or `"auto"` (the default) for as many as fit above
116
+ * {@link GridProps.minColumn}.
117
+ *
118
+ * `"auto"` is the responsive answer and it takes no breakpoint: the track floor makes the
119
+ * grid reflow to whatever its **container** can hold, which is what a caller almost always
120
+ * means and is more nearly right than a viewport query — a grid inside a narrow panel
121
+ * should go one-column whatever the window is doing.
122
+ */
123
+ columns?: GridColumns;
124
+ /** Track floor for an `"auto"` grid (default `"sm"`); ignored at a fixed count. */
125
+ minColumn?: GridMinColumn;
126
+ /** Gap between cells, as a step on the token spacing scale (default `4`). */
127
+ gap?: SpaceToken;
128
+ /** Inset around the cells, as a step on the token spacing scale (default none). */
129
+ padding?: SpaceToken;
130
+ /**
131
+ * Block alignment of each cell within its row (default `"stretch"`).
132
+ *
133
+ * A closed set of four, unlike `Stack`'s `align` — see the note on {@link Grid}.
134
+ */
135
+ align?: "start" | "center" | "end" | "stretch";
136
+ children?: ReactNode;
137
+ }
138
+
139
+ /**
140
+ * The two-dimensional layout primitive, and the one that lifts a real ceiling: with `Stack`
141
+ * as the entire vocabulary, a two-column form could not be expressed at all, so a fifteen-field
142
+ * form shipped as one long vertical run. App modules may not write `style` or `className`
143
+ * (ADR 0059), so that was not awkwardness — it was unbuildable.
144
+ *
145
+ * Every prop is a closed set and becomes a `data-*` attribute with a rule each, so `Grid`
146
+ * renders **no inline style** and stays out of the inline-style ledger (ADR 0097). Three
147
+ * consequences of that are worth knowing, because each was a choice:
148
+ *
149
+ * - **`align` takes four values, where `Stack`'s takes any alignment keyword.** The divergence
150
+ * is deliberate rather than an oversight. `Stack`'s is open because it was already inline
151
+ * when ADR 0094 drew the line, and an open vocabulary stays inline; a *new* component picks
152
+ * the closed set so it needs no `style` at all. `justify` is absent for the same reason it
153
+ * would be least useful: a grid's tracks already fill their container, so distributing them
154
+ * is a question about the tracks rather than the cells, and no consumer has asked.
155
+ * - **`minColumn` is a scale, not a length.** `Icon`'s `size` takes any CSS length and stays
156
+ * inline for it; a grid's column floor could have gone the same way. It is a scale instead
157
+ * for the reason `Stack`'s `gap` is a token index: so there are no arbitrary widths. An app
158
+ * wanting 17.5rem cannot have it, which is the same trade `gap` already makes.
159
+ * - **There is no `span`,** and therefore no twelve-column option. A span system needs a child
160
+ * component to carry it, and a `columns={12}` with no way to span is a grid of twelve narrow
161
+ * cells rather than a layout system — worse than not offering it.
162
+ */
163
+ export function Grid({
164
+ as: Component = "div",
165
+ columns = "auto",
166
+ minColumn = "sm",
167
+ gap = 4,
168
+ padding,
169
+ align = "stretch",
170
+ ...rest
171
+ }: GridProps) {
172
+ return (
173
+ <Component
174
+ {...rest}
175
+ data-terp="grid"
176
+ // The defaults are the base rule, so their attributes match nothing and are left off —
177
+ // the shape density and Button's `md` already use. String(columns) rather than the
178
+ // number, because a `data-` attribute is text and `columns={2}` must produce "2".
179
+ data-columns={columns === "auto" ? undefined : String(columns)}
180
+ data-min-column={columns === "auto" && minColumn !== "sm" ? minColumn : undefined}
181
+ data-gap={String(gap)}
182
+ data-padding={padding === undefined ? undefined : String(padding)}
183
+ data-align={align === "stretch" ? undefined : align}
184
+ />
185
+ );
186
+ }
187
+
188
+ export interface DividerProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
189
+ /** `"horizontal"` (default) rules across a column; `"vertical"` separates a row. */
190
+ orientation?: "horizontal" | "vertical";
191
+ }
192
+
193
+ /**
194
+ * A rule between two groups of content — `Separator` under its other common name, shipped
195
+ * once rather than twice.
196
+ *
197
+ * An `<hr>`, so the separation is in the accessibility tree rather than only in the pixels; a
198
+ * bordered `<div>` is what a module reaches for when it has no primitive, and it says nothing
199
+ * to a screen reader. The vertical form carries `aria-orientation`, which `<hr>` does not
200
+ * imply.
201
+ *
202
+ * The vertical case needs a height from somewhere and deliberately does not invent one: it
203
+ * stretches to its flex or grid line, so it works between the items of a row `Stack` and is
204
+ * zero-height in a plain block parent. That is the one thing about it worth knowing before
205
+ * reaching for it, and the reason its specimen renders inside a fixed-height row.
206
+ */
207
+ export function Divider({ orientation = "horizontal", ...rest }: DividerProps) {
208
+ return (
209
+ <hr
210
+ {...rest}
211
+ data-terp="divider"
212
+ // Horizontal is the base rule, so only the vertical case carries an attribute.
213
+ data-orientation={orientation === "vertical" ? "vertical" : undefined}
214
+ aria-orientation={orientation === "vertical" ? "vertical" : undefined}
215
+ />
216
+ );
217
+ }
218
+
64
219
  export interface DetailItem {
65
220
  /** The item's label (rendered as `<dt>`). */
66
221
  label: UiText;
@@ -68,23 +223,65 @@ export interface DetailItem {
68
223
  value: ReactNode;
69
224
  }
70
225
 
226
+ /** How a pair is arranged. */
227
+ export type DetailListLayout = "inline" | "aligned" | "stacked";
228
+
71
229
  export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>, "style"> {
72
230
  /** The label/value pairs to render, in order. */
73
231
  items: readonly DetailItem[];
232
+ /**
233
+ * How each pair reads (default `"inline"` — `Label: value` on one line).
234
+ *
235
+ * `"aligned"` puts every label in a shared left column, so the values line up; `"stacked"`
236
+ * puts the label above its value, which is what a narrow column or a long value wants.
237
+ *
238
+ * The default is the old behaviour on purpose: this component is in the `standard` layout
239
+ * contract's detail-page slot, so every governed detail screen already renders one.
240
+ */
241
+ layout?: DetailListLayout;
242
+ /** Pairs per row (default `1`). */
243
+ columns?: 1 | 2;
74
244
  }
75
245
 
76
246
  /**
77
247
  * Token-styled label/value pairs as a semantic `<dl>` — record metadata on a detail page,
78
- * an expanded row's summary. Centralizes the "Label: value" pattern so modules never
79
- * hand-style definition lists.
248
+ * an expanded row's summary.
249
+ *
250
+ * A real grid rather than an inline run, which is the thing the diagnosis was pointing at: nine
251
+ * pairs including two 64-character digests, "through an inline run 4px apart with no
252
+ * alignment". Two fixes rather than one, and the second was the actual defect:
253
+ *
254
+ * - `layout="aligned"` gives the labels a shared column. The row wrapper becomes
255
+ * `display: contents` so the `<dt>` and `<dd>` are grid items of the `<dl>` itself — no DOM
256
+ * change, and the only way to align across rows without one.
257
+ * - **The tracks are floored at zero.** An implicit grid column is `auto`, which floors at
258
+ * *min-content* — so a 64-character digest with nothing to break on widened the column and
259
+ * pushed the list past its container. `minmax(0, 1fr)` is what stops that, and it is the same
260
+ * declaration `Grid`'s fixed counts need for the same reason.
261
+ *
262
+ * The colon lives in the sheet rather than in the markup, because it belongs to the inline
263
+ * layout alone — `aligned` and `stacked` must not have one, and a text node cannot be
264
+ * withdrawn by a rule. It is decorative either way: the `<dt>` / `<dd>` pairing is what carries
265
+ * the relationship to assistive tech.
80
266
  */
81
- export function DetailList({ items, ...rest }: DetailListProps) {
267
+ export function DetailList({
268
+ items,
269
+ layout = "inline",
270
+ columns = 1,
271
+ ...rest
272
+ }: DetailListProps) {
82
273
  const text = useUiText();
83
274
  return (
84
- <dl {...rest} data-terp="detail-list">
275
+ <dl
276
+ {...rest}
277
+ data-terp="detail-list"
278
+ // `inline` and one column are the base rule, so neither stamps an attribute.
279
+ data-layout={layout === "inline" ? undefined : layout}
280
+ data-columns={columns === 1 ? undefined : String(columns)}
281
+ >
85
282
  {items.map((item, index) => (
86
- <div key={index}>
87
- <dt data-terp="detail-list-term">{text(item.label)}: </dt>
283
+ <div key={index} data-terp="detail-list-row">
284
+ <dt data-terp="detail-list-term">{text(item.label)}</dt>
88
285
  <dd data-terp="detail-list-value">{item.value}</dd>
89
286
  </div>
90
287
  ))}