@terpjs/react-core 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/ModuleNav.tsx +1 -1
- package/src/PageActions.tsx +5 -10
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +13 -4
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +33 -37
- package/src/index.ts +0 -3
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +27 -4
- package/src/markers.test.ts +468 -0
- package/src/raw.d.ts +15 -1
- package/src/router.tsx +6 -9
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +823 -6
- package/src/styles.ts +2699 -153
- package/src/theme.test.tsx +39 -0
- package/src/theme.themes.test.ts +124 -0
- package/src/theme.tsx +62 -14
- package/src/toast.tsx +35 -71
- package/src/tokens.guard.test.ts +3 -12
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +13 -25
- package/src/ui/Button.test.tsx +17 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/uiText.tsx +9 -0
- package/src/ui/controlStyles.ts +0 -9
package/src/styles.test.ts
CHANGED
|
@@ -1,7 +1,51 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { afterEach, describe, expect, it } from "vitest";
|
|
3
3
|
|
|
4
|
-
import { TERP_STYLES_ID, injectTerpStyles } from "./styles";
|
|
4
|
+
import { TERP_STYLES_ID, TERP_STYLES_CSS, injectTerpStyles } from "./styles";
|
|
5
|
+
|
|
6
|
+
/** The sheet with comments removed — prose must not satisfy a structural assertion. */
|
|
7
|
+
const css = TERP_STYLES_CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The body of one `@layer <name> { … }` block, brace-matched.
|
|
11
|
+
*
|
|
12
|
+
* Throws rather than returning "" for a name that is not in the sheet: several assertions
|
|
13
|
+
* below are `.not.toContain(...)`, which an empty string satisfies, so a typo'd or renamed
|
|
14
|
+
* layer would silently disable them instead of failing.
|
|
15
|
+
*/
|
|
16
|
+
function layerBody(name: string): string {
|
|
17
|
+
const open = css.indexOf(`@layer ${name} {`);
|
|
18
|
+
if (open === -1) throw new Error(`styles.ts declares no @layer ${name}`);
|
|
19
|
+
let depth = 0;
|
|
20
|
+
for (let i = css.indexOf("{", open); i < css.length; i += 1) {
|
|
21
|
+
if (css[i] === "{") depth += 1;
|
|
22
|
+
else if (css[i] === "}") {
|
|
23
|
+
depth -= 1;
|
|
24
|
+
if (depth === 0) return css.slice(css.indexOf("{", open) + 1, i);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`@layer ${name} is not brace-balanced`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether `body` declares a rule whose selector list contains EXACTLY `selector` — not
|
|
32
|
+
* merely a selector containing it as a substring.
|
|
33
|
+
*
|
|
34
|
+
* The distinction is the whole point: `[data-terp="button"]` is a substring of
|
|
35
|
+
* `[data-terp="button"][data-variant="primary"]`, so a substring check keeps passing after
|
|
36
|
+
* the component's actual base block is deleted and only its variants remain.
|
|
37
|
+
*/
|
|
38
|
+
function declaresRuleFor(body: string, selector: string): boolean {
|
|
39
|
+
// The selector group excludes braces, so each match starts naturally after the previous
|
|
40
|
+
// rule's closing brace. Anchoring on that brace instead would consume it and match only
|
|
41
|
+
// every other rule — which is how this helper first read the sheet, and what made it
|
|
42
|
+
// report a missing base rule for `input` and a missing gap-0 rule for `stack`.
|
|
43
|
+
for (const match of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
44
|
+
const selectors = match[1].split(",").map((part) => part.trim().replace(/\s+/g, " "));
|
|
45
|
+
if (selectors.includes(selector) && match[2].trim().length > 0) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
5
49
|
|
|
6
50
|
afterEach(() => {
|
|
7
51
|
document.querySelectorAll(`style#${TERP_STYLES_ID}`).forEach((node) => node.remove());
|
|
@@ -21,10 +65,783 @@ describe("injectTerpStyles", () => {
|
|
|
21
65
|
|
|
22
66
|
it("themes scrollbars against the token palette (thin, not the OS default)", () => {
|
|
23
67
|
injectTerpStyles();
|
|
24
|
-
const
|
|
25
|
-
expect(
|
|
26
|
-
expect(
|
|
27
|
-
expect(
|
|
28
|
-
expect(
|
|
68
|
+
const sheet = document.querySelector(`style#${TERP_STYLES_ID}`)?.textContent ?? "";
|
|
69
|
+
expect(sheet).toContain("scrollbar-width: thin");
|
|
70
|
+
expect(sheet).toContain("scrollbar-color: var(--color-neutral-300) transparent");
|
|
71
|
+
expect(sheet).toContain("::-webkit-scrollbar");
|
|
72
|
+
expect(sheet).toContain("::-webkit-scrollbar-thumb");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// The cascade structure the migration rests on (ADR 0094). None of it was pinned by
|
|
77
|
+
// anything, and it is invisible to every other lane in the repo: jsdom does not compute the
|
|
78
|
+
// cascade, and the visual baselines only capture resting state, so a broken focus ring, a
|
|
79
|
+
// broken disabled treatment or an ignored reduced-motion preference all render as a passing
|
|
80
|
+
// suite. These assertions are cheap precisely because they read the sheet as text.
|
|
81
|
+
describe("cascade structure", () => {
|
|
82
|
+
it("declares the layer order the rules depend on, before any rule", () => {
|
|
83
|
+
// Without this statement the layers would be ordered by first appearance, which is the
|
|
84
|
+
// same order today and would silently stop being so the moment a rule is added above.
|
|
85
|
+
expect(css).toContain("@layer terp.reset, terp.base, terp.state, terp.motion;");
|
|
86
|
+
expect(css.indexOf("@layer terp.reset, terp.base")).toBeLessThan(css.indexOf("@layer terp.base {"));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("gives the focus ring an opaque colour, not a transparent outline", () => {
|
|
90
|
+
// SC 1.4.11 asks 3:1 of a focus indicator. The ring used to declare a TRANSPARENT
|
|
91
|
+
// outline, which left a translucent box-shadow as the whole visible indicator — and a
|
|
92
|
+
// translucent shadow's effective colour is its alpha blend over the surface behind it.
|
|
93
|
+
// Blended, it measured 1.67 in light and never better than 2.73 in any theme but
|
|
94
|
+
// contrast, so four of five shipped palettes failed on every focusable component.
|
|
95
|
+
//
|
|
96
|
+
// A text assertion because nothing else can hold it: the baselines capture the resting
|
|
97
|
+
// state, axe does not evaluate focus indicators, and the keyboard lane is about where
|
|
98
|
+
// focus goes. Mutating the colour back to transparent must fail here.
|
|
99
|
+
const state = layerBody("terp.state");
|
|
100
|
+
const at = state.indexOf("[data-terp]:focus-visible");
|
|
101
|
+
expect(at).toBeGreaterThan(-1);
|
|
102
|
+
const block = state.slice(state.indexOf("{", at) + 1, state.indexOf("}", at));
|
|
103
|
+
expect(block, "the focus ring's outline must carry a colour").toContain(
|
|
104
|
+
"outline: 2px solid var(--color-fg-accent)",
|
|
105
|
+
);
|
|
106
|
+
expect(block, "a transparent outline leaves only a translucent shadow").not.toContain(
|
|
107
|
+
"solid transparent",
|
|
108
|
+
);
|
|
109
|
+
// And the halo stays, because it is what makes the indicator legible against a busy
|
|
110
|
+
// surface rather than a hairline on it.
|
|
111
|
+
expect(block).toContain("box-shadow: 0 0 0 3px var(--color-focus-ring)");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("keeps the shared focus ring in terp.state, not terp.base", () => {
|
|
115
|
+
// The ring and [data-terp="button"][data-variant="primary"] both weigh (0,2,0), so in a
|
|
116
|
+
// single layer the later rule wins — and the ring is declared first. In terp.base the
|
|
117
|
+
// primary button's resting box-shadow suppresses it entirely (measured: the focused
|
|
118
|
+
// button computes its resting shadow instead of the ring).
|
|
119
|
+
expect(layerBody("terp.state")).toContain("[data-terp]:focus-visible");
|
|
120
|
+
expect(layerBody("terp.base")).not.toContain(":focus-visible");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("declares no !important in terp.base — a base rule never needs to shout", () => {
|
|
124
|
+
// !important in this sheet means exactly one thing: a rule that must beat an inline base
|
|
125
|
+
// style on a component that has not migrated yet. That is always a state rule, so an
|
|
126
|
+
// !important appearing in terp.base is a sign someone escalated a resting style.
|
|
127
|
+
expect(layerBody("terp.base")).not.toContain("!important");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("has no rule left that needs an escalation, and says which file was the last", () => {
|
|
131
|
+
// The tax comes off per CONSUMER, not per rule, and the condition is the LAST element a
|
|
132
|
+
// selector matches rather than the first. Every entry below names the inline declaration it
|
|
133
|
+
// has to out-shout, so the next reader can re-derive when it may retire instead of trusting
|
|
134
|
+
// this list — the previous version of this test named ConfirmDialog and Popover as reasons
|
|
135
|
+
// for the focus ring, and both had migrated a stage earlier.
|
|
136
|
+
//
|
|
137
|
+
// This direction matters more than the converse and used to be pinned six times worse: the
|
|
138
|
+
// regression that shipped — a disabled Combobox painted exactly like an enabled one — came
|
|
139
|
+
// from dropping an escalation early, and only three of twelve were asserted. Every rule here
|
|
140
|
+
// is a hover, disabled or focus state, which is to say invisible to both lanes: the
|
|
141
|
+
// baselines capture the resting state and axe does not evaluate hover.
|
|
142
|
+
// This list is empty, and keeping the test rather than deleting it is the point: the
|
|
143
|
+
// direction it guards is the one that shipped a regression (a disabled Combobox painted
|
|
144
|
+
// exactly like an enabled one, from dropping an escalation while an inline consumer
|
|
145
|
+
// remained). It stays as the place a new escalation has to justify itself.
|
|
146
|
+
//
|
|
147
|
+
// AppShell was the last file, and it blocked five of the seven on its own: the shared
|
|
148
|
+
// icon-button hover's background and colour (toggleStyle), the nav link's background and
|
|
149
|
+
// colour (NAV_LINK_STYLE), and the reduced-motion override (three inline transitions, one
|
|
150
|
+
// of them on an <aside> carrying no marker at all).
|
|
151
|
+
const state = layerBody("terp.state");
|
|
152
|
+
const stillInline: [string, string][] = [];
|
|
153
|
+
for (const [rule, consumer] of stillInline) {
|
|
154
|
+
const at = state.indexOf(rule);
|
|
155
|
+
expect(at, `${rule} should still be declared`).toBeGreaterThan(-1);
|
|
156
|
+
expect(
|
|
157
|
+
state.slice(at, state.indexOf("}", at)),
|
|
158
|
+
`${rule} must keep its escalation while ${consumer} is inline`,
|
|
159
|
+
).toContain("!important");
|
|
160
|
+
}
|
|
161
|
+
// The reduced-motion override no longer shouts either, and that one is a decision rather
|
|
162
|
+
// than bookkeeping. Its three inline consumers are gone, so layer order is enough —
|
|
163
|
+
// terp.motion sits above terp.base and terp.state. Retiring it also has a merit of its own:
|
|
164
|
+
// the block sets `transition: none` for EVERY transition, including pure colour fades that
|
|
165
|
+
// are not motion at all, so leaving it unoverridable was the over-broad choice. An app can
|
|
166
|
+
// now narrow it from theme.css, which is exactly the power this phase exists to hand over.
|
|
167
|
+
const motion = layerBody("terp.motion");
|
|
168
|
+
expect(motion, "the reduced-motion override should still be declared").toContain(
|
|
169
|
+
"transition: none",
|
|
170
|
+
);
|
|
171
|
+
expect(motion, "its last inline consumer migrated with AppShell").not.toContain("!important");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("keeps the shared icon-button hover from impersonating a pressed toggle", () => {
|
|
175
|
+
// This rule shouts, because AppShell's toggleStyle declares background and colour inline
|
|
176
|
+
// on the shell's two header toggles and nothing but !important reaches a style attribute.
|
|
177
|
+
// Its two values are byte-identical to the PRESSED look of the DataView toolbar's layout
|
|
178
|
+
// toggles, which wear the same marker — so unguarded, hovering an inactive toggle paints it
|
|
179
|
+
// exactly like the active one, and the toggle group's only job is to say which layout is
|
|
180
|
+
// current.
|
|
181
|
+
//
|
|
182
|
+
// What makes that unrecoverable rather than merely wrong is the escalation. For important
|
|
183
|
+
// declarations the layer order reverses and unlayered styles sort last, so no author rule
|
|
184
|
+
// at any specificity — including an app's theme.css — can put the inactive colour back. The
|
|
185
|
+
// fix is therefore a guard on the rule and not an override anywhere else.
|
|
186
|
+
//
|
|
187
|
+
// Invisible to all three lanes: the baselines capture the resting state, axe reads a static
|
|
188
|
+
// tree, and the keyboard lane asserts where focus goes rather than what it paints.
|
|
189
|
+
const state = layerBody("terp.state");
|
|
190
|
+
const at = state.indexOf('[data-terp="iconbutton"]:hover');
|
|
191
|
+
expect(at, "the shared icon-button hover should still be declared").toBeGreaterThan(-1);
|
|
192
|
+
expect(
|
|
193
|
+
state.slice(at, state.indexOf("{", at)),
|
|
194
|
+
"an !important hover must not reach a pressed toggle",
|
|
195
|
+
).toContain(':not([aria-pressed="true"])');
|
|
196
|
+
// And the precedent it copies, verbatim, so deleting either guard is one failure and not
|
|
197
|
+
// two independent ones nobody connects.
|
|
198
|
+
expect(
|
|
199
|
+
state,
|
|
200
|
+
"the tab precedent this guard is modelled on must stay in the sheet",
|
|
201
|
+
).toContain('[data-terp="tab"]:hover:not(:disabled):not([aria-selected="true"])');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps the shell declarations no lane can reach", () => {
|
|
205
|
+
// Three groups, and every one of them was established by mutation rather than assumed.
|
|
206
|
+
//
|
|
207
|
+
// The sidebar's flex-shrink. It is a flex item with an explicit width in a container that
|
|
208
|
+
// has room to spare at the pinned 1280px viewport, so deleting it moves no baseline —
|
|
209
|
+
// measured. It bites at narrow DESKTOP widths, above the mobile breakpoint where the drawer
|
|
210
|
+
// takes over, which the screenshot lane cannot reach because the viewport is pinned.
|
|
211
|
+
const base = layerBody("terp.base");
|
|
212
|
+
const at = base.indexOf('[data-terp="appshell-sidebar"] {');
|
|
213
|
+
expect(at, "the sidebar should have a base rule").toBeGreaterThan(-1);
|
|
214
|
+
expect(
|
|
215
|
+
base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at)),
|
|
216
|
+
"without this the rail squeezes instead of the content, between the breakpoint and wide",
|
|
217
|
+
).toContain("flex-shrink: 0");
|
|
218
|
+
// The three z-index tokens the shell is the first reader of. Before this the family shipped
|
|
219
|
+
// with --z-index-drawer read by nothing at all, while the one element that wanted it — this
|
|
220
|
+
// drawer — hardcoded 50, and the token's only binding anywhere pointed at the popover level
|
|
221
|
+
// instead. A hardcoded number here would move no baseline and read as correct.
|
|
222
|
+
for (const [token, selector] of [
|
|
223
|
+
["--z-index-drawer", "the mobile drawer"],
|
|
224
|
+
["--z-index-backdrop", "the drawer's backdrop"],
|
|
225
|
+
["--z-index-sticky", "the sticky header"],
|
|
226
|
+
] as const) {
|
|
227
|
+
expect(base, `${selector} must read ${token} rather than hardcoding its number`).toContain(
|
|
228
|
+
`z-index: var(${token})`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
// And the mobile half of the shell, which the screenshot lane cannot render at all: the
|
|
232
|
+
// viewport is pinned at 1280 and the drawer needs 768 or less. The BEHAVIOUR is covered by
|
|
233
|
+
// AppShell.test.tsx (focus containment, inert page, close-on-nav) with a stubbed matchMedia;
|
|
234
|
+
// the geometry is covered by nothing, so these three exist as text or not at all.
|
|
235
|
+
for (const selector of [
|
|
236
|
+
'[data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-sidebar"]',
|
|
237
|
+
'[data-terp="appshell-backdrop"]',
|
|
238
|
+
'[data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-main"]',
|
|
239
|
+
]) {
|
|
240
|
+
expect(
|
|
241
|
+
declaresRuleFor(base, selector),
|
|
242
|
+
`${selector} is mobile-only, so no baseline can hold it`,
|
|
243
|
+
).toBe(true);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("keeps the hub-card declarations the lanes cannot explain", () => {
|
|
248
|
+
// Both of these were found by mutation: deleting either moves no baseline, and neither is
|
|
249
|
+
// dead. This is their only gate.
|
|
250
|
+
//
|
|
251
|
+
// The placeholder rows. A non-breaking space paints nothing, so a hidden placeholder and a
|
|
252
|
+
// visible one are pixel-identical — measured at width 557 and height 89 either way. The
|
|
253
|
+
// declaration exists for the accessibility tree: visibility: hidden removes the text, and
|
|
254
|
+
// without it a screen reader announces a blank row on every card missing a description or a
|
|
255
|
+
// stat. It must therefore stay `visibility` and never become `opacity`, which hides the text
|
|
256
|
+
// from sight while leaving it announced.
|
|
257
|
+
const base = layerBody("terp.base");
|
|
258
|
+
const at = base.indexOf('[data-terp="hubcard-description"][data-empty="true"]');
|
|
259
|
+
expect(at, "the placeholder rule should be declared").toBeGreaterThan(-1);
|
|
260
|
+
const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
|
|
261
|
+
expect(block, "a placeholder row must leave the accessibility tree, not just the page").toContain(
|
|
262
|
+
"visibility: hidden",
|
|
263
|
+
);
|
|
264
|
+
expect(block, "opacity would hide the text from sight and leave it announced").not.toContain(
|
|
265
|
+
"opacity",
|
|
266
|
+
);
|
|
267
|
+
// The card's height chain, and this assertion had to be corrected once: the link's
|
|
268
|
+
// display: block and height: 100% look like the middle rung and are not. Measured in a row
|
|
269
|
+
// stretched past the body's 10rem floor, forcing that anchor inline changes nothing —
|
|
270
|
+
// percentage heights resolve against the nearest block container and skip inline boxes, so
|
|
271
|
+
// the body reaches past the anchor to the <li> the grid stretched. Nor does removing the
|
|
272
|
+
// grid's align-items: stretch (grid's default anyway), its grid-auto-rows: 1fr, or the
|
|
273
|
+
// card's own height: 100%.
|
|
274
|
+
//
|
|
275
|
+
// Exactly one declaration equalises two cards, and this is it. Removing it in that same
|
|
276
|
+
// stretched row drops the bare card to 160 while its neighbour stays at 189 — which
|
|
277
|
+
// hub-card-bare now catches, and catches only because its full card is deliberately long
|
|
278
|
+
// enough to set the row height. Kept here as well as in the baseline, because the baseline
|
|
279
|
+
// cannot say WHY the two cards match.
|
|
280
|
+
const body = base.indexOf('[data-terp="hubcard-body"]');
|
|
281
|
+
expect(body, "the hub-card body rule should be declared").toBeGreaterThan(-1);
|
|
282
|
+
expect(
|
|
283
|
+
base.slice(base.indexOf("{", body) + 1, base.indexOf("}", body)),
|
|
284
|
+
"the body's percentage height is the only thing equalising two cards in a row",
|
|
285
|
+
).toContain("height: 100%");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("puts the DataView's surface on the full variant, not on the bare marker", () => {
|
|
289
|
+
// Both values of data-variant are stamped and only one has a rule, which is what keeps
|
|
290
|
+
// the embedded variant a bare grid. The alternative — surface on the marker, un-declared
|
|
291
|
+
// under [data-variant="embedded"] — needs background: transparent, border: 0 and
|
|
292
|
+
// border-radius: 0, the un-declaring shape ADR 0094 exists to avoid.
|
|
293
|
+
//
|
|
294
|
+
// dataview-embedded is the negative evidence this rests on: deleting the full-variant
|
|
295
|
+
// rule must move five baselines and leave that one untouched.
|
|
296
|
+
const base = layerBody("terp.base");
|
|
297
|
+
expect(declaresRuleFor(base, '[data-terp="dataview"]'), "the root needs a display").toBe(true);
|
|
298
|
+
expect(
|
|
299
|
+
declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
|
|
300
|
+
"the surface belongs to the full variant",
|
|
301
|
+
).toBe(true);
|
|
302
|
+
expect(
|
|
303
|
+
base,
|
|
304
|
+
"un-declaring a surface under the embedded variant is the shape ADR 0094 avoids",
|
|
305
|
+
).not.toContain('[data-variant="embedded"]');
|
|
306
|
+
// display: grid is asserted HERE because nothing else can: forcing the root to block
|
|
307
|
+
// leaves every measured dimension identical on dataview-wide, dataview-full and
|
|
308
|
+
// dataview-embedded — the three children are full-width block boxes with no margins, which
|
|
309
|
+
// a single-column grid and a block container stack the same way — so deleting it moves no
|
|
310
|
+
// baseline. This assertion is the only thing standing between that declaration and a
|
|
311
|
+
// silent removal.
|
|
312
|
+
//
|
|
313
|
+
// It matters in one direction: under grid, a wide table without the scroll wrapper's
|
|
314
|
+
// overflow-x widens the whole DataView, because a grid item's automatic minimum size is
|
|
315
|
+
// content-based unless its overflow is not visible. The two are coupled, and the second
|
|
316
|
+
// half is what dataview-wide catches.
|
|
317
|
+
const at = base.indexOf('[data-terp="dataview"]');
|
|
318
|
+
expect(base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at))).toContain("display: grid");
|
|
319
|
+
expect(declaresRuleFor(base, '[data-terp="dataview-scroll"]'), "the pair's other half").toBe(
|
|
320
|
+
true,
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("keeps the toolbar band declaring its own surface, and no ink", () => {
|
|
325
|
+
// Three separate invariants about one element, and each has a way of going wrong that
|
|
326
|
+
// nothing else in the suite can see.
|
|
327
|
+
//
|
|
328
|
+
// The background. The inline style this replaced read `selectionMode ? neutral-50 :
|
|
329
|
+
// neutral-0` — an explicit value in BOTH branches, so the resting rule has to carry
|
|
330
|
+
// neutral-0 rather than leaving it to the host. Against every composed DataView specimen
|
|
331
|
+
// dropping it moves nothing, because the full variant's root and the workbench's specimen
|
|
332
|
+
// card are both neutral-0; it breaks the EMBEDDED variant in a real app, whose root
|
|
333
|
+
// declares nothing but a display, and the band would show the page canvas through it.
|
|
334
|
+
// `dataview-toolbar-bare` renders on a neutral-50 host so that mutation fails a baseline.
|
|
335
|
+
//
|
|
336
|
+
// The two top radii, which are load-bearing rather than decorative: the DataView root
|
|
337
|
+
// rounds its border with no overflow: hidden, so nothing else keeps the selection band's
|
|
338
|
+
// neutral-50 inside the rounded frame.
|
|
339
|
+
//
|
|
340
|
+
// And NO colour. Two of this element's direct children are arbitrary caller slots
|
|
341
|
+
// (`children` and `trailing`), so a muted ink here would inherit into app-authored filter
|
|
342
|
+
// controls — a silent restyle of app DOM, which is exactly what this migration exists to
|
|
343
|
+
// stop the framework doing.
|
|
344
|
+
const base = layerBody("terp.base");
|
|
345
|
+
const at = base.indexOf('[data-terp="dataview-toolbar"]');
|
|
346
|
+
expect(at, "the toolbar band should have a base rule").toBeGreaterThan(-1);
|
|
347
|
+
const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
|
|
348
|
+
expect(block, "the band must declare its own surface, not inherit the host's").toContain(
|
|
349
|
+
"background: var(--color-neutral-0)",
|
|
350
|
+
);
|
|
351
|
+
for (const radius of ["border-top-left-radius", "border-top-right-radius"]) {
|
|
352
|
+
expect(block, `${radius} keeps the selection band inside the root's rounded frame`).toContain(
|
|
353
|
+
`${radius}: var(--radius-lg)`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
expect(
|
|
357
|
+
/(^|;)\s*color:/.test(block),
|
|
358
|
+
"a colour here inherits into the caller's filter slot and trailing slot",
|
|
359
|
+
).toBe(false);
|
|
360
|
+
// Selection mode is a resting surface, so it belongs in terp.base and wins at (0,2,0)
|
|
361
|
+
// against the band's (0,1,0) — on specificity, with no :not() and no source-order
|
|
362
|
+
// dependency. In terp.state it would beat the focus ring's layer for no reason.
|
|
363
|
+
expect(
|
|
364
|
+
declaresRuleFor(base, '[data-terp="dataview-toolbar"][data-variant="selection"]'),
|
|
365
|
+
"selection mode must be a base rule of its own",
|
|
366
|
+
).toBe(true);
|
|
367
|
+
expect(
|
|
368
|
+
layerBody("terp.state"),
|
|
369
|
+
"selection mode is a resting surface, not an interaction state",
|
|
370
|
+
).not.toContain('[data-variant="selection"]');
|
|
371
|
+
// The bar reads the density token rather than a literal --space-3, which is what makes a
|
|
372
|
+
// compact view's band line up with its first cell. Zero-diff at comfortable, because
|
|
373
|
+
// comfortable --density-cell-pad-x IS --space-3 — so only dataview-compact can catch it.
|
|
374
|
+
expect(block, "the band's inline padding must follow density").toContain(
|
|
375
|
+
"padding: var(--space-2) var(--density-cell-pad-x)",
|
|
376
|
+
);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("signals the active layout with more than a colour", () => {
|
|
380
|
+
// Two icon-only controls whose entire job is to say which layout is current, so which one
|
|
381
|
+
// is pressed cannot be carried by ink alone (SC 1.4.1) and its indicator has to clear 3:1
|
|
382
|
+
// (SC 1.4.11). Measured over the manifest's per-theme values: the neutral-100 fill against
|
|
383
|
+
// the band's transparent is 1.09-1.16 across the five themes, nowhere near it; the accent
|
|
384
|
+
// border is 4.95 at worst against its own fill and 5.75 at worst against the band.
|
|
385
|
+
//
|
|
386
|
+
// A text assertion because nothing else can hold it. The baselines DO see the border, but
|
|
387
|
+
// they cannot say why it is there — re-recording them is one keystroke, and the reason this
|
|
388
|
+
// rule carries a border rather than only a wash is exactly what a re-record erases.
|
|
389
|
+
//
|
|
390
|
+
// Keyed on aria-pressed rather than a data attribute duplicating it — the
|
|
391
|
+
// [data-terp="tab"][aria-selected="true"] precedent — and the reuse is safe in the strong
|
|
392
|
+
// sense that case defines: DataViewToolbar is the sole author of that attribute on these
|
|
393
|
+
// two elements, setting it from its own layout prop, and no router, wrapper or caller can
|
|
394
|
+
// reach them.
|
|
395
|
+
//
|
|
396
|
+
// Deliberately NOT a box-shadow: [data-terp]:focus-visible sets box-shadow in this same
|
|
397
|
+
// layer at (0,2,0) against this rule's (0,3,0), so an inset shadow here would win and a
|
|
398
|
+
// focused active toggle would lose its focus ring.
|
|
399
|
+
const state = layerBody("terp.state");
|
|
400
|
+
const selector =
|
|
401
|
+
'[data-terp="dataview-toolbar-layout"] > [data-terp="iconbutton"][aria-pressed="true"]';
|
|
402
|
+
expect(declaresRuleFor(state, selector), `${selector} must be declared`).toBe(true);
|
|
403
|
+
const at = state.indexOf(selector);
|
|
404
|
+
const block = state.slice(state.indexOf("{", at) + 1, state.indexOf("}", at));
|
|
405
|
+
expect(block, "pressed must carry a non-colour signal, not a wash alone").toContain(
|
|
406
|
+
"border-color: var(--color-fg-accent)",
|
|
407
|
+
);
|
|
408
|
+
expect(
|
|
409
|
+
block,
|
|
410
|
+
"an inset box-shadow here outranks the shared focus ring in the same layer",
|
|
411
|
+
).not.toContain("box-shadow");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("keeps the focus-within tint scoped to rows something will actually open", () => {
|
|
415
|
+
// The row marker is unconditional, so this selector reaches every row unless it says
|
|
416
|
+
// otherwise. Unguarded it paints a brand wash on any row holding focus — a selection
|
|
417
|
+
// checkbox in a view with no onRowClick — over that row's status tone, for a row nothing
|
|
418
|
+
// will open. Invisible to all three lanes: resting baselines, a static tree, and a
|
|
419
|
+
// keyboard lane that asserts where focus goes rather than what it paints.
|
|
420
|
+
const state = layerBody("terp.state");
|
|
421
|
+
expect(
|
|
422
|
+
declaresRuleFor(state, '[data-terp="dataview-row"][data-clickable="true"]:focus-within td'),
|
|
423
|
+
"the row's focus-within tint must be guarded on data-clickable",
|
|
424
|
+
).toBe(true);
|
|
425
|
+
expect(
|
|
426
|
+
state,
|
|
427
|
+
"an unguarded row focus-within selector would reach every row with a checkbox in it",
|
|
428
|
+
).not.toContain('[data-terp="dataview-row"]:focus-within');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("names every element the reduced-motion block has to reach", () => {
|
|
432
|
+
// The layer wins the cascade, but only over elements a selector actually matches. A
|
|
433
|
+
// transition on an element with no data-terp of its own escapes unless it is listed
|
|
434
|
+
// here by name — which is how the breadcrumb link kept animating.
|
|
435
|
+
const motion = layerBody("terp.motion");
|
|
436
|
+
for (const selector of [
|
|
437
|
+
"[data-terp]",
|
|
438
|
+
'[data-terp="appshell-nav"] a',
|
|
439
|
+
'[data-terp="breadcrumbs"] a',
|
|
440
|
+
'[data-terp="dataview-table"] tbody tr',
|
|
441
|
+
]) {
|
|
442
|
+
expect(motion, `${selector} must be in the reduced-motion selector list`).toContain(selector);
|
|
443
|
+
}
|
|
444
|
+
// Every transition this sheet declares is either on a marked element or on one of the
|
|
445
|
+
// descendant selectors above. A new descendant rule with a transition has to join them.
|
|
446
|
+
const base = layerBody("terp.base");
|
|
447
|
+
for (const match of base.matchAll(/([^{}]+)\{([^{}]*transition:[^{}]*)\}/g)) {
|
|
448
|
+
const selector = match[1].trim().replace(/\s+/g, " ");
|
|
449
|
+
const marked = selector.includes("[data-terp") && !/\s[a-z]+$/.test(selector);
|
|
450
|
+
expect(
|
|
451
|
+
marked || motion.includes(selector),
|
|
452
|
+
`${selector} declares a transition but reduced motion cannot reach it`,
|
|
453
|
+
).toBe(true);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("declares a gap rule for every step SpaceToken allows", () => {
|
|
458
|
+
// gap moved from a computed inline value to a rule per step, so the union and the sheet
|
|
459
|
+
// are now two lists maintained by hand. Widening SpaceToken without adding rules would
|
|
460
|
+
// silently fall back to the default gap rather than fail.
|
|
461
|
+
const base = layerBody("terp.base");
|
|
462
|
+
for (const token of [0, 1, 2, 3, 4, 6, 8]) {
|
|
463
|
+
for (const marker of ["stack", "card"]) {
|
|
464
|
+
expect(
|
|
465
|
+
declaresRuleFor(base, `[data-terp="${marker}"][data-gap="${token}"]`),
|
|
466
|
+
`${marker} has no rule for gap ${token}`,
|
|
467
|
+
).toBe(true);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("carries no !important on a marker whose every consumer has migrated", () => {
|
|
473
|
+
// The converse, so the escalation cannot outlive its reason. A rule left shouting after
|
|
474
|
+
// its last inline consumer is gone is invisible: it works, and it silently outranks the
|
|
475
|
+
// app theme.css that ADR 0094 exists to empower.
|
|
476
|
+
const state = layerBody("terp.state");
|
|
477
|
+
for (const rule of [
|
|
478
|
+
'[data-terp="input"]:hover',
|
|
479
|
+
'[data-terp="input"]:focus',
|
|
480
|
+
'[data-terp="input"]:disabled',
|
|
481
|
+
'[data-terp="input"][aria-invalid="true"]',
|
|
482
|
+
'[data-terp="tab"]:hover',
|
|
483
|
+
'[data-terp="tab"]:disabled',
|
|
484
|
+
// MenuItem is the only element in the package wearing this marker, so its escalation
|
|
485
|
+
// retired the moment the component stopped carrying inline base styles — unlike
|
|
486
|
+
// `input`, which six elements wear and which had to wait for the last of them.
|
|
487
|
+
'[data-terp="menu-item"]:hover',
|
|
488
|
+
'[data-terp="menu-item"][data-selected="true"]',
|
|
489
|
+
'[data-terp="menu-item"]:disabled',
|
|
490
|
+
'[data-terp="menu-trigger"]:hover',
|
|
491
|
+
// The brand link declares no inline background — brandLinkStyle sets colour, radius and
|
|
492
|
+
// box-sizing and nothing else — so this rule never had anything to out-shout.
|
|
493
|
+
'[data-terp="appshell-brand"]:hover',
|
|
494
|
+
// The textbook per-consumer case, in both directions. This selector has two halves
|
|
495
|
+
// and they were never in the same state: the row half had nothing to out-shout,
|
|
496
|
+
// because a body cell declares no background, while the card half faced
|
|
497
|
+
// DataViewCardList's inline background on the element it matches. The escalation
|
|
498
|
+
// came off when the card migrated, which is the LAST of the two rather than the
|
|
499
|
+
// first — and the card's tone rules now lose to it on layer instead.
|
|
500
|
+
'[data-terp="dataview-card"]:focus-within',
|
|
501
|
+
// Re-derived rather than assumed, because the condition is not "has something
|
|
502
|
+
// migrated" but "can any element this selector matches still beat it". Of the SIXTEEN
|
|
503
|
+
// sites wearing the iconbutton marker, only six can carry the disabled attribute at all:
|
|
504
|
+
// the four pagination arrows and the two reorder arrows. The shell's toggles, the toast
|
|
505
|
+
// dismisser, the combobox's clear button, the calendar's month arrows, the expand toggle
|
|
506
|
+
// and the toolbar's clear-search button and two layout toggles have no disabled state —
|
|
507
|
+
// and each of the six set cursor inline until it migrated. The day a calendar arrow gains
|
|
508
|
+
// a min/max bound, this answer changes back.
|
|
509
|
+
'[data-terp="iconbutton"]:disabled',
|
|
510
|
+
// HubPage was the condition for both of these, and it was the condition in the strongest
|
|
511
|
+
// form: hubcard-body's border and hubcard-title's colour were declared inline on the very
|
|
512
|
+
// elements these selectors match, so no layered rule could reach them at any specificity.
|
|
513
|
+
// Both surfaces come from terp.base now.
|
|
514
|
+
'[data-terp="hubcard"]:hover [data-terp="hubcard-body"]',
|
|
515
|
+
'[data-terp="hubcard"]:hover [data-terp="hubcard-title"]',
|
|
516
|
+
// AppShell's, and it blocked these two on its own: toggleStyle declared background and
|
|
517
|
+
// colour inline on the shell's two toggles, the last elements wearing the icon-button
|
|
518
|
+
// marker able to out-rank a layered rule. Their resting look is a scoped base rule now,
|
|
519
|
+
// so this wins on LAYER — terp.state over terp.base — whatever its specificity.
|
|
520
|
+
'[data-terp="iconbutton"]:hover',
|
|
521
|
+
// And the nav link's, whose consumer was NAV_LINK_STYLE: a CSSProperties object exported
|
|
522
|
+
// for every router's link renderer to spread onto its own element, so colour and
|
|
523
|
+
// background were inline on the very elements this selector matches.
|
|
524
|
+
'[data-terp="appshell-nav"] a:hover',
|
|
525
|
+
]) {
|
|
526
|
+
const at = state.indexOf(rule);
|
|
527
|
+
expect(at, `${rule} should still be declared`).toBeGreaterThan(-1);
|
|
528
|
+
const block = state.slice(at, state.indexOf("}", at));
|
|
529
|
+
expect(block, `${rule}: every element wearing that marker is migrated`)
|
|
530
|
+
.not.toContain("!important");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// The two widest escalations in the sheet retired in stage 4, and they are worth their own
|
|
534
|
+
// assertions because both had a real consumer until it migrated.
|
|
535
|
+
//
|
|
536
|
+
// The focus ring's !important could only ever beat an INLINE box-shadow on an element the
|
|
537
|
+
// selector matches, and there is none left: ConfirmDialog's dialog and Popover's panel were
|
|
538
|
+
// the two, and both took their surface from the sheet in stage 4. Of the three inline
|
|
539
|
+
// box-shadows remaining, AppShell's drawer and LoginView's panel sit on elements with no
|
|
540
|
+
// marker at all, and DataViewCardList's card is a div with onClick and no tabIndex, so it
|
|
541
|
+
// cannot match :focus-visible — which is why the sheet reaches it with :focus-within.
|
|
542
|
+
// Measured after removing it: a keyboard-focused primary button and a focused menu item both
|
|
543
|
+
// still compute rgba(37,99,235,0.35) 0 0 0 3px, because the LAYER carries the ring, not the
|
|
544
|
+
// escalation. That was always the claim in this file's header; the escalation was belt.
|
|
545
|
+
const at = state.indexOf("[data-terp]:focus-visible");
|
|
546
|
+
expect(at, "the shared focus ring should still be declared").toBeGreaterThan(-1);
|
|
547
|
+
expect(
|
|
548
|
+
state.slice(at, state.indexOf("}", at)),
|
|
549
|
+
"the focus ring's last inline box-shadow consumer migrated in stage 4",
|
|
550
|
+
).not.toContain("!important");
|
|
551
|
+
|
|
552
|
+
// And the spinner's reduced-motion override never had a consumer: the rotation is declared by
|
|
553
|
+
// this sheet in terp.state, terp.motion sits above it, and no component has ever set
|
|
554
|
+
// `animation` in a style object. Measured under prefers-reduced-motion: animation-name
|
|
555
|
+
// computes `none` either way.
|
|
556
|
+
const motion = layerBody("terp.motion");
|
|
557
|
+
const spin = motion.indexOf('[data-terp="spinner-ring"]');
|
|
558
|
+
expect(spin, "the spinner's reduced-motion rule should still be declared").toBeGreaterThan(-1);
|
|
559
|
+
expect(
|
|
560
|
+
motion.slice(spin, motion.indexOf("}", spin)),
|
|
561
|
+
"the spinner's animation is a sheet rule in a lower layer, so layer order already wins",
|
|
562
|
+
).not.toContain("!important");
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("states the real cost of an escalation: a layered !important is a wall, not a hurdle", () => {
|
|
566
|
+
// The reason both directions of this ledger are gated, recorded where the rules are.
|
|
567
|
+
//
|
|
568
|
+
// For NORMAL declarations an unlayered author rule beats a layered one whatever its
|
|
569
|
+
// specificity, which is what lets an app's theme.css override this sheet. For IMPORTANT
|
|
570
|
+
// declarations the layer order REVERSES, and unlayered styles sort last — so a layered
|
|
571
|
+
// !important beats an unlayered !important. Measured in a browser against the real sheet:
|
|
572
|
+
// with the ring escalated, an unlayered rule lost both with and without !important; with it
|
|
573
|
+
// retired, an unlayered rule wins either way.
|
|
574
|
+
//
|
|
575
|
+
// So an escalation does not merely outrank theme.css. It makes that declaration
|
|
576
|
+
// UNTHEMEABLE — there is no author-side override at all. That is why the count is the
|
|
577
|
+
// phase's measurable and why retiring one is a feature rather than tidying.
|
|
578
|
+
expect(css).toContain("@layer terp.reset, terp.base, terp.state, terp.motion;");
|
|
579
|
+
// ZERO. Every rule in this sheet is now beatable by an app's unlayered theme.css without
|
|
580
|
+
// !important and without out-specifying anything, which is what the phase was for: an
|
|
581
|
+
// escalation does not merely outrank an app's stylesheet, it makes that one declaration
|
|
582
|
+
// unthemeable, because for important declarations the layer order reverses and unlayered
|
|
583
|
+
// styles sort last.
|
|
584
|
+
//
|
|
585
|
+
// Asserted as exact equality in both directions. A new escalation fails here and has to
|
|
586
|
+
// name its inline consumer in the ledger above, which is now empty.
|
|
587
|
+
const declarations = css.split("!important").length - 1;
|
|
588
|
+
expect(declarations).toBe(0);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("declares a rule for every DataView surface reached structurally", () => {
|
|
592
|
+
// The check below asks for an EXACT `[data-terp="x"]` selector, and several DataView
|
|
593
|
+
// surfaces deliberately have none: a table has hundreds of cells, so stamping an
|
|
594
|
+
// attribute on each of them buys nothing a descendant selector does not already do.
|
|
595
|
+
// Those rules are therefore invisible to that check — deleting one leaves the marker
|
|
596
|
+
// inventory intact and the whole table unpadded. So they are named here as the
|
|
597
|
+
// selectors they actually are.
|
|
598
|
+
//
|
|
599
|
+
// Note which ancestor each descends from. Cells hang off the ROW rather than off the
|
|
600
|
+
// table, because a "tbody td" selector also matches the expanded row's cell — which
|
|
601
|
+
// carries a padding and a border of its own and would then have to out-specify this
|
|
602
|
+
// rule instead of simply not matching it. And the two visually-hidden surfaces are
|
|
603
|
+
// qualified by element: the actions BODY cell's only child is the row-actions cluster,
|
|
604
|
+
// so an unqualified `> span` would clip a live control out of the layout.
|
|
605
|
+
const base = layerBody("terp.base");
|
|
606
|
+
for (const selector of [
|
|
607
|
+
// The toolbar's three descendant rules, and the fourth on the layout group. The search
|
|
608
|
+
// wrapper's `> span` is its glyph, unqualified because the clear button is a <button> and
|
|
609
|
+
// the field is an <input>, so a span there can only be the glyph. The field rule has to
|
|
610
|
+
// out-rank input[data-terp="input"] { padding } and does so at (0,2,0) against (0,1,1) —
|
|
611
|
+
// deleting it puts the glyph on top of the text with the marker inventory intact.
|
|
612
|
+
// The skeleton's five bars, structural because repeated identical boxes in a place
|
|
613
|
+
// where only bars sit is the card-main / actions-cell precedent. Deleting it collapses
|
|
614
|
+
// dataview-loading and leaves the marker inventory intact.
|
|
615
|
+
'[data-terp="dataview-skeleton"] > div',
|
|
616
|
+
'[data-terp="dataview-toolbar-search"] > span',
|
|
617
|
+
'[data-terp="dataview-toolbar-search"] > [data-terp="input"]',
|
|
618
|
+
'[data-terp="dataview-toolbar-search"] > [data-terp="iconbutton"]',
|
|
619
|
+
'[data-terp="dataview-toolbar-layout"] > [data-terp="iconbutton"]',
|
|
620
|
+
'[data-terp="dataview-table"] > thead > tr > th',
|
|
621
|
+
'[data-terp="dataview-row"] > td',
|
|
622
|
+
'[data-terp="dataview-row"][data-clickable="true"]',
|
|
623
|
+
'th[data-terp="dataview-expand-cell"]',
|
|
624
|
+
'th[data-terp="dataview-select-cell"]',
|
|
625
|
+
'th[data-terp="dataview-actions-cell"]',
|
|
626
|
+
'td[data-terp="dataview-actions-cell"]',
|
|
627
|
+
'th[data-terp="dataview-actions-cell"] > span',
|
|
628
|
+
'[data-terp="dataview-card-main"] > span',
|
|
629
|
+
// The expand toggle, in both places it renders. It wears the shared iconbutton
|
|
630
|
+
// marker, so its geometry is reachable only through the ancestor it sits in — and
|
|
631
|
+
// losing either selector leaves the toggle unstyled in one of the two layouts,
|
|
632
|
+
// which the marker inventory cannot see because the marker is still rendered.
|
|
633
|
+
'[data-terp="dataview-expand-cell"] > [data-terp="iconbutton"]',
|
|
634
|
+
'[data-terp="dataview-card-main"] > [data-terp="iconbutton"]',
|
|
635
|
+
'[data-terp="dataview-pager"] > [data-terp="iconbutton"]',
|
|
636
|
+
// Two structural span rules in the row-action cluster, and they mean different
|
|
637
|
+
// things: the first reaches the custom-control wrappers (the only span that is a
|
|
638
|
+
// direct child of the cluster — the inline actions are buttons and the overflow
|
|
639
|
+
// menu's root is a div), the second reaches an action's leading icon.
|
|
640
|
+
'[data-terp="dataview-row-actions"] > span',
|
|
641
|
+
'[data-terp="dataview-inline-action"] > span',
|
|
642
|
+
'[data-terp="dataview-column-option"] > label',
|
|
643
|
+
'[data-terp="dataview-column-option"] > [data-terp="iconbutton"]',
|
|
644
|
+
]) {
|
|
645
|
+
expect(
|
|
646
|
+
declaresRuleFor(base, selector),
|
|
647
|
+
`${selector} must be declared as a rule of its own`,
|
|
648
|
+
).toBe(true);
|
|
649
|
+
}
|
|
650
|
+
// Every row tone, or a tone silently renders untinted — and the row-tones baseline only
|
|
651
|
+
// covers the three the specimen happens to use.
|
|
652
|
+
for (const tone of ["neutral", "info", "success", "warning", "danger"]) {
|
|
653
|
+
expect(
|
|
654
|
+
declaresRuleFor(base, `[data-terp="dataview-row"][data-tone="${tone}"]`),
|
|
655
|
+
`row tone ${tone} has no rule`,
|
|
656
|
+
).toBe(true);
|
|
657
|
+
expect(
|
|
658
|
+
declaresRuleFor(base, `[data-terp="dataview-card"][data-tone="${tone}"]`),
|
|
659
|
+
`card tone ${tone} has no rule`,
|
|
660
|
+
).toBe(true);
|
|
661
|
+
}
|
|
662
|
+
// The selection tint must stay guarded against a toned row. Both weigh (0,2,0) without
|
|
663
|
+
// the :not(), which would leave source order deciding whether a failed row reads as
|
|
664
|
+
// failed or merely as selected.
|
|
665
|
+
expect(
|
|
666
|
+
declaresRuleFor(base, '[data-terp="dataview-row"][data-selected="true"]:not([data-tone])'),
|
|
667
|
+
"the selection tint must exclude toned rows explicitly, not by source order",
|
|
668
|
+
).toBe(true);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("reads the density tokens from rules, and re-scopes them unlayered", () => {
|
|
672
|
+
// The four cell-padding tokens were published a stage before anything read them and
|
|
673
|
+
// were deleted for exactly that. These are the readers; without them the tokens are
|
|
674
|
+
// back to being decoration, and the compact attribute silently does nothing.
|
|
675
|
+
for (const token of ["--density-cell-pad-y", "--density-cell-pad-x"]) {
|
|
676
|
+
expect(css, `${token} is published but nothing reads it`).toContain(`var(${token})`);
|
|
677
|
+
}
|
|
678
|
+
// And the re-scoping stays OUTSIDE every layer. Inside one it would lose to the
|
|
679
|
+
// contract's own unlayered :root values whenever the attribute sits on the same element
|
|
680
|
+
// those target — the app-wide case, data-density on <html> — and compact would do
|
|
681
|
+
// nothing at all. Asserted by subtracting the layer bodies from the sheet.
|
|
682
|
+
const density = '[data-density="compact"]';
|
|
683
|
+
expect(css).toContain(density);
|
|
684
|
+
for (const layer of ["terp.reset", "terp.base", "terp.state", "terp.motion"]) {
|
|
685
|
+
expect(layerBody(layer), `${density} must not be inside @layer ${layer}`).not.toContain(
|
|
686
|
+
density,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const at = css.indexOf(density);
|
|
690
|
+
const block = css.slice(at, css.indexOf("}", at));
|
|
691
|
+
for (const token of [
|
|
692
|
+
"--density-control-min-height",
|
|
693
|
+
"--density-cell-pad-y",
|
|
694
|
+
"--density-cell-pad-x",
|
|
695
|
+
]) {
|
|
696
|
+
expect(block, `${token} is not re-scoped under compact`).toContain(`${token}: var(`);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("keeps the markdown wrapper boxless", () => {
|
|
701
|
+
// display: contents is the entire rule, and it has to stay the entire rule. Every
|
|
702
|
+
// non-inherited property on such an element is silently dropped, so a padding or a border
|
|
703
|
+
// added here would do nothing and nothing else in the suite would say so — the base-rule
|
|
704
|
+
// check below only asks that the block is non-empty, which a block of dead declarations
|
|
705
|
+
// satisfies. Prose rhythm belongs in descendant rules.
|
|
706
|
+
const base = layerBody("terp.base");
|
|
707
|
+
const at = base.indexOf('[data-terp="markdown"]');
|
|
708
|
+
expect(at).toBeGreaterThan(-1);
|
|
709
|
+
const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
|
|
710
|
+
expect(block.trim()).toBe("display: contents;");
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("gives every migrated component a base rule in terp.base", () => {
|
|
714
|
+
// markers.test.ts pins the marker join in both directions but cannot see a *deleted*
|
|
715
|
+
// rule: removing a whole block only shrinks the styled set, which still passes. These
|
|
716
|
+
// components render no inline base styles at all, so a missing rule here means the
|
|
717
|
+
// component renders unstyled.
|
|
718
|
+
const base = layerBody("terp.base");
|
|
719
|
+
for (const marker of [
|
|
720
|
+
"button",
|
|
721
|
+
"badge",
|
|
722
|
+
"alert",
|
|
723
|
+
"tooltip",
|
|
724
|
+
"input",
|
|
725
|
+
"field",
|
|
726
|
+
"control-label",
|
|
727
|
+
"checkbox",
|
|
728
|
+
"radio",
|
|
729
|
+
"switch",
|
|
730
|
+
"stack",
|
|
731
|
+
"detail-list",
|
|
732
|
+
"combobox",
|
|
733
|
+
"combobox-list",
|
|
734
|
+
"combobox-option",
|
|
735
|
+
"calendar",
|
|
736
|
+
"calendar-day",
|
|
737
|
+
"calendar-grid",
|
|
738
|
+
"calendar-week",
|
|
739
|
+
"card",
|
|
740
|
+
"card-header",
|
|
741
|
+
"card-title",
|
|
742
|
+
"tabs",
|
|
743
|
+
"tab",
|
|
744
|
+
"tab-list",
|
|
745
|
+
"tab-panel",
|
|
746
|
+
"breadcrumbs",
|
|
747
|
+
"empty-state",
|
|
748
|
+
"error-state",
|
|
749
|
+
"loading-state",
|
|
750
|
+
"spinner-ring",
|
|
751
|
+
"icon",
|
|
752
|
+
"nav-icon",
|
|
753
|
+
"nav-icon-fallback",
|
|
754
|
+
"language-switcher",
|
|
755
|
+
"theme-toggle",
|
|
756
|
+
"theme-toggle-label",
|
|
757
|
+
"language-switcher-label",
|
|
758
|
+
"markdown",
|
|
759
|
+
"menu",
|
|
760
|
+
"menu-item",
|
|
761
|
+
"menu-trigger",
|
|
762
|
+
"menu-item-icon",
|
|
763
|
+
"menu-item-check",
|
|
764
|
+
"page-actions",
|
|
765
|
+
"popover",
|
|
766
|
+
"popover-panel",
|
|
767
|
+
"toast-viewport",
|
|
768
|
+
"toast",
|
|
769
|
+
"toast-icon",
|
|
770
|
+
"toast-body",
|
|
771
|
+
"toast-title",
|
|
772
|
+
"dialog",
|
|
773
|
+
"dialog-body",
|
|
774
|
+
"dialog-title",
|
|
775
|
+
"dialog-description",
|
|
776
|
+
"dialog-actions",
|
|
777
|
+
"user-menu",
|
|
778
|
+
"user-menu-avatar",
|
|
779
|
+
"user-menu-email",
|
|
780
|
+
"user-menu-header",
|
|
781
|
+
"user-menu-identity",
|
|
782
|
+
"user-menu-role",
|
|
783
|
+
"breadcrumbs-current",
|
|
784
|
+
"dataview-table",
|
|
785
|
+
"dataview-column-sort",
|
|
786
|
+
"dataview-column-resizer",
|
|
787
|
+
"dataview-row-open",
|
|
788
|
+
"dataview-card",
|
|
789
|
+
"dataview-card-list",
|
|
790
|
+
"dataview-card-main",
|
|
791
|
+
"dataview-card-body",
|
|
792
|
+
"dataview-card-expanded",
|
|
793
|
+
"dataview-card-fields",
|
|
794
|
+
"dataview-card-heading",
|
|
795
|
+
"dataview-card-title",
|
|
796
|
+
"dataview-card-status",
|
|
797
|
+
"dataview-card-meta",
|
|
798
|
+
"dataview-expanded-cell",
|
|
799
|
+
"dataview-pagination",
|
|
800
|
+
"dataview-pager",
|
|
801
|
+
"dataview-row-actions",
|
|
802
|
+
"dataview-inline-action",
|
|
803
|
+
"dataview",
|
|
804
|
+
"dataview-error",
|
|
805
|
+
"dataview-scroll",
|
|
806
|
+
"dataview-skeleton",
|
|
807
|
+
"dataview-toolbar",
|
|
808
|
+
"dataview-toolbar-actions",
|
|
809
|
+
"dataview-toolbar-count",
|
|
810
|
+
"dataview-toolbar-layout",
|
|
811
|
+
"dataview-toolbar-search",
|
|
812
|
+
"dataview-toolbar-spacer",
|
|
813
|
+
"dataview-toolbar-status",
|
|
814
|
+
"dataview-column-settings",
|
|
815
|
+
"dataview-column-settings-title",
|
|
816
|
+
"dataview-column-option",
|
|
817
|
+
"hubpage-grid",
|
|
818
|
+
"hubcard",
|
|
819
|
+
"hubcard-link",
|
|
820
|
+
"hubcard-body",
|
|
821
|
+
"hubcard-heading",
|
|
822
|
+
"hubcard-icon",
|
|
823
|
+
"hubcard-title",
|
|
824
|
+
"hubcard-description",
|
|
825
|
+
"hubcard-stat",
|
|
826
|
+
"appshell",
|
|
827
|
+
"appshell-sidebar",
|
|
828
|
+
"appshell-backdrop",
|
|
829
|
+
"appshell-brand",
|
|
830
|
+
"appshell-brand-row",
|
|
831
|
+
"appshell-brand-title",
|
|
832
|
+
"appshell-nav",
|
|
833
|
+
"appshell-nav-list",
|
|
834
|
+
"appshell-nav-label",
|
|
835
|
+
"appshell-column",
|
|
836
|
+
"appshell-header",
|
|
837
|
+
"appshell-header-group",
|
|
838
|
+
"appshell-main",
|
|
839
|
+
"appshell-footer",
|
|
840
|
+
]) {
|
|
841
|
+
expect(
|
|
842
|
+
declaresRuleFor(base, `[data-terp="${marker}"]`),
|
|
843
|
+
`[data-terp="${marker}"] must have a base rule of its own, not merely appear in one`,
|
|
844
|
+
).toBe(true);
|
|
845
|
+
}
|
|
29
846
|
});
|
|
30
847
|
});
|