@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.
Files changed (130) hide show
  1. package/.rush/temp/chunked-rush-logs/quark.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/quark.build_package-metas.chunks.jsonl +1 -0
  3. package/.rush/temp/operation/apply-exports/all.log +1 -0
  4. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  5. package/.rush/temp/operation/apply-exports/state.json +3 -0
  6. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  7. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  8. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +3 -0
  10. package/config/rig.json +6 -0
  11. package/index.ts +4 -0
  12. package/language.ts +7 -0
  13. package/package.json +45 -0
  14. package/rush-logs/quark.apply-exports.cache.log +1 -0
  15. package/rush-logs/quark.apply-exports.log +1 -0
  16. package/rush-logs/quark.build_package-metas.cache.log +1 -0
  17. package/rush-logs/quark.build_package-metas.log +1 -0
  18. package/src/actions.ts +445 -0
  19. package/src/ast.ts +187 -0
  20. package/src/bindings.ts +115 -0
  21. package/src/builtin-modules.ts +401 -0
  22. package/src/constants.ts +47 -0
  23. package/src/cycle-check.ts +121 -0
  24. package/src/devtools-hook.ts +303 -0
  25. package/src/element-api.ts +109 -0
  26. package/src/evaluator.ts +317 -0
  27. package/src/form-controls.ts +69 -0
  28. package/src/language-tables.ts +137 -0
  29. package/src/language.ts +1225 -0
  30. package/src/observer.ts +110 -0
  31. package/src/paint.ts +305 -0
  32. package/src/properties.ts +818 -0
  33. package/src/props.ts +94 -0
  34. package/src/quark-element.ts +7 -0
  35. package/src/quark-internal.ts +365 -0
  36. package/src/quark.ts +817 -0
  37. package/src/resolvers.ts +867 -0
  38. package/src/rule.ts +800 -0
  39. package/src/scope-id.ts +47 -0
  40. package/src/selector-utils.ts +430 -0
  41. package/src/settle.ts +117 -0
  42. package/src/types.ts +155 -0
  43. package/src/utils.ts +79 -0
  44. package/src/variables.ts +359 -0
  45. package/support/demos/css-variables.html +18 -0
  46. package/support/demos/dispatch.html +20 -0
  47. package/support/demos/events.html +9 -0
  48. package/support/demos/iterate.html +13 -0
  49. package/support/demos/js-api.html +17 -0
  50. package/support/demos/provider-list.html +16 -0
  51. package/support/demos/toggle-content.html +14 -0
  52. package/support/demos/unset.html +12 -0
  53. package/support/demos/view-transition.html +36 -0
  54. package/support/docs/ATTRIBUTES.md +37 -0
  55. package/support/docs/AT_RULES.md +45 -0
  56. package/support/docs/BUILTINS.md +57 -0
  57. package/support/docs/CONTENT.md +40 -0
  58. package/support/docs/CSS_VARIABLES.md +16 -0
  59. package/support/docs/DECLARATIONS.md +26 -0
  60. package/support/docs/DELAY.md +37 -0
  61. package/support/docs/DIAGNOSTICS.md +32 -0
  62. package/support/docs/DISPATCH.md +81 -0
  63. package/support/docs/ELEMENT_PROPERTIES.md +34 -0
  64. package/support/docs/EXPRESSIONS.md +31 -0
  65. package/support/docs/INTERNAL.md +8 -0
  66. package/support/docs/JS_API.md +53 -0
  67. package/support/docs/JS_WRITES.md +36 -0
  68. package/support/docs/LIMITATIONS.md +26 -0
  69. package/support/docs/LOOP_GUARD.md +30 -0
  70. package/support/docs/METHODS.md +43 -0
  71. package/support/docs/MODULES.md +111 -0
  72. package/support/docs/NO_REVERSION.md +18 -0
  73. package/support/docs/ON.md +87 -0
  74. package/support/docs/REACTIVITY.md +24 -0
  75. package/support/docs/README.md +143 -0
  76. package/support/docs/SELECTORS.md +87 -0
  77. package/support/docs/SHEETS.md +67 -0
  78. package/support/docs/SYNTAX.md +36 -0
  79. package/support/docs/USE.md +24 -0
  80. package/support/docs/VALUES.md +27 -0
  81. package/support/docs/VARIABLES.md +47 -0
  82. package/support/docs/VIEW_TRANSITION.md +64 -0
  83. package/support/docs-sections.json +65 -0
  84. package/support/package-meta.json +208 -0
  85. package/support/scripts/build-language-docs.mjs +196 -0
  86. package/support/tests/__snapshots__/css-variables.view.test.ts.snap +23 -0
  87. package/support/tests/__snapshots__/dispatch.view.test.ts.snap +23 -0
  88. package/support/tests/__snapshots__/events.view.test.ts.snap +23 -0
  89. package/support/tests/__snapshots__/iterate.view.test.ts.snap +23 -0
  90. package/support/tests/__snapshots__/js-api.view.test.ts.snap +23 -0
  91. package/support/tests/__snapshots__/provider-list.view.test.ts.snap +23 -0
  92. package/support/tests/__snapshots__/quark-features.test.ts.snap +1079 -0
  93. package/support/tests/__snapshots__/quark-with-providers.test.ts.snap +89 -0
  94. package/support/tests/__snapshots__/quark.test.ts.snap +221 -0
  95. package/support/tests/__snapshots__/selectors.test.ts.snap +133 -0
  96. package/support/tests/__snapshots__/toggle-content.view.test.ts.snap +23 -0
  97. package/support/tests/__snapshots__/unset.view.test.ts.snap +23 -0
  98. package/support/tests/__snapshots__/view-transition.view.test.ts.snap +23 -0
  99. package/support/tests/actions.test.ts +437 -0
  100. package/support/tests/builtin-modules.test.ts +337 -0
  101. package/support/tests/builtins.test.ts +189 -0
  102. package/support/tests/css-variables.view.test.ts +35 -0
  103. package/support/tests/delay.test.ts +244 -0
  104. package/support/tests/devtools-hook.test.ts +496 -0
  105. package/support/tests/diagnostics.test.ts +224 -0
  106. package/support/tests/dispatch.view.test.ts +43 -0
  107. package/support/tests/element-api.test.ts +202 -0
  108. package/support/tests/evaluator.test.ts +237 -0
  109. package/support/tests/events.view.test.ts +40 -0
  110. package/support/tests/form-controls.test.ts +452 -0
  111. package/support/tests/helpers.ts +354 -0
  112. package/support/tests/internals.test.ts +568 -0
  113. package/support/tests/iterate.view.test.ts +40 -0
  114. package/support/tests/js-api.view.test.ts +45 -0
  115. package/support/tests/language-docs.test.ts +147 -0
  116. package/support/tests/lifecycle.test.ts +440 -0
  117. package/support/tests/loop-guard.test.ts +323 -0
  118. package/support/tests/provider-list.view.test.ts +47 -0
  119. package/support/tests/quark-features.test.ts +2887 -0
  120. package/support/tests/quark-with-providers.test.ts +169 -0
  121. package/support/tests/quark.test.ts +359 -0
  122. package/support/tests/resolvers.test.ts +346 -0
  123. package/support/tests/selector-analysis.test.ts +281 -0
  124. package/support/tests/selectors.test.ts +670 -0
  125. package/support/tests/toggle-content.view.test.ts +40 -0
  126. package/support/tests/unset.view.test.ts +42 -0
  127. package/support/tests/view-helpers.ts +81 -0
  128. package/support/tests/view-transition.test.ts +1069 -0
  129. package/support/tests/view-transition.view.test.ts +76 -0
  130. package/tsconfig.json +5 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * `@dispatch` / `@command` statements: the outgoing half of `@on`. They
