@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.
- package/README.md +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/layout.test.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
13
|
-
|
|
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={
|
|
57
|
-
data-
|
|
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.
|
|
79
|
-
*
|
|
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({
|
|
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
|
|
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)}
|
|
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
|
))}
|