@terpjs/react-core 0.9.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 +56 -20
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +17 -4
- 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 +3 -0
- package/src/markers.test.ts +25 -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 +202 -19
- package/src/styles.test.ts +483 -24
- package/src/styles.ts +956 -85
- 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 +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 +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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A channel into a running app, for the tool that is showing it.
|
|
3
|
+
*
|
|
4
|
+
* A development tool that embeds a running app in an iframe cannot see into it — a cross-origin
|
|
5
|
+
* frame is opaque by design, and deliberately so. Which leaves someone looking at a button they
|
|
6
|
+
* want changed with no way to say WHICH button, except in prose.
|
|
7
|
+
*
|
|
8
|
+
* This package does not know or name who is asking. It answers a protocol, not a product.
|
|
9
|
+
*
|
|
10
|
+
* The hook this uses already exists: every sanctioned component stamps `data-terp` on its root,
|
|
11
|
+
* and the marker inventory is a pinned, gated list. So "which component is this?" is a question
|
|
12
|
+
* the DOM can already answer; what was missing is someone to ask it.
|
|
13
|
+
*
|
|
14
|
+
* FOUR DECISIONS, and each is the reason a bridge like this is usually a bad idea.
|
|
15
|
+
*
|
|
16
|
+
* **It exists only in a development build.** The whole module is behind `import.meta.env.DEV`,
|
|
17
|
+
* which a production build folds to `false` and strips — the same mechanism the template uses for
|
|
18
|
+
* its dev sign-in credentials, and for the same reason. A deployed app has no listener, so none of
|
|
19
|
+
* what follows is a production attack surface. It is not a setting that can be left on.
|
|
20
|
+
*
|
|
21
|
+
* **The tool speaks first, and the FIRST one wins.** The app never volunteers anything: it
|
|
22
|
+
* records the origin and the window of the first well-formed handshake and answers that one
|
|
23
|
+
* alone, thereafter — a second party cannot take the conversation over, and cannot start one.
|
|
24
|
+
* First-come rather than validated, because there is nothing to validate against: a tool
|
|
25
|
+
* embedding an app has no way to configure this package, and the two are on different origins by
|
|
26
|
+
* design (an embedded preview sharing its host would carry that host's session cookie into app
|
|
27
|
+
* code). An opaque origin is refused, since `"null"` is what a sandboxed or `file://` document
|
|
28
|
+
* reports and it matches every other one.
|
|
29
|
+
*
|
|
30
|
+
* The window is kept as well as the origin. `window.parent` is the frame's embedder only when
|
|
31
|
+
* there IS an embedder — open the app in a tab and `window.parent` is the app itself, so a reply
|
|
32
|
+
* addressed there talks to nobody while looking like it worked.
|
|
33
|
+
*
|
|
34
|
+
* **Three things leave, and they are named exhaustively.** A reply carries `data-terp` markers,
|
|
35
|
+
* element tag names, and the route path the page is on. Never text, never values, never
|
|
36
|
+
* attributes — nothing an app renders data into. An app under development is an app with real
|
|
37
|
+
* data in it, and a channel that could read the page would be a way out for that data, dev-only
|
|
38
|
+
* or not.
|
|
39
|
+
*
|
|
40
|
+
* The route path is the one of the three that is not purely structural, and it is listed rather
|
|
41
|
+
* than glossed: `/records/42` carries an identifier. It is sent because "which component" is not
|
|
42
|
+
* a useful answer without "on which screen", it is the same string already visible in the address
|
|
43
|
+
* bar of the frame the asker is displaying, and it is bounded below. An earlier version of this
|
|
44
|
+
* docstring said "structure, never content" and sent it anyway, which is the kind of sentence
|
|
45
|
+
* this file exists to not write.
|
|
46
|
+
*
|
|
47
|
+
* A bounding rectangle used to leave too. Nothing consumed it, so it does not.
|
|
48
|
+
*
|
|
49
|
+
* **The app draws its own highlight.** Nothing outside a cross-origin iframe can paint over it,
|
|
50
|
+
* so pointing at something has to happen on this side. One outline, from the token layer, removed
|
|
51
|
+
* with the mode.
|
|
52
|
+
*
|
|
53
|
+
* ADR 0006's quadruple is not fully satisfied and the missing halves are named in ADR 0101 §5:
|
|
54
|
+
* there is a typed protocol with a safe default (off, and absent in production) and a fail-closed
|
|
55
|
+
* runtime check (every message is validated and a stranger is ignored), but no build-time rule and
|
|
56
|
+
* no escape hatch — a lint rule would have nothing to check, since the module is not something an
|
|
57
|
+
* app writes, and there is nothing to opt out of in a build where it does not exist.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The protocol name, carried on every message in both directions.
|
|
62
|
+
*
|
|
63
|
+
* Versioned in the name rather than in a field: a tool and an app can be from different releases,
|
|
64
|
+
* and a version mismatch has to be silence rather than a half-understood conversation. An app that
|
|
65
|
+
* does not recognise the name ignores the message, which is exactly what an older app does with a
|
|
66
|
+
* newer tool and the reverse.
|
|
67
|
+
*/
|
|
68
|
+
export const PREVIEW_BRIDGE_PROTOCOL = "terp.preview.1";
|
|
69
|
+
|
|
70
|
+
/** One step of the chain from the clicked element up to the page root. */
|
|
71
|
+
export interface PreviewBridgeStep {
|
|
72
|
+
/** The sanctioned component's marker, e.g. "button" or "dataview". */
|
|
73
|
+
readonly marker: string;
|
|
74
|
+
/** The element it was stamped on, lowercased. */
|
|
75
|
+
readonly tag: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What the app answers with when something is picked. */
|
|
79
|
+
export interface PreviewBridgeSelection {
|
|
80
|
+
/** Innermost first: the marker chain from what was clicked up to the page. */
|
|
81
|
+
readonly path: readonly PreviewBridgeStep[];
|
|
82
|
+
/** The route the preview is on, so a tool can say where the component was found. */
|
|
83
|
+
readonly path_name: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The shape a marker may have: the shape every name in the pinned inventory has.
|
|
88
|
+
*
|
|
89
|
+
* Checked rather than trusted, even though this code runs inside the app it is describing.
|
|
90
|
+
* `getAttribute("data-terp")` returns whatever is on the element, and an app under development
|
|
91
|
+
* is allowed to put anything anywhere — so an unbounded string here is an app writing arbitrary
|
|
92
|
+
* text into whatever the asking tool does with it. Bounded in length for the same reason.
|
|
93
|
+
*/
|
|
94
|
+
const MARKER_RE = /^[a-z0-9-]{1,64}$/;
|
|
95
|
+
|
|
96
|
+
/** A tag name, bounded the same way. Custom elements are hyphenated ASCII. */
|
|
97
|
+
const TAG_RE = /^[a-z0-9-]{1,64}$/;
|
|
98
|
+
|
|
99
|
+
/** A route path, bounded. Long enough for any real route, short enough not to be a payload. */
|
|
100
|
+
const MAX_PATH_NAME = 512;
|
|
101
|
+
|
|
102
|
+
/** The attribute the highlight is styled from — an attribute, like every other marker here. */
|
|
103
|
+
const HIGHLIGHT_ATTRIBUTE = "data-terp-preview-pick";
|
|
104
|
+
|
|
105
|
+
const STYLE_ID = "terp-preview-bridge";
|
|
106
|
+
|
|
107
|
+
const HIGHLIGHT_CSS = `
|
|
108
|
+
[${HIGHLIGHT_ATTRIBUTE}] {
|
|
109
|
+
outline: 2px solid var(--color-brand-primary, #2563eb);
|
|
110
|
+
outline-offset: 1px;
|
|
111
|
+
cursor: crosshair;
|
|
112
|
+
}
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
interface Incoming {
|
|
116
|
+
protocol?: unknown;
|
|
117
|
+
kind?: unknown;
|
|
118
|
+
on?: unknown;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isIncoming(data: unknown): data is Incoming & { protocol: string; kind: string } {
|
|
122
|
+
return (
|
|
123
|
+
typeof data === "object" &&
|
|
124
|
+
data !== null &&
|
|
125
|
+
(data as Incoming).protocol === PREVIEW_BRIDGE_PROTOCOL &&
|
|
126
|
+
typeof (data as Incoming).kind === "string"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The chain of sanctioned components from *element* up to the document.
|
|
132
|
+
*
|
|
133
|
+
* A step whose marker or tag is not the shape a marker has is DROPPED rather than sent. The
|
|
134
|
+
* attribute is whatever is on the element, and this code cannot tell a component's marker from
|
|
135
|
+
* a string an app put there — so the reply carries only what looks like the closed vocabulary the
|
|
136
|
+
* asker is expecting, and never becomes a way to write arbitrary text into that asker's tools.
|
|
137
|
+
*/
|
|
138
|
+
function markerPath(element: Element | null): PreviewBridgeStep[] {
|
|
139
|
+
const path: PreviewBridgeStep[] = [];
|
|
140
|
+
for (let node: Element | null = element; node !== null; node = node.parentElement) {
|
|
141
|
+
const marker = node.getAttribute("data-terp");
|
|
142
|
+
const tag = node.tagName.toLowerCase();
|
|
143
|
+
if (marker !== null && MARKER_RE.test(marker) && TAG_RE.test(tag)) {
|
|
144
|
+
path.push({ marker, tag });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return path;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The nearest ancestor that a sanctioned component stamped, including *element* itself. */
|
|
151
|
+
function nearestMarked(element: Element | null): Element | null {
|
|
152
|
+
for (let node: Element | null = element; node !== null; node = node.parentElement) {
|
|
153
|
+
if (node.getAttribute("data-terp")) return node;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Listen for a tool asking about this app, and answer it.
|
|
160
|
+
*
|
|
161
|
+
* Returns a function that removes every listener, the style element and the highlight — so a test
|
|
162
|
+
* can run this twice without leaking, and so a caller that ever wants to stop can.
|
|
163
|
+
*
|
|
164
|
+
* Safe to call with no `window` (server rendering): it does nothing and returns a no-op.
|
|
165
|
+
*/
|
|
166
|
+
export function installPreviewBridge(): () => void {
|
|
167
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
168
|
+
return () => {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Who said hello: the origin to address a reply to, and the window to send it to.
|
|
173
|
+
*
|
|
174
|
+
* Both, because they answer different questions. The origin is what stops another site
|
|
175
|
+
* reading a reply; the window is what makes the reply arrive at all — `window.parent` is the
|
|
176
|
+
* embedder only when the app IS embedded, and is the app itself when it is open in a tab.
|
|
177
|
+
*/
|
|
178
|
+
let asker: { origin: string; window: MessageEventSource } | null = null;
|
|
179
|
+
/**
|
|
180
|
+
* Who turned select mode on, or `null` when it is off.
|
|
181
|
+
*
|
|
182
|
+
* The asker rather than a boolean, and that is what removes the last unreachable branch from
|
|
183
|
+
* this module: "select mode is on" and "there is someone to answer" are the same fact, so a
|
|
184
|
+
* click handler holding this holds a destination and `reply` has no null case to guard. Written
|
|
185
|
+
* as a boolean first, which left a `if (asker === null) return` in `reply` that no test could
|
|
186
|
+
* reach — dead defensive code in the one module where dead code is least welcome.
|
|
187
|
+
*/
|
|
188
|
+
let selecting: { origin: string; window: MessageEventSource } | null = null;
|
|
189
|
+
let highlighted: Element | null = null;
|
|
190
|
+
|
|
191
|
+
const reply = (to: { origin: string; window: MessageEventSource }, message: object) => {
|
|
192
|
+
(to.window as Window).postMessage(
|
|
193
|
+
{ protocol: PREVIEW_BRIDGE_PROTOCOL, ...message },
|
|
194
|
+
to.origin,
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const clearHighlight = () => {
|
|
199
|
+
highlighted?.removeAttribute(HIGHLIGHT_ATTRIBUTE);
|
|
200
|
+
highlighted = null;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const onPointerOver = (event: Event) => {
|
|
204
|
+
if (selecting === null) return;
|
|
205
|
+
const target = nearestMarked(event.target as Element | null);
|
|
206
|
+
if (target === highlighted) return;
|
|
207
|
+
clearHighlight();
|
|
208
|
+
if (target !== null) {
|
|
209
|
+
target.setAttribute(HIGHLIGHT_ATTRIBUTE, "");
|
|
210
|
+
highlighted = target;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const onClick = (event: MouseEvent) => {
|
|
215
|
+
const to = selecting;
|
|
216
|
+
if (to === null) return;
|
|
217
|
+
// Captured and cancelled: in select mode a click picks a component rather than doing what it
|
|
218
|
+
// normally does. Letting it through would navigate away from the thing being pointed at.
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
event.stopPropagation();
|
|
221
|
+
const target = nearestMarked(event.target as Element | null);
|
|
222
|
+
if (target === null) return;
|
|
223
|
+
reply(to, {
|
|
224
|
+
kind: "selected",
|
|
225
|
+
selection: {
|
|
226
|
+
path: markerPath(target),
|
|
227
|
+
path_name: window.location.pathname.slice(0, MAX_PATH_NAME),
|
|
228
|
+
} satisfies PreviewBridgeSelection,
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const setSelecting = (to: { origin: string; window: MessageEventSource } | null) => {
|
|
233
|
+
selecting = to;
|
|
234
|
+
if (to === null) clearHighlight();
|
|
235
|
+
if (to !== null && document.getElementById(STYLE_ID) === null) {
|
|
236
|
+
const style = document.createElement("style");
|
|
237
|
+
style.id = STYLE_ID;
|
|
238
|
+
// textContent, never innerHTML: no HTML-injection sink is touched, which is the same rule
|
|
239
|
+
// the component stylesheet's own injector follows.
|
|
240
|
+
style.textContent = HIGHLIGHT_CSS;
|
|
241
|
+
document.head.appendChild(style);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const onMessage = (event: MessageEvent) => {
|
|
246
|
+
if (!isIncoming(event.data)) return;
|
|
247
|
+
if (asker === null && event.data.kind === "hello") {
|
|
248
|
+
// `"null"` is what a sandboxed iframe, a `file://` page and a data: URL all report, so it
|
|
249
|
+
// identifies nobody and matches everybody — adopting it would make the next such document
|
|
250
|
+
// the asker too. A source is required for the same reason: without one there is no window
|
|
251
|
+
// to answer.
|
|
252
|
+
if (event.origin === "null" || event.origin === "" || event.source === null) return;
|
|
253
|
+
asker = { origin: event.origin, window: event.source };
|
|
254
|
+
}
|
|
255
|
+
if (asker === null || event.origin !== asker.origin || event.source !== asker.window) return;
|
|
256
|
+
if (event.data.kind === "hello") {
|
|
257
|
+
reply(asker, { kind: "ready" });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (event.data.kind === "select") {
|
|
261
|
+
setSelecting(event.data.on === true ? asker : null);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
window.addEventListener("message", onMessage);
|
|
267
|
+
document.addEventListener("pointerover", onPointerOver, true);
|
|
268
|
+
document.addEventListener("click", onClick, true);
|
|
269
|
+
|
|
270
|
+
return () => {
|
|
271
|
+
window.removeEventListener("message", onMessage);
|
|
272
|
+
document.removeEventListener("pointerover", onPointerOver, true);
|
|
273
|
+
document.removeEventListener("click", onClick, true);
|
|
274
|
+
selecting = null;
|
|
275
|
+
clearHighlight();
|
|
276
|
+
document.getElementById(STYLE_ID)?.remove();
|
|
277
|
+
};
|
|
278
|
+
}
|
package/src/raw.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Minimal ambient declarations for the
|
|
3
|
-
* `"types": []` so component source never sees ambient Node globals.
|
|
2
|
+
* Minimal ambient declarations for the two things this package reads off `import.meta` — the
|
|
3
|
+
* package keeps `"types": []` so component source never sees ambient Node globals.
|
|
4
|
+
*
|
|
5
|
+
* Safe to declare here even though every consuming app already has `vite/client`: nothing
|
|
6
|
+
* imports this file, so it is part of THIS package's program and not of a consumer's, and the
|
|
7
|
+
* two `ImportMeta` augmentations never meet.
|
|
4
8
|
*
|
|
5
9
|
* Both live here rather than inline in the tests that use them: a global augmentation
|
|
6
10
|
* repeated in two files is a TS2717 the moment the copies disagree, and they disagree
|
|
@@ -18,4 +22,12 @@ interface ImportMeta {
|
|
|
18
22
|
pattern: string,
|
|
19
23
|
options: { query: "?raw"; import: "default"; eager: true },
|
|
20
24
|
) => Record<string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* The build-mode flag, `false` in a production build.
|
|
27
|
+
*
|
|
28
|
+
* Written out verbatim at its one call site rather than read through a helper or a cast: a
|
|
29
|
+
* bundler folds the expression `import.meta.env.DEV` TEXTUALLY, so anything else leaves the
|
|
30
|
+
* branch — and the module behind it — in a production bundle.
|
|
31
|
+
*/
|
|
32
|
+
readonly env: { readonly DEV: boolean };
|
|
21
33
|
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { Checkbox } from "./ui/Checkbox";
|
|
6
|
+
import { Radio } from "./ui/Radio";
|
|
7
|
+
import { Switch } from "./ui/Switch";
|
|
8
|
+
import { Popover } from "./ui/Popover";
|
|
9
|
+
import { Tabs } from "./ui/Tabs";
|
|
10
|
+
import { TERP_STYLES_CSS } from "./styles";
|
|
11
|
+
|
|
12
|
+
// Defects the phases 1-4 review found, gated.
|
|
13
|
+
//
|
|
14
|
+
// Every one of these shipped past four browser lanes and a full unit suite, so an assertion that
|
|
15
|
+
// merely restates the fix would be worth nothing. Each row names the mutation that turns it red
|
|
16
|
+
// and was checked against it.
|
|
17
|
+
//
|
|
18
|
+
// Several read SOURCE TEXT or the stylesheet rather than behaviour, which is unusual here and
|
|
19
|
+
// deliberate rather than lazy. Each of those is a fact about something this suite cannot execute:
|
|
20
|
+
// what a DIFFERENT React major serializes, what an entry point no test mounts forwards, or which
|
|
21
|
+
// rule wins a cascade jsdom does not compute. A behavioural assertion would pass under the bug in
|
|
22
|
+
// every one of those cases. Where behaviour CAN see it — the frozen controls, the tabpanel's
|
|
23
|
+
// name, the popover's focus return — the test renders and asserts on the result instead.
|
|
24
|
+
//
|
|
25
|
+
// No count here on purpose: an earlier version of this comment said "two of the four" and was
|
|
26
|
+
// wrong by the time the file had ten. A citation keeps; a tally rots.
|
|
27
|
+
//
|
|
28
|
+
// One trap has now appeared twice in gates written for this very review, so it is worth stating
|
|
29
|
+
// where the next reader will meet it: a `toContain` on a CSS selector is satisfied by any LONGER
|
|
30
|
+
// selector containing it. `:root[data-density="x"]` contains `[data-density="x"]`, and the pager's
|
|
31
|
+
// own disabled rule contains the shared one. Both mutations came back GREEN until the assertions
|
|
32
|
+
// were anchored to the start of a line with a regex.
|
|
33
|
+
|
|
34
|
+
const sources = import.meta.glob("./**/*.tsx", {
|
|
35
|
+
query: "?raw",
|
|
36
|
+
import: "default",
|
|
37
|
+
eager: true,
|
|
38
|
+
}) as Record<string, string>;
|
|
39
|
+
|
|
40
|
+
/** The package manifest as text, so the peer range can be asserted without importing it. */
|
|
41
|
+
const packageJson = import.meta.glob("../package.json", {
|
|
42
|
+
query: "?raw",
|
|
43
|
+
import: "default",
|
|
44
|
+
eager: true,
|
|
45
|
+
}) as Record<string, string>;
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
cleanup();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("defects the phases 1-4 review found", () => {
|
|
52
|
+
it("warns when a checked control is given no onChange, instead of freezing silently", () => {
|
|
53
|
+
// React's own guard is the only thing that would tell an author they pinned `checked` and
|
|
54
|
+
// forgot the handler. Passing `onChange` unconditionally suppresses it, so the control looked
|
|
55
|
+
// operable, never changed, and said nothing — the exact failure `Select` documents at its own
|
|
56
|
+
// call site (ui/Select.tsx) and avoids. Mutation: restore the unconditional handler in any of
|
|
57
|
+
// the three and the count drops.
|
|
58
|
+
const seen: string[] = [];
|
|
59
|
+
const spy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
60
|
+
seen.push(args.map(String).join(" "));
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
render(
|
|
64
|
+
<>
|
|
65
|
+
<Checkbox label="frozen box" checked />
|
|
66
|
+
<Switch label="frozen switch" checked />
|
|
67
|
+
<Radio name="group" value="a" label="frozen radio" checked />
|
|
68
|
+
</>,
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
spy.mockRestore();
|
|
72
|
+
}
|
|
73
|
+
// React de-duplicates this warning per component type, so three distinct components warn
|
|
74
|
+
// three times. Matched loosely because the exact wording differs across React majors.
|
|
75
|
+
const complaints = seen.filter((line) => /without an .?onChange.? handler/.test(line));
|
|
76
|
+
expect(complaints).toHaveLength(3);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("does not repaint an invalid field's border on hover", () => {
|
|
80
|
+
// Both rules live in terp.state, so the cascade is decided by specificity alone: the hover
|
|
81
|
+
// selector weighed (0,4,0) against the danger border's (0,2,0) and won. Pointing at a field
|
|
82
|
+
// that had just failed validation removed its error border for as long as the pointer rested
|
|
83
|
+
// there — and the pointer rests there precisely because the user is about to fix it.
|
|
84
|
+
// Mutation: drop the :not([aria-invalid="true"]).
|
|
85
|
+
expect(TERP_STYLES_CSS).toContain(
|
|
86
|
+
'[data-terp="input"]:hover:not(:disabled):not(:focus):not([aria-invalid="true"])',
|
|
87
|
+
);
|
|
88
|
+
// And the unnarrowed aggressor must not exist anywhere, in any form.
|
|
89
|
+
expect(TERP_STYLES_CSS).not.toContain(
|
|
90
|
+
'[data-terp="input"]:hover:not(:disabled):not(:focus) {',
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does not promise a React major on which the drawer's containment does not exist", () => {
|
|
95
|
+
// The drawer inerts the page column and marks it aria-hidden. Measured with
|
|
96
|
+
// renderToStaticMarkup against both renderers:
|
|
97
|
+
//
|
|
98
|
+
// spelling React 18.3.1 React 19.2.8
|
|
99
|
+
// inert={true} DROPPED (warns) inert=""
|
|
100
|
+
// inert="" inert="" DROPPED (warns: treated as false)
|
|
101
|
+
//
|
|
102
|
+
// So under the old `^18.3.0 || ^19.0.0` peer range, an app on 18.3 got the worst half of
|
|
103
|
+
// the pair — a subtree announced as hidden to assistive technology with every control in it
|
|
104
|
+
// still focusable and clickable — and no spelling is correct and quiet on both. The review
|
|
105
|
+
// that found this proposed `inert=""`, which would have broken React 19 instead; the
|
|
106
|
+
// measurement is the only reason that did not ship. The defect is the promise, so the fix
|
|
107
|
+
// is the range. Mutation: widen the peer range back to include ^18.3.0.
|
|
108
|
+
const manifest = JSON.parse(
|
|
109
|
+
(packageJson as Record<string, string>)["../package.json"] ?? "{}",
|
|
110
|
+
) as { peerDependencies?: Record<string, string> };
|
|
111
|
+
const peers = manifest.peerDependencies ?? {};
|
|
112
|
+
for (const name of ["react", "react-dom"] as const) {
|
|
113
|
+
expect(peers[name], `${name} peer range`).toBeDefined();
|
|
114
|
+
expect(peers[name]).not.toMatch(/18\./);
|
|
115
|
+
}
|
|
116
|
+
// And the attribute stays the spelling React 19 actually renders.
|
|
117
|
+
const shell = sources["./AppShell.tsx"] ?? "";
|
|
118
|
+
expect(shell, "AppShell.tsx is not in the scanned sources").not.toBe("");
|
|
119
|
+
expect(shell).toContain("inert={isMobile && drawerOpen ? true : undefined}");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("gives a tabpanel an accessible name even when the tab value contains whitespace", () => {
|
|
123
|
+
// `Tabs` built element ids by interpolating the caller's `value`. A value is caller data and
|
|
124
|
+
// an id is an IDREF, so a value with a space turned `aria-labelledby` into a list of two
|
|
125
|
+
// tokens, neither of which resolves — and the tabpanel silently lost its name. Nothing
|
|
126
|
+
// reported it: axe sees a well-formed reference to nothing, and the visible tab still reads
|
|
127
|
+
// correctly. Ids key on the tab's index now.
|
|
128
|
+
// Mutation: interpolate `tab.value` / `selectedTab.value` back into the two ids.
|
|
129
|
+
render(
|
|
130
|
+
<Tabs
|
|
131
|
+
label="Sections"
|
|
132
|
+
tabs={[
|
|
133
|
+
{ value: "my tab", label: "My tab", content: <p>first</p> },
|
|
134
|
+
{ value: "other one", label: "Other one", content: <p>second</p> },
|
|
135
|
+
]}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
expect(screen.getByRole("tabpanel", { name: "My tab" })).toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("wins the density tie against the contract's own :root declarations", () => {
|
|
142
|
+
// The shell's density attribute sets the same custom properties the contract declares on
|
|
143
|
+
// :root, both unlayered. A bare [data-density="..."] on <html> weighs (0,1,0) — exactly what
|
|
144
|
+
// :root weighs — so the two tied and only the order the sheets happened to load decided it.
|
|
145
|
+
// A production build extracts tokens.css to a <link> that precedes the injected sheet, so
|
|
146
|
+
// react-core won by construction and the exposure was the dev server and any host loading
|
|
147
|
+
// the tokens late. The :root-qualified copy is (0,2,0) and wins outright.
|
|
148
|
+
// Mutation: drop either :root-qualified selector.
|
|
149
|
+
for (const density of ["comfortable", "compact"] as const) {
|
|
150
|
+
expect(TERP_STYLES_CSS).toContain(`:root[data-density="${density}"]`);
|
|
151
|
+
// The unqualified copy stays, for a density island on a subtree where there is no :root.
|
|
152
|
+
// Anchored to the start of a line, because `:root[data-density="x"]` CONTAINS
|
|
153
|
+
// `[data-density="x"]` — a bare toContain here would be satisfied by the qualified copy
|
|
154
|
+
// alone and could never notice the unqualified one being deleted.
|
|
155
|
+
expect(TERP_STYLES_CSS).toMatch(
|
|
156
|
+
new RegExp(`^\\[data-density="${density}"\\]`, "m"),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("forwards logoDark from both sanctioned entry points", () => {
|
|
162
|
+
// The third slot to exist on the shell and be unreachable from the entry points every app
|
|
163
|
+
// uses, after `headerActions` — which ADR 0097's own Context complains about. This one was
|
|
164
|
+
// worse: the project template instructs every new app to pass `logoDark` to `renderTerpApp`
|
|
165
|
+
// (template/project/frontend/src/main.tsx.jinja), so the documented example did not
|
|
166
|
+
// typecheck. Mutation: delete either forwarding line.
|
|
167
|
+
//
|
|
168
|
+
// Matched as "the option appears in what the prop is given" rather than as one exact
|
|
169
|
+
// expression, because the exact expression changed the day `shell.brand` landed and the
|
|
170
|
+
// invariant did not: the slot now prefers a declared PATH and falls back to the option, and
|
|
171
|
+
// the old `toContain("logoDark={options.logoDark}")` failed on a router that still forwards
|
|
172
|
+
// it perfectly. An assertion on a spelling fails for the wrong reason, which is only one
|
|
173
|
+
// step better than passing for the wrong reason.
|
|
174
|
+
const router = sources["./router.tsx"] ?? "";
|
|
175
|
+
const bootstrap = sources["./bootstrap.tsx"] ?? "";
|
|
176
|
+
expect(router, "router.tsx is not in the scanned sources").not.toBe("");
|
|
177
|
+
expect(bootstrap, "bootstrap.tsx is not in the scanned sources").not.toBe("");
|
|
178
|
+
expect(router).toMatch(/logoDark=\{[^}]*options\.logoDark/);
|
|
179
|
+
// `logo=` alone would also match inside `logoDark=`; the negated class stops at the
|
|
180
|
+
// first `}`, so this reads the whole expression the slot is given.
|
|
181
|
+
expect(router).toMatch(/[^a-zA-Z]logo=\{[^}]*options\.logo}/);
|
|
182
|
+
expect(bootstrap).toContain("logoDark: options.logoDark,");
|
|
183
|
+
expect(bootstrap).toMatch(/logoDark\?: ReactNode;/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("returns focus to the trigger when Tab leaves a popover panel", () => {
|
|
187
|
+
// The panel is portalled to the END of document.body, so with no Tab branch the
|
|
188
|
+
// sequential-navigation starting point stayed inside a node at the wrong end of the
|
|
189
|
+
// document: Tab out landed past every piece of page content, and Shift+Tab landed on the
|
|
190
|
+
// last focusable element on the page rather than back on the control that opened it. `Menu`
|
|
191
|
+
// has implemented the APG contract for this all along and documents the exact failure;
|
|
192
|
+
// `Popover` carried the same portal with an Escape-only handler.
|
|
193
|
+
// Mutation: delete the Tab branch from the panel's onKeyDown.
|
|
194
|
+
render(
|
|
195
|
+
<Popover trigger={<button type="button">Open</button>}>
|
|
196
|
+
{() => (
|
|
197
|
+
<button type="button">Inside</button>
|
|
198
|
+
)}
|
|
199
|
+
</Popover>,
|
|
200
|
+
);
|
|
201
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
202
|
+
fireEvent.click(trigger);
|
|
203
|
+
const inside = screen.getByRole("button", { name: "Inside" });
|
|
204
|
+
inside.focus();
|
|
205
|
+
expect(document.activeElement).toBe(inside);
|
|
206
|
+
fireEvent.keyDown(inside, { key: "Tab" });
|
|
207
|
+
// Closed, and focus is back on the trigger — from which the browser's own Tab default
|
|
208
|
+
// moves on to whatever genuinely follows it in the document.
|
|
209
|
+
expect(screen.queryByRole("button", { name: "Inside" })).not.toBeInTheDocument();
|
|
210
|
+
expect(document.activeElement).toBe(trigger);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps a pager button focusable when its own click disables it", () => {
|
|
214
|
+
// Each of the pager's four buttons has a bound condition recomputed from what its own click
|
|
215
|
+
// just changed, so pressing "next" until the last page disabled the control the user was
|
|
216
|
+
// operating — and a disabled element cannot hold focus, so the browser dropped it to
|
|
217
|
+
// <body>. A keyboard user paging to the end lost their place at the moment they arrived.
|
|
218
|
+
// Mutation: change aria-disabled back to disabled on the next/last pair.
|
|
219
|
+
const pagination = sources["./dataview/DataViewPagination.tsx"] ?? "";
|
|
220
|
+
expect(pagination, "DataViewPagination.tsx is not in the scanned sources").not.toBe("");
|
|
221
|
+
expect(pagination, "the native disabled attribute drops focus to <body>").not.toContain(
|
|
222
|
+
"disabled={atFirst}",
|
|
223
|
+
);
|
|
224
|
+
expect(pagination).not.toContain("disabled={atLast}");
|
|
225
|
+
expect(pagination).toContain("aria-disabled={atLast || undefined}");
|
|
226
|
+
expect(pagination).toContain("aria-disabled={atFirst || undefined}");
|
|
227
|
+
// And the sheet must paint the announced state exactly as it painted the native one, or the
|
|
228
|
+
// fix trades a focus bug for a visual one.
|
|
229
|
+
// Both anchored to the start of a line. The pager rule CONTAINS the shared selector as a
|
|
230
|
+
// substring, so a bare toContain on the shared one is satisfied by the pager rule alone
|
|
231
|
+
// and cannot notice it being deleted — which is exactly what the mutation showed.
|
|
232
|
+
expect(TERP_STYLES_CSS).toMatch(
|
|
233
|
+
/^\[data-terp="iconbutton"\]\[aria-disabled="true"\]/m,
|
|
234
|
+
);
|
|
235
|
+
expect(TERP_STYLES_CSS).toMatch(
|
|
236
|
+
/^\[data-terp="dataview-pager"\] > \[data-terp="iconbutton"\]\[aria-disabled="true"\]/m,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("caps Markdown's blocks in a measured shell, past its own boxless wrapper", () => {
|
|
241
|
+
// Markdown is display: contents, so it generates no box — and a non-inherited property on a
|
|
242
|
+
// boxless element is dropped. The measured-width rule is a child-star selector on the page's
|
|
243
|
+
// body children, so it MATCHED the markdown wrapper and then had nothing to apply a width
|
|
244
|
+
// to: prose ran the full width of a measured shell, on the one component whose entire
|
|
245
|
+
// purpose is long-form text. The sheet's own comment beside the wrapper asserted that no
|
|
246
|
+
// child-star selector existed in the sheet, which the measured-width rule had already
|
|
247
|
+
// falsified. Mutation: delete the reach-through rule.
|
|
248
|
+
expect(TERP_STYLES_CSS).toContain('[data-terp="page"] > [data-terp="markdown"] > *');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("paints the account menu from the sidebar family it actually sits on", () => {
|
|
252
|
+
// The trigger renders inside the sidebar (and inside the header group under
|
|
253
|
+
// navPlacement="header", which takes the sidebar surface), so its ink against that
|
|
254
|
+
// background is a pairing in play — but it read the NEUTRAL family, which the contrast gate
|
|
255
|
+
// has no sidebar-scoped pairing for. Reading the sidebar family instead puts it under
|
|
256
|
+
// `sidebar-text` and `sidebar-muted-text`, both already declared, so the gate covers it with
|
|
257
|
+
// no new entries. Provably zero-diff: the two families are byte-equal in all five themes.
|
|
258
|
+
//
|
|
259
|
+
// The role marker renders TWICE — trigger and portalled panel — so it is scoped rather than
|
|
260
|
+
// swapped; the panel is in document.body and the sidebar palette does not apply there.
|
|
261
|
+
// Mutation: put --color-neutral-900 back on the trigger.
|
|
262
|
+
const menuRule = TERP_STYLES_CSS.slice(
|
|
263
|
+
TERP_STYLES_CSS.indexOf('[data-terp="user-menu"] [data-terp="menu-trigger"] {'),
|
|
264
|
+
).slice(0, 900);
|
|
265
|
+
expect(menuRule).toContain("color: var(--color-sidebar-fg)");
|
|
266
|
+
expect(menuRule).not.toContain("color: var(--color-neutral-900)");
|
|
267
|
+
const roleRule = TERP_STYLES_CSS.slice(
|
|
268
|
+
TERP_STYLES_CSS.indexOf('[data-terp="user-menu"] [data-terp="user-menu-role"] {'),
|
|
269
|
+
).slice(0, 120);
|
|
270
|
+
expect(roleRule).toContain("color: var(--color-sidebar-muted)");
|
|
271
|
+
});
|
|
272
|
+
});
|