@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,346 @@
1
+ /**
2
+ * Field resolvers: value semantics (wipe / preserve) per declaration kind,
3
+ * content rendering of raw nodes and promises, and the DevTools result
4
+ * presentation of each shape.
5
+ */
6
+ import { Quark } from "../../index";
7
+ import { SYMBOL_NOOP } from "../../src/constants";
8
+ import { FIELD_RESOLVERS, resolveField } from "../../src/resolvers";
9
+ import { NUCLEUS_DEVTOOLS_HOOK_KEY } from "@excom/kit-devtools";
10
+ import {
11
+ afterEach,
12
+ describe,
13
+ expect,
14
+ it,
15
+ vi,
16
+ wait,
17
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
18
+ import { flush, mount, unregisterAll } from "./helpers";
19
+
20
+ describe("resolvers", () => {
21
+ afterEach(() => {
22
+ unregisterAll();
23
+ document.body.innerHTML = "";
24
+ delete (globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY];
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ describe("content", () => {
29
+ it("wipes rendered rows but keeps the source <template>", async () => {
30
+ const { root } = mount(
31
+ `<ul><template><li></li></template><li>old</li></ul>`,
32
+ `ul { content: none; }`
33
+ );
34
+ await flush();
35
+ const ul = root.querySelector("ul")!;
36
+ expect(ul.querySelector("template")).toBeTruthy();
37
+ expect(ul.querySelectorAll("li")).toHaveLength(0);
38
+ });
39
+
40
+ it("wipes on null and on unbound variables", async () => {
41
+ const { root } = mount(
42
+ `<p bind-a>stale</p><p bind-b>stale</p>`,
43
+ `[bind-a] { content: null; }
44
+ [bind-b] { content: $missing; }`
45
+ );
46
+ await flush();
47
+ expect(root.querySelector("[bind-a]")?.textContent).toBe("");
48
+ expect(root.querySelector("[bind-b]")?.textContent).toBe("");
49
+ });
50
+
51
+ it("paints a Node and a NodeList into a regular element", async () => {
52
+ const node = document.createElement("em");
53
+ node.textContent = "one";
54
+ const wrap = document.createElement("div");
55
+ wrap.innerHTML = `<i>a</i><i>b</i>`;
56
+ const { root } = mount(
57
+ `<p bind-node></p><p bind-list></p>`,
58
+ `[bind-node] { content: getNode(); }
59
+ [bind-list] { content: getList(); }`,
60
+ { getNode: () => node, getList: () => wrap.childNodes }
61
+ );
62
+ await flush();
63
+ expect(root.querySelector("[bind-node]")?.innerHTML).toBe("<em>one</em>");
64
+ expect(root.querySelector("[bind-list]")?.innerHTML).toBe(
65
+ "<i>a</i><i>b</i>"
66
+ );
67
+ });
68
+
69
+ it("settles promises: strings paint, wipes clear, preserve keeps", async () => {
70
+ const { root } = mount(
71
+ `<p bind-s>stale</p><p bind-w>stale</p><p bind-p>stale</p>`,
72
+ `[bind-s] { content: later("done"); }
73
+ [bind-w] { content: later(null); }
74
+ [bind-p] { content: later(keep); }`,
75
+ {
76
+ later: (v: unknown) => Promise.resolve(v),
77
+ keep: SYMBOL_NOOP,
78
+ }
79
+ );
80
+ await flush();
81
+ expect(root.querySelector("[bind-s]")?.textContent).toBe("done");
82
+ expect(root.querySelector("[bind-w]")?.textContent).toBe("");
83
+ expect(root.querySelector("[bind-p]")?.textContent).toBe("stale");
84
+ });
85
+
86
+ it("logs a rejected content promise without touching the element", async () => {
87
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
88
+ const { root } = mount(
89
+ `<p bind-x>stale</p>`,
90
+ `[bind-x] { content: failing(); }`,
91
+ { failing: () => Promise.reject(new Error("nope")) }
92
+ );
93
+ await flush();
94
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("stale");
95
+ expect(error).toHaveBeenCalled();
96
+ });
97
+
98
+ it("re-paints only when the text differs from the sole text node", async () => {
99
+ const { root, quark } = mount(
100
+ `<p bind-x>same</p>`,
101
+ `[bind-x] { content: "same"; }`
102
+ );
103
+ await flush();
104
+ const p = root.querySelector("[bind-x]")!;
105
+ const first = p.firstChild;
106
+ quark.queueRunRules({ element: p as HTMLElement, attribute: "NEW_SELF" });
107
+ await flush();
108
+ expect(p.firstChild).toBe(first);
109
+ });
110
+ });
111
+
112
+ describe("class", () => {
113
+ it("accepts strings, arrays and maps; preserve and none", async () => {
114
+ const { root } = mount(
115
+ `<p bind-a class="old"></p><p bind-b class="old"></p>
116
+ <p bind-c class="keep"></p><p bind-d class="gone"></p>`,
117
+ `[bind-a] { class: ["x", "y"]; }
118
+ [bind-b] { class: (old: false, z: true); }
119
+ [bind-c] { class: preserve; }
120
+ [bind-d] { class: none; }`
121
+ );
122
+ await flush();
123
+ expect(root.querySelector("[bind-a]")?.className).toBe("x y");
124
+ expect(root.querySelector("[bind-b]")?.className).toBe("z");
125
+ expect(root.querySelector("[bind-c]")?.className).toBe("keep");
126
+ expect(root.querySelector("[bind-d]")?.hasAttribute("class")).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe("dataset / ariaset", () => {
131
+ it("replaces previously written prefixed attributes and honours wipe / preserve", async () => {
132
+ const { root } = mount(
133
+ `<p bind-x data-mode="a" title="t"></p><p bind-y></p><p bind-z aria-label="k"></p>`,
134
+ `[bind-x] { title: "t2"; dataset: prop("box"); ariaset: prop("aria"); }
135
+ [bind-y] { dataset: none; ariaset: none; }
136
+ [bind-z] { dataset: preserve; ariaset: preserve; }`
137
+ );
138
+ const p = root.querySelector("[bind-x]") as HTMLElement & {
139
+ box?: unknown;
140
+ aria?: unknown;
141
+ };
142
+ p.box = { first: 1 };
143
+ p.aria = { label: "one" };
144
+ await flush();
145
+ expect(p.getAttribute("data-first")).toBe("1");
146
+ expect(p.getAttribute("aria-label")).toBe("one");
147
+ p.box = { second: 2 };
148
+ p.aria = { hidden: "true" };
149
+ await flush();
150
+ // attributes written by the earlier object are removed; foreign ones stay
151
+ expect(p.hasAttribute("data-first")).toBe(false);
152
+ expect(p.getAttribute("data-second")).toBe("2");
153
+ expect(p.hasAttribute("aria-label")).toBe(false);
154
+ expect(p.getAttribute("aria-hidden")).toBe("true");
155
+ expect(p.getAttribute("data-mode")).toBe("a");
156
+ expect(p.getAttribute("title")).toBe("t2");
157
+ expect(root.querySelector("[bind-z]")?.getAttribute("aria-label")).toBe(
158
+ "k"
159
+ );
160
+ });
161
+ });
162
+
163
+ describe("direct resolver calls", () => {
164
+ it("publishes failures without a rule and with non-Error throws", async () => {
165
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
166
+ const publications: { path: string[]; meta: any }[] = [];
167
+ Quark.attachDevtools({
168
+ version: 1,
169
+ inject: () => {},
170
+ publicize: (path, meta) => {
171
+ publications.push({ path: path as string[], meta });
172
+ },
173
+ });
174
+ const element = document.createElement("p");
175
+ expect(
176
+ resolveField({
177
+ element,
178
+ key: "data-x",
179
+ value: "nope()",
180
+ options: {},
181
+ hash: "h",
182
+ })
183
+ ).toBe(SYMBOL_NOOP);
184
+ const [err] = publications.filter(
185
+ (p) => p.path.join("/") === "quark/error"
186
+ );
187
+ expect(err.meta).toMatchObject({
188
+ selector: null,
189
+ ruleId: null,
190
+ sheetId: null,
191
+ runId: null,
192
+ errorName: "QuarkEvalError",
193
+ });
194
+ const [apply] = publications.filter(
195
+ (p) => p.path.join("/") === "quark/apply"
196
+ );
197
+ expect(apply.meta).toMatchObject({
198
+ selector: null,
199
+ ruleId: null,
200
+ isNoop: true,
201
+ });
202
+ expect(error).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it("stringifies thrown non-Error values", async () => {
206
+ vi.spyOn(console, "error").mockImplementation(() => {});
207
+ const publications: { path: string[]; meta: any }[] = [];
208
+ Quark.attachDevtools({
209
+ version: 1,
210
+ inject: () => {},
211
+ publicize: (path, meta) => {
212
+ publications.push({ path: path as string[], meta });
213
+ },
214
+ });
215
+ const { root } = mount(
216
+ `<p bind-x>stale</p>`,
217
+ `[bind-x] { content: boom(); }`,
218
+ {
219
+ boom: () => {
220
+ throw "plain string";
221
+ },
222
+ }
223
+ );
224
+ await flush();
225
+ expect(root.querySelector("[bind-x]")?.textContent).toBe("stale");
226
+ const [err] = publications.filter(
227
+ (p) => p.path.join("/") === "quark/error"
228
+ );
229
+ expect(err.meta.errorMessage).toBe("plain string");
230
+ expect(err.meta.errorName).toBeUndefined();
231
+ });
232
+
233
+ it("ignores falsy non-wipe dataset values and unsupported class shapes", async () => {
234
+ const { root } = mount(
235
+ `<p bind-x class="keep"></p>`,
236
+ `[bind-x] { dataset: false; ariaset: 0; class: 5; }`
237
+ );
238
+ await flush();
239
+ const p = root.querySelector("[bind-x]")!;
240
+ expect(p.className).toBe("keep");
241
+ expect([...p.attributes].map((a) => a.name)).toEqual(["bind-x", "class"]);
242
+ });
243
+
244
+ it("treats an empty declaration value as a wipe", async () => {
245
+ const element = document.createElement("p");
246
+ element.setAttribute("data-x", "1");
247
+ const result = FIELD_RESOLVERS.attribute({
248
+ element,
249
+ key: "data-x",
250
+ value: "",
251
+ options: {},
252
+ hash: "h",
253
+ });
254
+ expect(result).toBeUndefined();
255
+ await wait(0);
256
+ expect(element.hasAttribute("data-x")).toBe(false);
257
+ });
258
+ });
259
+
260
+ describe("listener", () => {
261
+ it("leaves listeners untouched when the handler list is preserve", async () => {
262
+ const handler = vi.fn();
263
+ const { root } = mount(
264
+ `<button type="button" bind-x></button>`,
265
+ `[bind-x] { @on click (handle: handler); }
266
+ [bind-x] { @on click (handle: preserve); }`,
267
+ { handler }
268
+ );
269
+ await flush();
270
+ (root.querySelector("[bind-x]") as HTMLButtonElement).click();
271
+ expect(handler).toHaveBeenCalledTimes(1);
272
+ });
273
+ });
274
+
275
+ describe("DevTools result presentation", () => {
276
+ const publications: { path: string[]; meta: any }[] = [];
277
+ const installHook = () =>
278
+ Quark.attachDevtools({
279
+ version: 1,
280
+ inject: () => {},
281
+ publicize: (path, meta) => {
282
+ publications.push({ path: path as string[], meta });
283
+ },
284
+ });
285
+ afterEach(() => {
286
+ publications.length = 0;
287
+ });
288
+ const applyFor = (selector: string) =>
289
+ publications.find(
290
+ (p) =>
291
+ p.path.join("/") === "quark/apply" &&
292
+ p.meta.key === "content" &&
293
+ p.meta.selector === selector
294
+ )!;
295
+
296
+ it("expands NodeLists, fragments and arrays; unwraps rejected promises", async () => {
297
+ installHook();
298
+ const wrap = document.createElement("div");
299
+ wrap.innerHTML = `<i>a</i><i>b</i>`;
300
+ const fragment = document.createDocumentFragment();
301
+ fragment.appendChild(document.createTextNode(" "));
302
+ fragment.appendChild(document.createTextNode("txt"));
303
+ fragment.appendChild(document.createElement("b"));
304
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
305
+ mount(
306
+ `<p bind-list></p><p bind-frag></p><p bind-arr></p><p bind-fail></p>`,
307
+ `[bind-list] { content: getList(); }
308
+ [bind-frag] { content: getFragment(); }
309
+ [bind-arr] { data-x: getArray(); }
310
+ [bind-fail] { content: failing(); }`,
311
+ {
312
+ getList: () => wrap.childNodes,
313
+ getFragment: () => fragment,
314
+ getArray: () => ["a", 1],
315
+ failing: () => Promise.reject(new Error("nope")),
316
+ }
317
+ );
318
+ await flush();
319
+ expect(applyFor("[bind-list]").meta.result).toEqual([
320
+ { $element: "i", id: null },
321
+ { $element: "i", id: null },
322
+ ]);
323
+ // whitespace-only text nodes are dropped; others serialise as nodes
324
+ expect(applyFor("[bind-frag]").meta.result).toEqual([
325
+ { $node: "#text" },
326
+ { $element: "b", id: null },
327
+ ]);
328
+ const arr = publications.find((p) => p.meta.key === "data-x")!;
329
+ expect(arr.meta.result).toEqual(["a", 1]);
330
+ const failed = applyFor("[bind-fail]");
331
+ expect(failed.meta.result).toBeUndefined();
332
+ expect(failed.meta.isNoop).toBe(false);
333
+ expect(error).toHaveBeenCalled();
334
+ });
335
+
336
+ it("publishes a settled promise that resolves to preserve as a no-op", async () => {
337
+ installHook();
338
+ mount(`<p bind-x>stale</p>`, `[bind-x] { content: later(keep); }`, {
339
+ later: (v: unknown) => Promise.resolve(v),
340
+ keep: SYMBOL_NOOP,
341
+ });
342
+ await flush();
343
+ expect(applyFor("[bind-x]").meta.isNoop).toBe(true);
344
+ });
345
+ });
346
+ });
@@ -0,0 +1,281 @@
1
+ /**
2
+ * `analyzeSelector` classifies what a selector depends on and where the
3
+ * subjects sit relative to each change (see `CompoundDependency`). These
4
+ * are the build-time facts `Rule._run` dispatches on; the DOM behaviour
5
+ * they drive is covered in `selectors.test.ts`.
6
+ */
7
+ import { PSEUDO_CLASSES } from "../../src/language";
8
+ import { analyzeSelector } from "../../src/selector-utils";
9
+ import {
10
+ describe,
11
+ expect,
12
+ it,
13
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
14
+
15
+ const sorted = (set: Set<string>) => [...set].sort();
16
+
17
+ describe("analyzeSelector", () => {
18
+ it("buckets plain attribute gates on their own compound", () => {
19
+ const { path, attrs, compounds, reactsToRemovals, unobserved } =
20
+ analyzeSelector("section[data-on] > li[data-x]");
21
+ expect(path).toEqual(["section[data-on]", ">", "li[data-x]"]);
22
+ expect(sorted(attrs)).toEqual(["data-on", "data-x"]);
23
+ expect(compounds).toHaveLength(2);
24
+ expect(compounds[0]).toMatchObject({
25
+ pathIndex: 0,
26
+ prefix: "section[data-on]",
27
+ prefixNeedsScope: false,
28
+ isSubject: false,
29
+ root: "self",
30
+ });
31
+ expect(sorted(compounds[0].selfAttrs)).toEqual(["data-on"]);
32
+ expect(compounds[1]).toMatchObject({
33
+ pathIndex: 2,
34
+ prefix: "section[data-on] > li[data-x]",
35
+ isSubject: true,
36
+ });
37
+ expect(sorted(compounds[1].selfAttrs)).toEqual(["data-x"]);
38
+ expect(reactsToRemovals).toBe(false);
39
+ expect(unobserved).toEqual([]);
40
+ });
41
+
42
+ it("scopes only a prefix that starts with a combinator", () => {
43
+ expect(analyzeSelector("> span").compounds[0].prefixNeedsScope).toBe(true);
44
+ expect(analyzeSelector("+ span").compounds[0].prefixNeedsScope).toBe(true);
45
+ expect(analyzeSelector("span").compounds[0].prefixNeedsScope).toBe(false);
46
+ });
47
+
48
+ it("ignores blacklisted attribute names", () => {
49
+ const { attrs } = analyzeSelector(
50
+ "[q-scope] [n-x] [style] [content] [data-ok]"
51
+ );
52
+ expect(sorted(attrs)).toEqual(["data-ok"]);
53
+ });
54
+
55
+ describe("class and id selectors", () => {
56
+ it("gates on class / id like attributes and collects the class tokens", () => {
57
+ const { attrs, compounds, classNames } = analyzeSelector(
58
+ "section.card[data-on] > li.item#main"
59
+ );
60
+ expect(sorted(attrs)).toEqual(["class", "data-on", "id"]);
61
+ expect(sorted(compounds[0].selfAttrs)).toEqual(["class", "data-on"]);
62
+ expect(sorted(compounds[1].selfAttrs)).toEqual(["class", "id"]);
63
+ expect(sorted(classNames!)).toEqual(["card", "item"]);
64
+ });
65
+
66
+ it("follows classes into arguments", () => {
67
+ const { compounds, classNames } = analyzeSelector(
68
+ "ul:has(li.is-active) li:not(.is-done):is(section.is-open *)"
69
+ );
70
+ expect(sorted(compounds[0].hasAttrs)).toEqual(["class"]);
71
+ expect(sorted(compounds[1].selfAttrs)).toEqual(["class"]);
72
+ expect(sorted(compounds[1].looseAttrs)).toEqual(["class"]);
73
+ expect(sorted(classNames!)).toEqual(["is-active", "is-done", "is-open"]);
74
+ });
75
+
76
+ it("drops the class filter when the whole class value matters", () => {
77
+ expect(analyzeSelector('li[class~="is-done"]').classNames).toBeNull();
78
+ expect(analyzeSelector("li.done:not([class])").classNames).toBeNull();
79
+ expect(analyzeSelector("li.sm\\:hidden").classNames).toBeNull();
80
+ expect(analyzeSelector("li[data-x]").classNames).toEqual(new Set());
81
+ });
82
+ });
83
+
84
+ describe("logical pseudo-classes", () => {
85
+ it("treats simple :is() / :where() / :not() arguments as the compound's own", () => {
86
+ const { compounds, attrs } = analyzeSelector(
87
+ "li:is([data-a], [data-b]):where([data-c]):not([data-d])"
88
+ );
89
+ expect(sorted(attrs)).toEqual(["data-a", "data-b", "data-c", "data-d"]);
90
+ expect(sorted(compounds[0].selfAttrs)).toEqual([
91
+ "data-a",
92
+ "data-b",
93
+ "data-c",
94
+ "data-d",
95
+ ]);
96
+ expect(compounds[0].looseAttrs.size).toBe(0);
97
+ });
98
+
99
+ it("puts the earlier compounds of a complex argument in the loose bucket", () => {
100
+ const [c] = analyzeSelector(
101
+ ":is(section[data-on] li[data-x], [data-y])"
102
+ ).compounds;
103
+ expect(sorted(c.selfAttrs)).toEqual(["data-x", "data-y"]);
104
+ expect(sorted(c.looseAttrs)).toEqual(["data-on"]);
105
+ expect(c.looseRoot).toBe("self");
106
+ });
107
+
108
+ it("fans loose changes out from the parent when an argument uses a sibling combinator", () => {
109
+ const { compounds, positional, reactsToRemovals } = analyzeSelector(
110
+ "b:is(a[data-x] ~ b)"
111
+ );
112
+ expect(sorted(compounds[0].looseAttrs)).toEqual(["data-x"]);
113
+ expect(compounds[0].siblingInArgs).toBe(true);
114
+ expect(compounds[0].looseRoot).toBe("parent");
115
+ expect(positional).toBe(true);
116
+ expect(reactsToRemovals).toBe(true);
117
+ });
118
+ });
119
+
120
+ describe("sibling combinators", () => {
121
+ it("roots the compound before + / ~ at the parent", () => {
122
+ const { compounds, positional, reactsToRemovals } = analyzeSelector(
123
+ "a[data-x] ~ b[data-y] + c > d"
124
+ );
125
+ expect(compounds.map((c) => c.root)).toEqual([
126
+ "parent",
127
+ "parent",
128
+ "self",
129
+ "self",
130
+ ]);
131
+ expect(sorted(compounds[0].selfAttrs)).toEqual(["data-x"]);
132
+ expect(sorted(compounds[1].selfAttrs)).toEqual(["data-y"]);
133
+ expect(compounds[3].isSubject).toBe(true);
134
+ expect(positional).toBe(true);
135
+ expect(reactsToRemovals).toBe(true);
136
+ });
137
+ });
138
+
139
+ describe(":has()", () => {
140
+ it("puts descendant-relative argument attributes in the has bucket", () => {
141
+ const { compounds, usesHas, reactsToRemovals } = analyzeSelector(
142
+ "div:not([data-a]) > [data-b]:has([q-x], [data-c])"
143
+ );
144
+ const [ancestor, subject] = compounds;
145
+ expect(sorted(ancestor.selfAttrs)).toEqual(["data-a"]);
146
+ expect(ancestor.reactsToChildren).toBe(false);
147
+ expect(sorted(subject.selfAttrs)).toEqual(["data-b"]);
148
+ expect(sorted(subject.hasAttrs)).toEqual(["data-c"]);
149
+ expect(subject.reactsToChildren).toBe(true);
150
+ expect(subject.broad).toBe(false);
151
+ expect(usesHas).toBe(true);
152
+ expect(reactsToRemovals).toBe(true);
153
+ });
154
+
155
+ it("keeps :has() in an ancestor compound rooted there", () => {
156
+ const { compounds } = analyzeSelector("section:has([data-open]) button");
157
+ expect(sorted(compounds[0].hasAttrs)).toEqual(["data-open"]);
158
+ expect(compounds[0].isSubject).toBe(false);
159
+ expect(compounds[0].root).toBe("self");
160
+ expect(compounds[1].hasAttrs.size).toBe(0);
161
+ });
162
+
163
+ it("marks sibling-relative arguments broad", () => {
164
+ const [c] = analyzeSelector("label:has(+ input[data-invalid])").compounds;
165
+ expect(sorted(c.broadAttrs)).toEqual(["data-invalid"]);
166
+ expect(c.hasAttrs.size).toBe(0);
167
+ expect(c.broad).toBe(true);
168
+ expect(c.reactsToChildren).toBe(true);
169
+ });
170
+
171
+ it("marks the earlier compounds of a complex :has() argument broad", () => {
172
+ const [c] = analyzeSelector(
173
+ "div:has(section[data-s] [data-x])"
174
+ ).compounds;
175
+ expect(sorted(c.hasAttrs)).toEqual(["data-x"]);
176
+ expect(sorted(c.broadAttrs)).toEqual(["data-s"]);
177
+ expect(c.broad).toBe(false);
178
+ });
179
+
180
+ it("keeps :not(:has()) in the has bucket but a :has() inside a complex :is() arm broad", () => {
181
+ const [negated] = analyzeSelector("ul:not(:has([data-x]))").compounds;
182
+ expect(sorted(negated.hasAttrs)).toEqual(["data-x"]);
183
+ expect(negated.broad).toBe(false);
184
+ const [nested] = analyzeSelector(
185
+ "p:is(section:has([data-x]) p)"
186
+ ).compounds;
187
+ expect(sorted(nested.broadAttrs)).toEqual(["data-x"]);
188
+ expect(nested.broad).toBe(true);
189
+ });
190
+ });
191
+
192
+ describe("structural pseudo-classes", () => {
193
+ it("flags sibling position and removals", () => {
194
+ for (const pseudo of [
195
+ ":first-child",
196
+ ":last-child",
197
+ ":only-child",
198
+ ":nth-child(2n+1)",
199
+ ":nth-last-child(2)",
200
+ ":first-of-type",
201
+ ":last-of-type",
202
+ ":only-of-type",
203
+ ":nth-of-type(3)",
204
+ ":nth-last-of-type(odd)",
205
+ ]) {
206
+ const { compounds, positional, reactsToRemovals, usesHas } =
207
+ analyzeSelector(`li${pseudo}`);
208
+ expect(compounds[0].positional, pseudo).toBe(true);
209
+ expect(positional, pseudo).toBe(true);
210
+ expect(reactsToRemovals, pseudo).toBe(true);
211
+ expect(usesHas, pseudo).toBe(false);
212
+ }
213
+ });
214
+
215
+ it("observes the selector of `of S` on the siblings", () => {
216
+ const [c] = analyzeSelector(
217
+ "li:nth-child(1 of [data-visible]:not([data-x]))"
218
+ ).compounds;
219
+ expect(sorted(c.looseAttrs)).toEqual(["data-visible", "data-x"]);
220
+ expect(c.looseRoot).toBe("parent");
221
+ expect(c.positional).toBe(true);
222
+ });
223
+
224
+ it("re-checks :empty on child changes", () => {
225
+ const { compounds, usesHas, reactsToRemovals } =
226
+ analyzeSelector("ul:empty");
227
+ expect(compounds[0].reactsToChildren).toBe(true);
228
+ expect(usesHas).toBe(true);
229
+ expect(reactsToRemovals).toBe(true);
230
+ });
231
+ });
232
+
233
+ describe("attribute-backed pseudo-classes", () => {
234
+ it("observes the backing attribute on the element and above it", () => {
235
+ const cases: Record<string, string[]> = {
236
+ ":disabled": ["disabled"],
237
+ ":enabled": ["disabled"],
238
+ ":required": ["required"],
239
+ ":optional": ["required"],
240
+ ":read-only": ["contenteditable", "disabled", "readonly"],
241
+ ":read-write": ["contenteditable", "disabled", "readonly"],
242
+ ":any-link": ["href"],
243
+ ":lang(en)": ["lang"],
244
+ ":open": ["open"],
245
+ };
246
+ for (const [pseudo, names] of Object.entries(cases)) {
247
+ const { compounds, attrs, unobserved } = analyzeSelector(`*${pseudo}`);
248
+ expect(sorted(attrs), pseudo).toEqual(names);
249
+ expect(sorted(compounds[0].selfAttrs), pseudo).toEqual(names);
250
+ expect(sorted(compounds[0].looseAttrs), pseudo).toEqual(names);
251
+ expect(unobserved, pseudo).toEqual([]);
252
+ }
253
+ });
254
+
255
+ it("keeps an attribute pseudo-class inside :has() in the has bucket", () => {
256
+ const [c] = analyzeSelector("form:has(input:required)").compounds;
257
+ expect(sorted(c.hasAttrs)).toEqual(["required"]);
258
+ expect(c.looseAttrs.size).toBe(0);
259
+ });
260
+ });
261
+
262
+ describe("unobserved selectors", () => {
263
+ it("reports interaction pseudo-classes, pseudo-elements and unknown names", () => {
264
+ expect(analyzeSelector("a:hover::before").unobserved).toEqual([
265
+ ":hover",
266
+ "::before",
267
+ ]);
268
+ expect(analyzeSelector("input:checked").unobserved).toEqual([":checked"]);
269
+ expect(analyzeSelector("x:made-up").unobserved).toEqual([":made-up"]);
270
+ expect(analyzeSelector(":scope:root").unobserved).toEqual([]);
271
+ });
272
+
273
+ it("documents every unobserved pseudo-class it warns for", () => {
274
+ for (const doc of PSEUDO_CLASSES.filter((d) => d.kind === "unobserved")) {
275
+ expect(analyzeSelector(`a:${doc.name}`).unobserved).toEqual([
276
+ `:${doc.name}`,
277
+ ]);
278
+ }
279
+ });
280
+ });
281
+ });