3
+ * run at the end of an `@on` block (after its writes are queued, before
4
+ * they paint), evaluate their options per event, and are one loop-guard
5
+ * hop each. Rule-level use is refused at build.
6
+ */
7
+ import { Quark } from "../../index";
8
+ import type { QuarkRenderer } from "../../src/devtools-hook";
9
+ import { QuarkLogger } from "../../src/utils";
10
+ import {
11
+ type DevtoolsHook,
12
+ NUCLEUS_DEVTOOLS_HOOK_KEY,
13
+ pathMatches,
14
+ type PublicizeMeta,
15
+ type PublicizePath,
16
+ } from "@excom/kit-devtools";
17
+ import {
18
+ afterEach,
19
+ describe,
20
+ expect,
21
+ it,
22
+ vi,
23
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
24
+ import { LoopGuard } from "@excom/kit-utils";
25
+ import { createSheet, flush, mount, unregisterAll } from "./helpers";
26
+
27
+ type Spy = ReturnType<typeof vi.spyOn>;
28
+ const messages = (spy: Spy) =>
29
+ spy.mock.calls.map(([arg]) => String((arg as { message?: string })?.message ?? arg));
30
+
31
+ const click = (el: Element | null) =>
32
+ el!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
33
+
34
+ /**
35
+ * The shared Vitest setup shims the Command API (`commandForElement`) so
36
+ * view tests can click `<button command>`; tests of the no-API fallback
37
+ * remove the shim for their duration.
38
+ */
39
+ const withoutInvokers = async (fn: () => Promise<void>) => {
40
+ const proto = HTMLButtonElement.prototype as unknown as Record<string, unknown>;
41
+ const saved = Object.getOwnPropertyDescriptor(proto, "commandForElement");
42
+ delete proto.commandForElement;
43
+ try {
44
+ await fn();
45
+ } finally {
46
+ if (saved) Object.defineProperty(proto, "commandForElement", saved);
47
+ }
48
+ };
49
+
50
+ describe("@dispatch", () => {
51
+ afterEach(() => {
52
+ unregisterAll();
53
+ document.body.innerHTML = "";
54
+ vi.restoreAllMocks();
55
+ LoopGuard.reset();
56
+ delete (globalThis as Record<string, unknown>)[
57
+ NUCLEUS_DEVTOOLS_HOOK_KEY
58
+ ];
59
+ });
60
+
61
+ it("dispatches from the block's element with bubbles / cancelable on and composed off by default, after the block's writes are queued", async () => {
62
+ const seen: Array<[string, boolean, boolean, boolean, string | null]> = [];
63
+ const { root } = mount(
64
+ `<section id="s"><button type="button" id="b">go</button></section>`,
65
+ `#b { @on click { data-clicked: ""; @dispatch demo-ping; } }`
66
+ );
67
+ await flush();
68
+ const button = root.querySelector("#b")!;
69
+ root.querySelector("#s")!.addEventListener("demo-ping", (e) => {
70
+ seen.push([
71
+ e.type,
72
+ e.bubbles,
73
+ e.cancelable,
74
+ e.composed,
75
+ // the block's writes are queued, not painted, when the event fires
76
+ button.getAttribute("data-clicked"),
77
+ ]);
78
+ });
79
+ click(button);
80
+ expect(seen).toEqual([["demo-ping", true, true, false, null]]);
81
+ await flush();
82
+ expect(button.hasAttribute("data-clicked")).toBe(true);
83
+ });
84
+
85
+ it("evaluates detail per event in the block's scope and honors the flags", async () => {
86
+ const details: unknown[] = [];
87
+ const flags: Array<[boolean, boolean, boolean]> = [];
88
+ const { root } = mount(
89
+ `<ul id="list" data-n="1"><li data-id="a"><span>a</span></li><li data-id="b"><span>b</span></li></ul>`,
90
+ `#list {
91
+ $n: attr("data-n");
92
+ @on click (target: "li") {
93
+ @dispatch todo-pick (detail: (id: target.getAttribute("data-id"), n: $n, origin: event.target.localName), bubbles: false, cancelable: false, composed);
94
+ }
95
+ }`
96
+ );
97
+ await flush();
98
+ const list = root.querySelector("#list")!;
99
+ list.addEventListener("todo-pick", (e) => {
100
+ details.push((e as CustomEvent).detail);
101
+ flags.push([e.bubbles, e.cancelable, e.composed]);
102
+ });
103
+ click(root.querySelector("li[data-id='b'] span"));
104
+ list.setAttribute("data-n", "2");
105
+ await flush();
106
+ click(root.querySelector("li[data-id='a'] span"));
107
+ expect(details).toEqual([
108
+ { id: "b", n: "1", origin: "span" },
109
+ { id: "a", n: "2", origin: "span" },
110
+ ]);
111
+ expect(flags).toEqual([
112
+ [false, false, true],
113
+ [false, false, true],
114
+ ]);
115
+ });
116
+
117
+ it("targets every match of a selector in the element's document, `:scope` meaning the block's element (like event-handler target-ref), and elements from expressions", async () => {
118
+ const hits: string[] = [];
119
+ const { root } = mount(
120
+ `<div id="a" class="out"></div><div id="b" class="out"></div><p id="c" class="out"></p>
121
+ <button type="button" id="sel">sel</button>
122
+ <button type="button" id="scope">scope</button>
123
+ <button type="button" id="expr">expr</button>
124
+ <button type="button" id="list">list</button>
125
+ <provider-fetch id="before"></provider-fetch><button type="button" id="rel">rel</button><output id="after"></output>`,
126
+ `#sel { @on click { @dispatch out-ping (target: "div.out"); } }
127
+ #scope { @on click { @dispatch out-ping (target: ":scope"); } }
128
+ #expr { @on click { @dispatch out-ping (target: element.previousElementSibling); } }
129
+ #list { @on click { @dispatch out-ping (target: closest("section").children); } }
130
+ #rel { @on click { @dispatch out-ping (target: "provider-fetch:has(+ :scope), :scope + output"); } }`
131
+ );
132
+ await flush();
133
+ const outside = document.createElement("div");
134
+ outside.className = "out";
135
+ outside.id = "outside";
136
+ document.body.appendChild(outside);
137
+ root.addEventListener("out-ping", (e) => hits.push((e.target as Element).id));
138
+ outside.addEventListener("out-ping", (e) => hits.push((e.target as Element).id));
139
+ click(root.querySelector("#sel"));
140
+ // the element's whole document, not the sheet's scope — as event-handler target-ref resolves
141
+ expect(hits).toEqual(["a", "b", "outside"]);
142
+ hits.length = 0;
143
+ click(root.querySelector("#scope"));
144
+ // `:scope` is the block's element, not the sheet host
145
+ expect(hits).toEqual(["scope"]);
146
+ hits.length = 0;
147
+ click(root.querySelector("#rel"));
148
+ expect(hits).toEqual(["before", "after"]);
149
+ hits.length = 0;
150
+ click(root.querySelector("#expr"));
151
+ expect(hits).toEqual(["scope"]);
152
+ hits.length = 0;
153
+ click(root.querySelector("#list"));
154
+ expect(hits).toEqual(
155
+ Array.from(root.children).map((el) => el.id)
156
+ );
157
+ expect(hits).toContain("c");
158
+ expect(hits).not.toContain("outside");
159
+ outside.remove();
160
+ });
161
+ it("dispatches on window / document with host:, and several names get the same detail", async () => {
162
+ const seen: string[] = [];
163
+ const { root } = mount(
164
+ `<button type="button" id="b">go</button>`,
165
+ `#b { @on click { @dispatch app-a, "app:b" (host: document, detail: (at: 1)); @dispatch app-c (host: window); } }`
166
+ );
167
+ await flush();
168
+ const onDoc = (e: Event) => seen.push(`${e.type}:${(e as CustomEvent).detail?.at}`);
169
+ document.addEventListener("app-a", onDoc);
170
+ document.addEventListener("app:b", onDoc);
171
+ window.addEventListener("app-c", onDoc);
172
+ click(root.querySelector("#b"));
173
+ expect(seen).toEqual(["app-a:1", "app:b:1", "app-c:undefined"]);
174
+ document.removeEventListener("app-a", onDoc);
175
+ document.removeEventListener("app:b", onDoc);
176
+ window.removeEventListener("app-c", onDoc);
177
+ });
178
+
179
+ it("form: sends the form's values as detail, merged under an explicit detail map", async () => {
180
+ const details: unknown[] = [];
181
+ const { root } = mount(
182
+ `<form id="f"><input name="detail-title" value="Milk"><input name="qty" value="2"><button type="button" id="b">add</button></form>`,
183
+ `#b {
184
+ @on click { @dispatch cart-add (form: "form", detail: (qty: 3)); @dispatch cart-raw (form: closest("form")); }
185
+ }`
186
+ );
187
+ await flush();
188
+ root.querySelector("#f")!.addEventListener("cart-add", (e) => details.push((e as CustomEvent).detail));
189
+ root.querySelector("#f")!.addEventListener("cart-raw", (e) => details.push((e as CustomEvent).detail));
190
+ click(root.querySelector("#b"));
191
+ expect(details).toEqual([
192
+ { "detail-title": "Milk", qty: 3 },
193
+ { "detail-title": "Milk", qty: "2" },
194
+ ]);
195
+ });
196
+
197
+ it("dispatches from each matching descendant in a nested rule, and from a @delay block inside @on", async () => {
198
+ const seen: string[] = [];
199
+ const { root } = mount(
200
+ `<section id="s"><ul><li id="x"></li><li id="y"></li></ul><button type="button" id="b">go</button></section>`,
201
+ `#s {
202
+ @on click (target: "button") {
203
+ li { @dispatch row-ping; }
204
+ @delay 5 { @dispatch late-ping; }
205
+ }
206
+ }`
207
+ );
208
+ await flush();
209
+ root.querySelector("#s")!.addEventListener("row-ping", (e) => seen.push((e.target as Element).id));
210
+ root.querySelector("#s")!.addEventListener("late-ping", (e) => seen.push(`late:${(e.target as Element).id}`));
211
+ click(root.querySelector("#b"));
212
+ expect(seen).toEqual(["x", "y"]);
213
+ await new Promise((r) => setTimeout(r, 30));
214
+ expect(seen).toEqual(["x", "y", "late:s"]);
215
+ });
216
+
217
+ it("refuses to dispatch the enclosing event type, and warns once about unknown options and unmatched targets", async () => {
218
+ const warn = vi.spyOn(QuarkLogger, "warn").mockImplementation(() => {});
219
+ const seen: string[] = [];
220
+ const { root } = mount(
221
+ `<button type="button" id="b">go</button>`,
222
+ `#b { @on click { @dispatch click, other-ping (bogus, cancelable: "yes"); @dispatch never-ping (target: ".nope"); } }`
223
+ );
224
+ await flush();
225
+ root.addEventListener("other-ping", (e) => seen.push(`other:${e.cancelable}`));
226
+ root.addEventListener("never-ping", () => seen.push("never"));
227
+ let clicks = 0;
228
+ root.addEventListener("click", () => clicks++);
229
+ click(root.querySelector("#b"));
230
+ click(root.querySelector("#b"));
231
+ expect(clicks).toBe(2);
232
+ // the unknown / malformed options are ignored, the dispatch still happens
233
+ expect(seen).toEqual(["other:true", "other:true"]);
234
+ const m = messages(warn);
235
+ expect(m.filter((x) => /unknown option "bogus"/.test(x))).toHaveLength(1);
236
+ expect(m.filter((x) => /"cancelable" needs a boolean/.test(x))).toHaveLength(1);
237
+ expect(m.filter((x) => /matches no element/.test(x))).toHaveLength(1);
238
+ expect(m.filter((x) => /refusing to dispatch "click"/.test(x))).toHaveLength(1);
239
+ });
240
+
241
+ it("is refused at rule level, at sheet level and in a rule-level @delay (a match is not an occurrence)", async () => {
242
+ const error = vi.spyOn(QuarkLogger, "error").mockImplementation(() => {});
243
+ const seen: string[] = [];
244
+ const { root } = mount(
245
+ `<p id="p" is-on></p>`,
246
+ `@dispatch sheet-ping;
247
+ #p[is-on] { @dispatch rule-ping; @delay 1 { @dispatch delay-ping; } @on click { @dispatch ok-ping; } }`
248
+ );
249
+ root.addEventListener("rule-ping", () => seen.push("rule"));
250
+ root.addEventListener("delay-ping", () => seen.push("delay"));
251
+ root.addEventListener("ok-ping", () => seen.push("ok"));
252
+ await flush();
253
+ await new Promise((r) => setTimeout(r, 20));
254
+ click(root.querySelector("#p"));
255
+ expect(seen).toEqual(["ok"]);
256
+ const m = messages(error);
257
+ expect(m.filter((x) => /@dispatch must be written inside an @on block/.test(x))).toHaveLength(3);
258
+ });
259
+
260
+ it("cuts an event cycle with the loop guard instead of overflowing the stack", async () => {
261
+ LoopGuard.configure({ limit: 8, log: () => {} });
262
+ const trips: string[] = [];
263
+ const off = LoopGuard.onTrip((trip) => trips.push(trip.name));
264
+ let pings = 0;
265
+ const { root } = mount(
266
+ `<div id="a"><div id="b"></div></div>`,
267
+ `#a { @on pong { @dispatch ping (target: "#b"); } }
268
+ #b { @on ping { @dispatch pong (target: "#a"); } }`
269
+ );
270
+ await flush();
271
+ root.querySelector("#b")!.addEventListener("ping", () => pings++);
272
+ root.querySelector("#b")!.dispatchEvent(new CustomEvent("ping"));
273
+ expect(pings).toBeGreaterThan(1);
274
+ expect(pings).toBeLessThan(8);
275
+ expect(trips.some((name) => /@dispatch (ping|pong)/.test(name))).toBe(true);
276
+ off();
277
+ });
278
+
279
+ it("publishes quark/dispatch records and shows the statement in DevTools", async () => {
280
+ const publications: { path: PublicizePath; meta: PublicizeMeta }[] = [];
281
+ const renderers: QuarkRenderer[] = [];
282
+ const hook: DevtoolsHook = {
283
+ version: 1,
284
+ inject: (renderer) => {
285
+ renderers.push(renderer as QuarkRenderer);
286
+ },
287
+ publicize: (path, meta) => {
288
+ publications.push({ path, meta });
289
+ },
290
+ };
291
+ Quark.attachDevtools(hook);
292
+ const { root, register } = createSheet(
293
+ `<button type="button" id="b">go</button>`,
294
+ `#b { @on click { @dispatch demo-ping (detail: 1); @dispatch click; } }`
295
+ );
296
+ register();
297
+ await flush();
298
+ root.addEventListener("demo-ping", (e) => e.preventDefault());
299
+ click(root.querySelector("#b"));
300
+ const records = publications
301
+ .filter((p) => pathMatches(p.path, ["quark", "dispatch"]))
302
+ .map(({ meta }) => [meta.tag, meta.name, meta.phase, meta.targets, meta.reason ?? null, meta.defaultPrevented ?? null]);
303
+ expect(records).toEqual([
304
+ ["button", "demo-ping", "dispatched", 1, null, true],
305
+ ["button", "click", "dropped", 1, "same-event", null],
306
+ ]);
307
+ });
308
+ });
309
+
310
+ describe("@command", () => {
311
+ afterEach(() => {
312
+ unregisterAll();
313
+ document.body.innerHTML = "";
314
+ vi.restoreAllMocks();
315
+ LoopGuard.reset();
316
+ });
317
+
318
+ it("without the Command API, dispatches custom --commands as `command` events on the targets with the block's element as source", () =>
319
+ withoutInvokers(async () => {
320
+ const seen: Array<[string, string, string]> = [];
321
+ const { root } = mount(
322
+ `<div id="feed"></div><div id="other"></div><button type="button" id="b">refresh</button>`,
323
+ `#b { @on click { @command --refresh, --other (target: "#feed, #other"); } }`
324
+ );
325
+ await flush();
326
+ for (const id of ["feed", "other"]) {
327
+ root.querySelector(`#${id}`)!.addEventListener("command", (e) => {
328
+ const ce = e as Event & { command: string; source: Element };
329
+ seen.push([id, ce.command, ce.source.id]);
330
+ });
331
+ }
332
+ click(root.querySelector("#b"));
333
+ expect(seen).toEqual([
334
+ ["feed", "--refresh", "b"],
335
+ ["other", "--refresh", "b"],
336
+ ["feed", "--other", "b"],
337
+ ["other", "--other", "b"],
338
+ ]);
339
+ }));
340
+
341
+ it("without the Command API, invokes native commands through the element's method and warns once when unsupported", () =>
342
+ withoutInvokers(async () => {
343
+ const warn = vi.spyOn(QuarkLogger, "warn").mockImplementation(() => {});
344
+ const { root } = mount(
345
+ `<dialog id="dlg"></dialog><button type="button" id="open">open</button><button type="button" id="close">close</button><button type="button" id="nope">nope</button>`,
346
+ `#open { @on click { @command show-modal (target: "#dlg"); } }
347
+ #close { @on click { @command close (target: "#dlg"); } }
348
+ #nope { @on click { @command teleport (target: "#dlg", detail: 1); } }`
349
+ );
350
+ await flush();
351
+ const dlg = root.querySelector("#dlg") as HTMLDialogElement;
352
+ const showModal = vi.spyOn(dlg, "showModal");
353
+ const close = vi.spyOn(dlg, "close");
354
+ click(root.querySelector("#open"));
355
+ expect(showModal).toHaveBeenCalledTimes(1);
356
+ click(root.querySelector("#close"));
357
+ expect(close).toHaveBeenCalledTimes(1);
358
+ click(root.querySelector("#nope"));
359
+ click(root.querySelector("#nope"));
360
+ const m = messages(warn);
361
+ expect(m.filter((x) => /command "teleport" is not supported/.test(x))).toHaveLength(1);
362
+ expect(m.filter((x) => /"detail" is not a @command option/.test(x))).toHaveLength(1);
363
+ }));
364
+
365
+ it("uses a hidden invoker button when the browser supports commandForElement", async () => {
366
+ const proto = HTMLButtonElement.prototype as unknown as Record<string, unknown>;
367
+ const invoked: Array<[Element | undefined, string | undefined]> = [];
368
+ const originalClick = proto.click;
369
+ Object.defineProperty(proto, "commandForElement", {
370
+ configurable: true,
371
+ writable: true,
372
+ value: undefined,
373
+ });
374
+ proto.click = function (this: HTMLButtonElement & { commandForElement?: Element; command?: string }) {
375
+ if (this.commandForElement) invoked.push([this.commandForElement, this.command]);
376
+ else (originalClick as () => void).call(this);
377
+ };
378
+ try {
379
+ const { root } = mount(
380
+ `<div id="feed"></div><button type="button" id="b">go</button>`,
381
+ `#b { @on click { @command --refresh (target: "#feed"); } }`
382
+ );
383
+ await flush();
384
+ click(root.querySelector("#b"));
385
+ expect(invoked).toEqual([[root.querySelector("#feed"), "--refresh"]]);
386
+ expect(document.body.querySelectorAll("button[style]")).toHaveLength(0);
387
+ } finally {
388
+ proto.click = originalClick;
389
+ delete proto.commandForElement;
390
+ }
391
+ });
392
+ });
393
+
394
+ describe("@on nested sibling rules", () => {
395
+ afterEach(() => {
396
+ unregisterAll();
397
+ document.body.innerHTML = "";
398
+ vi.restoreAllMocks();
399
+ LoopGuard.reset();
400
+ });
401
+
402
+ it("runs a `+ selector` nested rule against the next sibling", async () => {
403
+ const seen: string[] = [];
404
+ const { root } = mount(
405
+ `<button type="button" id="b">go</button><provider-fetch id="pf"></provider-fetch>`,
406
+ `:scope { button { @on click { + provider-fetch { @command --fetch; } } } }`
407
+ );
408
+ await flush();
409
+ root.querySelector("#pf")!.addEventListener("command", (e) => {
410
+ seen.push((e as Event & { command: string }).command);
411
+ });
412
+ click(root.querySelector("#b"));
413
+ expect(seen).toEqual(["--fetch"]);
414
+ });
415
+
416
+ it("runs a `~ selector` nested rule against later siblings, not earlier ones or descendants", async () => {
417
+ const seen: string[] = [];
418
+ const { root } = mount(
419
+ `<output id="before"></output>
420
+ <button type="button" id="b"><output id="inside"></output></button>
421
+ <output id="after-1"></output><span><output id="nested"></output></span><output id="after-2"></output>`,
422
+ `#b { @on click { ~ output { @dispatch row-ping; } } }`
423
+ );
424
+ await flush();
425
+ root.addEventListener("row-ping", (e) => {
426
+ seen.push((e.target as Element).id);
427
+ });
428
+ click(root.querySelector("#b"));
429
+ /*
430
+ * happy-dom resolves `~` to the first following sibling only (a
431
+ * browser matches every later one), so the environment can show
432
+ * "after-1" but not "after-2". What matters here is the direction:
433
+ * never the earlier sibling, never a descendant of either.
434
+ */
435
+ expect(seen).toEqual(["after-1"]);
436
+ });
437
+ });