@excom/quark 0.1.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/.rush/temp/chunked-rush-logs/quark.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/quark.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +6 -0
- package/index.ts +4 -0
- package/language.ts +7 -0
- package/package.json +45 -0
- package/rush-logs/quark.apply-exports.cache.log +1 -0
- package/rush-logs/quark.apply-exports.log +1 -0
- package/rush-logs/quark.build_package-metas.cache.log +1 -0
- package/rush-logs/quark.build_package-metas.log +1 -0
- package/src/actions.ts +445 -0
- package/src/ast.ts +187 -0
- package/src/bindings.ts +115 -0
- package/src/builtin-modules.ts +401 -0
- package/src/constants.ts +47 -0
- package/src/cycle-check.ts +121 -0
- package/src/devtools-hook.ts +303 -0
- package/src/element-api.ts +109 -0
- package/src/evaluator.ts +317 -0
- package/src/form-controls.ts +69 -0
- package/src/language-tables.ts +137 -0
- package/src/language.ts +1225 -0
- package/src/observer.ts +110 -0
- package/src/paint.ts +305 -0
- package/src/properties.ts +818 -0
- package/src/props.ts +94 -0
- package/src/quark-element.ts +7 -0
- package/src/quark-internal.ts +365 -0
- package/src/quark.ts +817 -0
- package/src/resolvers.ts +867 -0
- package/src/rule.ts +800 -0
- package/src/scope-id.ts +47 -0
- package/src/selector-utils.ts +430 -0
- package/src/settle.ts +117 -0
- package/src/types.ts +155 -0
- package/src/utils.ts +79 -0
- package/src/variables.ts +359 -0
- package/support/demos/css-variables.html +18 -0
- package/support/demos/dispatch.html +20 -0
- package/support/demos/events.html +9 -0
- package/support/demos/iterate.html +13 -0
- package/support/demos/js-api.html +17 -0
- package/support/demos/provider-list.html +16 -0
- package/support/demos/toggle-content.html +14 -0
- package/support/demos/unset.html +12 -0
- package/support/demos/view-transition.html +36 -0
- package/support/docs/ATTRIBUTES.md +37 -0
- package/support/docs/AT_RULES.md +45 -0
- package/support/docs/BUILTINS.md +57 -0
- package/support/docs/CONTENT.md +40 -0
- package/support/docs/CSS_VARIABLES.md +16 -0
- package/support/docs/DECLARATIONS.md +26 -0
- package/support/docs/DELAY.md +37 -0
- package/support/docs/DIAGNOSTICS.md +32 -0
- package/support/docs/DISPATCH.md +81 -0
- package/support/docs/ELEMENT_PROPERTIES.md +34 -0
- package/support/docs/EXPRESSIONS.md +31 -0
- package/support/docs/INTERNAL.md +8 -0
- package/support/docs/JS_API.md +53 -0
- package/support/docs/JS_WRITES.md +36 -0
- package/support/docs/LIMITATIONS.md +26 -0
- package/support/docs/LOOP_GUARD.md +30 -0
- package/support/docs/METHODS.md +43 -0
- package/support/docs/MODULES.md +111 -0
- package/support/docs/NO_REVERSION.md +18 -0
- package/support/docs/ON.md +87 -0
- package/support/docs/REACTIVITY.md +24 -0
- package/support/docs/README.md +143 -0
- package/support/docs/SELECTORS.md +87 -0
- package/support/docs/SHEETS.md +67 -0
- package/support/docs/SYNTAX.md +36 -0
- package/support/docs/USE.md +24 -0
- package/support/docs/VALUES.md +27 -0
- package/support/docs/VARIABLES.md +47 -0
- package/support/docs/VIEW_TRANSITION.md +64 -0
- package/support/docs-sections.json +65 -0
- package/support/package-meta.json +208 -0
- package/support/scripts/build-language-docs.mjs +196 -0
- package/support/tests/__snapshots__/css-variables.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/dispatch.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/events.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/iterate.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/js-api.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/provider-list.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/quark-features.test.ts.snap +1079 -0
- package/support/tests/__snapshots__/quark-with-providers.test.ts.snap +89 -0
- package/support/tests/__snapshots__/quark.test.ts.snap +221 -0
- package/support/tests/__snapshots__/selectors.test.ts.snap +133 -0
- package/support/tests/__snapshots__/toggle-content.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/unset.view.test.ts.snap +23 -0
- package/support/tests/__snapshots__/view-transition.view.test.ts.snap +23 -0
- package/support/tests/actions.test.ts +437 -0
- package/support/tests/builtin-modules.test.ts +337 -0
- package/support/tests/builtins.test.ts +189 -0
- package/support/tests/css-variables.view.test.ts +35 -0
- package/support/tests/delay.test.ts +244 -0
- package/support/tests/devtools-hook.test.ts +496 -0
- package/support/tests/diagnostics.test.ts +224 -0
- package/support/tests/dispatch.view.test.ts +43 -0
- package/support/tests/element-api.test.ts +202 -0
- package/support/tests/evaluator.test.ts +237 -0
- package/support/tests/events.view.test.ts +40 -0
- package/support/tests/form-controls.test.ts +452 -0
- package/support/tests/helpers.ts +354 -0
- package/support/tests/internals.test.ts +568 -0
- package/support/tests/iterate.view.test.ts +40 -0
- package/support/tests/js-api.view.test.ts +45 -0
- package/support/tests/language-docs.test.ts +147 -0
- package/support/tests/lifecycle.test.ts +440 -0
- package/support/tests/loop-guard.test.ts +323 -0
- package/support/tests/provider-list.view.test.ts +47 -0
- package/support/tests/quark-features.test.ts +2887 -0
- package/support/tests/quark-with-providers.test.ts +169 -0
- package/support/tests/quark.test.ts +359 -0
- package/support/tests/resolvers.test.ts +346 -0
- package/support/tests/selector-analysis.test.ts +281 -0
- package/support/tests/selectors.test.ts +670 -0
- package/support/tests/toggle-content.view.test.ts +40 -0
- package/support/tests/unset.view.test.ts +42 -0
- package/support/tests/view-helpers.ts +81 -0
- package/support/tests/view-transition.test.ts +1069 -0
- package/support/tests/view-transition.view.test.ts +76 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,2887 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature coverage for Quark. Each case asserts DOM correctness and a complexity
|
|
3
|
+
* budget. Broken expected behavior is left failing (do not "fix" via source/test
|
|
4
|
+
* hacks) so regressions stay visible.
|
|
5
|
+
*/
|
|
6
|
+
import { Quark } from "../../index";
|
|
7
|
+
import { isObservedProperty } from "@excom/kit-utils";
|
|
8
|
+
import { QuarkLogger } from "../../src/utils";
|
|
9
|
+
import {
|
|
10
|
+
afterEach,
|
|
11
|
+
describe,
|
|
12
|
+
expect,
|
|
13
|
+
fixture,
|
|
14
|
+
it,
|
|
15
|
+
spyFetch,
|
|
16
|
+
vi,
|
|
17
|
+
wait,
|
|
18
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
19
|
+
import {
|
|
20
|
+
createSheet,
|
|
21
|
+
expectComplexity,
|
|
22
|
+
flush,
|
|
23
|
+
measureComplexity,
|
|
24
|
+
unregisterAll,
|
|
25
|
+
} from "./helpers";
|
|
26
|
+
|
|
27
|
+
const bindIterateRows = async (root: ParentNode) => {
|
|
28
|
+
await flush();
|
|
29
|
+
return [...root.querySelectorAll("ul > li")];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("Quark features", () => {
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
unregisterAll();
|
|
35
|
+
document.body.innerHTML = "";
|
|
36
|
+
vi.restoreAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("content", () => {
|
|
40
|
+
it("clears text with content: none", async () => {
|
|
41
|
+
const { root, quark, register } = createSheet(
|
|
42
|
+
`<p>stale</p>`,
|
|
43
|
+
`p { content: none; }`
|
|
44
|
+
);
|
|
45
|
+
const meter = measureComplexity(quark);
|
|
46
|
+
register();
|
|
47
|
+
await flush();
|
|
48
|
+
const budget = meter.take();
|
|
49
|
+
meter.stop();
|
|
50
|
+
|
|
51
|
+
expect(root.querySelector("p")?.textContent).toBe("");
|
|
52
|
+
expectComplexity(budget);
|
|
53
|
+
quark.unregister();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps // inside strings and ends /* */ comments correctly", async () => {
|
|
57
|
+
const { root, quark, register } = createSheet(
|
|
58
|
+
`<p></p><span></span>`,
|
|
59
|
+
`/* a comment before a rule */
|
|
60
|
+
p { content: "http://example.com/a"; } /* trailing comment */
|
|
61
|
+
span { content: "ok"; }`
|
|
62
|
+
);
|
|
63
|
+
register();
|
|
64
|
+
await flush();
|
|
65
|
+
|
|
66
|
+
expect(root.querySelector("p")?.textContent).toBe("http://example.com/a");
|
|
67
|
+
expect(root.querySelector("span")?.textContent).toBe("ok");
|
|
68
|
+
quark.unregister();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("renders a DOM template via template(#id)", async () => {
|
|
72
|
+
const { root, quark, register } = createSheet(
|
|
73
|
+
`<article></article>
|
|
74
|
+
<template id="article-tmpl"><p bind-body>from-template</p></template>`,
|
|
75
|
+
`article {
|
|
76
|
+
content: template("#article-tmpl");
|
|
77
|
+
}`
|
|
78
|
+
);
|
|
79
|
+
const meter = measureComplexity(quark);
|
|
80
|
+
register();
|
|
81
|
+
await flush();
|
|
82
|
+
const budget = meter.take();
|
|
83
|
+
meter.stop();
|
|
84
|
+
|
|
85
|
+
expect(root.querySelector("article p")?.textContent).toBe(
|
|
86
|
+
"from-template"
|
|
87
|
+
);
|
|
88
|
+
expectComplexity(budget);
|
|
89
|
+
quark.unregister();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("renders a fetched template via template(url)", async () => {
|
|
93
|
+
spyFetch({
|
|
94
|
+
status: 200,
|
|
95
|
+
body: `<p bind-body>from-url</p>`,
|
|
96
|
+
headers: new Headers({ "content-type": "text/html" }),
|
|
97
|
+
});
|
|
98
|
+
const { root, quark, register } = createSheet(
|
|
99
|
+
`<article></article>`,
|
|
100
|
+
`article {
|
|
101
|
+
content: template("/tpls/article.html");
|
|
102
|
+
}`
|
|
103
|
+
);
|
|
104
|
+
const meter = measureComplexity(quark);
|
|
105
|
+
register();
|
|
106
|
+
await flush();
|
|
107
|
+
const budget = meter.take();
|
|
108
|
+
meter.stop();
|
|
109
|
+
|
|
110
|
+
expect(root.querySelector("article p")?.textContent).toBe("from-url");
|
|
111
|
+
expectComplexity(budget);
|
|
112
|
+
quark.unregister();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("injects markup via dangerous-html()", async () => {
|
|
116
|
+
const { root, quark, register } = createSheet(
|
|
117
|
+
`<header></header>`,
|
|
118
|
+
`header {
|
|
119
|
+
content: dangerous-html(getMyHTML());
|
|
120
|
+
}`,
|
|
121
|
+
{ getMyHTML: () => "<strong bind-x>hi</strong>" }
|
|
122
|
+
);
|
|
123
|
+
const meter = measureComplexity(quark);
|
|
124
|
+
register();
|
|
125
|
+
await flush();
|
|
126
|
+
const budget = meter.take();
|
|
127
|
+
meter.stop();
|
|
128
|
+
|
|
129
|
+
expect(root.querySelector("header strong")?.textContent).toBe("hi");
|
|
130
|
+
expectComplexity(budget);
|
|
131
|
+
quark.unregister();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("toggles content from open state (details)", async () => {
|
|
135
|
+
const { root, quark, register } = createSheet(
|
|
136
|
+
`<details><p bind-status>idle</p></details>`,
|
|
137
|
+
`details[open] [bind-status] { content: "opened"; }
|
|
138
|
+
details:not([open]) [bind-status] { content: none; }`
|
|
139
|
+
);
|
|
140
|
+
register();
|
|
141
|
+
await flush();
|
|
142
|
+
expect(root.querySelector("[bind-status]")?.textContent).toBe("");
|
|
143
|
+
|
|
144
|
+
const meter = measureComplexity(quark);
|
|
145
|
+
root.querySelector("details")!.setAttribute("open", "");
|
|
146
|
+
await flush();
|
|
147
|
+
expect(root.querySelector("[bind-status]")?.textContent).toBe("opened");
|
|
148
|
+
|
|
149
|
+
root.querySelector("details")!.removeAttribute("open");
|
|
150
|
+
await flush();
|
|
151
|
+
const budget = meter.take();
|
|
152
|
+
meter.stop();
|
|
153
|
+
|
|
154
|
+
expect(root.querySelector("[bind-status]")?.textContent).toBe("");
|
|
155
|
+
expectComplexity(budget);
|
|
156
|
+
quark.unregister();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("rendering into a <template> host", () => {
|
|
160
|
+
it("writes a string into the template's document fragment", async () => {
|
|
161
|
+
const { root, quark, register } = createSheet(
|
|
162
|
+
`<template id="host"></template>`,
|
|
163
|
+
`#host { content: "hello"; }`
|
|
164
|
+
);
|
|
165
|
+
const host = root.querySelector("#host") as HTMLTemplateElement;
|
|
166
|
+
const meter = measureComplexity(quark);
|
|
167
|
+
register();
|
|
168
|
+
await flush();
|
|
169
|
+
const budget = meter.take();
|
|
170
|
+
meter.stop();
|
|
171
|
+
|
|
172
|
+
// `textContent` on <template> does not write the fragment
|
|
173
|
+
expect(host.content.textContent).toBe("hello");
|
|
174
|
+
expect(host.childNodes).toHaveLength(0);
|
|
175
|
+
expectComplexity(budget);
|
|
176
|
+
quark.unregister();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("writes a settled string into the template's document fragment", async () => {
|
|
180
|
+
const { root, quark, register } = createSheet(
|
|
181
|
+
`<template id="host"></template>`,
|
|
182
|
+
`#host { content: getText(); }`,
|
|
183
|
+
{ getText: () => Promise.resolve("async-hello") }
|
|
184
|
+
);
|
|
185
|
+
const host = root.querySelector("#host") as HTMLTemplateElement;
|
|
186
|
+
const meter = measureComplexity(quark);
|
|
187
|
+
register();
|
|
188
|
+
await flush();
|
|
189
|
+
const budget = meter.take();
|
|
190
|
+
meter.stop();
|
|
191
|
+
|
|
192
|
+
expect(host.content.textContent).toBe("async-hello");
|
|
193
|
+
expect(host.childNodes).toHaveLength(0);
|
|
194
|
+
expectComplexity(budget);
|
|
195
|
+
quark.unregister();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("injects dangerous-html() into the fragment without requeueing rules", async () => {
|
|
199
|
+
const { root, quark, register } = createSheet(
|
|
200
|
+
`<template id="host"></template>`,
|
|
201
|
+
`#host { content: dangerous-html(getMyHTML()); }`,
|
|
202
|
+
{ getMyHTML: () => "<strong>hi</strong>" }
|
|
203
|
+
);
|
|
204
|
+
const host = root.querySelector("#host") as HTMLTemplateElement;
|
|
205
|
+
const meter = measureComplexity(quark);
|
|
206
|
+
register();
|
|
207
|
+
await flush();
|
|
208
|
+
const budget = meter.take();
|
|
209
|
+
meter.stop();
|
|
210
|
+
|
|
211
|
+
expect(host.content.querySelector("strong")?.textContent).toBe("hi");
|
|
212
|
+
expect(host.querySelector("strong")).toBeNull();
|
|
213
|
+
expectComplexity(budget);
|
|
214
|
+
quark.unregister();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("clones template() onto a template host without requeueing rules", async () => {
|
|
218
|
+
const { root, quark, register } = createSheet(
|
|
219
|
+
`<template id="host"></template>
|
|
220
|
+
<template id="src"><p>from-template</p></template>`,
|
|
221
|
+
`#host { content: template("#src"); }`
|
|
222
|
+
);
|
|
223
|
+
const host = root.querySelector("#host") as HTMLTemplateElement;
|
|
224
|
+
const meter = measureComplexity(quark);
|
|
225
|
+
register();
|
|
226
|
+
await flush();
|
|
227
|
+
const budget = meter.take();
|
|
228
|
+
meter.stop();
|
|
229
|
+
|
|
230
|
+
expect(host.content.querySelector("p")?.textContent).toBe(
|
|
231
|
+
"from-template"
|
|
232
|
+
);
|
|
233
|
+
expectComplexity(budget);
|
|
234
|
+
quark.unregister();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("paints a Node into a template host without requeueing rules", async () => {
|
|
238
|
+
const node = document.createElement("span");
|
|
239
|
+
node.textContent = "idle";
|
|
240
|
+
const { root, quark, register } = createSheet(
|
|
241
|
+
`<template id="host"></template>`,
|
|
242
|
+
`#host { content: getNode(); }`,
|
|
243
|
+
{ getNode: () => node }
|
|
244
|
+
);
|
|
245
|
+
const host = root.querySelector("#host") as HTMLTemplateElement;
|
|
246
|
+
const meter = measureComplexity(quark);
|
|
247
|
+
register();
|
|
248
|
+
await flush();
|
|
249
|
+
const budget = meter.take();
|
|
250
|
+
meter.stop();
|
|
251
|
+
|
|
252
|
+
expect(host.content.querySelector("span")?.textContent).toBe("idle");
|
|
253
|
+
expectComplexity(budget);
|
|
254
|
+
quark.unregister();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("paints a NodeList into a template host without requeueing rules", async () => {
|
|
258
|
+
const wrap = document.createElement("div");
|
|
259
|
+
wrap.innerHTML = `<span>a</span><span>b</span>`;
|
|
260
|
+
const { root, quark, register } = createSheet(
|
|
261
|
+
`<template id="host"></template>`,
|
|
262
|
+
`#host { content: getNodes(); }`,
|
|
263
|
+
{ getNodes: () => wrap.childNodes }
|
|
264
|
+
);
|
|
265
|
+
const host = root.querySelector("#host") as HTMLTemplateElement;
|
|
266
|
+
const meter = measureComplexity(quark);
|
|
267
|
+
register();
|
|
268
|
+
await flush();
|
|
269
|
+
const budget = meter.take();
|
|
270
|
+
meter.stop();
|
|
271
|
+
|
|
272
|
+
expect(
|
|
273
|
+
[...host.content.querySelectorAll("span")].map((el) => el.textContent)
|
|
274
|
+
).toEqual(["a", "b"]);
|
|
275
|
+
expectComplexity(budget);
|
|
276
|
+
quark.unregister();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("attributes and special fields", () => {
|
|
282
|
+
it("sets boolean-ish empty attributes", async () => {
|
|
283
|
+
const { root, quark, register } = createSheet(
|
|
284
|
+
`<input />`,
|
|
285
|
+
`input { autofocus: ""; }`
|
|
286
|
+
);
|
|
287
|
+
const meter = measureComplexity(quark);
|
|
288
|
+
register();
|
|
289
|
+
await flush();
|
|
290
|
+
const budget = meter.take();
|
|
291
|
+
meter.stop();
|
|
292
|
+
|
|
293
|
+
expect(root.querySelector("input")?.hasAttribute("autofocus")).toBe(true);
|
|
294
|
+
expectComplexity(budget);
|
|
295
|
+
quark.unregister();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("applies dataset from an object", async () => {
|
|
299
|
+
const { root, quark, register } = createSheet(
|
|
300
|
+
`<span></span>`,
|
|
301
|
+
`span {
|
|
302
|
+
dataset: getMeta();
|
|
303
|
+
}`,
|
|
304
|
+
{ getMeta: () => ({ userId: "42", role: "admin" }) }
|
|
305
|
+
);
|
|
306
|
+
const meter = measureComplexity(quark);
|
|
307
|
+
register();
|
|
308
|
+
await flush();
|
|
309
|
+
const budget = meter.take();
|
|
310
|
+
meter.stop();
|
|
311
|
+
|
|
312
|
+
const span = root.querySelector("span")!;
|
|
313
|
+
expect(span.getAttribute("data-user-id")).toBe("42");
|
|
314
|
+
expect(span.getAttribute("data-role")).toBe("admin");
|
|
315
|
+
expectComplexity(budget);
|
|
316
|
+
quark.unregister();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("applies ariaset from an object", async () => {
|
|
320
|
+
const { root, quark, register } = createSheet(
|
|
321
|
+
`<button type="button"></button>`,
|
|
322
|
+
`button {
|
|
323
|
+
ariaset: getAria();
|
|
324
|
+
}`,
|
|
325
|
+
{ getAria: () => ({ label: "Save", pressed: "false" }) }
|
|
326
|
+
);
|
|
327
|
+
const meter = measureComplexity(quark);
|
|
328
|
+
register();
|
|
329
|
+
await flush();
|
|
330
|
+
const budget = meter.take();
|
|
331
|
+
meter.stop();
|
|
332
|
+
|
|
333
|
+
const button = root.querySelector("button")!;
|
|
334
|
+
expect(button.getAttribute("aria-label")).toBe("Save");
|
|
335
|
+
expect(button.getAttribute("aria-pressed")).toBe("false");
|
|
336
|
+
expectComplexity(budget);
|
|
337
|
+
quark.unregister();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("sets class from a string", async () => {
|
|
341
|
+
const { root, quark, register } = createSheet(
|
|
342
|
+
`<div></div>`,
|
|
343
|
+
`div { class: "flavor-a"; }`
|
|
344
|
+
);
|
|
345
|
+
const meter = measureComplexity(quark);
|
|
346
|
+
register();
|
|
347
|
+
await flush();
|
|
348
|
+
const budget = meter.take();
|
|
349
|
+
meter.stop();
|
|
350
|
+
|
|
351
|
+
expect(root.querySelector("div")?.getAttribute("class")).toBe("flavor-a");
|
|
352
|
+
expectComplexity(budget);
|
|
353
|
+
quark.unregister();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("variables and expressions", () => {
|
|
358
|
+
it("reads nested object fields from a JS module value", async () => {
|
|
359
|
+
const { root, quark, register } = createSheet(
|
|
360
|
+
`<main><span bind-id></span></main>`,
|
|
361
|
+
`main {
|
|
362
|
+
$myObj: getMyObj();
|
|
363
|
+
[bind-id] { content: $myObj.id; }
|
|
364
|
+
}`,
|
|
365
|
+
{ getMyObj: () => ({ id: "abc-123" }) }
|
|
366
|
+
);
|
|
367
|
+
const meter = measureComplexity(quark);
|
|
368
|
+
register();
|
|
369
|
+
await flush();
|
|
370
|
+
const budget = meter.take();
|
|
371
|
+
meter.stop();
|
|
372
|
+
|
|
373
|
+
expect(root.querySelector("[bind-id]")?.textContent).toBe("abc-123");
|
|
374
|
+
expectComplexity(budget);
|
|
375
|
+
quark.unregister();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("concatenates simple string expressions", async () => {
|
|
379
|
+
const { root, quark, register } = createSheet(
|
|
380
|
+
`<output bind-label></output>`,
|
|
381
|
+
`[bind-label] {
|
|
382
|
+
content: "ID is " + getId();
|
|
383
|
+
}`,
|
|
384
|
+
{ getId: () => "9" }
|
|
385
|
+
);
|
|
386
|
+
const meter = measureComplexity(quark);
|
|
387
|
+
register();
|
|
388
|
+
await flush();
|
|
389
|
+
const budget = meter.take();
|
|
390
|
+
meter.stop();
|
|
391
|
+
|
|
392
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("ID is 9");
|
|
393
|
+
expectComplexity(budget);
|
|
394
|
+
quark.unregister();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("resolves ternary()", async () => {
|
|
398
|
+
const { root, quark, register } = createSheet(
|
|
399
|
+
`<span bind-label></span>`,
|
|
400
|
+
`[bind-label] {
|
|
401
|
+
content: ternary(true, "yes", "no");
|
|
402
|
+
}`
|
|
403
|
+
);
|
|
404
|
+
const meter = measureComplexity(quark);
|
|
405
|
+
register();
|
|
406
|
+
await flush();
|
|
407
|
+
const budget = meter.take();
|
|
408
|
+
meter.stop();
|
|
409
|
+
|
|
410
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("yes");
|
|
411
|
+
expectComplexity(budget);
|
|
412
|
+
quark.unregister();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("propagates null silently through accessors", async () => {
|
|
416
|
+
const { root, quark, register } = createSheet(
|
|
417
|
+
`<span bind-label></span>`,
|
|
418
|
+
`[bind-label] {
|
|
419
|
+
$pkg: getPkg();
|
|
420
|
+
content: $pkg.cssAliases.length or "none";
|
|
421
|
+
data-first: $pkg.cssAliases[0] or "empty";
|
|
422
|
+
data-slug: $pkg.missing.toLowerCase() or "no-slug";
|
|
423
|
+
}`,
|
|
424
|
+
{ getPkg: () => ({ name: "x" }) }
|
|
425
|
+
);
|
|
426
|
+
const meter = measureComplexity(quark);
|
|
427
|
+
register();
|
|
428
|
+
await flush();
|
|
429
|
+
const budget = meter.take();
|
|
430
|
+
meter.stop();
|
|
431
|
+
|
|
432
|
+
const el = root.querySelector("[bind-label]");
|
|
433
|
+
expect(el?.textContent).toBe("none");
|
|
434
|
+
expect(el?.getAttribute("data-first")).toBe("empty");
|
|
435
|
+
expect(el?.getAttribute("data-slug")).toBe("no-slug");
|
|
436
|
+
expectComplexity(budget);
|
|
437
|
+
quark.unregister();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("resolves CSS-style if() to the first truthy arm", async () => {
|
|
441
|
+
const { root, quark, register } = createSheet(
|
|
442
|
+
`<span bind-label></span>`,
|
|
443
|
+
`[bind-label] {
|
|
444
|
+
$count: getCount();
|
|
445
|
+
content: if($count == 0: "none"; $count > 3: "many"; else: "few");
|
|
446
|
+
}`,
|
|
447
|
+
{ getCount: () => 5 }
|
|
448
|
+
);
|
|
449
|
+
const meter = measureComplexity(quark);
|
|
450
|
+
register();
|
|
451
|
+
await flush();
|
|
452
|
+
const budget = meter.take();
|
|
453
|
+
meter.stop();
|
|
454
|
+
|
|
455
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("many");
|
|
456
|
+
expectComplexity(budget);
|
|
457
|
+
quark.unregister();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("falls back to the else arm in if()", async () => {
|
|
461
|
+
const { root, quark, register } = createSheet(
|
|
462
|
+
`<span bind-label></span>`,
|
|
463
|
+
`[bind-label] {
|
|
464
|
+
content: if(false: "never"; else: "fallback");
|
|
465
|
+
}`
|
|
466
|
+
);
|
|
467
|
+
const meter = measureComplexity(quark);
|
|
468
|
+
register();
|
|
469
|
+
await flush();
|
|
470
|
+
const budget = meter.take();
|
|
471
|
+
meter.stop();
|
|
472
|
+
|
|
473
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("fallback");
|
|
474
|
+
expectComplexity(budget);
|
|
475
|
+
quark.unregister();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("reads attributes via attr()", async () => {
|
|
479
|
+
const { root, quark, register } = createSheet(
|
|
480
|
+
`<span bind-label data-src="from-attr"></span>`,
|
|
481
|
+
`[bind-label] {
|
|
482
|
+
content: attr("data-src");
|
|
483
|
+
}`
|
|
484
|
+
);
|
|
485
|
+
const meter = measureComplexity(quark);
|
|
486
|
+
register();
|
|
487
|
+
await flush();
|
|
488
|
+
const budget = meter.take();
|
|
489
|
+
meter.stop();
|
|
490
|
+
|
|
491
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("from-attr");
|
|
492
|
+
expectComplexity(budget);
|
|
493
|
+
quark.unregister();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("re-runs when an attr() attribute changes without a selector gate", async () => {
|
|
497
|
+
const { root, quark, register } = createSheet(
|
|
498
|
+
`<span bind-label></span>`,
|
|
499
|
+
`:scope {
|
|
500
|
+
$src: attr("data-src");
|
|
501
|
+
[bind-label] { content: $src; }
|
|
502
|
+
}`
|
|
503
|
+
);
|
|
504
|
+
const spy = vi.spyOn(quark, "queueRunRules");
|
|
505
|
+
register();
|
|
506
|
+
await flush();
|
|
507
|
+
spy.mockClear();
|
|
508
|
+
|
|
509
|
+
root.setAttribute("data-src", "hello");
|
|
510
|
+
await wait(0);
|
|
511
|
+
expect(quark.allAttrs).toContain("data-src");
|
|
512
|
+
expect(spy).toHaveBeenCalledWith(
|
|
513
|
+
expect.objectContaining({ attribute: "data-src", element: root })
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
const meter = measureComplexity(quark);
|
|
517
|
+
await flush();
|
|
518
|
+
const budget = meter.take();
|
|
519
|
+
meter.stop();
|
|
520
|
+
|
|
521
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("hello");
|
|
522
|
+
expectComplexity(budget);
|
|
523
|
+
quark.unregister();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("does not rematch a sibling attr() rule when a different attribute changes", async () => {
|
|
527
|
+
const { root, quark, register } = createSheet(
|
|
528
|
+
`<span bind-celsius></span><span bind-fahrenheit></span>`,
|
|
529
|
+
`:scope {
|
|
530
|
+
$celsius: attr("celsius");
|
|
531
|
+
[bind-fahrenheit] { content: $celsius; }
|
|
532
|
+
}
|
|
533
|
+
:scope {
|
|
534
|
+
$fahrenheit: attr("fahrenheit");
|
|
535
|
+
[bind-celsius] { content: $fahrenheit; }
|
|
536
|
+
}`
|
|
537
|
+
);
|
|
538
|
+
register();
|
|
539
|
+
await flush();
|
|
540
|
+
|
|
541
|
+
root.setAttribute("celsius", "10");
|
|
542
|
+
await flush();
|
|
543
|
+
expect(root.querySelector("[bind-fahrenheit]")?.textContent).toBe("10");
|
|
544
|
+
expect(root.querySelector("[bind-celsius]")?.textContent).toBe("");
|
|
545
|
+
|
|
546
|
+
root.setAttribute("celsius", "20");
|
|
547
|
+
await flush();
|
|
548
|
+
expect(root.querySelector("[bind-fahrenheit]")?.textContent).toBe("20");
|
|
549
|
+
expect(root.querySelector("[bind-celsius]")?.textContent).toBe("");
|
|
550
|
+
quark.unregister();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("warns when attr() is not a static string", async () => {
|
|
554
|
+
QuarkLogger.suppress();
|
|
555
|
+
const warnSpy = vi.spyOn(QuarkLogger, "warn");
|
|
556
|
+
const { quark, register } = createSheet(
|
|
557
|
+
`<span bind-label></span>`,
|
|
558
|
+
`[bind-label] { content: attr($name); }`
|
|
559
|
+
);
|
|
560
|
+
register();
|
|
561
|
+
await flush();
|
|
562
|
+
expect(
|
|
563
|
+
warnSpy.mock.calls.some((args) =>
|
|
564
|
+
String((args[0] as { message?: string })?.message).includes("attr()")
|
|
565
|
+
)
|
|
566
|
+
).toBe(true);
|
|
567
|
+
warnSpy.mockRestore();
|
|
568
|
+
QuarkLogger.unsuppress();
|
|
569
|
+
quark.unregister();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("resolves closest() to an ancestor element", async () => {
|
|
573
|
+
const { root, quark, register } = createSheet(
|
|
574
|
+
`<main data-section="alpha"><span bind-label></span></main>`,
|
|
575
|
+
`[bind-label] {
|
|
576
|
+
$host: closest("main");
|
|
577
|
+
content: $host.getAttribute("data-section");
|
|
578
|
+
}`
|
|
579
|
+
);
|
|
580
|
+
const meter = measureComplexity(quark);
|
|
581
|
+
register();
|
|
582
|
+
await flush();
|
|
583
|
+
const budget = meter.take();
|
|
584
|
+
meter.stop();
|
|
585
|
+
|
|
586
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("alpha");
|
|
587
|
+
expectComplexity(budget);
|
|
588
|
+
quark.unregister();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("exposes the matched element as `element` (the listening element inside @on blocks)", async () => {
|
|
592
|
+
const seen: HTMLElement[] = [];
|
|
593
|
+
// a handler factory: `fire(element)` resolves per event to the listener
|
|
594
|
+
const fire = (el: HTMLElement) => () => seen.push(el);
|
|
595
|
+
const { root, quark, register } = createSheet(
|
|
596
|
+
`<main><ul><li data-id="1"><span bind-label></span></li></ul></main>`,
|
|
597
|
+
`[bind-label] {
|
|
598
|
+
content: element.localName + ":" + element.closest("li").getAttribute("data-id");
|
|
599
|
+
}
|
|
600
|
+
ul {
|
|
601
|
+
@on click (target: "li", handle: fire(element)) {
|
|
602
|
+
data-clicked: element.localName + ">" + target.localName;
|
|
603
|
+
}
|
|
604
|
+
}`,
|
|
605
|
+
{ fire }
|
|
606
|
+
);
|
|
607
|
+
register();
|
|
608
|
+
await flush();
|
|
609
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("span:1");
|
|
610
|
+
root
|
|
611
|
+
.querySelector("span")!
|
|
612
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
613
|
+
await flush();
|
|
614
|
+
expect(seen).toEqual([root.querySelector("ul")]);
|
|
615
|
+
expect(root.querySelector("ul")?.getAttribute("data-clicked")).toBe(
|
|
616
|
+
"ul>li"
|
|
617
|
+
);
|
|
618
|
+
quark.unregister();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("resolves a variable from a DOM ancestor set by a sibling rule", async () => {
|
|
622
|
+
// The defining rule is NOT an ancestor in the rule tree: DOM-tree
|
|
623
|
+
// scoping resolves $theme from the nearest DOM ancestor that holds it.
|
|
624
|
+
const { root, quark, register } = createSheet(
|
|
625
|
+
`<main><span bind-theme></span></main>`,
|
|
626
|
+
`main { $theme: "dark"; data-theme-host: ""; }
|
|
627
|
+
[bind-theme] { content: $theme; }`
|
|
628
|
+
);
|
|
629
|
+
const meter = measureComplexity(quark);
|
|
630
|
+
register();
|
|
631
|
+
await flush();
|
|
632
|
+
const budget = meter.take();
|
|
633
|
+
meter.stop();
|
|
634
|
+
|
|
635
|
+
expect(root.querySelector("[bind-theme]")?.textContent).toBe("dark");
|
|
636
|
+
expectComplexity(budget);
|
|
637
|
+
quark.unregister();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("resolves to the nearest DOM ancestor when bindings shadow", async () => {
|
|
641
|
+
const { root, quark, register } = createSheet(
|
|
642
|
+
`<section>
|
|
643
|
+
<span bind-label id="outer-consumer"></span>
|
|
644
|
+
<article><span bind-label id="inner-consumer"></span></article>
|
|
645
|
+
</section>`,
|
|
646
|
+
`section { $label: "outer"; }
|
|
647
|
+
article { $label: "inner"; }
|
|
648
|
+
[bind-label] { content: $label; }`
|
|
649
|
+
);
|
|
650
|
+
register();
|
|
651
|
+
await flush();
|
|
652
|
+
|
|
653
|
+
expect(root.querySelector("#outer-consumer")?.textContent).toBe("outer");
|
|
654
|
+
expect(root.querySelector("#inner-consumer")?.textContent).toBe("inner");
|
|
655
|
+
quark.unregister();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("uses list.reverse() on an array before iterate", async () => {
|
|
659
|
+
const root = fixture<HTMLElement>(`<section>
|
|
660
|
+
<div id="sheet"></div>
|
|
661
|
+
<data-provider is-success>
|
|
662
|
+
<ul>
|
|
663
|
+
<template><li bind-label></li></template>
|
|
664
|
+
</ul>
|
|
665
|
+
</data-provider>
|
|
666
|
+
</section>`);
|
|
667
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
668
|
+
provision?: string[];
|
|
669
|
+
};
|
|
670
|
+
provider.provision = ["a", "b", "c"];
|
|
671
|
+
|
|
672
|
+
const quark = new Quark({
|
|
673
|
+
options: { isScoped: true },
|
|
674
|
+
src: `@use "quark:list" as list;
|
|
675
|
+
data-provider[is-success] {
|
|
676
|
+
$items: list.reverse(prop("provision"));
|
|
677
|
+
ul { content: iterate($items); }
|
|
678
|
+
[bind-label] { content: item; }
|
|
679
|
+
}`,
|
|
680
|
+
});
|
|
681
|
+
const meter = measureComplexity(quark);
|
|
682
|
+
quark.register({
|
|
683
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
684
|
+
});
|
|
685
|
+
const rows = await bindIterateRows(root);
|
|
686
|
+
const budget = meter.take();
|
|
687
|
+
meter.stop();
|
|
688
|
+
|
|
689
|
+
expect(rows.map((el) => el.textContent)).toEqual(["c", "b", "a"]);
|
|
690
|
+
expectComplexity(budget);
|
|
691
|
+
quark.unregister();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("uses list.find() to pick a record from an array", async () => {
|
|
695
|
+
const root = fixture<HTMLElement>(`<section>
|
|
696
|
+
<div id="sheet"></div>
|
|
697
|
+
<data-provider is-success>
|
|
698
|
+
<span bind-label></span>
|
|
699
|
+
</data-provider>
|
|
700
|
+
</section>`);
|
|
701
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
702
|
+
provision?: { id: string; label: string }[];
|
|
703
|
+
};
|
|
704
|
+
provider.provision = [
|
|
705
|
+
{ id: "1", label: "one" },
|
|
706
|
+
{ id: "2", label: "two" },
|
|
707
|
+
];
|
|
708
|
+
|
|
709
|
+
const quark = new Quark({
|
|
710
|
+
options: { isScoped: true },
|
|
711
|
+
src: `@use "quark:list" as list;
|
|
712
|
+
data-provider[is-success] {
|
|
713
|
+
$match: list.find(prop("provision"), "id", "2");
|
|
714
|
+
[bind-label] { content: $match.label; }
|
|
715
|
+
}`,
|
|
716
|
+
});
|
|
717
|
+
const meter = measureComplexity(quark);
|
|
718
|
+
quark.register({
|
|
719
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
720
|
+
});
|
|
721
|
+
await flush();
|
|
722
|
+
const budget = meter.take();
|
|
723
|
+
meter.stop();
|
|
724
|
+
|
|
725
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("two");
|
|
726
|
+
expectComplexity(budget);
|
|
727
|
+
quark.unregister();
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
describe("prop()", () => {
|
|
732
|
+
it("re-runs when a native element property is assigned from JS", async () => {
|
|
733
|
+
const { root, quark, register } = createSheet(
|
|
734
|
+
`<input value="typed">`,
|
|
735
|
+
`input { data-len: prop("value").length; }`
|
|
736
|
+
);
|
|
737
|
+
register();
|
|
738
|
+
await flush();
|
|
739
|
+
const input = root.querySelector("input") as HTMLInputElement;
|
|
740
|
+
expect(input.getAttribute("data-len")).toBe("5");
|
|
741
|
+
input.value = "re-typed";
|
|
742
|
+
await flush();
|
|
743
|
+
expect(input.getAttribute("data-len")).toBe("8");
|
|
744
|
+
// the wrapper delegates to the platform accessor
|
|
745
|
+
expect(input.getAttribute("value")).toBe("typed");
|
|
746
|
+
quark.unregister();
|
|
747
|
+
// subscriptions are released with the sheet
|
|
748
|
+
expect(isObservedProperty(input, "value")).toBe(false);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("coalesces several assignments in one tick into one re-run", async () => {
|
|
752
|
+
const root = fixture<HTMLElement>(`<section>
|
|
753
|
+
<div id="sheet"></div>
|
|
754
|
+
<data-provider is-success><span bind-title></span></data-provider>
|
|
755
|
+
</section>`);
|
|
756
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
757
|
+
provision?: { title: string };
|
|
758
|
+
};
|
|
759
|
+
provider.provision = { title: "one" };
|
|
760
|
+
const quark = new Quark({
|
|
761
|
+
options: { isScoped: true },
|
|
762
|
+
src: `data-provider[is-success] {
|
|
763
|
+
$todo: prop("provision");
|
|
764
|
+
[bind-title] { content: $todo.title; }
|
|
765
|
+
}`,
|
|
766
|
+
});
|
|
767
|
+
quark.register({
|
|
768
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
769
|
+
});
|
|
770
|
+
await flush();
|
|
771
|
+
const meter = measureComplexity(quark);
|
|
772
|
+
provider.provision = { title: "two" };
|
|
773
|
+
provider.provision = { title: "three" };
|
|
774
|
+
provider.provision = { title: "four" };
|
|
775
|
+
await flush();
|
|
776
|
+
const budget = meter.take();
|
|
777
|
+
meter.stop();
|
|
778
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("four");
|
|
779
|
+
// one def run + one reader run for three assignments
|
|
780
|
+
expect(budget.variableRuns).toBe(1);
|
|
781
|
+
expect(budget.attributeRuns).toBe(1);
|
|
782
|
+
quark.unregister();
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("does not observe in-place mutation, only assignment", async () => {
|
|
786
|
+
const root = fixture<HTMLElement>(`<section>
|
|
787
|
+
<div id="sheet"></div>
|
|
788
|
+
<data-provider is-success><span bind-title></span></data-provider>
|
|
789
|
+
</section>`);
|
|
790
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
791
|
+
provision?: { title: string };
|
|
792
|
+
};
|
|
793
|
+
provider.provision = { title: "one" };
|
|
794
|
+
const quark = new Quark({
|
|
795
|
+
options: { isScoped: true },
|
|
796
|
+
src: `data-provider[is-success] {
|
|
797
|
+
$todo: prop("provision");
|
|
798
|
+
[bind-title] { content: $todo.title; }
|
|
799
|
+
}`,
|
|
800
|
+
});
|
|
801
|
+
quark.register({
|
|
802
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
803
|
+
});
|
|
804
|
+
await flush();
|
|
805
|
+
provider.provision!.title = "mutated";
|
|
806
|
+
await flush();
|
|
807
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("one");
|
|
808
|
+
provider.provision = { ...provider.provision!, title: "assigned" };
|
|
809
|
+
await flush();
|
|
810
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("assigned");
|
|
811
|
+
quark.unregister();
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("reads a dynamic name without subscribing (warns once at build)", async () => {
|
|
815
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
816
|
+
const { root, quark, register } = createSheet(
|
|
817
|
+
`<input value="v">`,
|
|
818
|
+
`:scope { $name: "value"; }
|
|
819
|
+
input { data-x: prop($name); }`
|
|
820
|
+
);
|
|
821
|
+
register();
|
|
822
|
+
await flush();
|
|
823
|
+
const input = root.querySelector("input") as HTMLInputElement;
|
|
824
|
+
expect(input.getAttribute("data-x")).toBe("v");
|
|
825
|
+
expect(isObservedProperty(input, "value")).toBe(false);
|
|
826
|
+
warn.mockRestore();
|
|
827
|
+
quark.unregister();
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it("reads a provision through a binding defined on the provider", async () => {
|
|
831
|
+
const root = fixture<HTMLElement>(`<section>
|
|
832
|
+
<div id="sheet"></div>
|
|
833
|
+
<data-provider is-success>
|
|
834
|
+
<div>
|
|
835
|
+
<span bind-title></span>
|
|
836
|
+
</div>
|
|
837
|
+
</data-provider>
|
|
838
|
+
</section>`);
|
|
839
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
840
|
+
provision?: { title: string };
|
|
841
|
+
};
|
|
842
|
+
provider.provision = { title: "explicit" };
|
|
843
|
+
|
|
844
|
+
const quark = new Quark({
|
|
845
|
+
options: { isScoped: true },
|
|
846
|
+
src: `data-provider[is-success] { $todo: prop("provision"); }
|
|
847
|
+
[bind-title] { content: $todo.title; }`,
|
|
848
|
+
});
|
|
849
|
+
const meter = measureComplexity(quark);
|
|
850
|
+
quark.register({
|
|
851
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
852
|
+
});
|
|
853
|
+
await flush();
|
|
854
|
+
const budget = meter.take();
|
|
855
|
+
meter.stop();
|
|
856
|
+
|
|
857
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("explicit");
|
|
858
|
+
expectComplexity(budget);
|
|
859
|
+
quark.unregister();
|
|
860
|
+
});
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
describe("iterate", () => {
|
|
864
|
+
it("iterates a plain object (index=key, item=value)", async () => {
|
|
865
|
+
const root = fixture<HTMLElement>(`<section>
|
|
866
|
+
<div id="sheet"></div>
|
|
867
|
+
<data-provider is-success>
|
|
868
|
+
<ul>
|
|
869
|
+
<template><li bind-label></li></template>
|
|
870
|
+
</ul>
|
|
871
|
+
</data-provider>
|
|
872
|
+
</section>`);
|
|
873
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
874
|
+
provision?: Record<string, string>;
|
|
875
|
+
};
|
|
876
|
+
provider.provision = { a: "alpha", b: "beta" };
|
|
877
|
+
|
|
878
|
+
const quark = new Quark({
|
|
879
|
+
options: { isScoped: true },
|
|
880
|
+
src: `data-provider[is-success] {
|
|
881
|
+
$map: prop("provision");
|
|
882
|
+
ul { content: iterate($map); }
|
|
883
|
+
[bind-label] { content: index + "=" + item; }
|
|
884
|
+
}`,
|
|
885
|
+
});
|
|
886
|
+
const meter = measureComplexity(quark);
|
|
887
|
+
quark.register({
|
|
888
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
889
|
+
});
|
|
890
|
+
const rows = await bindIterateRows(root);
|
|
891
|
+
const budget = meter.take();
|
|
892
|
+
meter.stop();
|
|
893
|
+
|
|
894
|
+
expect(rows.map((el) => el.textContent).sort()).toEqual([
|
|
895
|
+
"a=alpha",
|
|
896
|
+
"b=beta",
|
|
897
|
+
]);
|
|
898
|
+
expectComplexity(budget);
|
|
899
|
+
quark.unregister();
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it("binds both index and item for array rows", async () => {
|
|
903
|
+
const root = fixture<HTMLElement>(`<section>
|
|
904
|
+
<div id="sheet"></div>
|
|
905
|
+
<data-provider is-success>
|
|
906
|
+
<ul>
|
|
907
|
+
<template><li bind-label></li></template>
|
|
908
|
+
</ul>
|
|
909
|
+
</data-provider>
|
|
910
|
+
</section>`);
|
|
911
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
912
|
+
provision?: { id: string }[];
|
|
913
|
+
};
|
|
914
|
+
provider.provision = [{ id: "x" }, { id: "y" }];
|
|
915
|
+
|
|
916
|
+
const quark = new Quark({
|
|
917
|
+
options: { isScoped: true },
|
|
918
|
+
src: `data-provider[is-success] {
|
|
919
|
+
$items: prop("provision");
|
|
920
|
+
ul { content: iterate($items); }
|
|
921
|
+
[bind-label] { content: "Index: " + index + ". ID: " + item.id; }
|
|
922
|
+
}`,
|
|
923
|
+
});
|
|
924
|
+
const meter = measureComplexity(quark);
|
|
925
|
+
quark.register({
|
|
926
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
927
|
+
});
|
|
928
|
+
const rows = await bindIterateRows(root);
|
|
929
|
+
const budget = meter.take();
|
|
930
|
+
meter.stop();
|
|
931
|
+
|
|
932
|
+
expect(rows.map((el) => el.textContent)).toEqual([
|
|
933
|
+
"Index: 0. ID: x",
|
|
934
|
+
"Index: 1. ID: y",
|
|
935
|
+
]);
|
|
936
|
+
expectComplexity(budget);
|
|
937
|
+
quark.unregister();
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("clears iterated children when the list becomes empty", async () => {
|
|
941
|
+
const root = fixture<HTMLElement>(`<section>
|
|
942
|
+
<div id="sheet"></div>
|
|
943
|
+
<data-provider is-success>
|
|
944
|
+
<ul>
|
|
945
|
+
<template><li bind-label></li></template>
|
|
946
|
+
</ul>
|
|
947
|
+
</data-provider>
|
|
948
|
+
</section>`);
|
|
949
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
950
|
+
provision?: { label: string }[];
|
|
951
|
+
};
|
|
952
|
+
provider.provision = [{ label: "one" }];
|
|
953
|
+
|
|
954
|
+
const quark = new Quark({
|
|
955
|
+
options: { isScoped: true },
|
|
956
|
+
src: `data-provider[is-success] {
|
|
957
|
+
$items: prop("provision");
|
|
958
|
+
ul { content: iterate($items); }
|
|
959
|
+
[bind-label] { content: item.label; }
|
|
960
|
+
}`,
|
|
961
|
+
});
|
|
962
|
+
quark.register({
|
|
963
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
964
|
+
});
|
|
965
|
+
await bindIterateRows(root);
|
|
966
|
+
expect(root.querySelectorAll("ul > li")).toHaveLength(1);
|
|
967
|
+
|
|
968
|
+
const meter = measureComplexity(quark);
|
|
969
|
+
provider.provision = [];
|
|
970
|
+
await flush();
|
|
971
|
+
const budget = meter.take();
|
|
972
|
+
meter.stop();
|
|
973
|
+
|
|
974
|
+
expect(root.querySelectorAll("ul > li")).toHaveLength(0);
|
|
975
|
+
expect(root.querySelector("ul > template")).toBeTruthy();
|
|
976
|
+
expectComplexity(budget);
|
|
977
|
+
quark.unregister();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it("reuses rows across provisions when a key property is provided", async () => {
|
|
981
|
+
const root = fixture<HTMLElement>(`<section>
|
|
982
|
+
<div id="sheet"></div>
|
|
983
|
+
<data-provider is-success>
|
|
984
|
+
<ul>
|
|
985
|
+
<template><li bind-label></li></template>
|
|
986
|
+
</ul>
|
|
987
|
+
</data-provider>
|
|
988
|
+
</section>`);
|
|
989
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
990
|
+
provision?: { id: string; label: string }[];
|
|
991
|
+
};
|
|
992
|
+
provider.provision = [
|
|
993
|
+
{ id: "1", label: "one" },
|
|
994
|
+
{ id: "2", label: "two" },
|
|
995
|
+
];
|
|
996
|
+
|
|
997
|
+
const quark = new Quark({
|
|
998
|
+
options: { isScoped: true },
|
|
999
|
+
src: `data-provider[is-success] {
|
|
1000
|
+
$items: prop("provision");
|
|
1001
|
+
ul { content: iterate($items, ":scope > template", "id"); }
|
|
1002
|
+
[bind-label] { content: item.label; }
|
|
1003
|
+
}`,
|
|
1004
|
+
});
|
|
1005
|
+
quark.register({
|
|
1006
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
1007
|
+
});
|
|
1008
|
+
const firstRows = await bindIterateRows(root);
|
|
1009
|
+
expect(firstRows.map((el) => el.textContent)).toEqual(["one", "two"]);
|
|
1010
|
+
|
|
1011
|
+
const meter = measureComplexity(quark);
|
|
1012
|
+
provider.provision = [
|
|
1013
|
+
{ id: "2", label: "two!" },
|
|
1014
|
+
{ id: "1", label: "one!" },
|
|
1015
|
+
];
|
|
1016
|
+
await flush();
|
|
1017
|
+
const secondRows = [...root.querySelectorAll("ul > li")];
|
|
1018
|
+
const budget = meter.take();
|
|
1019
|
+
meter.stop();
|
|
1020
|
+
|
|
1021
|
+
// Same DOM nodes reused (keyed), content updated / reordered.
|
|
1022
|
+
expect(secondRows).toHaveLength(2);
|
|
1023
|
+
expect(secondRows[0]).toBe(firstRows[1]);
|
|
1024
|
+
expect(secondRows[1]).toBe(firstRows[0]);
|
|
1025
|
+
expect(secondRows.map((el) => el.textContent)).toEqual(["two!", "one!"]);
|
|
1026
|
+
expectComplexity(budget);
|
|
1027
|
+
quark.unregister();
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
describe("listeners", () => {
|
|
1032
|
+
it("calls the handle function, and a handle list in order, with the event and the element as `this`", async () => {
|
|
1033
|
+
const calls: string[] = [];
|
|
1034
|
+
const first = vi.fn(function (this: Element, e: Event) {
|
|
1035
|
+
calls.push(`first:${e.type}:${this.id}`);
|
|
1036
|
+
});
|
|
1037
|
+
const second = vi.fn((e: Event) => calls.push(`second:${e.type}`));
|
|
1038
|
+
const { root, quark, register } = createSheet(
|
|
1039
|
+
`<button type="button" id="target"></button><button type="button" id="single"></button>`,
|
|
1040
|
+
`#target { @on click (handle: (first, second)); }
|
|
1041
|
+
#single { @on click (handle: second); }`,
|
|
1042
|
+
{ first, second }
|
|
1043
|
+
);
|
|
1044
|
+
const meter = measureComplexity(quark);
|
|
1045
|
+
register();
|
|
1046
|
+
await flush();
|
|
1047
|
+
const budget = meter.take();
|
|
1048
|
+
meter.stop();
|
|
1049
|
+
|
|
1050
|
+
(root.querySelector("#target") as HTMLButtonElement).click();
|
|
1051
|
+
expect(calls).toEqual(["first:click:target", "second:click"]);
|
|
1052
|
+
(root.querySelector("#single") as HTMLButtonElement).click();
|
|
1053
|
+
expect(second).toHaveBeenCalledTimes(2);
|
|
1054
|
+
expectComplexity(budget);
|
|
1055
|
+
quark.unregister();
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("prevent-default: the flag and the handle built-in both cancel the event", async () => {
|
|
1059
|
+
const { root, quark, register } = createSheet(
|
|
1060
|
+
`<a id="flag" href="/nope">go</a><a id="fn" href="/nope">go</a>`,
|
|
1061
|
+
`#flag { @on click (prevent-default); }
|
|
1062
|
+
#fn { @on click (handle: prevent-default); }`
|
|
1063
|
+
);
|
|
1064
|
+
const meter = measureComplexity(quark);
|
|
1065
|
+
register();
|
|
1066
|
+
await flush();
|
|
1067
|
+
const budget = meter.take();
|
|
1068
|
+
meter.stop();
|
|
1069
|
+
|
|
1070
|
+
for (const id of ["flag", "fn"]) {
|
|
1071
|
+
const event = new MouseEvent("click", {
|
|
1072
|
+
bubbles: true,
|
|
1073
|
+
cancelable: true,
|
|
1074
|
+
});
|
|
1075
|
+
root.querySelector(`#${id}`)!.dispatchEvent(event);
|
|
1076
|
+
expect(event.defaultPrevented, id).toBe(true);
|
|
1077
|
+
}
|
|
1078
|
+
expectComplexity(budget);
|
|
1079
|
+
quark.unregister();
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
it("stop-propagation / stop-immediate-propagation flags act before any timing", async () => {
|
|
1083
|
+
const parentHandler = vi.fn();
|
|
1084
|
+
const sibling = vi.fn();
|
|
1085
|
+
const { root, quark, register } = createSheet(
|
|
1086
|
+
`<div id="parent"><button type="button" id="child">x</button><button type="button" id="other">y</button></div>`,
|
|
1087
|
+
`#child { @on click (stop-propagation, debounce: 20); }
|
|
1088
|
+
#other { @on click (stop-immediate-propagation); }`
|
|
1089
|
+
);
|
|
1090
|
+
root.querySelector("#parent")!.addEventListener("click", parentHandler);
|
|
1091
|
+
const meter = measureComplexity(quark);
|
|
1092
|
+
register();
|
|
1093
|
+
await flush();
|
|
1094
|
+
const budget = meter.take();
|
|
1095
|
+
meter.stop();
|
|
1096
|
+
// registered after Quark's listener: stop-immediate-propagation skips it
|
|
1097
|
+
root.querySelector("#other")!.addEventListener("click", sibling);
|
|
1098
|
+
|
|
1099
|
+
(root.querySelector("#child") as HTMLButtonElement).click();
|
|
1100
|
+
expect(parentHandler).not.toHaveBeenCalled();
|
|
1101
|
+
(root.querySelector("#other") as HTMLButtonElement).click();
|
|
1102
|
+
expect(sibling).not.toHaveBeenCalled();
|
|
1103
|
+
expect(parentHandler).not.toHaveBeenCalled();
|
|
1104
|
+
expectComplexity(budget);
|
|
1105
|
+
quark.unregister();
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("listens for several events with one registration and one block", async () => {
|
|
1109
|
+
const { root, quark, register } = createSheet(
|
|
1110
|
+
`<form id="f"><input name="q"></form>`,
|
|
1111
|
+
`#f { @on input, change, "my:evt" { data-last: event.type; } }`
|
|
1112
|
+
);
|
|
1113
|
+
const form = root.querySelector("#f") as HTMLFormElement;
|
|
1114
|
+
const spy = vi.spyOn(form, "addEventListener");
|
|
1115
|
+
register();
|
|
1116
|
+
await flush();
|
|
1117
|
+
expect(spy.mock.calls.map(([type]) => type)).toEqual([
|
|
1118
|
+
"input",
|
|
1119
|
+
"change",
|
|
1120
|
+
"my:evt",
|
|
1121
|
+
]);
|
|
1122
|
+
const input = root.querySelector("input")!;
|
|
1123
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1124
|
+
await flush();
|
|
1125
|
+
expect(form.getAttribute("data-last")).toBe("input");
|
|
1126
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1127
|
+
await flush();
|
|
1128
|
+
expect(form.getAttribute("data-last")).toBe("change");
|
|
1129
|
+
form.dispatchEvent(new CustomEvent("my:evt"));
|
|
1130
|
+
await flush();
|
|
1131
|
+
expect(form.getAttribute("data-last")).toBe("my:evt");
|
|
1132
|
+
const internal = (form as any)._q_;
|
|
1133
|
+
const rule = Object.values<any>(
|
|
1134
|
+
Object.values<any>(internal.instances)[0].rules
|
|
1135
|
+
)[0];
|
|
1136
|
+
expect(Object.keys(rule.listeners)).toEqual([
|
|
1137
|
+
'@on input, change, "my:evt"',
|
|
1138
|
+
]);
|
|
1139
|
+
spy.mockRestore();
|
|
1140
|
+
quark.unregister();
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it("applies an @on block's declarations to the matched element once per event", async () => {
|
|
1144
|
+
const { root, quark, register } = createSheet(
|
|
1145
|
+
`<article id="host" data-count="0"><button type="button" id="inc">+</button><span bind-count></span></article>`,
|
|
1146
|
+
`#host {
|
|
1147
|
+
$count: +attr("data-count");
|
|
1148
|
+
[bind-count] { content: $count; }
|
|
1149
|
+
@on counter-increment { data-count: $count + 1; }
|
|
1150
|
+
}`
|
|
1151
|
+
);
|
|
1152
|
+
register();
|
|
1153
|
+
await flush();
|
|
1154
|
+
const host = root.querySelector("#host")!;
|
|
1155
|
+
const fire = () =>
|
|
1156
|
+
root
|
|
1157
|
+
.querySelector("#inc")!
|
|
1158
|
+
.dispatchEvent(
|
|
1159
|
+
new CustomEvent("counter-increment", { bubbles: true })
|
|
1160
|
+
);
|
|
1161
|
+
fire();
|
|
1162
|
+
await flush();
|
|
1163
|
+
expect(host.getAttribute("data-count")).toBe("1");
|
|
1164
|
+
expect(root.querySelector("[bind-count]")!.textContent).toBe("1");
|
|
1165
|
+
fire();
|
|
1166
|
+
await flush();
|
|
1167
|
+
expect(host.getAttribute("data-count")).toBe("2");
|
|
1168
|
+
expect(root.querySelector("[bind-count]")!.textContent).toBe("2");
|
|
1169
|
+
quark.unregister();
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it("exposes the DOM event as `event` inside the block and supports preserve", async () => {
|
|
1173
|
+
const { root, quark, register } = createSheet(
|
|
1174
|
+
`<article id="host"><input name="data-celsius" value="0"><input name="data-fahrenheit" value="32"></article>`,
|
|
1175
|
+
`#host {
|
|
1176
|
+
@on input {
|
|
1177
|
+
data-source: event.target.name;
|
|
1178
|
+
data-celsius: if(event.target.name == "data-celsius": event.target.value; else: preserve);
|
|
1179
|
+
data-fahrenheit: if(event.target.name == "data-fahrenheit": event.target.value; else: preserve);
|
|
1180
|
+
}
|
|
1181
|
+
}`
|
|
1182
|
+
);
|
|
1183
|
+
register();
|
|
1184
|
+
await flush();
|
|
1185
|
+
const host = root.querySelector("#host")!;
|
|
1186
|
+
const [c, f] = Array.from(root.querySelectorAll("input"));
|
|
1187
|
+
c.value = "100";
|
|
1188
|
+
c.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1189
|
+
await flush();
|
|
1190
|
+
expect(host.getAttribute("data-source")).toBe("data-celsius");
|
|
1191
|
+
expect(host.getAttribute("data-celsius")).toBe("100");
|
|
1192
|
+
expect(host.hasAttribute("data-fahrenheit")).toBe(false);
|
|
1193
|
+
f.value = "50";
|
|
1194
|
+
f.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1195
|
+
await flush();
|
|
1196
|
+
expect(host.getAttribute("data-source")).toBe("data-fahrenheit");
|
|
1197
|
+
expect(host.getAttribute("data-celsius")).toBe("100");
|
|
1198
|
+
expect(host.getAttribute("data-fahrenheit")).toBe("50");
|
|
1199
|
+
quark.unregister();
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
it("runs handle before the block and applies nested rules to descendants", async () => {
|
|
1203
|
+
const calls: string[] = [];
|
|
1204
|
+
const note = () => calls.push("handler");
|
|
1205
|
+
const { root, quark, register } = createSheet(
|
|
1206
|
+
`<div id="host"><form><input type="checkbox" name="is-active"></form><section id="panel"><p bind-state></p></section></div>`,
|
|
1207
|
+
`#host {
|
|
1208
|
+
@on change (handle: note) {
|
|
1209
|
+
$active: event.target.checked;
|
|
1210
|
+
#panel {
|
|
1211
|
+
is-active: if($active: ""; else: none);
|
|
1212
|
+
[bind-state] { content: if($active: "on"; else: "off"); }
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}`,
|
|
1216
|
+
{ note }
|
|
1217
|
+
);
|
|
1218
|
+
register();
|
|
1219
|
+
await flush();
|
|
1220
|
+
const box = root.querySelector<HTMLInputElement>("input")!;
|
|
1221
|
+
const panel = root.querySelector("#panel")!;
|
|
1222
|
+
box.checked = true;
|
|
1223
|
+
box.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1224
|
+
await flush();
|
|
1225
|
+
expect(calls).toEqual(["handler"]);
|
|
1226
|
+
expect(panel.hasAttribute("is-active")).toBe(true);
|
|
1227
|
+
expect(panel.querySelector("[bind-state]")!.textContent).toBe("on");
|
|
1228
|
+
box.checked = false;
|
|
1229
|
+
box.dispatchEvent(new Event("change", { bubbles: true }));
|
|
1230
|
+
await flush();
|
|
1231
|
+
expect(calls).toEqual(["handler", "handler"]);
|
|
1232
|
+
expect(panel.hasAttribute("is-active")).toBe(false);
|
|
1233
|
+
expect(panel.querySelector("[bind-state]")!.textContent).toBe("off");
|
|
1234
|
+
quark.unregister();
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it("keeps one block listener per element across rule re-runs and does not run it during passes", async () => {
|
|
1238
|
+
const { root, quark, register } = createSheet(
|
|
1239
|
+
`<div id="host" data-tick="0"><span bind-hits></span></div>`,
|
|
1240
|
+
`#host {
|
|
1241
|
+
$tick: attr("data-tick");
|
|
1242
|
+
[bind-hits] { content: $tick; }
|
|
1243
|
+
@on ping { data-hits: (+attr("data-hits") or 0) + 1; }
|
|
1244
|
+
}`
|
|
1245
|
+
);
|
|
1246
|
+
register();
|
|
1247
|
+
await flush();
|
|
1248
|
+
const host = root.querySelector("#host")!;
|
|
1249
|
+
expect(host.hasAttribute("data-hits")).toBe(false);
|
|
1250
|
+
// re-run the rule several times; the block must not fire on its own
|
|
1251
|
+
host.setAttribute("data-tick", "1");
|
|
1252
|
+
await flush();
|
|
1253
|
+
host.setAttribute("data-tick", "2");
|
|
1254
|
+
await flush();
|
|
1255
|
+
expect(host.hasAttribute("data-hits")).toBe(false);
|
|
1256
|
+
host.dispatchEvent(new CustomEvent("ping"));
|
|
1257
|
+
await flush();
|
|
1258
|
+
// a single listener registration, so exactly one increment per event
|
|
1259
|
+
expect(host.getAttribute("data-hits")).toBe("1");
|
|
1260
|
+
quark.unregister();
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
it("rejects @off and the removed handler list at construction with a parse error", () => {
|
|
1264
|
+
expect(
|
|
1265
|
+
() => new Quark({ src: "#target { @off click handleClick; }" })
|
|
1266
|
+
).toThrow(/@off is not supported/);
|
|
1267
|
+
expect(
|
|
1268
|
+
() => new Quark({ src: "#target { @on click handleClick; }" })
|
|
1269
|
+
).toThrow(/handle:/);
|
|
1270
|
+
expect(() => new Quark({ src: "#target { @on click; }" })).toThrow(
|
|
1271
|
+
/nothing to do/
|
|
1272
|
+
);
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
describe("options", () => {
|
|
1276
|
+
const keydown = (
|
|
1277
|
+
el: Element,
|
|
1278
|
+
key: string,
|
|
1279
|
+
init: KeyboardEventInit = {}
|
|
1280
|
+
) =>
|
|
1281
|
+
el.dispatchEvent(
|
|
1282
|
+
new KeyboardEvent("keydown", {
|
|
1283
|
+
key,
|
|
1284
|
+
bubbles: true,
|
|
1285
|
+
cancelable: true,
|
|
1286
|
+
...init,
|
|
1287
|
+
})
|
|
1288
|
+
);
|
|
1289
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
1290
|
+
|
|
1291
|
+
it("target: delegates to a matching descendant and exposes it as `target` in the block and in handle", async () => {
|
|
1292
|
+
const pick = vi.fn();
|
|
1293
|
+
const { root, quark, register } = createSheet(
|
|
1294
|
+
`<ul id="list"><li data-id="1"><span>one</span></li><li data-id="2"><span>two</span></li><li><span>none</span></li></ul>`,
|
|
1295
|
+
`#list {
|
|
1296
|
+
@on click (target: "li[data-id]", handle: pick(target.getAttribute("data-id"))) {
|
|
1297
|
+
data-picked: target.getAttribute("data-id");
|
|
1298
|
+
data-origin: event.target.localName;
|
|
1299
|
+
}
|
|
1300
|
+
}`,
|
|
1301
|
+
{ pick }
|
|
1302
|
+
);
|
|
1303
|
+
register();
|
|
1304
|
+
await flush();
|
|
1305
|
+
const list = root.querySelector("#list")!;
|
|
1306
|
+
const spans = root.querySelectorAll("span");
|
|
1307
|
+
spans[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1308
|
+
await flush();
|
|
1309
|
+
expect(pick).toHaveBeenCalledTimes(1);
|
|
1310
|
+
expect(pick).toHaveBeenCalledWith("2");
|
|
1311
|
+
expect(list.getAttribute("data-picked")).toBe("2");
|
|
1312
|
+
expect(list.getAttribute("data-origin")).toBe("span");
|
|
1313
|
+
// a row without data-id and the list itself do not pass the filter
|
|
1314
|
+
spans[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1315
|
+
list.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1316
|
+
await flush();
|
|
1317
|
+
expect(pick).toHaveBeenCalledTimes(1);
|
|
1318
|
+
expect(list.getAttribute("data-picked")).toBe("2");
|
|
1319
|
+
quark.unregister();
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it("target: ignores matches outside the matched element", async () => {
|
|
1323
|
+
const hit = vi.fn();
|
|
1324
|
+
const { root, quark, register } = createSheet(
|
|
1325
|
+
`<div id="box"><p class="row">in</p></div><p class="row" id="outside">out</p>`,
|
|
1326
|
+
`#box { @on click (target: ".row", handle: hit); }`,
|
|
1327
|
+
{ hit }
|
|
1328
|
+
);
|
|
1329
|
+
register();
|
|
1330
|
+
await flush();
|
|
1331
|
+
root
|
|
1332
|
+
.querySelector("#outside")!
|
|
1333
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1334
|
+
expect(hit).not.toHaveBeenCalled();
|
|
1335
|
+
root
|
|
1336
|
+
.querySelector("#box .row")!
|
|
1337
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1338
|
+
expect(hit).toHaveBeenCalledTimes(1);
|
|
1339
|
+
quark.unregister();
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it("self: fires only when the event target is the matched element", async () => {
|
|
1343
|
+
const hit = vi.fn();
|
|
1344
|
+
const { root, quark, register } = createSheet(
|
|
1345
|
+
`<div id="box"><button type="button" id="inner">x</button></div>`,
|
|
1346
|
+
`#box { @on click (self, handle: hit); }`,
|
|
1347
|
+
{ hit }
|
|
1348
|
+
);
|
|
1349
|
+
register();
|
|
1350
|
+
await flush();
|
|
1351
|
+
(root.querySelector("#inner") as HTMLButtonElement).click();
|
|
1352
|
+
expect(hit).not.toHaveBeenCalled();
|
|
1353
|
+
(root.querySelector("#box") as HTMLElement).click();
|
|
1354
|
+
expect(hit).toHaveBeenCalledTimes(1);
|
|
1355
|
+
quark.unregister();
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
it("key: gates keyboard events by chord, with space-separated alternatives", async () => {
|
|
1359
|
+
const hit = vi.fn();
|
|
1360
|
+
const { root, quark, register } = createSheet(
|
|
1361
|
+
`<input id="field">`,
|
|
1362
|
+
`#field { @on keydown (key: "Escape Shift+K ctrl+Enter", handle: hit); }`,
|
|
1363
|
+
{ hit }
|
|
1364
|
+
);
|
|
1365
|
+
register();
|
|
1366
|
+
await flush();
|
|
1367
|
+
const field = root.querySelector("#field")!;
|
|
1368
|
+
keydown(field, "Escape");
|
|
1369
|
+
expect(hit).toHaveBeenCalledTimes(1);
|
|
1370
|
+
keydown(field, "k", { shiftKey: true });
|
|
1371
|
+
expect(hit).toHaveBeenCalledTimes(2);
|
|
1372
|
+
keydown(field, "Enter", { ctrlKey: true });
|
|
1373
|
+
expect(hit).toHaveBeenCalledTimes(3);
|
|
1374
|
+
// wrong key, missing modifier
|
|
1375
|
+
keydown(field, "k");
|
|
1376
|
+
keydown(field, "Enter");
|
|
1377
|
+
keydown(field, "a", { shiftKey: true });
|
|
1378
|
+
expect(hit).toHaveBeenCalledTimes(3);
|
|
1379
|
+
quark.unregister();
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it("once: detaches after the first event that passes the filters", async () => {
|
|
1383
|
+
const hit = vi.fn();
|
|
1384
|
+
const { root, quark, register } = createSheet(
|
|
1385
|
+
`<div id="box"><span id="yes">y</span><b id="no">n</b></div>`,
|
|
1386
|
+
`#box { @on click (once, target: "span", handle: hit); }`,
|
|
1387
|
+
{ hit }
|
|
1388
|
+
);
|
|
1389
|
+
register();
|
|
1390
|
+
await flush();
|
|
1391
|
+
const box = root.querySelector("#box")!;
|
|
1392
|
+
// filtered events must not consume the listener
|
|
1393
|
+
root
|
|
1394
|
+
.querySelector("#no")!
|
|
1395
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1396
|
+
expect(hit).not.toHaveBeenCalled();
|
|
1397
|
+
root
|
|
1398
|
+
.querySelector("#yes")!
|
|
1399
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1400
|
+
expect(hit).toHaveBeenCalledTimes(1);
|
|
1401
|
+
root
|
|
1402
|
+
.querySelector("#yes")!
|
|
1403
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1404
|
+
expect(hit).toHaveBeenCalledTimes(1);
|
|
1405
|
+
const internal = (box as any)._q_;
|
|
1406
|
+
const rule = Object.values<any>(
|
|
1407
|
+
Object.values<any>(internal.instances)[0].rules
|
|
1408
|
+
)[0];
|
|
1409
|
+
expect(
|
|
1410
|
+
rule.listeners['@on click (once, target: "span", handle: hit)']
|
|
1411
|
+
).toEqual([]);
|
|
1412
|
+
quark.unregister();
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it("debounce: applies the block once after typing settles, with the last event", async () => {
|
|
1416
|
+
const hit = vi.fn();
|
|
1417
|
+
const { root, quark, register } = createSheet(
|
|
1418
|
+
`<form id="f"><input name="q"></form>`,
|
|
1419
|
+
`#f { @on input (debounce: 30, handle: hit) { data-draft: event.target.value; } }`,
|
|
1420
|
+
{ hit }
|
|
1421
|
+
);
|
|
1422
|
+
register();
|
|
1423
|
+
await flush();
|
|
1424
|
+
const form = root.querySelector("#f")!;
|
|
1425
|
+
const input = root.querySelector<HTMLInputElement>("input")!;
|
|
1426
|
+
for (const v of ["a", "ab", "abc"]) {
|
|
1427
|
+
input.value = v;
|
|
1428
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
1429
|
+
}
|
|
1430
|
+
expect(hit).not.toHaveBeenCalled();
|
|
1431
|
+
expect(form.hasAttribute("data-draft")).toBe(false);
|
|
1432
|
+
await sleep(60);
|
|
1433
|
+
await flush();
|
|
1434
|
+
expect(hit).toHaveBeenCalledTimes(1);
|
|
1435
|
+
expect(form.getAttribute("data-draft")).toBe("abc");
|
|
1436
|
+
quark.unregister();
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
it("throttle: runs the leading event, then at most once per window", async () => {
|
|
1440
|
+
const hit = vi.fn();
|
|
1441
|
+
const { root, quark, register } = createSheet(
|
|
1442
|
+
`<button type="button" id="b">x</button>`,
|
|
1443
|
+
`#b { @on click (throttle: 40, handle: hit); }`,
|
|
1444
|
+
{ hit }
|
|
1445
|
+
);
|
|
1446
|
+
register();
|
|
1447
|
+
await flush();
|
|
1448
|
+
const b = root.querySelector("#b") as HTMLButtonElement;
|
|
1449
|
+
b.click();
|
|
1450
|
+
b.click();
|
|
1451
|
+
b.click();
|
|
1452
|
+
expect(hit).toHaveBeenCalledTimes(1);
|
|
1453
|
+
await sleep(60);
|
|
1454
|
+
b.click();
|
|
1455
|
+
expect(hit).toHaveBeenCalledTimes(2);
|
|
1456
|
+
quark.unregister();
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
it("host: window / document — listens there, resolves target document-wide, and lets go once the element is disconnected", async () => {
|
|
1460
|
+
const esc = vi.fn();
|
|
1461
|
+
const doc = vi.fn();
|
|
1462
|
+
const { root, quark, register } = createSheet(
|
|
1463
|
+
`<dialog id="dlg" is-open><p>hi</p></dialog><p id="elsewhere" class="row">x</p>`,
|
|
1464
|
+
`#dlg {
|
|
1465
|
+
@on keydown (host: window, key: "Escape", handle: esc) { is-open: none; }
|
|
1466
|
+
@on click (host: document, target: ".row", handle: doc);
|
|
1467
|
+
}`,
|
|
1468
|
+
{ esc, doc }
|
|
1469
|
+
);
|
|
1470
|
+
register();
|
|
1471
|
+
await flush();
|
|
1472
|
+
const dlg = root.querySelector("#dlg")!;
|
|
1473
|
+
keydown(document.body, "Escape");
|
|
1474
|
+
await flush();
|
|
1475
|
+
expect(esc).toHaveBeenCalledTimes(1);
|
|
1476
|
+
expect(dlg.hasAttribute("is-open")).toBe(false);
|
|
1477
|
+
// a `target:` with a host is not confined to the element
|
|
1478
|
+
root
|
|
1479
|
+
.querySelector("#elsewhere")!
|
|
1480
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1481
|
+
expect(doc).toHaveBeenCalledTimes(1);
|
|
1482
|
+
// after removal the next event unhooks the listener instead of running it
|
|
1483
|
+
dlg.remove();
|
|
1484
|
+
keydown(document.body, "Escape");
|
|
1485
|
+
keydown(document.body, "Escape");
|
|
1486
|
+
expect(esc).toHaveBeenCalledTimes(1);
|
|
1487
|
+
const internal = (dlg as any)._q_;
|
|
1488
|
+
const rule = Object.values<any>(
|
|
1489
|
+
Object.values<any>(internal.instances)[0].rules
|
|
1490
|
+
)[0];
|
|
1491
|
+
expect(
|
|
1492
|
+
rule.listeners[
|
|
1493
|
+
'@on keydown (host: window, key: "Escape", handle: esc)'
|
|
1494
|
+
]
|
|
1495
|
+
).toEqual([]);
|
|
1496
|
+
quark.unregister();
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it("capture / passive: registers with the native options", async () => {
|
|
1500
|
+
const calls: string[] = [];
|
|
1501
|
+
const first = () => calls.push("parent-capture");
|
|
1502
|
+
const second = () => calls.push("child");
|
|
1503
|
+
const { root, quark, register } = createSheet(
|
|
1504
|
+
`<div id="parent"><button type="button" id="child">x</button></div>`,
|
|
1505
|
+
`#parent { @on click (capture, handle: first); }
|
|
1506
|
+
#child { @on click (passive, handle: second); }`,
|
|
1507
|
+
{ first, second }
|
|
1508
|
+
);
|
|
1509
|
+
const child = root.querySelector("#child") as HTMLButtonElement;
|
|
1510
|
+
const spy = vi.spyOn(child, "addEventListener");
|
|
1511
|
+
register();
|
|
1512
|
+
await flush();
|
|
1513
|
+
child.click();
|
|
1514
|
+
// capture on the parent runs before the child's bubbling listener
|
|
1515
|
+
expect(calls).toEqual(["parent-capture", "child"]);
|
|
1516
|
+
expect(spy).toHaveBeenCalledWith("click", expect.any(Function), {
|
|
1517
|
+
passive: true,
|
|
1518
|
+
});
|
|
1519
|
+
spy.mockRestore();
|
|
1520
|
+
quark.unregister();
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
it("keeps two @on for one event apart when their options differ", async () => {
|
|
1524
|
+
const esc = vi.fn();
|
|
1525
|
+
const enter = vi.fn();
|
|
1526
|
+
const { root, quark, register } = createSheet(
|
|
1527
|
+
`<input id="field">`,
|
|
1528
|
+
`#field {
|
|
1529
|
+
@on keydown (key: "Escape", handle: esc);
|
|
1530
|
+
@on keydown (key: "Enter", handle: enter);
|
|
1531
|
+
}`,
|
|
1532
|
+
{ esc, enter }
|
|
1533
|
+
);
|
|
1534
|
+
register();
|
|
1535
|
+
await flush();
|
|
1536
|
+
const field = root.querySelector("#field")!;
|
|
1537
|
+
keydown(field, "Escape");
|
|
1538
|
+
keydown(field, "Enter");
|
|
1539
|
+
keydown(field, "Enter");
|
|
1540
|
+
expect(esc).toHaveBeenCalledTimes(1);
|
|
1541
|
+
expect(enter).toHaveBeenCalledTimes(2);
|
|
1542
|
+
quark.unregister();
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it("evaluates handle and target when the event fires: current bindings, no re-registration", async () => {
|
|
1546
|
+
const seen: string[] = [];
|
|
1547
|
+
const inc = (n: string) => () => seen.push(n);
|
|
1548
|
+
const { root, quark, register } = createSheet(
|
|
1549
|
+
`<div id="box" data-n="1" data-row="b"><b><i>x</i></b><em>y</em></div>`,
|
|
1550
|
+
`#box {
|
|
1551
|
+
$n: attr("data-n");
|
|
1552
|
+
$row: attr("data-row");
|
|
1553
|
+
@on click (target: $row, handle: inc($n)) { data-hit: target.localName; }
|
|
1554
|
+
}`,
|
|
1555
|
+
{ inc }
|
|
1556
|
+
);
|
|
1557
|
+
const box = root.querySelector("#box") as HTMLElement;
|
|
1558
|
+
const spy = vi.spyOn(box, "addEventListener");
|
|
1559
|
+
register();
|
|
1560
|
+
await flush();
|
|
1561
|
+
const click = (sel: string) =>
|
|
1562
|
+
root
|
|
1563
|
+
.querySelector(sel)!
|
|
1564
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1565
|
+
click("i");
|
|
1566
|
+
await flush();
|
|
1567
|
+
expect(seen).toEqual(["1"]);
|
|
1568
|
+
expect(box.getAttribute("data-hit")).toBe("b");
|
|
1569
|
+
// the bindings change; the next event reads them, nothing re-registers
|
|
1570
|
+
box.setAttribute("data-n", "2");
|
|
1571
|
+
box.setAttribute("data-row", "em");
|
|
1572
|
+
await flush();
|
|
1573
|
+
click("i");
|
|
1574
|
+
click("em");
|
|
1575
|
+
await flush();
|
|
1576
|
+
expect(seen).toEqual(["1", "2"]);
|
|
1577
|
+
expect(box.getAttribute("data-hit")).toBe("em");
|
|
1578
|
+
expect(
|
|
1579
|
+
spy.mock.calls.filter(([type]) => type === "click")
|
|
1580
|
+
).toHaveLength(1);
|
|
1581
|
+
spy.mockRestore();
|
|
1582
|
+
quark.unregister();
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
it("hands the event to a handle call and accepts expression values", async () => {
|
|
1586
|
+
const calls: unknown[] = [];
|
|
1587
|
+
const note = (e: Event, where: string) => calls.push([e.type, where]);
|
|
1588
|
+
const { root, quark, register } = createSheet(
|
|
1589
|
+
`<div id="host" data-row="li"><ul><li><span>a</span></li></ul><output></output></div>`,
|
|
1590
|
+
`#host {
|
|
1591
|
+
$sel: attr("data-row");
|
|
1592
|
+
@on click (target: $sel, debounce: 10 * 2, handle: note(event, target.localName)) {
|
|
1593
|
+
output { content: "clicked " + target.localName; }
|
|
1594
|
+
}
|
|
1595
|
+
}`,
|
|
1596
|
+
{ note }
|
|
1597
|
+
);
|
|
1598
|
+
register();
|
|
1599
|
+
await flush();
|
|
1600
|
+
root
|
|
1601
|
+
.querySelector("span")!
|
|
1602
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1603
|
+
expect(calls).toEqual([]);
|
|
1604
|
+
await sleep(40);
|
|
1605
|
+
await flush();
|
|
1606
|
+
expect(calls).toEqual([["click", "li"]]);
|
|
1607
|
+
expect(root.querySelector("output")!.textContent).toBe("clicked li");
|
|
1608
|
+
quark.unregister();
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
it("warns on unknown, malformed or conflicting options and ignores them", async () => {
|
|
1612
|
+
const warn = vi.spyOn(QuarkLogger, "warn").mockImplementation(() => {});
|
|
1613
|
+
const hit = vi.fn();
|
|
1614
|
+
const { root, quark, register } = createSheet(
|
|
1615
|
+
`<button type="button" id="b">x</button>`,
|
|
1616
|
+
`#b {
|
|
1617
|
+
@on click (bogus, once: 1, key: 3, host: body, throttle: 10, debounce: 5, handle: hit);
|
|
1618
|
+
@on keydown (debounce: "soon", handle: "hit");
|
|
1619
|
+
}`,
|
|
1620
|
+
{ hit }
|
|
1621
|
+
);
|
|
1622
|
+
register();
|
|
1623
|
+
await flush();
|
|
1624
|
+
const b = root.querySelector("#b") as HTMLButtonElement;
|
|
1625
|
+
b.click();
|
|
1626
|
+
keydown(b, "a");
|
|
1627
|
+
keydown(b, "a");
|
|
1628
|
+
const messages = warn.mock.calls.map(([arg]) => (arg as any).message);
|
|
1629
|
+
expect(messages.some((m) => /unknown option "bogus"/.test(m))).toBe(
|
|
1630
|
+
true
|
|
1631
|
+
);
|
|
1632
|
+
expect(messages.some((m) => /"once" takes no value/.test(m))).toBe(
|
|
1633
|
+
true
|
|
1634
|
+
);
|
|
1635
|
+
expect(messages.some((m) => /"key" needs a string/.test(m))).toBe(true);
|
|
1636
|
+
expect(
|
|
1637
|
+
messages.some((m) => /"host" must be window or document/.test(m))
|
|
1638
|
+
).toBe(true);
|
|
1639
|
+
expect(messages.some((m) => /exclusive/.test(m))).toBe(true);
|
|
1640
|
+
expect(
|
|
1641
|
+
messages.filter((m) => /"debounce" needs a positive number/.test(m))
|
|
1642
|
+
).toHaveLength(1);
|
|
1643
|
+
expect(
|
|
1644
|
+
messages.filter((m) => /handle: "hit" is a string/.test(m))
|
|
1645
|
+
).toHaveLength(1);
|
|
1646
|
+
// the click's `key: 3` warned and filtered it out; the keydown ran
|
|
1647
|
+
// unthrottled and undebounced, and its string handle did nothing
|
|
1648
|
+
expect(hit).not.toHaveBeenCalled();
|
|
1649
|
+
await sleep(20);
|
|
1650
|
+
expect(hit).not.toHaveBeenCalled();
|
|
1651
|
+
warn.mockRestore();
|
|
1652
|
+
quark.unregister();
|
|
1653
|
+
});
|
|
1654
|
+
});
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
describe("selectors and nesting", () => {
|
|
1658
|
+
it("applies autofocus only while dialog is open", async () => {
|
|
1659
|
+
const { root, quark, register } = createSheet(
|
|
1660
|
+
`<dialog><input /></dialog>`,
|
|
1661
|
+
`dialog[open] input { autofocus: ""; }
|
|
1662
|
+
dialog:not([open]) input { autofocus: none; }`
|
|
1663
|
+
);
|
|
1664
|
+
register();
|
|
1665
|
+
await flush();
|
|
1666
|
+
expect(root.querySelector("input")?.hasAttribute("autofocus")).toBe(
|
|
1667
|
+
false
|
|
1668
|
+
);
|
|
1669
|
+
|
|
1670
|
+
const meter = measureComplexity(quark);
|
|
1671
|
+
root.querySelector("dialog")!.setAttribute("open", "");
|
|
1672
|
+
await flush();
|
|
1673
|
+
expect(root.querySelector("input")?.hasAttribute("autofocus")).toBe(true);
|
|
1674
|
+
|
|
1675
|
+
root.querySelector("dialog")!.removeAttribute("open");
|
|
1676
|
+
await flush();
|
|
1677
|
+
const budget = meter.take();
|
|
1678
|
+
meter.stop();
|
|
1679
|
+
|
|
1680
|
+
expect(root.querySelector("input")?.hasAttribute("autofocus")).toBe(
|
|
1681
|
+
false
|
|
1682
|
+
);
|
|
1683
|
+
expectComplexity(budget);
|
|
1684
|
+
quark.unregister();
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
it("inherits a parent variable through nested rules", async () => {
|
|
1688
|
+
const { root, quark, register } = createSheet(
|
|
1689
|
+
`<main><section><span bind-msg></span></section></main>`,
|
|
1690
|
+
`main {
|
|
1691
|
+
$msg: "nested-ok";
|
|
1692
|
+
section {
|
|
1693
|
+
[bind-msg] { content: $msg; }
|
|
1694
|
+
}
|
|
1695
|
+
}`
|
|
1696
|
+
);
|
|
1697
|
+
const meter = measureComplexity(quark);
|
|
1698
|
+
register();
|
|
1699
|
+
await flush();
|
|
1700
|
+
const budget = meter.take();
|
|
1701
|
+
meter.stop();
|
|
1702
|
+
|
|
1703
|
+
expect(root.querySelector("[bind-msg]")?.textContent).toBe("nested-ok");
|
|
1704
|
+
expectComplexity(budget);
|
|
1705
|
+
quark.unregister();
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
it("targets by id", async () => {
|
|
1709
|
+
const { root, quark, register } = createSheet(
|
|
1710
|
+
`<p id="only">x</p>`,
|
|
1711
|
+
`#only { content: "by-id"; }`
|
|
1712
|
+
);
|
|
1713
|
+
const meter = measureComplexity(quark);
|
|
1714
|
+
register();
|
|
1715
|
+
await flush();
|
|
1716
|
+
const budget = meter.take();
|
|
1717
|
+
meter.stop();
|
|
1718
|
+
|
|
1719
|
+
expect(root.querySelector("#only")?.textContent).toBe("by-id");
|
|
1720
|
+
expectComplexity(budget);
|
|
1721
|
+
quark.unregister();
|
|
1722
|
+
});
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
describe("@use modules", () => {
|
|
1726
|
+
const originalLoader = Quark.moduleLoader;
|
|
1727
|
+
afterEach(() => {
|
|
1728
|
+
Quark.moduleLoader = originalLoader;
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
it("loads bare (as *) and namespaced modules before first run", async () => {
|
|
1732
|
+
Quark.moduleLoader = async (url: string) =>
|
|
1733
|
+
url === "/fake-utils" ? { greet: () => "hi" } : { version: () => "v2" };
|
|
1734
|
+
const { root, quark, register } = createSheet(
|
|
1735
|
+
`<span bind-label></span>`,
|
|
1736
|
+
`@use "/fake-utils" as *;
|
|
1737
|
+
@use "/api-mod" as api;
|
|
1738
|
+
[bind-label] {
|
|
1739
|
+
content: greet() + " " + api.version();
|
|
1740
|
+
}`
|
|
1741
|
+
);
|
|
1742
|
+
register();
|
|
1743
|
+
await flush();
|
|
1744
|
+
|
|
1745
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("hi v2");
|
|
1746
|
+
quark.unregister();
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
it("hoists leading @use imports out of the implicit @scope wrap", () => {
|
|
1750
|
+
Quark.moduleLoader = async () => ({ greet: () => "hi" });
|
|
1751
|
+
const quark = new Quark({
|
|
1752
|
+
src: `@use "/fake-utils" as *;
|
|
1753
|
+
@use "/api-mod" as api;
|
|
1754
|
+
span { content: greet(); }`,
|
|
1755
|
+
options: { isScoped: true },
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
expect(quark.src.startsWith(`@use "/fake-utils" as *;`)).toBe(true);
|
|
1759
|
+
// both imports precede the wrap; the rule sits inside it
|
|
1760
|
+
expect(quark.src).toMatch(/as api;\s*@scope\{\s*span/);
|
|
1761
|
+
expect(quark.src.endsWith("}")).toBe(true);
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
it("derives the namespace from the url when no alias is given", async () => {
|
|
1765
|
+
Quark.moduleLoader = async () => ({
|
|
1766
|
+
upper: (s: string) => s.toUpperCase(),
|
|
1767
|
+
});
|
|
1768
|
+
const { root, quark, register } = createSheet(
|
|
1769
|
+
`<span bind-label></span>`,
|
|
1770
|
+
`@use "/mods/string-utils.js";
|
|
1771
|
+
[bind-label] {
|
|
1772
|
+
content: string-utils.upper("shout");
|
|
1773
|
+
}`
|
|
1774
|
+
);
|
|
1775
|
+
register();
|
|
1776
|
+
await flush();
|
|
1777
|
+
|
|
1778
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("SHOUT");
|
|
1779
|
+
quark.unregister();
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
it("skips a failing @use module but still runs the sheet", async () => {
|
|
1783
|
+
Quark.moduleLoader = async (url: string) => {
|
|
1784
|
+
if (url === "/broken") throw new Error("404");
|
|
1785
|
+
return { greet: () => "hi" };
|
|
1786
|
+
};
|
|
1787
|
+
const { root, quark, register } = createSheet(
|
|
1788
|
+
`<span bind-label></span>`,
|
|
1789
|
+
`@use "/broken" as nope;
|
|
1790
|
+
@use "/fake-utils" as *;
|
|
1791
|
+
[bind-label] { content: greet(); }`
|
|
1792
|
+
);
|
|
1793
|
+
register();
|
|
1794
|
+
await flush();
|
|
1795
|
+
|
|
1796
|
+
expect(root.querySelector("[bind-label]")?.textContent).toBe("hi");
|
|
1797
|
+
quark.unregister();
|
|
1798
|
+
});
|
|
1799
|
+
});
|
|
1800
|
+
|
|
1801
|
+
describe("dynamics", () => {
|
|
1802
|
+
it("reacts when a custom element toggles its own state attribute", async () => {
|
|
1803
|
+
const root = fixture<HTMLElement>(`<section>
|
|
1804
|
+
<div id="sheet"></div>
|
|
1805
|
+
<data-provider>
|
|
1806
|
+
<span bind-title></span>
|
|
1807
|
+
</data-provider>
|
|
1808
|
+
</section>`);
|
|
1809
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
1810
|
+
provision?: { title: string };
|
|
1811
|
+
};
|
|
1812
|
+
provider.provision = { title: "provisioned" };
|
|
1813
|
+
|
|
1814
|
+
const quark = new Quark({
|
|
1815
|
+
options: { isScoped: true },
|
|
1816
|
+
src: `data-provider[is-success] {
|
|
1817
|
+
$todo: prop("provision");
|
|
1818
|
+
[bind-title] { content: $todo.title; }
|
|
1819
|
+
}`,
|
|
1820
|
+
});
|
|
1821
|
+
quark.register({
|
|
1822
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
1823
|
+
});
|
|
1824
|
+
await flush();
|
|
1825
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("");
|
|
1826
|
+
|
|
1827
|
+
const meter = measureComplexity(quark);
|
|
1828
|
+
// Simulates the element flipping its own state (e.g. fetch success).
|
|
1829
|
+
provider.setAttribute("is-success", "");
|
|
1830
|
+
await flush();
|
|
1831
|
+
const budget = meter.take();
|
|
1832
|
+
meter.stop();
|
|
1833
|
+
|
|
1834
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe(
|
|
1835
|
+
"provisioned"
|
|
1836
|
+
);
|
|
1837
|
+
expectComplexity(budget);
|
|
1838
|
+
quark.unregister();
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
it("chains rules: a quark-set attribute triggers another rule", async () => {
|
|
1842
|
+
const { root, quark, register } = createSheet(
|
|
1843
|
+
`<span></span>`,
|
|
1844
|
+
`span { data-state: "ready"; }
|
|
1845
|
+
span[data-state="ready"] { content: "chained"; }`
|
|
1846
|
+
);
|
|
1847
|
+
const meter = measureComplexity(quark);
|
|
1848
|
+
register();
|
|
1849
|
+
await flush();
|
|
1850
|
+
// Second flush window: observer sees the quark-set attribute.
|
|
1851
|
+
await flush();
|
|
1852
|
+
const budget = meter.take();
|
|
1853
|
+
meter.stop();
|
|
1854
|
+
|
|
1855
|
+
expect(root.querySelector("span")?.textContent).toBe("chained");
|
|
1856
|
+
expectComplexity(budget);
|
|
1857
|
+
quark.unregister();
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
it("re-runs chained variables when the source provision changes", async () => {
|
|
1861
|
+
const root = fixture<HTMLElement>(`<section>
|
|
1862
|
+
<div id="sheet"></div>
|
|
1863
|
+
<data-provider is-success>
|
|
1864
|
+
<span bind-title></span>
|
|
1865
|
+
</data-provider>
|
|
1866
|
+
</section>`);
|
|
1867
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
1868
|
+
provision?: { title: string };
|
|
1869
|
+
};
|
|
1870
|
+
provider.provision = { title: "first" };
|
|
1871
|
+
|
|
1872
|
+
const quark = new Quark({
|
|
1873
|
+
options: { isScoped: true },
|
|
1874
|
+
src: `data-provider[is-success] {
|
|
1875
|
+
$res: prop("provision");
|
|
1876
|
+
$title: $res.title;
|
|
1877
|
+
[bind-title] { content: $title; }
|
|
1878
|
+
}`,
|
|
1879
|
+
});
|
|
1880
|
+
quark.register({
|
|
1881
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
1882
|
+
});
|
|
1883
|
+
await flush();
|
|
1884
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("first");
|
|
1885
|
+
|
|
1886
|
+
const meter = measureComplexity(quark);
|
|
1887
|
+
provider.provision = { title: "second" };
|
|
1888
|
+
await flush();
|
|
1889
|
+
const budget = meter.take();
|
|
1890
|
+
meter.stop();
|
|
1891
|
+
|
|
1892
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("second");
|
|
1893
|
+
expectComplexity(budget);
|
|
1894
|
+
quark.unregister();
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
it("skips dependent re-runs when a provision carries the same value", async () => {
|
|
1898
|
+
const root = fixture<HTMLElement>(`<section>
|
|
1899
|
+
<div id="sheet"></div>
|
|
1900
|
+
<data-provider is-success>
|
|
1901
|
+
<span bind-title></span>
|
|
1902
|
+
</data-provider>
|
|
1903
|
+
</section>`);
|
|
1904
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
1905
|
+
provision?: { title: string };
|
|
1906
|
+
};
|
|
1907
|
+
provider.provision = { title: "same" };
|
|
1908
|
+
|
|
1909
|
+
const quark = new Quark({
|
|
1910
|
+
options: { isScoped: true },
|
|
1911
|
+
src: `data-provider[is-success] {
|
|
1912
|
+
$todo: prop("provision");
|
|
1913
|
+
[bind-title] { content: $todo.title; }
|
|
1914
|
+
}`,
|
|
1915
|
+
});
|
|
1916
|
+
quark.register({
|
|
1917
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
1918
|
+
});
|
|
1919
|
+
await flush();
|
|
1920
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("same");
|
|
1921
|
+
|
|
1922
|
+
const meter = measureComplexity(quark);
|
|
1923
|
+
// Same payload (same hash), setVar reports no change, no fan-out.
|
|
1924
|
+
provider.provision = { title: "same" };
|
|
1925
|
+
await flush();
|
|
1926
|
+
const budget = meter.take();
|
|
1927
|
+
meter.stop();
|
|
1928
|
+
|
|
1929
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("same");
|
|
1930
|
+
expectComplexity(budget);
|
|
1931
|
+
quark.unregister();
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
it("propagates async variable updates to consumers in sibling rules", async () => {
|
|
1935
|
+
const root = fixture<HTMLElement>(`<section>
|
|
1936
|
+
<div id="sheet"></div>
|
|
1937
|
+
<data-provider is-success>
|
|
1938
|
+
<span bind-title></span>
|
|
1939
|
+
</data-provider>
|
|
1940
|
+
</section>`);
|
|
1941
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
1942
|
+
provision?: { title: string };
|
|
1943
|
+
};
|
|
1944
|
+
provider.provision = { title: "before" };
|
|
1945
|
+
|
|
1946
|
+
// Consumer rule is a sibling of the defining rule; the update reaches
|
|
1947
|
+
// it because the binding owner is a DOM ancestor of the consumer.
|
|
1948
|
+
const quark = new Quark({
|
|
1949
|
+
options: { isScoped: true },
|
|
1950
|
+
src: `data-provider[is-success] { $todo: prop("provision"); }
|
|
1951
|
+
[bind-title] { content: $todo.title; }`,
|
|
1952
|
+
});
|
|
1953
|
+
quark.register({
|
|
1954
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
1955
|
+
});
|
|
1956
|
+
await flush();
|
|
1957
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("before");
|
|
1958
|
+
|
|
1959
|
+
const meter = measureComplexity(quark);
|
|
1960
|
+
provider.provision = { title: "after" };
|
|
1961
|
+
await flush();
|
|
1962
|
+
const budget = meter.take();
|
|
1963
|
+
meter.stop();
|
|
1964
|
+
|
|
1965
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("after");
|
|
1966
|
+
expectComplexity(budget);
|
|
1967
|
+
quark.unregister();
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
it("cascades a binding set by one sheet into another sheet's rules", async () => {
|
|
1971
|
+
/*
|
|
1972
|
+
* CSS-custom-property semantics: bindings live on the element, not
|
|
1973
|
+
* the sheet. Same-named writers collide (last writer wins per
|
|
1974
|
+
* element), namespace app bindings accordingly.
|
|
1975
|
+
*/
|
|
1976
|
+
const root = fixture<HTMLElement>(`<section>
|
|
1977
|
+
<div id="sheet-a"></div>
|
|
1978
|
+
<div id="sheet-b"></div>
|
|
1979
|
+
<main>
|
|
1980
|
+
<span bind-b></span>
|
|
1981
|
+
</main>
|
|
1982
|
+
</section>`);
|
|
1983
|
+
const a = new Quark({
|
|
1984
|
+
options: { isScoped: true },
|
|
1985
|
+
src: `main { $label: "from-a"; }`,
|
|
1986
|
+
});
|
|
1987
|
+
const b = new Quark({
|
|
1988
|
+
options: { isScoped: true },
|
|
1989
|
+
src: `[bind-b] { content: $label; }`,
|
|
1990
|
+
});
|
|
1991
|
+
a.register({
|
|
1992
|
+
sheetElement: root.querySelector("#sheet-a") as HTMLElement,
|
|
1993
|
+
});
|
|
1994
|
+
await flush();
|
|
1995
|
+
b.register({
|
|
1996
|
+
sheetElement: root.querySelector("#sheet-b") as HTMLElement,
|
|
1997
|
+
});
|
|
1998
|
+
await flush();
|
|
1999
|
+
|
|
2000
|
+
expect(root.querySelector("[bind-b]")?.textContent).toBe("from-a");
|
|
2001
|
+
a.unregister();
|
|
2002
|
+
b.unregister();
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
it("delivers a def to a reader declared earlier in the sheet on first run", async () => {
|
|
2006
|
+
/*
|
|
2007
|
+
* The def's change event is deferred to the end of the run and only
|
|
2008
|
+
* readers that ran before the write are re-run, rule order no longer
|
|
2009
|
+
* decides whether a first-run reader sees the value.
|
|
2010
|
+
*/
|
|
2011
|
+
const { root, quark, register } = createSheet(
|
|
2012
|
+
`<div id="host"><span bind-early></span></div>`,
|
|
2013
|
+
`[bind-early] { content: $late; }
|
|
2014
|
+
#host { $late: "seen"; }`
|
|
2015
|
+
);
|
|
2016
|
+
register();
|
|
2017
|
+
await flush();
|
|
2018
|
+
expect(root.querySelector("[bind-early]")?.textContent).toBe("seen");
|
|
2019
|
+
quark.unregister();
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
it("does not fan out a def to readers the same run already reached", async () => {
|
|
2023
|
+
/*
|
|
2024
|
+
* `$ref` is written on every row while the rule pass is still
|
|
2025
|
+
* running; readers below each row run later in the same pass with
|
|
2026
|
+
* the new value, so the deferred change events are self-covered
|
|
2027
|
+
* (no nested run, no per-row querySelectorAll).
|
|
2028
|
+
*/
|
|
2029
|
+
const root = fixture<HTMLElement>(`<section>
|
|
2030
|
+
<div id="sheet"></div>
|
|
2031
|
+
<ul>
|
|
2032
|
+
<template><li><span bind-ref></span></li></template>
|
|
2033
|
+
</ul>
|
|
2034
|
+
</section>`);
|
|
2035
|
+
const quark = new Quark({
|
|
2036
|
+
options: { isScoped: true },
|
|
2037
|
+
src: `:scope { $items: ("a", "b", "c", "d"); }
|
|
2038
|
+
ul { content: iterate($items); }
|
|
2039
|
+
li { $ref: "row-" + index; }
|
|
2040
|
+
li [bind-ref] { content: $ref; }`,
|
|
2041
|
+
});
|
|
2042
|
+
const meter = measureComplexity(quark);
|
|
2043
|
+
quark.register({
|
|
2044
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
2045
|
+
});
|
|
2046
|
+
// rows paint, their run follows, then their text paints
|
|
2047
|
+
await flush();
|
|
2048
|
+
await flush();
|
|
2049
|
+
const budget = meter.take();
|
|
2050
|
+
meter.stop();
|
|
2051
|
+
expect(
|
|
2052
|
+
[...root.querySelectorAll("li")].map((el) => el.textContent)
|
|
2053
|
+
).toEqual(["row-0", "row-1", "row-2", "row-3"]);
|
|
2054
|
+
// first run + the run for the rendered rows; no nested per-row runs
|
|
2055
|
+
expect(budget.quarkRuns).toBe(2);
|
|
2056
|
+
expectComplexity(budget);
|
|
2057
|
+
quark.unregister();
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
it("re-runs another sheet's consumers when a binding changes", async () => {
|
|
2061
|
+
const root = fixture<HTMLElement>(`<section>
|
|
2062
|
+
<div id="sheet-a"></div>
|
|
2063
|
+
<div id="sheet-b"></div>
|
|
2064
|
+
<data-provider is-success>
|
|
2065
|
+
<span bind-title></span>
|
|
2066
|
+
</data-provider>
|
|
2067
|
+
</section>`);
|
|
2068
|
+
const provider = root.querySelector("data-provider") as HTMLElement & {
|
|
2069
|
+
provision?: { title: string };
|
|
2070
|
+
};
|
|
2071
|
+
provider.provision = { title: "before" };
|
|
2072
|
+
|
|
2073
|
+
// Sheet A owns the binding; sheet B only consumes it. The bubbling
|
|
2074
|
+
// quark-binding-change event is what crosses the sheet boundary.
|
|
2075
|
+
const a = new Quark({
|
|
2076
|
+
options: { isScoped: true },
|
|
2077
|
+
src: `data-provider[is-success] { $todo: prop("provision"); }`,
|
|
2078
|
+
});
|
|
2079
|
+
const b = new Quark({
|
|
2080
|
+
options: { isScoped: true },
|
|
2081
|
+
src: `[bind-title] { content: $todo.title; }`,
|
|
2082
|
+
});
|
|
2083
|
+
a.register({
|
|
2084
|
+
sheetElement: root.querySelector("#sheet-a") as HTMLElement,
|
|
2085
|
+
});
|
|
2086
|
+
await flush();
|
|
2087
|
+
b.register({
|
|
2088
|
+
sheetElement: root.querySelector("#sheet-b") as HTMLElement,
|
|
2089
|
+
});
|
|
2090
|
+
await flush();
|
|
2091
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("before");
|
|
2092
|
+
|
|
2093
|
+
const meterB = measureComplexity(b);
|
|
2094
|
+
provider.provision = { title: "after" };
|
|
2095
|
+
await flush();
|
|
2096
|
+
const budgetB = meterB.take();
|
|
2097
|
+
meterB.stop();
|
|
2098
|
+
|
|
2099
|
+
expect(root.querySelector("[bind-title]")?.textContent).toBe("after");
|
|
2100
|
+
expectComplexity(budgetB);
|
|
2101
|
+
a.unregister();
|
|
2102
|
+
b.unregister();
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
it("unset deletes a binding so consumers fall through to an ancestor", async () => {
|
|
2106
|
+
const { root, quark, register } = createSheet(
|
|
2107
|
+
`<main>
|
|
2108
|
+
<details>
|
|
2109
|
+
<p bind-foo></p>
|
|
2110
|
+
</details>
|
|
2111
|
+
</main>`,
|
|
2112
|
+
`main { $foo: 1; }
|
|
2113
|
+
details[is-open] { $foo: 2; }
|
|
2114
|
+
details:not([is-open]) { $foo: unset; }
|
|
2115
|
+
[bind-foo] { content: $foo; }`
|
|
2116
|
+
);
|
|
2117
|
+
const details = root.querySelector("details") as HTMLElement;
|
|
2118
|
+
register();
|
|
2119
|
+
await flush();
|
|
2120
|
+
// closed: details holds no binding, falls through to main's 1
|
|
2121
|
+
expect(root.querySelector("[bind-foo]")?.textContent).toBe("1");
|
|
2122
|
+
|
|
2123
|
+
details.setAttribute("is-open", "");
|
|
2124
|
+
await flush();
|
|
2125
|
+
// open: details' nearer binding shadows main's
|
|
2126
|
+
expect(root.querySelector("[bind-foo]")?.textContent).toBe("2");
|
|
2127
|
+
|
|
2128
|
+
details.removeAttribute("is-open");
|
|
2129
|
+
await flush();
|
|
2130
|
+
// closed again: unset deletes the shadow, main's value returns
|
|
2131
|
+
expect(root.querySelector("[bind-foo]")?.textContent).toBe("1");
|
|
2132
|
+
quark.unregister();
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
it("skips re-runs when a farther (shadowed) binding owner changes", async () => {
|
|
2136
|
+
// Nearness optimization: the consumer last read $v from the inner
|
|
2137
|
+
// provider, so a change on the outer provider must not re-run it.
|
|
2138
|
+
const root = fixture<HTMLElement>(`<section>
|
|
2139
|
+
<div id="sheet"></div>
|
|
2140
|
+
<data-provider outer is-success>
|
|
2141
|
+
<data-provider inner is-success>
|
|
2142
|
+
<span bind-v></span>
|
|
2143
|
+
</data-provider>
|
|
2144
|
+
</data-provider>
|
|
2145
|
+
</section>`);
|
|
2146
|
+
const outer = root.querySelector("[outer]") as HTMLElement & {
|
|
2147
|
+
provision?: { label: string };
|
|
2148
|
+
};
|
|
2149
|
+
const inner = root.querySelector("[inner]") as HTMLElement & {
|
|
2150
|
+
provision?: { label: string };
|
|
2151
|
+
};
|
|
2152
|
+
outer.provision = { label: "outer-1" };
|
|
2153
|
+
inner.provision = { label: "inner-1" };
|
|
2154
|
+
|
|
2155
|
+
const quark = new Quark({
|
|
2156
|
+
options: { isScoped: true },
|
|
2157
|
+
src: `data-provider[outer][is-success] { $v: prop("provision"); }
|
|
2158
|
+
data-provider[inner][is-success] { $v: prop("provision"); }
|
|
2159
|
+
[bind-v] { content: $v.label; }`,
|
|
2160
|
+
});
|
|
2161
|
+
quark.register({
|
|
2162
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
2163
|
+
});
|
|
2164
|
+
await flush();
|
|
2165
|
+
expect(root.querySelector("[bind-v]")?.textContent).toBe("inner-1");
|
|
2166
|
+
|
|
2167
|
+
const meter = measureComplexity(quark);
|
|
2168
|
+
outer.provision = { label: "outer-2" };
|
|
2169
|
+
await flush();
|
|
2170
|
+
const budget = meter.take();
|
|
2171
|
+
meter.stop();
|
|
2172
|
+
|
|
2173
|
+
// shadowed change: consumer untouched (attributeRuns stays 0)
|
|
2174
|
+
expect(root.querySelector("[bind-v]")?.textContent).toBe("inner-1");
|
|
2175
|
+
expect(budget.attributeRuns).toBe(0);
|
|
2176
|
+
expectComplexity(budget);
|
|
2177
|
+
|
|
2178
|
+
inner.provision = { label: "inner-2" };
|
|
2179
|
+
await flush();
|
|
2180
|
+
// nearest-owner change still propagates
|
|
2181
|
+
expect(root.querySelector("[bind-v]")?.textContent).toBe("inner-2");
|
|
2182
|
+
quark.unregister();
|
|
2183
|
+
});
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
describe("@scope / :scope", () => {
|
|
2187
|
+
it(":scope targets the host element itself", async () => {
|
|
2188
|
+
const { root, quark, register } = createSheet(
|
|
2189
|
+
`<span></span>`,
|
|
2190
|
+
`:scope { data-host: "yes"; }`
|
|
2191
|
+
);
|
|
2192
|
+
register();
|
|
2193
|
+
await flush();
|
|
2194
|
+
|
|
2195
|
+
expect(root.getAttribute("data-host")).toBe("yes");
|
|
2196
|
+
quark.unregister();
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
it("reacts to host state changes via :scope[attr]", async () => {
|
|
2200
|
+
const { root, quark, register } = createSheet(
|
|
2201
|
+
`<p bind-mode></p>`,
|
|
2202
|
+
`:scope[data-mode="on"] [bind-mode] { content: "enabled"; }
|
|
2203
|
+
:scope:not([data-mode="on"]) [bind-mode] { content: "disabled"; }`
|
|
2204
|
+
);
|
|
2205
|
+
register();
|
|
2206
|
+
await flush();
|
|
2207
|
+
expect(root.querySelector("[bind-mode]")?.textContent).toBe("disabled");
|
|
2208
|
+
|
|
2209
|
+
root.setAttribute("data-mode", "on");
|
|
2210
|
+
await flush();
|
|
2211
|
+
expect(root.querySelector("[bind-mode]")?.textContent).toBe("enabled");
|
|
2212
|
+
quark.unregister();
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
it("keeps identical selectors scoped to their own host (no sibling leaks)", async () => {
|
|
2216
|
+
// Selectors are bare now (no self-anchoring q-id prefix), scoping
|
|
2217
|
+
// must come from containment checks and query-root clamping.
|
|
2218
|
+
const root = fixture<HTMLElement>(`<div>
|
|
2219
|
+
<section id="a"><div id="sheet-a"></div><span></span></section>
|
|
2220
|
+
<section id="b"><div id="sheet-b"></div><span></span></section>
|
|
2221
|
+
</div>`);
|
|
2222
|
+
const a = new Quark({
|
|
2223
|
+
src: `span { content: "A"; }`,
|
|
2224
|
+
options: { isScoped: true },
|
|
2225
|
+
});
|
|
2226
|
+
const b = new Quark({
|
|
2227
|
+
src: `span { content: "B"; }`,
|
|
2228
|
+
options: { isScoped: true },
|
|
2229
|
+
});
|
|
2230
|
+
a.register({
|
|
2231
|
+
sheetElement: root.querySelector("#sheet-a") as HTMLElement,
|
|
2232
|
+
});
|
|
2233
|
+
b.register({
|
|
2234
|
+
sheetElement: root.querySelector("#sheet-b") as HTMLElement,
|
|
2235
|
+
});
|
|
2236
|
+
await flush();
|
|
2237
|
+
|
|
2238
|
+
expect(root.querySelector("#a span")?.textContent).toBe("A");
|
|
2239
|
+
expect(root.querySelector("#b span")?.textContent).toBe("B");
|
|
2240
|
+
a.unregister();
|
|
2241
|
+
b.unregister();
|
|
2242
|
+
});
|
|
2243
|
+
});
|
|
2244
|
+
|
|
2245
|
+
describe("@scope containment (ancestor clamping)", () => {
|
|
2246
|
+
/**
|
|
2247
|
+
* CSS `@scope` is scope-contained: every compound of a scoped selector.
|
|
2248
|
+
* not just the subject, must match an element in scope (host + subtree).
|
|
2249
|
+
* Native matches()/querySelectorAll() evaluate ancestor compounds against
|
|
2250
|
+
* the whole document, so these cases guard against rules leaking in when
|
|
2251
|
+
* a matching ancestor exists ABOVE the host.
|
|
2252
|
+
*/
|
|
2253
|
+
const registerAt = (
|
|
2254
|
+
root: HTMLElement,
|
|
2255
|
+
src: string,
|
|
2256
|
+
modules?: Record<string, unknown>
|
|
2257
|
+
) => {
|
|
2258
|
+
const quark = new Quark({ src, options: { isScoped: true } });
|
|
2259
|
+
quark.register({
|
|
2260
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
2261
|
+
...(modules ? { modules: { dfault: modules } } : {}),
|
|
2262
|
+
});
|
|
2263
|
+
return quark;
|
|
2264
|
+
};
|
|
2265
|
+
|
|
2266
|
+
/*
|
|
2267
|
+
* The provider-demo shape: the sheet's host is nested inside an OUTER
|
|
2268
|
+
* element that matches the ancestor compound; the in-scope one does not
|
|
2269
|
+
* (yet). Reproduces the "undefined flash" seen in docs-site demos.
|
|
2270
|
+
*/
|
|
2271
|
+
const nestedProviderFixture = () =>
|
|
2272
|
+
fixture<HTMLElement>(
|
|
2273
|
+
`<x-provider is-success><section><div id="sheet"></div>` +
|
|
2274
|
+
`<x-provider><h4>loading</h4></x-provider></section></x-provider>`
|
|
2275
|
+
);
|
|
2276
|
+
|
|
2277
|
+
it("ignores an ancestor compound matched only outside the scope", async () => {
|
|
2278
|
+
const root = nestedProviderFixture();
|
|
2279
|
+
const quark = registerAt(
|
|
2280
|
+
root,
|
|
2281
|
+
`x-provider[is-success] {
|
|
2282
|
+
$todo: getTodo();
|
|
2283
|
+
h4 { content: "Todo #" + $todo.id; }
|
|
2284
|
+
}`,
|
|
2285
|
+
{ getTodo: () => ({ id: 7 }) }
|
|
2286
|
+
);
|
|
2287
|
+
await flush();
|
|
2288
|
+
// only the OUT-of-scope ancestor is successful: the rule must not run
|
|
2289
|
+
expect(root.querySelector("h4")?.textContent).toBe("loading");
|
|
2290
|
+
|
|
2291
|
+
// flipping the IN-scope ancestor activates the rule
|
|
2292
|
+
root.querySelector("section x-provider")!.setAttribute("is-success", "");
|
|
2293
|
+
await flush();
|
|
2294
|
+
expect(root.querySelector("h4")?.textContent).toBe("Todo #7");
|
|
2295
|
+
quark.unregister();
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
it("clamps ancestor compounds in :scope-prefixed selectors too", async () => {
|
|
2299
|
+
const root = nestedProviderFixture();
|
|
2300
|
+
const quark = registerAt(
|
|
2301
|
+
root,
|
|
2302
|
+
`:scope x-provider[is-success] {
|
|
2303
|
+
h4 { content: "ready"; }
|
|
2304
|
+
}`
|
|
2305
|
+
);
|
|
2306
|
+
await flush();
|
|
2307
|
+
expect(root.querySelector("h4")?.textContent).toBe("loading");
|
|
2308
|
+
|
|
2309
|
+
root.querySelector("section x-provider")!.setAttribute("is-success", "");
|
|
2310
|
+
await flush();
|
|
2311
|
+
expect(root.querySelector("h4")?.textContent).toBe("ready");
|
|
2312
|
+
quark.unregister();
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
it("requires every compound of a chain to match inside the scope", async () => {
|
|
2316
|
+
// [data-a] only above the host, [data-b] inside: half-in-scope chains
|
|
2317
|
+
// must not match
|
|
2318
|
+
const root = fixture<HTMLElement>(
|
|
2319
|
+
`<div data-a><section><div id="sheet"></div>` +
|
|
2320
|
+
`<div id="mid"><div data-b><span></span></div></div></section></div>`
|
|
2321
|
+
);
|
|
2322
|
+
const quark = registerAt(
|
|
2323
|
+
root,
|
|
2324
|
+
`[data-a] [data-b] span { content: "on"; }`
|
|
2325
|
+
);
|
|
2326
|
+
await flush();
|
|
2327
|
+
expect(root.querySelector("span")?.textContent).toBe("");
|
|
2328
|
+
|
|
2329
|
+
// completing the chain inside the scope matches
|
|
2330
|
+
root.querySelector("#mid")!.setAttribute("data-a", "");
|
|
2331
|
+
await flush();
|
|
2332
|
+
expect(root.querySelector("span")?.textContent).toBe("on");
|
|
2333
|
+
quark.unregister();
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
it("excludes the host from bare ancestor compounds (root needs explicit :scope)", async () => {
|
|
2337
|
+
/*
|
|
2338
|
+
* CSS `@scope`: bare selectors are implicitly `:scope <descendant>`,
|
|
2339
|
+
* so every compound matches strict descendants, the root itself is
|
|
2340
|
+
* only reachable via an explicit `:scope`
|
|
2341
|
+
*/
|
|
2342
|
+
const { root, quark, register } = createSheet(
|
|
2343
|
+
`<span></span>`,
|
|
2344
|
+
`section[data-on] span { content: "on"; }`
|
|
2345
|
+
);
|
|
2346
|
+
root.setAttribute("data-on", "");
|
|
2347
|
+
register();
|
|
2348
|
+
await flush();
|
|
2349
|
+
|
|
2350
|
+
expect(root.querySelector("span")?.textContent).toBe("");
|
|
2351
|
+
quark.unregister();
|
|
2352
|
+
});
|
|
2353
|
+
|
|
2354
|
+
it("matches the host's state via an explicit :scope compound", async () => {
|
|
2355
|
+
const { root, quark, register } = createSheet(
|
|
2356
|
+
`<span></span>`,
|
|
2357
|
+
`:scope[data-on] span { content: "on"; }`
|
|
2358
|
+
);
|
|
2359
|
+
root.setAttribute("data-on", "");
|
|
2360
|
+
register();
|
|
2361
|
+
await flush();
|
|
2362
|
+
|
|
2363
|
+
expect(root.querySelector("span")?.textContent).toBe("on");
|
|
2364
|
+
quark.unregister();
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
it("excludes the host from bare subject selectors", async () => {
|
|
2368
|
+
// host is the <section>: `section {}` must not mutate it, `:scope {}`
|
|
2369
|
+
// must
|
|
2370
|
+
const { root, quark, register } = createSheet(
|
|
2371
|
+
`<span></span>`,
|
|
2372
|
+
`section { data-bare: "hit"; }
|
|
2373
|
+
:scope { data-scope: "hit"; }`
|
|
2374
|
+
);
|
|
2375
|
+
register();
|
|
2376
|
+
await flush();
|
|
2377
|
+
|
|
2378
|
+
expect(root.hasAttribute("data-bare")).toBe(false);
|
|
2379
|
+
expect(root.getAttribute("data-scope")).toBe("hit");
|
|
2380
|
+
quark.unregister();
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
it("excludes the host from ancestor compounds under :scope (strict descendants)", async () => {
|
|
2384
|
+
// `:scope section[data-on] span`, the compound must match a strict
|
|
2385
|
+
// descendant of the host, so the host itself does not qualify
|
|
2386
|
+
const { root, quark, register } = createSheet(
|
|
2387
|
+
`<span></span>`,
|
|
2388
|
+
`:scope section[data-on] span { content: "on"; }`
|
|
2389
|
+
);
|
|
2390
|
+
root.setAttribute("data-on", "");
|
|
2391
|
+
register();
|
|
2392
|
+
await flush();
|
|
2393
|
+
|
|
2394
|
+
expect(root.querySelector("span")?.textContent).toBe("");
|
|
2395
|
+
quark.unregister();
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
it("backtracks past a nearer decoy when a child combinator constrains the chain", async () => {
|
|
2399
|
+
// nearest [data-b] ancestor is not a child of [data-a]; the farther
|
|
2400
|
+
// one is, greedy nearest-ancestor matching alone would miss it
|
|
2401
|
+
const root = fixture<HTMLElement>(
|
|
2402
|
+
`<section><div id="sheet"></div><div data-a><div data-b>` +
|
|
2403
|
+
`<section><div data-b><span></span></div></section>` +
|
|
2404
|
+
`</div></div></section>`
|
|
2405
|
+
);
|
|
2406
|
+
const quark = registerAt(
|
|
2407
|
+
root,
|
|
2408
|
+
`[data-a] > [data-b] span { content: "on"; }`
|
|
2409
|
+
);
|
|
2410
|
+
await flush();
|
|
2411
|
+
|
|
2412
|
+
expect(root.querySelector("span")?.textContent).toBe("on");
|
|
2413
|
+
quark.unregister();
|
|
2414
|
+
});
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
describe("global (unscoped) sheets", () => {
|
|
2418
|
+
/**
|
|
2419
|
+
* Without `isScoped`, top-level rules run in the root context (the whole
|
|
2420
|
+
* document); rules inside an explicit `@scope { }` block stay
|
|
2421
|
+
* host-scoped. This is the plumbing for `quark-sheet[is-global]`.
|
|
2422
|
+
*/
|
|
2423
|
+
const mountGlobal = (root: HTMLElement, src: string) => {
|
|
2424
|
+
const quark = new Quark({ src, options: { isScoped: false } });
|
|
2425
|
+
quark.register({
|
|
2426
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
2427
|
+
});
|
|
2428
|
+
return quark;
|
|
2429
|
+
};
|
|
2430
|
+
|
|
2431
|
+
// ancestor provider ABOVE the sheet's host, the `is-global` use case
|
|
2432
|
+
const providerFixture = () =>
|
|
2433
|
+
fixture<HTMLElement & { provision?: { name: string } }>(
|
|
2434
|
+
`<x-provider id="root-user"><section><div id="sheet"></div>` +
|
|
2435
|
+
`<span></span></section></x-provider>`
|
|
2436
|
+
);
|
|
2437
|
+
|
|
2438
|
+
const MIXED_SRC = `x-provider#root-user[is-success] {
|
|
2439
|
+
$user: prop("provision");
|
|
2440
|
+
}
|
|
2441
|
+
@scope {
|
|
2442
|
+
span { content: $user.name; }
|
|
2443
|
+
}`;
|
|
2444
|
+
|
|
2445
|
+
it("top-level rules read prop() from an ancestor provider outside @scope", async () => {
|
|
2446
|
+
/*
|
|
2447
|
+
* Attribute changes above the host are not observed (the observer
|
|
2448
|
+
* watches the host subtree), so the provider is already matchable;
|
|
2449
|
+
* the property itself is what changes later.
|
|
2450
|
+
*/
|
|
2451
|
+
const provider = providerFixture();
|
|
2452
|
+
provider.setAttribute("is-success", "");
|
|
2453
|
+
const quark = mountGlobal(provider, MIXED_SRC);
|
|
2454
|
+
await flush();
|
|
2455
|
+
// matched and read (subscribed) while still unprovisioned: wipes
|
|
2456
|
+
expect(provider.querySelector("span")?.textContent).toBe("");
|
|
2457
|
+
|
|
2458
|
+
provider.provision = { name: "Ada" };
|
|
2459
|
+
await flush();
|
|
2460
|
+
expect(provider.querySelector("span")?.textContent).toBe("Ada");
|
|
2461
|
+
quark.unregister();
|
|
2462
|
+
});
|
|
2463
|
+
|
|
2464
|
+
it("matches an already-provisioned ancestor on the first run", async () => {
|
|
2465
|
+
const provider = providerFixture();
|
|
2466
|
+
provider.provision = { name: "Ada" };
|
|
2467
|
+
provider.setAttribute("is-success", "");
|
|
2468
|
+
const quark = mountGlobal(provider, MIXED_SRC);
|
|
2469
|
+
await flush();
|
|
2470
|
+
|
|
2471
|
+
expect(provider.querySelector("span")?.textContent).toBe("Ada");
|
|
2472
|
+
quark.unregister();
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
it("isScoped retains the implicit @scope wrapping (quark-sheet default)", async () => {
|
|
2476
|
+
const provider = providerFixture();
|
|
2477
|
+
provider.provision = { name: "Ada" };
|
|
2478
|
+
provider.setAttribute("is-success", "");
|
|
2479
|
+
const quark = new Quark({
|
|
2480
|
+
src: `x-provider#root-user[is-success] { $user: prop("provision"); }
|
|
2481
|
+
span { content: $user.name; }`,
|
|
2482
|
+
options: { isScoped: true },
|
|
2483
|
+
});
|
|
2484
|
+
quark.register({
|
|
2485
|
+
sheetElement: provider.querySelector("#sheet") as HTMLElement,
|
|
2486
|
+
});
|
|
2487
|
+
await flush();
|
|
2488
|
+
// the provider is outside the scope: the def never runs
|
|
2489
|
+
expect(provider.querySelector("span")?.textContent).toBe("");
|
|
2490
|
+
|
|
2491
|
+
provider.provision = { name: "Grace" };
|
|
2492
|
+
await flush();
|
|
2493
|
+
expect(provider.querySelector("span")?.textContent).toBe("");
|
|
2494
|
+
quark.unregister();
|
|
2495
|
+
});
|
|
2496
|
+
|
|
2497
|
+
it("global top-level rules match elements outside the host subtree", async () => {
|
|
2498
|
+
const root = fixture<HTMLElement>(
|
|
2499
|
+
`<div><section><div id="sheet"></div><span bind-x></span></section>` +
|
|
2500
|
+
`<aside><span bind-x></span></aside></div>`
|
|
2501
|
+
);
|
|
2502
|
+
const quark = mountGlobal(root, `[bind-x] { data-hit: ""; }`);
|
|
2503
|
+
await flush();
|
|
2504
|
+
|
|
2505
|
+
expect(root.querySelectorAll("[bind-x][data-hit]")).toHaveLength(2);
|
|
2506
|
+
quark.unregister();
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
it("@scope rules stay host-scoped inside a global sheet", async () => {
|
|
2510
|
+
const root = fixture<HTMLElement>(
|
|
2511
|
+
`<div><section><div id="sheet"></div><span bind-x></span></section>` +
|
|
2512
|
+
`<aside><span bind-x></span></aside></div>`
|
|
2513
|
+
);
|
|
2514
|
+
const quark = mountGlobal(
|
|
2515
|
+
root,
|
|
2516
|
+
`@scope { [bind-x] { data-scoped: ""; } }`
|
|
2517
|
+
);
|
|
2518
|
+
await flush();
|
|
2519
|
+
|
|
2520
|
+
expect(
|
|
2521
|
+
root.querySelector("section [bind-x]")?.hasAttribute("data-scoped")
|
|
2522
|
+
).toBe(true);
|
|
2523
|
+
expect(
|
|
2524
|
+
root.querySelector("aside [bind-x]")?.hasAttribute("data-scoped")
|
|
2525
|
+
).toBe(false);
|
|
2526
|
+
quark.unregister();
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
it("top-level :scope rules stay host-anchored in a global sheet", async () => {
|
|
2530
|
+
const root = fixture<HTMLElement>(
|
|
2531
|
+
`<div><section><div id="sheet"></div><span bind-x></span></section>` +
|
|
2532
|
+
`<aside><span bind-x></span></aside></div>`
|
|
2533
|
+
);
|
|
2534
|
+
const quark = new Quark({
|
|
2535
|
+
src: `:scope [bind-x] { data-host: ""; }`,
|
|
2536
|
+
options: { isScoped: false },
|
|
2537
|
+
});
|
|
2538
|
+
quark.register({
|
|
2539
|
+
sheetElement: root.querySelector("#sheet") as HTMLElement,
|
|
2540
|
+
});
|
|
2541
|
+
await flush();
|
|
2542
|
+
|
|
2543
|
+
expect(
|
|
2544
|
+
root.querySelector("section [bind-x]")?.hasAttribute("data-host")
|
|
2545
|
+
).toBe(true);
|
|
2546
|
+
expect(
|
|
2547
|
+
root.querySelector("aside [bind-x]")?.hasAttribute("data-host")
|
|
2548
|
+
).toBe(false);
|
|
2549
|
+
quark.unregister();
|
|
2550
|
+
});
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
describe("value semantics (wipe / preserve)", () => {
|
|
2554
|
+
it("wipes content and attributes when an expression resolves to undefined", async () => {
|
|
2555
|
+
const { root, quark, register } = createSheet(
|
|
2556
|
+
`<p bind-x>initial</p><span bind-y data-flag="on"></span>`,
|
|
2557
|
+
`[bind-x] { content: maybe(); }
|
|
2558
|
+
[bind-y] { data-flag: maybe(); }`,
|
|
2559
|
+
{ maybe: () => undefined }
|
|
2560
|
+
);
|
|
2561
|
+
register();
|
|
2562
|
+
await flush();
|
|
2563
|
+
|
|
2564
|
+
expect(root.querySelector("[bind-x]")?.textContent).toBe("");
|
|
2565
|
+
expect(root.querySelector("[bind-y]")?.hasAttribute("data-flag")).toBe(
|
|
2566
|
+
false
|
|
2567
|
+
);
|
|
2568
|
+
quark.unregister();
|
|
2569
|
+
});
|
|
2570
|
+
|
|
2571
|
+
it("preserve keeps existing content and attributes untouched", async () => {
|
|
2572
|
+
const { root, quark, register } = createSheet(
|
|
2573
|
+
`<p bind-x>initial</p><span bind-y data-flag="on"></span>`,
|
|
2574
|
+
`[bind-x] { content: maybe() or preserve; }
|
|
2575
|
+
[bind-y] { data-flag: maybe() or preserve; }`,
|
|
2576
|
+
{ maybe: () => undefined }
|
|
2577
|
+
);
|
|
2578
|
+
register();
|
|
2579
|
+
await flush();
|
|
2580
|
+
|
|
2581
|
+
expect(root.querySelector("[bind-x]")?.textContent).toBe("initial");
|
|
2582
|
+
expect(root.querySelector("[bind-y]")?.getAttribute("data-flag")).toBe(
|
|
2583
|
+
"on"
|
|
2584
|
+
);
|
|
2585
|
+
quark.unregister();
|
|
2586
|
+
});
|
|
2587
|
+
|
|
2588
|
+
it("preserve leaves an existing binding in place", async () => {
|
|
2589
|
+
const { root, quark, register } = createSheet(
|
|
2590
|
+
`<main><p bind-x></p></main>`,
|
|
2591
|
+
`main { $val: "kept"; }
|
|
2592
|
+
main { $val: preserve; }
|
|
2593
|
+
[bind-x] { content: $val; }`
|
|
2594
|
+
);
|
|
2595
|
+
register();
|
|
2596
|
+
await flush();
|
|
2597
|
+
|
|
2598
|
+
expect(root.querySelector("[bind-x]")?.textContent).toBe("kept");
|
|
2599
|
+
quark.unregister();
|
|
2600
|
+
});
|
|
2601
|
+
|
|
2602
|
+
it("no-ops instead of wiping when an expression fails to evaluate", async () => {
|
|
2603
|
+
const { root, quark, register } = createSheet(
|
|
2604
|
+
`<p bind-x>initial</p>`,
|
|
2605
|
+
`[bind-x] { content: explode(); }`,
|
|
2606
|
+
{
|
|
2607
|
+
explode: () => {
|
|
2608
|
+
throw new Error("boom");
|
|
2609
|
+
},
|
|
2610
|
+
}
|
|
2611
|
+
);
|
|
2612
|
+
register();
|
|
2613
|
+
await flush();
|
|
2614
|
+
|
|
2615
|
+
expect(root.querySelector("[bind-x]")?.textContent).toBe("initial");
|
|
2616
|
+
quark.unregister();
|
|
2617
|
+
});
|
|
2618
|
+
|
|
2619
|
+
it("logs the failing element as a tag label, never as a node tree", async () => {
|
|
2620
|
+
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
2621
|
+
const { quark, register } = createSheet(
|
|
2622
|
+
`<p id="boom" bind-x>initial</p>`,
|
|
2623
|
+
`[bind-x] { content: explode(); }`,
|
|
2624
|
+
{
|
|
2625
|
+
explode: () => {
|
|
2626
|
+
throw new Error("boom");
|
|
2627
|
+
},
|
|
2628
|
+
}
|
|
2629
|
+
);
|
|
2630
|
+
register();
|
|
2631
|
+
await flush();
|
|
2632
|
+
|
|
2633
|
+
expect(spy).toHaveBeenCalled();
|
|
2634
|
+
const args = spy.mock.calls.flat();
|
|
2635
|
+
const hasNode = (value: unknown): boolean =>
|
|
2636
|
+
value instanceof Node ||
|
|
2637
|
+
(Array.isArray(value) && value.some(hasNode)) ||
|
|
2638
|
+
(!!value &&
|
|
2639
|
+
typeof value === "object" &&
|
|
2640
|
+
!(value instanceof Error) &&
|
|
2641
|
+
Object.values(value).some(hasNode));
|
|
2642
|
+
expect(args.some(hasNode)).toBe(false);
|
|
2643
|
+
expect(args).toContain("<p#boom>");
|
|
2644
|
+
spy.mockRestore();
|
|
2645
|
+
quark.unregister();
|
|
2646
|
+
});
|
|
2647
|
+
});
|
|
2648
|
+
|
|
2649
|
+
describe("CSS custom properties (--)", () => {
|
|
2650
|
+
const propOf = (el: Element | null, name: string) =>
|
|
2651
|
+
(el as HTMLElement | null)?.style.getPropertyValue(name);
|
|
2652
|
+
|
|
2653
|
+
it("sets a custom property from a quoted string", async () => {
|
|
2654
|
+
const { root, quark, register } = createSheet(
|
|
2655
|
+
`<details><summary>panel</summary></details>`,
|
|
2656
|
+
`details { --border-color: "transparent"; }`
|
|
2657
|
+
);
|
|
2658
|
+
register();
|
|
2659
|
+
await flush();
|
|
2660
|
+
|
|
2661
|
+
expect(propOf(root.querySelector("details"), "--border-color")).toBe(
|
|
2662
|
+
"transparent"
|
|
2663
|
+
);
|
|
2664
|
+
quark.unregister();
|
|
2665
|
+
});
|
|
2666
|
+
|
|
2667
|
+
it("reacts to attribute state like any other property", async () => {
|
|
2668
|
+
const { root, quark, register } = createSheet(
|
|
2669
|
+
`<details><summary>panel</summary></details>`,
|
|
2670
|
+
`details {
|
|
2671
|
+
--border-color: "transparent";
|
|
2672
|
+
&[open] { --border-color: "#3fa9f5"; }
|
|
2673
|
+
}`
|
|
2674
|
+
);
|
|
2675
|
+
register();
|
|
2676
|
+
await flush();
|
|
2677
|
+
const details = root.querySelector("details") as HTMLElement;
|
|
2678
|
+
expect(propOf(details, "--border-color")).toBe("transparent");
|
|
2679
|
+
|
|
2680
|
+
details.setAttribute("open", "");
|
|
2681
|
+
await flush();
|
|
2682
|
+
expect(propOf(details, "--border-color")).toBe("#3fa9f5");
|
|
2683
|
+
|
|
2684
|
+
// no unmatch reversion (documented divergence from CSS): the closed
|
|
2685
|
+
// state must be written by its own rule, so the value persists here
|
|
2686
|
+
details.removeAttribute("open");
|
|
2687
|
+
await flush();
|
|
2688
|
+
expect(propOf(details, "--border-color")).toBe("#3fa9f5");
|
|
2689
|
+
quark.unregister();
|
|
2690
|
+
});
|
|
2691
|
+
|
|
2692
|
+
it("resolves values from JS callouts and bindings reactively", async () => {
|
|
2693
|
+
const { root, quark, register } = createSheet(
|
|
2694
|
+
`<main><section></section></main>`,
|
|
2695
|
+
`main { $accent: "blue"; }
|
|
2696
|
+
section { --accent: getColor($accent); }`,
|
|
2697
|
+
{ getColor: (base: string) => `var(--${base})` }
|
|
2698
|
+
);
|
|
2699
|
+
register();
|
|
2700
|
+
await flush();
|
|
2701
|
+
const section = root.querySelector("section");
|
|
2702
|
+
expect(propOf(section, "--accent")).toBe("var(--blue)");
|
|
2703
|
+
// a JS write on the owner re-runs the reader with the new value
|
|
2704
|
+
root.querySelector("main")!.quark.setProperty("$accent", "red");
|
|
2705
|
+
await flush();
|
|
2706
|
+
expect(propOf(section, "--accent")).toBe("var(--red)");
|
|
2707
|
+
quark.unregister();
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
it("passes a trailing !important to the priority argument", async () => {
|
|
2711
|
+
const { root, quark, register } = createSheet(
|
|
2712
|
+
`<section></section>`,
|
|
2713
|
+
`section { --accent: "red !important"; }`
|
|
2714
|
+
);
|
|
2715
|
+
register();
|
|
2716
|
+
await flush();
|
|
2717
|
+
|
|
2718
|
+
const style = (root.querySelector("section") as HTMLElement).style;
|
|
2719
|
+
expect(style.getPropertyValue("--accent")).toBe("red");
|
|
2720
|
+
expect(style.getPropertyPriority("--accent")).toBe("important");
|
|
2721
|
+
quark.unregister();
|
|
2722
|
+
});
|
|
2723
|
+
|
|
2724
|
+
it("allows numeric expression results", async () => {
|
|
2725
|
+
const { root, quark, register } = createSheet(
|
|
2726
|
+
`<section></section>`,
|
|
2727
|
+
`section { --columns: 2 + 1; --progress: (3 / 4 * 100) + "%"; }`
|
|
2728
|
+
);
|
|
2729
|
+
register();
|
|
2730
|
+
await flush();
|
|
2731
|
+
|
|
2732
|
+
const section = root.querySelector("section");
|
|
2733
|
+
expect(propOf(section, "--columns")).toBe("3");
|
|
2734
|
+
expect(propOf(section, "--progress")).toBe("75%");
|
|
2735
|
+
quark.unregister();
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
it("removes the property on wipe values", async () => {
|
|
2739
|
+
const { root, quark, register } = createSheet(
|
|
2740
|
+
`<section data-x></section>`,
|
|
2741
|
+
`section { --accent: "red"; }
|
|
2742
|
+
section[data-x] { --accent: none; }`
|
|
2743
|
+
);
|
|
2744
|
+
register();
|
|
2745
|
+
await flush();
|
|
2746
|
+
|
|
2747
|
+
expect(propOf(root.querySelector("section"), "--accent")).toBe("");
|
|
2748
|
+
quark.unregister();
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
it("preserve leaves the current value untouched", async () => {
|
|
2752
|
+
const { root, quark, register } = createSheet(
|
|
2753
|
+
`<section></section>`,
|
|
2754
|
+
`section { --accent: "red"; }
|
|
2755
|
+
section { --accent: preserve; }`
|
|
2756
|
+
);
|
|
2757
|
+
register();
|
|
2758
|
+
await flush();
|
|
2759
|
+
|
|
2760
|
+
expect(propOf(root.querySelector("section"), "--accent")).toBe("red");
|
|
2761
|
+
quark.unregister();
|
|
2762
|
+
});
|
|
2763
|
+
|
|
2764
|
+
it("rejects bare CSS literals at construction (quote them)", async () => {
|
|
2765
|
+
QuarkLogger.suppress();
|
|
2766
|
+
const errorSpy = vi.spyOn(QuarkLogger, "error");
|
|
2767
|
+
const { root, quark, register } = createSheet(
|
|
2768
|
+
`<section></section>`,
|
|
2769
|
+
`section { --accent: #ccc; --width: 10px; }`
|
|
2770
|
+
);
|
|
2771
|
+
register();
|
|
2772
|
+
await flush();
|
|
2773
|
+
|
|
2774
|
+
expect(
|
|
2775
|
+
errorSpy.mock.calls.filter((args) =>
|
|
2776
|
+
String((args[0] as { message?: string })?.message).includes(
|
|
2777
|
+
"must be quoted"
|
|
2778
|
+
)
|
|
2779
|
+
)
|
|
2780
|
+
).toHaveLength(2);
|
|
2781
|
+
const section = root.querySelector("section");
|
|
2782
|
+
expect(propOf(section, "--accent")).toBe("");
|
|
2783
|
+
expect(propOf(section, "--width")).toBe("");
|
|
2784
|
+
errorSpy.mockRestore();
|
|
2785
|
+
QuarkLogger.unsuppress();
|
|
2786
|
+
quark.unregister();
|
|
2787
|
+
});
|
|
2788
|
+
|
|
2789
|
+
it("no-ops (with a logged error) on unquoted identifiers", async () => {
|
|
2790
|
+
// `red` parses as an identifier lookup, not defined, so the standard
|
|
2791
|
+
// failed-evaluation no-op applies
|
|
2792
|
+
QuarkLogger.suppress();
|
|
2793
|
+
const { root, quark, register } = createSheet(
|
|
2794
|
+
`<section></section>`,
|
|
2795
|
+
`section { --accent: red; }`
|
|
2796
|
+
);
|
|
2797
|
+
register();
|
|
2798
|
+
await flush();
|
|
2799
|
+
QuarkLogger.unsuppress();
|
|
2800
|
+
|
|
2801
|
+
expect(propOf(root.querySelector("section"), "--accent")).toBe("");
|
|
2802
|
+
quark.unregister();
|
|
2803
|
+
});
|
|
2804
|
+
|
|
2805
|
+
it("errors (without crashing) on non-string results", async () => {
|
|
2806
|
+
QuarkLogger.suppress();
|
|
2807
|
+
const errorSpy = vi.spyOn(QuarkLogger, "error");
|
|
2808
|
+
const { root, quark, register } = createSheet(
|
|
2809
|
+
`<section></section>`,
|
|
2810
|
+
`section { --meta: getObj(); }`,
|
|
2811
|
+
{ getObj: () => ({ nope: true }) }
|
|
2812
|
+
);
|
|
2813
|
+
register();
|
|
2814
|
+
await flush();
|
|
2815
|
+
|
|
2816
|
+
expect(
|
|
2817
|
+
errorSpy.mock.calls.some((args) =>
|
|
2818
|
+
String((args[0] as { message?: string })?.message).includes(
|
|
2819
|
+
"must resolve to a string"
|
|
2820
|
+
)
|
|
2821
|
+
)
|
|
2822
|
+
).toBe(true);
|
|
2823
|
+
expect(propOf(root.querySelector("section"), "--meta")).toBe("");
|
|
2824
|
+
errorSpy.mockRestore();
|
|
2825
|
+
QuarkLogger.unsuppress();
|
|
2826
|
+
quark.unregister();
|
|
2827
|
+
});
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
describe("lifecycle", () => {
|
|
2831
|
+
it("stops reacting after unregister", async () => {
|
|
2832
|
+
const { root, quark, register } = createSheet(
|
|
2833
|
+
`<details><p bind-status></p></details>`,
|
|
2834
|
+
`details[open] [bind-status] { content: "opened"; }`
|
|
2835
|
+
);
|
|
2836
|
+
register();
|
|
2837
|
+
await flush();
|
|
2838
|
+
quark.unregister();
|
|
2839
|
+
|
|
2840
|
+
const meter = measureComplexity(quark);
|
|
2841
|
+
root.querySelector("details")!.setAttribute("open", "");
|
|
2842
|
+
await flush();
|
|
2843
|
+
const budget = meter.take();
|
|
2844
|
+
meter.stop();
|
|
2845
|
+
|
|
2846
|
+
expect(root.querySelector("[bind-status]")?.textContent).toBe("");
|
|
2847
|
+
expectComplexity(budget);
|
|
2848
|
+
});
|
|
2849
|
+
|
|
2850
|
+
it("supports two sheets on the same host", async () => {
|
|
2851
|
+
const root = fixture<HTMLElement>(`<section>
|
|
2852
|
+
<div id="sheet-a"></div>
|
|
2853
|
+
<div id="sheet-b"></div>
|
|
2854
|
+
<span bind-a></span>
|
|
2855
|
+
<span bind-b></span>
|
|
2856
|
+
</section>`);
|
|
2857
|
+
const a = new Quark({
|
|
2858
|
+
options: { isScoped: true },
|
|
2859
|
+
src: `[bind-a] { content: "A"; }`,
|
|
2860
|
+
});
|
|
2861
|
+
const b = new Quark({
|
|
2862
|
+
options: { isScoped: true },
|
|
2863
|
+
src: `[bind-b] { content: "B"; }`,
|
|
2864
|
+
});
|
|
2865
|
+
const meterA = measureComplexity(a);
|
|
2866
|
+
const meterB = measureComplexity(b);
|
|
2867
|
+
a.register({
|
|
2868
|
+
sheetElement: root.querySelector("#sheet-a") as HTMLElement,
|
|
2869
|
+
});
|
|
2870
|
+
b.register({
|
|
2871
|
+
sheetElement: root.querySelector("#sheet-b") as HTMLElement,
|
|
2872
|
+
});
|
|
2873
|
+
await flush();
|
|
2874
|
+
const budgetA = meterA.take();
|
|
2875
|
+
const budgetB = meterB.take();
|
|
2876
|
+
meterA.stop();
|
|
2877
|
+
meterB.stop();
|
|
2878
|
+
|
|
2879
|
+
expect(root.querySelector("[bind-a]")?.textContent).toBe("A");
|
|
2880
|
+
expect(root.querySelector("[bind-b]")?.textContent).toBe("B");
|
|
2881
|
+
expectComplexity(budgetA);
|
|
2882
|
+
expectComplexity(budgetB);
|
|
2883
|
+
a.unregister();
|
|
2884
|
+
b.unregister();
|
|
2885
|
+
});
|
|
2886
|
+
});
|
|
2887
|
+
});
|