@terpjs/react-core 0.9.0 → 0.11.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 +57 -21
- package/package.json +6 -5
- package/src/AppShell.test.tsx +323 -4
- package/src/AppShell.tsx +401 -66
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +43 -18
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- 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 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- 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/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- 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 +12 -0
- package/src/markers.test.ts +27 -5
- 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/router.test.tsx +575 -2
- package/src/router.tsx +212 -19
- package/src/styles.test.ts +535 -58
- package/src/styles.ts +1130 -111
- 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 +239 -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 +69 -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 +139 -0
- package/src/ui/Combobox.tsx +255 -43
- package/src/ui/DatePicker.tsx +44 -12
- 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.test.tsx +28 -0
- package/src/ui/Tabs.tsx +30 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +36 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { Component } from "react";
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
@@ -11,6 +11,11 @@ import { afterEach, describe, expect, it } from "vitest";
|
|
|
11
11
|
import * as lintLayouts from "../../eslint-boundaries/src/layouts.js";
|
|
12
12
|
|
|
13
13
|
import { DetailPage } from "./DetailPage";
|
|
14
|
+
import { FormPage } from "./FormPage";
|
|
15
|
+
import { SettingsPage } from "./SettingsPage";
|
|
16
|
+
import { SplitPage, SplitPane } from "./SplitPage";
|
|
17
|
+
import { Input } from "./ui/Input";
|
|
18
|
+
import { Field } from "./Field";
|
|
14
19
|
import { HubCard, HubPage } from "./HubPage";
|
|
15
20
|
import {
|
|
16
21
|
LAYOUT_CONTRACTS,
|
|
@@ -19,8 +24,17 @@ import {
|
|
|
19
24
|
verifySlotChildren,
|
|
20
25
|
} from "./layoutContract";
|
|
21
26
|
import { OverviewPage } from "./OverviewPage";
|
|
27
|
+
|
|
28
|
+
// The public surface, as source: the archetype-coverage check below derives its list from
|
|
29
|
+
// the entry point's own exports rather than restating one. `raw.d.ts` declares the ambient
|
|
30
|
+
// ImportMeta.glob type this shares with the other scanning tests in the package.
|
|
31
|
+
const entryPoint = Object.values(
|
|
32
|
+
import.meta.glob("./index.ts", { query: "?raw", import: "default", eager: true }),
|
|
33
|
+
)[0] as string;
|
|
22
34
|
import { Page } from "./Page";
|
|
23
|
-
import {
|
|
35
|
+
import { EmptyState } from "./EmptyState";
|
|
36
|
+
import { DetailList, Divider, Grid, Stack } from "./layout";
|
|
37
|
+
import { Text } from "./typography";
|
|
24
38
|
import { PageActions } from "./PageActions";
|
|
25
39
|
import { ThemeProvider, ThemeToggle } from "./theme";
|
|
26
40
|
import { Button } from "./ui/Button";
|
|
@@ -55,6 +69,30 @@ function underContract(children: ReactNode, contract: string | null = "standard"
|
|
|
55
69
|
);
|
|
56
70
|
}
|
|
57
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Assert that a composition was ACCEPTED — after actually letting the check run.
|
|
74
|
+
*
|
|
75
|
+
* The obvious spelling is a false green, and it was the shape every acceptance test in this file
|
|
76
|
+
* used:
|
|
77
|
+
*
|
|
78
|
+
* await waitFor(() => expect(screen.queryByTestId("refused")).toBeNull());
|
|
79
|
+
*
|
|
80
|
+
* `waitFor` retries until its callback stops throwing, so a callback asserting that something is
|
|
81
|
+
* ABSENT passes on the very first attempt — before `Page`'s `setTimeout(0)` slot check has run at
|
|
82
|
+
* all. It waits for nothing and can never fail. Nine tests claimed to gate the acceptance half of
|
|
83
|
+
* ADR 0079 and gated nothing; verified by putting a `Grid` — which the OverviewPage slot table
|
|
84
|
+
* does not admit — into a governed overview body and watching the suite stay green.
|
|
85
|
+
*
|
|
86
|
+
* Flushing one macrotask inside `act` is what makes the assertion mean something: the check has
|
|
87
|
+
* then either thrown into the boundary or decided the body was legal.
|
|
88
|
+
*/
|
|
89
|
+
async function expectAccepted() {
|
|
90
|
+
await act(async () => {
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
92
|
+
});
|
|
93
|
+
expect(screen.queryByTestId("refused")).toBeNull();
|
|
94
|
+
}
|
|
95
|
+
|
|
58
96
|
describe("layout contract parity (docs/data can't drift)", () => {
|
|
59
97
|
it("mirrors the eslint-boundaries contract table byte-for-byte", () => {
|
|
60
98
|
expect(JSON.parse(JSON.stringify(LAYOUT_CONTRACTS))).toEqual(
|
|
@@ -69,6 +107,124 @@ describe("layout contract parity (docs/data can't drift)", () => {
|
|
|
69
107
|
});
|
|
70
108
|
});
|
|
71
109
|
|
|
110
|
+
describe("every archetype the package exports is governed, or says it is not", () => {
|
|
111
|
+
// The gap this closes was a green build. `verifySlotChildren` returns null for a slot the
|
|
112
|
+
// table does not name (layoutContract.ts:138-141) and the lint rule early-returns the same
|
|
113
|
+
// way, so an archetype exported WITHOUT a table entry is silently ungoverned by both halves
|
|
114
|
+
// of the control — no error, no warning, and a governed app renders it with the body slot
|
|
115
|
+
// wide open. Nothing asserted the two lists agreed, which means "forgot the table entry"
|
|
116
|
+
// was indistinguishable from "deliberately unconstrained".
|
|
117
|
+
//
|
|
118
|
+
// Derived from the public surface rather than restated as a list, because a list is the
|
|
119
|
+
// thing that was missing: a new `FormPage` export joins this check by existing, and has to
|
|
120
|
+
// either take a slot or come here and say why.
|
|
121
|
+
// Two independent derivations, unioned, because each has a blind spot the other covers.
|
|
122
|
+
//
|
|
123
|
+
// The named-export scan reads `export { X } from` and `export { X as Y } from` — the alias
|
|
124
|
+
// form matters, since the exported name is what a consumer writes. It cannot see
|
|
125
|
+
// `export * from`, and index.ts already contains one (`./dataview`), so on its own this
|
|
126
|
+
// derivation would let an archetype re-exported through a sub-namespace escape all three
|
|
127
|
+
// assertions below INCLUDING the vacuity guard, which is built from the same scan.
|
|
128
|
+
//
|
|
129
|
+
// The filename scan covers exactly that: an archetype lives in `<Name>Page.tsx` by
|
|
130
|
+
// convention, wherever in the tree it sits. It cannot see an archetype declared inside some
|
|
131
|
+
// other file — which the export scan can.
|
|
132
|
+
const namedExports = [...entryPoint.matchAll(/^export \{([^}]*)\} from/gm)]
|
|
133
|
+
.flatMap((match) => match[1]!.split(","))
|
|
134
|
+
// `A as B` exports B; a bare name exports itself.
|
|
135
|
+
.map((entry) => entry.trim().split(/\s+as\s+/).pop()!.trim());
|
|
136
|
+
const archetypeFiles = Object.keys(
|
|
137
|
+
import.meta.glob("./**/*Page.tsx", { query: "?raw", import: "default", eager: true }),
|
|
138
|
+
)
|
|
139
|
+
.filter((file) => !file.includes(".test."))
|
|
140
|
+
.map((file) => file.split("/").pop()!.replace(/\.tsx$/, ""));
|
|
141
|
+
const exported = [
|
|
142
|
+
...new Set(
|
|
143
|
+
[...namedExports, ...archetypeFiles]
|
|
144
|
+
// `endsWith` rather than a `\w*Page$` regex, which cannot match "Page" itself:
|
|
145
|
+
// after the leading capital there is no "Page" left to match. The vacuity guard below
|
|
146
|
+
// caught that on the first run, which is the whole reason it is there.
|
|
147
|
+
.filter((name) => /^[A-Z]\w*$/.test(name) && name.endsWith("Page")),
|
|
148
|
+
),
|
|
149
|
+
].sort();
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Archetypes with no slot entry, and the reason each is unconstrained.
|
|
153
|
+
*
|
|
154
|
+
* One entry, and it is the contract's own pressure valve rather than an omission: the
|
|
155
|
+
* plain `Page` is what a bespoke screen composes when no archetype fits, and the
|
|
156
|
+
* contract's description says so in as many words. Anything else added here is a claim
|
|
157
|
+
* that a screen shape has no vocabulary, which is the claim the table exists to refuse.
|
|
158
|
+
*/
|
|
159
|
+
const DELIBERATELY_UNCONSTRAINED: Record<string, string> = {
|
|
160
|
+
Page: "the bespoke pressure valve — a screen no archetype fits composes this, and the contract leaves it open by design",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
it("finds the archetype exports it is meant to check", () => {
|
|
164
|
+
// Vacuity guard: a derivation that matched nothing would make every assertion below pass.
|
|
165
|
+
expect(exported).toEqual([
|
|
166
|
+
"DetailPage",
|
|
167
|
+
"FormPage",
|
|
168
|
+
"HubPage",
|
|
169
|
+
"OverviewPage",
|
|
170
|
+
"Page",
|
|
171
|
+
"SettingsPage",
|
|
172
|
+
"SplitPage",
|
|
173
|
+
]);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("pins the namespace re-exports, which neither derivation can look inside", () => {
|
|
177
|
+
// `export * from "./x"` is opaque to both scans: the filename scan only sees files named
|
|
178
|
+
// `*Page.tsx`, and the named-export scan sees no names at all. So the set of namespace
|
|
179
|
+
// re-exports is pinned instead — adding one is the moment to check it carries no archetype,
|
|
180
|
+
// and this assertion is what forces that look.
|
|
181
|
+
const namespaces = [...entryPoint.matchAll(/^export \* from "([^"]+)"/gm)].map(
|
|
182
|
+
(match) => match[1]!,
|
|
183
|
+
);
|
|
184
|
+
expect(namespaces).toEqual(["./dataview"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("keeps no excuse for an archetype that is no longer exported", () => {
|
|
188
|
+
// The reverse-direction test filters these keys out of its comparison, so a stale excuse
|
|
189
|
+
// changes nothing there and would sit in the file for ever — quietly claiming that a
|
|
190
|
+
// component nobody exports is deliberately unconstrained.
|
|
191
|
+
for (const excused of Object.keys(DELIBERATELY_UNCONSTRAINED)) {
|
|
192
|
+
expect(
|
|
193
|
+
exported,
|
|
194
|
+
`${excused} is excused from the slot table but is not an exported archetype — the excuse is stale`,
|
|
195
|
+
).toContain(excused);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("gives every exported archetype a slot, or a named reason for having none", () => {
|
|
200
|
+
const slots = LAYOUT_CONTRACTS.standard!.slots;
|
|
201
|
+
for (const archetype of exported) {
|
|
202
|
+
const governed = archetype in slots;
|
|
203
|
+
const excused = archetype in DELIBERATELY_UNCONSTRAINED;
|
|
204
|
+
expect(
|
|
205
|
+
governed || excused,
|
|
206
|
+
`${archetype} is exported but has no LAYOUT_CONTRACTS.standard.slots entry, so both ` +
|
|
207
|
+
"halves of the layout contract silently skip it. Add the slot (in this file AND in " +
|
|
208
|
+
"eslint-boundaries/src/layouts.js), or add it to DELIBERATELY_UNCONSTRAINED with the " +
|
|
209
|
+
"reason its body has no vocabulary.",
|
|
210
|
+
).toBe(true);
|
|
211
|
+
expect(
|
|
212
|
+
governed && excused,
|
|
213
|
+
`${archetype} is both governed and excused — the excuse is stale, delete it`,
|
|
214
|
+
).toBe(false);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("names no slot that no archetype exports", () => {
|
|
219
|
+
// The other direction: a slot for an archetype that was renamed or withdrawn is a table
|
|
220
|
+
// entry nothing can ever satisfy, and the message it phrases names a component that is
|
|
221
|
+
// no longer there.
|
|
222
|
+
expect(Object.keys(LAYOUT_CONTRACTS.standard!.slots).sort()).toEqual(
|
|
223
|
+
exported.filter((name) => !(name in DELIBERATELY_UNCONSTRAINED)),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
72
228
|
describe("runtime slot enforcement", () => {
|
|
73
229
|
it("refuses a non-HubCard child in a HubPage grid, fail closed, with the directive message", async () => {
|
|
74
230
|
underContract(
|
|
@@ -242,14 +398,18 @@ describe("layout contract survives the roots the styling migration renames", ()
|
|
|
242
398
|
"dataview",
|
|
243
399
|
"detail-list",
|
|
244
400
|
"dialog",
|
|
401
|
+
"divider",
|
|
245
402
|
"empty-state",
|
|
246
403
|
"error-state",
|
|
404
|
+
"grid",
|
|
247
405
|
"hubcard",
|
|
248
406
|
"loading-state",
|
|
249
407
|
"module-nav",
|
|
250
408
|
"resource-list",
|
|
409
|
+
"splitpane",
|
|
251
410
|
"stack",
|
|
252
411
|
"tabs",
|
|
412
|
+
"text",
|
|
253
413
|
]);
|
|
254
414
|
});
|
|
255
415
|
|
|
@@ -274,3 +434,152 @@ describe("layout contract survives the roots the styling migration renames", ()
|
|
|
274
434
|
expect(screen.queryByTestId("refused")).toBeNull();
|
|
275
435
|
});
|
|
276
436
|
});
|
|
437
|
+
|
|
438
|
+
describe("what 4b's widening does and does not admit", () => {
|
|
439
|
+
it("accepts a Grid in a detail body, which is the two-column form case", async () => {
|
|
440
|
+
// The diagnosis's headline evidence was a fifteen-field form shipped as one vertical run.
|
|
441
|
+
// Shipping `Grid` without this widening would have shipped a component no governed page
|
|
442
|
+
// could use — and only a copier-generated project has the contract switched on, so the
|
|
443
|
+
// example app could not have detected that either.
|
|
444
|
+
underContract(
|
|
445
|
+
<DetailPage title="Record" parents={[{ label: "Records", to: "/records" }]}>
|
|
446
|
+
<Grid columns={2}>
|
|
447
|
+
<span>one</span>
|
|
448
|
+
<span>two</span>
|
|
449
|
+
</Grid>
|
|
450
|
+
</DetailPage>,
|
|
451
|
+
);
|
|
452
|
+
await expectAccepted();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("still refuses a Grid in an overview body, because a grid there is a hub", () => {
|
|
456
|
+
// The asymmetry IS the decision, so it is pinned rather than left to the table. An
|
|
457
|
+
// overview body is a data collection; a grid of summary cards is a hub, and the contract
|
|
458
|
+
// has a hub archetype for that. Widening both slots identically would have been the easy
|
|
459
|
+
// move and would have dissolved the distinction the three levels exist to carry.
|
|
460
|
+
underContract(
|
|
461
|
+
<OverviewPage title="Records">
|
|
462
|
+
<Grid columns={2}>
|
|
463
|
+
<span>one</span>
|
|
464
|
+
</Grid>
|
|
465
|
+
</OverviewPage>,
|
|
466
|
+
);
|
|
467
|
+
return waitFor(() => {
|
|
468
|
+
expect(screen.getByTestId("refused").textContent).toBe(
|
|
469
|
+
slotViolationMessage("standard", "OverviewPage", '<div data-terp="grid">'),
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("refuses a bare Field at the top of a form body, because that form cannot submit", () => {
|
|
475
|
+
// The one refusal in FormPage's slot that is a decision rather than an omission. A run of
|
|
476
|
+
// bare fields looks like a finished form and cannot be submitted: Enter does nothing
|
|
477
|
+
// without a <form>, and the house spelling of one is `Stack as="form"`. So the slot takes
|
|
478
|
+
// the container and not the fields — which is the opposite of what a reader would guess
|
|
479
|
+
// from "a form page's body is fields".
|
|
480
|
+
underContract(
|
|
481
|
+
<FormPage title="New sync" parents={[{ label: "Syncs", to: "/syncs" }]}>
|
|
482
|
+
<Field label="Name">
|
|
483
|
+
<Input />
|
|
484
|
+
</Field>
|
|
485
|
+
</FormPage>,
|
|
486
|
+
);
|
|
487
|
+
return waitFor(() => {
|
|
488
|
+
expect(screen.getByTestId("refused")).toHaveTextContent(
|
|
489
|
+
/the FormPage body slot accepts only Stack \/ Grid \/ Card/,
|
|
490
|
+
);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("accepts a form body that is a form container with sections beside it", () => {
|
|
495
|
+
underContract(
|
|
496
|
+
<FormPage title="New sync" parents={[{ label: "Syncs", to: "/syncs" }]}>
|
|
497
|
+
<Text>Every definition needs a source and a target.</Text>
|
|
498
|
+
<Stack as="form">
|
|
499
|
+
<Field label="Name">
|
|
500
|
+
<Input />
|
|
501
|
+
</Field>
|
|
502
|
+
</Stack>
|
|
503
|
+
</FormPage>,
|
|
504
|
+
);
|
|
505
|
+
return waitFor(() => {
|
|
506
|
+
expect(screen.queryByTestId("refused")).not.toBeInTheDocument();
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("refuses a collection in a settings body, because that screen is an overview", () => {
|
|
511
|
+
// SettingsPage's slot is Card sections and nothing that holds a collection. A settings
|
|
512
|
+
// screen whose body is a table is an overview with the wrong chrome, and the contract is
|
|
513
|
+
// where that distinction is enforceable rather than advisory.
|
|
514
|
+
underContract(
|
|
515
|
+
<SettingsPage title="Preferences">
|
|
516
|
+
<DetailList items={[{ label: "Theme", value: "Dark" }]} />
|
|
517
|
+
</SettingsPage>,
|
|
518
|
+
);
|
|
519
|
+
return waitFor(() => {
|
|
520
|
+
expect(screen.getByTestId("refused")).toHaveTextContent(
|
|
521
|
+
/the SettingsPage body slot accepts only Card \/ Stack/,
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("refuses anything but a SplitPane inside a split, and accepts the two panes", async () => {
|
|
527
|
+
// The split governs its PANE ROW rather than the page body, which is HubPage's shape and
|
|
528
|
+
// not DetailPage's — so this is the assertion that the choice actually took effect. A
|
|
529
|
+
// Card dropped straight into the split is the realistic mistake.
|
|
530
|
+
underContract(
|
|
531
|
+
<SplitPage title="Syncs">
|
|
532
|
+
<Card title="Not a pane" />
|
|
533
|
+
</SplitPage>,
|
|
534
|
+
);
|
|
535
|
+
await waitFor(() => {
|
|
536
|
+
expect(screen.getByTestId("refused")).toHaveTextContent(
|
|
537
|
+
/the SplitPage body slot accepts only SplitPane/,
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
cleanup();
|
|
541
|
+
underContract(
|
|
542
|
+
<SplitPage title="Syncs">
|
|
543
|
+
<SplitPane role="list" label="Definitions">
|
|
544
|
+
<DetailList items={[{ label: "Customer master", value: "ok" }]} />
|
|
545
|
+
</SplitPane>
|
|
546
|
+
<SplitPane role="detail" label="Selected definition">
|
|
547
|
+
<Card title="Customer master" />
|
|
548
|
+
</SplitPane>
|
|
549
|
+
</SplitPage>,
|
|
550
|
+
);
|
|
551
|
+
await waitFor(() => {
|
|
552
|
+
expect(screen.queryByTestId("refused")).not.toBeInTheDocument();
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("accepts a Divider and a lead paragraph in both governed bodies", async () => {
|
|
557
|
+
// A rule between sections and a paragraph above them — the two things a body wanted that
|
|
558
|
+
// needed no container. `Heading` is deliberately NOT admitted: a heading in a governed body
|
|
559
|
+
// must own its section, and `Card` (plain or boxed) is how a section is owned. A bare
|
|
560
|
+
// heading with siblings after it is a grouping the contract cannot see.
|
|
561
|
+
for (const page of [
|
|
562
|
+
<OverviewPage key="o" title="Records">
|
|
563
|
+
<Text>Lead paragraph.</Text>
|
|
564
|
+
<Divider />
|
|
565
|
+
<EmptyState title="No records yet" />
|
|
566
|
+
</OverviewPage>,
|
|
567
|
+
<DetailPage key="d" title="Record" parents={[{ label: "Records", to: "/records" }]}>
|
|
568
|
+
<Text>Lead paragraph.</Text>
|
|
569
|
+
<Divider />
|
|
570
|
+
<DetailList items={[{ label: "Owner", value: "Ada" }]} />
|
|
571
|
+
</DetailPage>,
|
|
572
|
+
]) {
|
|
573
|
+
cleanup();
|
|
574
|
+
underContract(page);
|
|
575
|
+
// Per iteration, not once after the loop. Two separate faults met here and only the
|
|
576
|
+
// second one mattered. `Page` schedules its check as a setTimeout(0) and clears it on
|
|
577
|
+
// unmount while RTL's `cleanup` unmounts synchronously, so a single trailing assertion
|
|
578
|
+
// cancelled the OverviewPage timer and only ever reached the DetailPage. But moving the
|
|
579
|
+
// assertion inside the loop changed nothing on its own, because the assertion itself
|
|
580
|
+
// could not fail — see `expectAccepted`. Verified together: an illegal Grid in the
|
|
581
|
+
// OverviewPage body above is green under either old form and red under this one.
|
|
582
|
+
await expectAccepted();
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
});
|
package/src/layoutContract.ts
CHANGED
|
@@ -36,9 +36,15 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
|
|
|
36
36
|
description:
|
|
37
37
|
"The standard three-level shape: hub bodies are card grids (HubCard only), " +
|
|
38
38
|
"overview bodies are data collections (DataView / ResourceList + framework " +
|
|
39
|
-
"states), detail bodies are record sections (DetailList / Stack / Tabs
|
|
40
|
-
"framework states); Card is allowed in overview and detail bodies as the " +
|
|
41
|
-
"sanctioned visual separation between sections
|
|
39
|
+
"states), detail bodies are record sections (DetailList / Stack / Grid / Tabs " +
|
|
40
|
+
"+ framework states); Card is allowed in overview and detail bodies as the " +
|
|
41
|
+
"sanctioned visual separation between sections, and Divider / Text as a rule " +
|
|
42
|
+
"between sections and a lead paragraph above them. Three specialised shapes sit " +
|
|
43
|
+
"beside those: a form body is a container (Stack) with optional Grid / Card " +
|
|
44
|
+
"sections and no Field at the top level, so the body is always a container rather " +
|
|
45
|
+
"than a loose run of controls; a settings body is Card sections and holds no " +
|
|
46
|
+
"collection; and a split " +
|
|
47
|
+
"body is two SplitPanes and nothing else. A bespoke screen composes " +
|
|
42
48
|
"the plain Page, which the contract deliberately leaves unconstrained.",
|
|
43
49
|
slots: {
|
|
44
50
|
HubPage: {
|
|
@@ -51,6 +57,8 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
|
|
|
51
57
|
ModuleNav: "module-nav",
|
|
52
58
|
Stack: "stack",
|
|
53
59
|
Card: "card",
|
|
60
|
+
Divider: "divider",
|
|
61
|
+
Text: "text",
|
|
54
62
|
EmptyState: "empty-state",
|
|
55
63
|
ErrorState: "error-state",
|
|
56
64
|
LoadingState: "loading-state",
|
|
@@ -58,6 +66,36 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
|
|
|
58
66
|
ConfirmDialog: "dialog",
|
|
59
67
|
},
|
|
60
68
|
},
|
|
69
|
+
FormPage: {
|
|
70
|
+
components: {
|
|
71
|
+
Stack: "stack",
|
|
72
|
+
Grid: "grid",
|
|
73
|
+
Card: "card",
|
|
74
|
+
Divider: "divider",
|
|
75
|
+
Text: "text",
|
|
76
|
+
EmptyState: "empty-state",
|
|
77
|
+
ErrorState: "error-state",
|
|
78
|
+
LoadingState: "loading-state",
|
|
79
|
+
Alert: "alert",
|
|
80
|
+
ConfirmDialog: "dialog",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
SettingsPage: {
|
|
84
|
+
components: {
|
|
85
|
+
Card: "card",
|
|
86
|
+
Stack: "stack",
|
|
87
|
+
Divider: "divider",
|
|
88
|
+
Text: "text",
|
|
89
|
+
EmptyState: "empty-state",
|
|
90
|
+
ErrorState: "error-state",
|
|
91
|
+
LoadingState: "loading-state",
|
|
92
|
+
Alert: "alert",
|
|
93
|
+
ConfirmDialog: "dialog",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
SplitPage: {
|
|
97
|
+
components: { SplitPane: "splitpane" },
|
|
98
|
+
},
|
|
61
99
|
DetailPage: {
|
|
62
100
|
components: {
|
|
63
101
|
DetailList: "detail-list",
|
|
@@ -66,6 +104,9 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
|
|
|
66
104
|
ModuleNav: "module-nav",
|
|
67
105
|
DataView: "dataview",
|
|
68
106
|
Card: "card",
|
|
107
|
+
Grid: "grid",
|
|
108
|
+
Divider: "divider",
|
|
109
|
+
Text: "text",
|
|
69
110
|
EmptyState: "empty-state",
|
|
70
111
|
ErrorState: "error-state",
|
|
71
112
|
LoadingState: "loading-state",
|