@excom/kit-utils 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.
@@ -0,0 +1,852 @@
1
+ import * as dom from "../../dom";
2
+ import { createElement } from "../../dom";
3
+ import {
4
+ afterEach,
5
+ describe,
6
+ expect,
7
+ fixture,
8
+ it,
9
+ vi,
10
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
11
+
12
+ describe("dom", () => {
13
+ it("Converter: converts attributes correctly", () => {
14
+ expect(dom.Converter.type("boolean").attr.convert("")).to.be.true;
15
+ expect(dom.Converter.type("boolean").attr.convert(null)).to.be.false;
16
+ expect(dom.Converter.type("number").attr.convert("1")).to.equal(1);
17
+ expect(dom.Converter.type("number").attr.convert("1.1")).to.equal(1.1);
18
+ expect(dom.Converter.type("number").attr.convert("")).to.equal(null);
19
+ expect(dom.Converter.type("string").attr.convert("")).to.equal("");
20
+ expect(dom.Converter.type("string").attr.convert(null)).to.equal(null);
21
+ expect(dom.Converter.type("string").attr.convert("hello")).to.equal(
22
+ "hello",
23
+ );
24
+ expect(dom.Converter.type("tokens").attr.convert("hello")).to.deep.equal([
25
+ "hello",
26
+ ]);
27
+ expect(
28
+ dom.Converter.type("tokens").attr.convert("hello world"),
29
+ ).to.deep.equal(["hello", "world"]);
30
+ expect(dom.Converter.type("tokens").attr.convert(null)).to.equal(null);
31
+ expect(dom.Converter.type("tokens").attr.convert("")).to.deep.equal([]);
32
+ });
33
+
34
+ it("Converter: converts properties correctly", () => {
35
+ expect(dom.Converter.type("boolean").prop.convert(true)).to.equal("");
36
+ expect(dom.Converter.type("boolean").prop.convert(false)).to.be.null;
37
+ expect(dom.Converter.type("tokens").prop.convert(["hello"])).to.equal(
38
+ "hello",
39
+ );
40
+ expect(
41
+ dom.Converter.type("tokens").prop.convert(["hello", "world"]),
42
+ ).to.equal("hello world");
43
+ expect(dom.Converter.type("number").prop.convert(1)).to.equal("1");
44
+ expect(dom.Converter.type("number").prop.convert(1.1)).to.equal("1.1");
45
+ expect(dom.Converter.type("string").prop.convert("hello")).to.equal(
46
+ "hello",
47
+ );
48
+ expect(dom.Converter.type("string").prop.convert(null)).to.equal(null);
49
+ expect(dom.Converter.type("string").prop.convert("")).to.equal("");
50
+ expect(dom.Converter.type("tokens").prop.convert(null)).to.equal(null);
51
+ expect(dom.Converter.type("tokens").prop.convert([])).to.equal("");
52
+ });
53
+
54
+ it("Converter: sets attributes correctly", () => {
55
+ const el = document.createElement("div");
56
+ dom.setAttr(
57
+ el,
58
+ "auto-play",
59
+ dom.Converter.type("boolean").prop.convert(true),
60
+ );
61
+ expect(el.getAttribute("auto-play")).to.equal("");
62
+ dom.setAttr(
63
+ el,
64
+ "auto-play",
65
+ dom.Converter.type("boolean").prop.convert(false),
66
+ );
67
+ expect(el.getAttribute("auto-play")).to.be.null;
68
+ dom.setAttr(el, "auto-play", dom.Converter.type("number").prop.convert(1));
69
+ expect(el.getAttribute("auto-play")).to.equal("1");
70
+ dom.setAttr(
71
+ el,
72
+ "auto-play",
73
+ dom.Converter.type("number").prop.convert(1.1),
74
+ );
75
+ expect(el.getAttribute("auto-play")).to.equal("1.1");
76
+ dom.setAttr(
77
+ el,
78
+ "auto-play",
79
+ dom.Converter.type("string").prop.convert("hello"),
80
+ );
81
+ expect(el.getAttribute("auto-play")).to.equal("hello");
82
+ dom.setAttr(
83
+ el,
84
+ "auto-play",
85
+ dom.Converter.type("tokens").prop.convert(["hello"]),
86
+ );
87
+ expect(el.getAttribute("auto-play")).to.equal("hello");
88
+ dom.setAttr(
89
+ el,
90
+ "auto-play",
91
+ dom.Converter.type("tokens").prop.convert(["hello", "world"]),
92
+ );
93
+ expect(el.getAttribute("auto-play")).to.equal("hello world");
94
+ });
95
+
96
+ it("Converter: gets attributes correctly", () => {
97
+ const el = document.createElement("div");
98
+ el.setAttribute("auto-play", "true");
99
+ expect(
100
+ dom.Converter.type("boolean").attr.convert(dom.getAttr(el, "auto-play")),
101
+ ).to.be.true;
102
+ el.setAttribute("auto-play", "1");
103
+ expect(
104
+ dom.Converter.type("number").attr.convert(dom.getAttr(el, "auto-play")),
105
+ ).to.equal(1);
106
+ el.setAttribute("auto-play", "hello");
107
+ expect(
108
+ dom.Converter.type("string").attr.convert(dom.getAttr(el, "auto-play")),
109
+ ).to.equal("hello");
110
+ el.setAttribute("auto-play", "hello world");
111
+ expect(
112
+ dom.Converter.type("tokens").attr.convert(dom.getAttr(el, "auto-play")),
113
+ ).to.deep.equal(["hello", "world"]);
114
+ });
115
+
116
+ it("Converter: gets attribute names correctly", () => {
117
+ expect(dom.Converter.getAttrName("myProp")).to.equal("my-prop");
118
+ expect(dom.Converter.getAttrName("myPropAbc")).to.equal("my-prop-abc");
119
+ expect(dom.Converter.getAttrName("myBOOL")).to.equal("my-b-o-o-l");
120
+ expect(dom.Converter.getAttrName("myTokens")).to.equal("my-tokens");
121
+ expect(dom.Converter.getAttrName("mytokens")).to.equal("mytokens");
122
+ });
123
+
124
+ it("Converter: gets property names correctly", () => {
125
+ expect(dom.Converter.getPropName("my-bool")).to.equal("myBool");
126
+ expect(dom.Converter.getPropName("my-b-o-o-l")).to.equal("myBOOL");
127
+ expect(dom.Converter.getPropName("my-prop-abc")).to.equal("myPropAbc");
128
+ expect(dom.Converter.getPropName("my-tokens")).to.equal("myTokens");
129
+ });
130
+
131
+ it("objToAttrs: converts object to object of attribute key-value pairs", () => {
132
+ const expectationMatrix = {
133
+ names: [
134
+ {
135
+ input: "autoPlay",
136
+ opts: { preserveKey: true, convertNonPrimitives: true },
137
+ output: "autoplay",
138
+ },
139
+ {
140
+ input: "autoPlay",
141
+ opts: { preserveKey: false, convertNonPrimitives: true },
142
+ output: "auto-play",
143
+ },
144
+ {
145
+ input: "autoPlay",
146
+ opts: { prefix: "data-", convertNonPrimitives: true },
147
+ output: "data-auto-play",
148
+ },
149
+ {
150
+ input: "autoPlay",
151
+ opts: {
152
+ prefix: "data-",
153
+ preserveKey: true,
154
+ convertNonPrimitives: true,
155
+ },
156
+ output: "data-autoplay",
157
+ },
158
+ {
159
+ input: "autoPlay",
160
+ opts: {
161
+ prefix: "data-",
162
+ preserveKey: false,
163
+ convertNonPrimitives: true,
164
+ },
165
+ output: "data-auto-play",
166
+ },
167
+ {
168
+ input: "autoPlay",
169
+ opts: {
170
+ prefix: "object",
171
+ preserveKey: false,
172
+ convertNonPrimitives: true,
173
+ },
174
+ output: "objectauto-play",
175
+ },
176
+ {
177
+ input: "play",
178
+ opts: {
179
+ prefix: "auto",
180
+ preserveKey: false,
181
+ convertNonPrimitives: true,
182
+ },
183
+ output: "autoplay",
184
+ },
185
+ ],
186
+ values: [
187
+ // nullish
188
+ [null, null],
189
+ [undefined, null],
190
+ // boolean
191
+ [true, ""],
192
+ [false, null],
193
+ // number
194
+ [1, "1"],
195
+ [1.1, "1.1"],
196
+ [-7, "-7"],
197
+ [Infinity, "Infinity"],
198
+ [-Infinity, "-Infinity"],
199
+ [NaN, "NaN"],
200
+ [0, "0"],
201
+ [-0, "0"],
202
+ // string
203
+ ["hello", "hello"],
204
+ ["", ""],
205
+ ["hello world", "hello world"],
206
+ // tokens
207
+ [["hello"], "hello"],
208
+ [["hello", "world"], "hello world"],
209
+ [[], ""],
210
+ // non-primitive array
211
+ [[new Object()], "1"],
212
+ [[new Object(), new Object()], "2"],
213
+ // object
214
+ [{ foo: "abc" }, "1"],
215
+ [{ foo: "abc", bar: "baz" }, "2"],
216
+ [{}, "0"],
217
+ // other non-primitives
218
+ [1n, ""],
219
+ [2n, ""],
220
+ [BigInt(1), ""],
221
+ [() => {}, ""],
222
+ [Symbol("hello"), ""],
223
+ [new Date(), ""],
224
+ [new RegExp("hello"), ""],
225
+ [new Map(), ""],
226
+ [new Set(), ""],
227
+ [new ArrayBuffer(1), ""],
228
+ [new Uint8Array(1), ""],
229
+ [new Uint16Array(1), ""],
230
+ [new Uint32Array(1), ""],
231
+ ],
232
+ } as const;
233
+ expectationMatrix.names.forEach(({ input, opts, output }) => {
234
+ expectationMatrix.values.forEach(([inputVal, outputVal]) => {
235
+ expect(
236
+ dom.objToAttrs({ [input]: inputVal }, opts),
237
+ `(${input}: ${inputVal?.constructor.name || "unknown"}<${String(inputVal)}>)`,
238
+ ).to.deep.equal({
239
+ [output]: outputVal,
240
+ });
241
+ });
242
+ });
243
+ });
244
+
245
+ it("selectOne/selectAll: uses root if provided", () => {
246
+ document.body.innerHTML = `
247
+ <section>
248
+ <div><span>hello</span><span>world</span></div>
249
+ </section>
250
+ `;
251
+ expect(dom.selectOne("span", { root: "section" })?.textContent).to.equal(
252
+ "hello",
253
+ );
254
+ expect(dom.selectAll("span", { root: "section" })?.length).to.be.equal(2);
255
+ });
256
+
257
+ it("selectOne/selectAll: uses scope if provided", () => {
258
+ document.body.innerHTML = `
259
+ <main>
260
+ <section id="section-0">
261
+ <div>
262
+ <a id="a-0">foo</a>
263
+ </div>
264
+ <div>
265
+ <a id="a-1">bar</a>
266
+ </div>
267
+ </section>
268
+ <section id="section-1">
269
+ <div>
270
+ <a id="a-2">baz</a>
271
+ </div>
272
+ <div>
273
+ <a id="a-3">qux</a>
274
+ </div>
275
+ </section>
276
+ </main>
277
+ `;
278
+ const a2 = document.querySelector("#a-2")!;
279
+ expect(
280
+ dom.selectOne("section:has(:scope)", { root: "main", scope: a2 })?.id,
281
+ ).to.equal("section-1");
282
+ // no root → document
283
+ expect(dom.selectOne("section:has(:scope)", { scope: a2 })?.id).to.equal(
284
+ "section-1",
285
+ );
286
+ // temp attr is gone after the query
287
+ expect(
288
+ !![...a2.attributes].find((a) => a.name.startsWith("n-util-select-id")),
289
+ ).to.be.false;
290
+
291
+ // selectAll
292
+ expect(
293
+ dom.selectAll("section:has(:scope)", { root: "main", scope: a2 })?.length,
294
+ ).to.be.equal(1);
295
+ // no root → document
296
+ expect(
297
+ dom.selectAll("section:has(:scope)", { scope: a2 })?.length,
298
+ ).to.be.equal(1);
299
+ // temp attr is gone after the query
300
+ expect(
301
+ !![...a2.attributes].find((a) => a.name.startsWith("n-util-select-id")),
302
+ ).to.be.false;
303
+
304
+ // compound selectors
305
+ expect(
306
+ dom
307
+ .selectAll("section:has(:scope), section:has(+section :scope) a#a-0", {
308
+ scope: a2,
309
+ })
310
+ ?.map((e) => e.id),
311
+ ).to.deep.equal(["a-0", "section-1"]);
312
+
313
+ // root === scope: leave `:scope` alone
314
+ const section = document.querySelector("#section-1")!;
315
+ expect(
316
+ dom.selectOne(":scope #a-2", { root: "#section-1", scope: section })?.id,
317
+ ).to.equal("a-2");
318
+ });
319
+
320
+ it("selectOne/selectAll: only tags the scope when the selector uses :scope", () => {
321
+ document.body.innerHTML = `
322
+ <main>
323
+ <section id="section-0"><a id="a-0">foo</a></section>
324
+ <section id="section-1"><a id="a-1">bar</a></section>
325
+ </main>
326
+ `;
327
+ const a1 = document.querySelector("#a-1")!;
328
+ const setAttribute = vi.spyOn(a1, "setAttribute");
329
+ const removeAttribute = vi.spyOn(a1, "removeAttribute");
330
+
331
+ // no `:scope` in the selector → no attribute churn at all
332
+ expect(dom.selectOne("a", { root: "main", scope: a1 })?.id).to.equal("a-0");
333
+ expect(dom.selectAll("a", { root: "main", scope: a1 })?.length).to.equal(2);
334
+ expect(setAttribute).not.toHaveBeenCalled();
335
+ expect(removeAttribute).not.toHaveBeenCalled();
336
+
337
+ // `:scope` still resolves relative to the scope element
338
+ expect(
339
+ dom.selectOne("section:has(:scope)", { root: "main", scope: a1 })?.id,
340
+ ).to.equal("section-1");
341
+ expect(setAttribute).toHaveBeenCalledTimes(1);
342
+ expect(removeAttribute).toHaveBeenCalledTimes(1);
343
+ expect(setAttribute.mock.calls[0][0]).to.match(/^n-util-select-id-/);
344
+ expect(removeAttribute.mock.calls[0][0]).to.equal(
345
+ setAttribute.mock.calls[0][0],
346
+ );
347
+ expect(
348
+ !![...a1.attributes].find((a) => a.name.startsWith("n-util-select-id")),
349
+ ).to.be.false;
350
+
351
+ setAttribute.mockRestore();
352
+ removeAttribute.mockRestore();
353
+ });
354
+
355
+ it("selectOne: enableRootRefs resolves window/document/html/body/head", () => {
356
+ expect(dom.selectOne("window", { enableRootRefs: true })).to.equal(window);
357
+ expect(dom.selectOne("document", { enableRootRefs: true })).to.equal(
358
+ document,
359
+ );
360
+ expect(dom.selectOne("html", { enableRootRefs: true })).to.equal(
361
+ document.documentElement,
362
+ );
363
+ expect(dom.selectOne("body", { enableRootRefs: true })).to.equal(
364
+ document.body,
365
+ );
366
+ expect(dom.selectOne("head", { enableRootRefs: true })).to.equal(
367
+ document.head,
368
+ );
369
+ expect(dom.selectOne(" window ", { enableRootRefs: true })).to.equal(
370
+ window,
371
+ );
372
+ });
373
+
374
+ it("selectOne: enableRootRefs false/omitted does not resolve window/document", () => {
375
+ /* `window` / `document` are not CSS-selectable; without
376
+ * `enableRootRefs` they must not resolve. (`html` / `body` / `head`
377
+ * still match via `querySelector`.) */
378
+ expect(dom.selectOne("window")).to.equal(null);
379
+ expect(dom.selectOne("document")).to.equal(null);
380
+ expect(dom.selectOne("window", { enableRootRefs: false })).to.equal(null);
381
+ expect(dom.selectOne("document", { enableRootRefs: false })).to.equal(
382
+ null,
383
+ );
384
+ });
385
+
386
+ it("selectOne: enableRootRefs still resolves normal selectors", () => {
387
+ document.body.innerHTML = `<div id="root-ref-target"></div>`;
388
+ expect(
389
+ dom.selectOne("#root-ref-target", { enableRootRefs: true })?.id,
390
+ ).to.equal("root-ref-target");
391
+ });
392
+
393
+ it("selectAll: enableRootRefs resolves the same root refs", () => {
394
+ expect(dom.selectAll("window", { enableRootRefs: true })).to.equal(window);
395
+ expect(dom.selectAll("#missing", { enableRootRefs: true })).to.deep.equal(
396
+ [],
397
+ );
398
+ });
399
+ });
400
+
401
+ describe("dom: element helpers", () => {
402
+ afterEach(() => {
403
+ document.body.innerHTML = "";
404
+ });
405
+
406
+ it("createElement: sets props, attributes and children", () => {
407
+ const span = document.createElement("span");
408
+ const el = dom.createElement(
409
+ "div",
410
+ { id: "made", className: "box", attributes: { "data-role": "card" } },
411
+ [span, "tail" as unknown as HTMLElement],
412
+ );
413
+ expect(el.tagName).to.equal("DIV");
414
+ expect(el.id).to.equal("made");
415
+ expect(el.className).to.equal("box");
416
+ expect(el.getAttribute("data-role")).to.equal("card");
417
+ expect(el.childNodes.length).to.equal(2);
418
+ expect(el.firstElementChild).to.equal(span);
419
+ expect(el.lastChild?.nodeType).to.equal(Node.TEXT_NODE);
420
+ expect(el.textContent).to.equal("tail");
421
+ });
422
+
423
+ it("createElement: tolerates missing props, empty attributes and no children", () => {
424
+ const bare = dom.createElement("p");
425
+ expect(bare.tagName).to.equal("P");
426
+ expect(bare.childNodes.length).to.equal(0);
427
+ const nullAttrs = dom.createElement("p", { attributes: null });
428
+ expect(nullAttrs.attributes.length).to.equal(0);
429
+ });
430
+
431
+ it("getChildren: splits a leading <template> from the other children", () => {
432
+ const el = fixture<HTMLElement>(
433
+ `<div><template><i></i></template><a></a><b></b></div>`,
434
+ );
435
+ const { templateChild, otherChildren } = dom.getChildren(el);
436
+ expect(templateChild).to.be.instanceOf(HTMLTemplateElement);
437
+ expect(otherChildren.map((c) => c.tagName)).to.deep.equal(["A", "B"]);
438
+ });
439
+
440
+ it("getChildren: only a first-child <template> counts as the template", () => {
441
+ const el = fixture<HTMLElement>(`<div><a></a><template></template></div>`);
442
+ const { templateChild, otherChildren } = dom.getChildren(el);
443
+ expect(templateChild).to.equal(null);
444
+ expect(otherChildren.map((c) => c.tagName)).to.deep.equal([
445
+ "A",
446
+ "TEMPLATE",
447
+ ]);
448
+ const empty = dom.getChildren(document.createElement("div"));
449
+ expect(empty.templateChild).to.equal(null);
450
+ expect(empty.otherChildren).to.deep.equal([]);
451
+ });
452
+
453
+ it("buildContent: unwraps a single child and clones by default", () => {
454
+ const tpl = document.createElement("template");
455
+ tpl.innerHTML = `<p class="only">one</p>`;
456
+ const built = dom.buildContent(tpl.content) as Element;
457
+ expect(built.className).to.equal("only");
458
+ expect(built).to.not.equal(tpl.content.firstElementChild);
459
+ // the template keeps its own child
460
+ expect(tpl.content.children.length).to.equal(1);
461
+ });
462
+
463
+ it("buildContent: wraps several children in a div", () => {
464
+ const tpl = document.createElement("template");
465
+ tpl.innerHTML = `<p>a</p><p>b</p>`;
466
+ const built = dom.buildContent(tpl.content) as Element;
467
+ expect(built.tagName).to.equal("DIV");
468
+ expect(built.children.length).to.equal(2);
469
+ expect(tpl.content.children.length).to.equal(2);
470
+ const emptyTpl = document.createElement("template");
471
+ const builtEmpty = dom.buildContent(emptyTpl.content) as Element;
472
+ expect(builtEmpty.tagName).to.equal("DIV");
473
+ expect(builtEmpty.children.length).to.equal(0);
474
+ });
475
+
476
+ it("buildContent: skipCloning returns the fragment's own nodes", () => {
477
+ const single = document.createElement("template");
478
+ single.innerHTML = `<p>one</p>`;
479
+ const own = single.content.firstElementChild;
480
+ expect(dom.buildContent(single.content, { skipCloning: true })).to.equal(
481
+ own,
482
+ );
483
+ const many = document.createElement("template");
484
+ many.innerHTML = `<p>a</p><p>b</p>`;
485
+ const wrapped = dom.buildContent(many.content, {
486
+ skipCloning: true,
487
+ }) as Element;
488
+ expect(wrapped.tagName).to.equal("DIV");
489
+ // the wrapper adopted the fragment's children (moved, not copied)
490
+ expect(wrapped.children.length).to.equal(2);
491
+ expect(many.content.children.length).to.equal(0);
492
+ });
493
+ });
494
+
495
+ describe("dom: replaceNonTemplateChildren", () => {
496
+ afterEach(() => {
497
+ document.body.innerHTML = "";
498
+ });
499
+
500
+ const tags = (el: Element) =>
501
+ [...el.children].map((c) => c.tagName.toLowerCase() + (c.id ? "#" + c.id : ""));
502
+
503
+ it("removes every non-template child when given nothing", () => {
504
+ const el = fixture<HTMLElement>(
505
+ `<div><template></template><a></a><b></b></div>`,
506
+ );
507
+ expect(dom.replaceNonTemplateChildren(el)).to.equal(true);
508
+ expect(tags(el)).to.deep.equal(["template"]);
509
+ // nothing left to remove: reports no change
510
+ expect(dom.replaceNonTemplateChildren(el, [])).to.equal(false);
511
+ });
512
+
513
+ it("appends when there are no old children and replaces mismatches", () => {
514
+ const el = fixture<HTMLElement>(`<div><template></template></div>`);
515
+ const a = document.createElement("a");
516
+ const b = document.createElement("b");
517
+ expect(dom.replaceNonTemplateChildren(el, [a, b])).to.equal(true);
518
+ expect(tags(el)).to.deep.equal(["template", "a", "b"]);
519
+ // same nodes again: no change
520
+ expect(dom.replaceNonTemplateChildren(el, [a, b])).to.equal(false);
521
+ // a fresh node replaces the old one at its index
522
+ const i = document.createElement("i");
523
+ expect(dom.replaceNonTemplateChildren(el, [a, i])).to.equal(true);
524
+ expect(tags(el)).to.deep.equal(["template", "a", "i"]);
525
+ expect(b.isConnected).to.equal(false);
526
+ });
527
+
528
+ it("drops trailing old children beyond the new list", () => {
529
+ const el = fixture<HTMLElement>(
530
+ `<div><a id="a"></a><b id="b"></b><i id="i"></i></div>`,
531
+ );
532
+ const a = el.querySelector("#a")!;
533
+ expect(dom.replaceNonTemplateChildren(el, [a])).to.equal(true);
534
+ expect(tags(el)).to.deep.equal(["a#a"]);
535
+ });
536
+
537
+ it("swaps in a new child that is already a sibling", () => {
538
+ const el = fixture<HTMLElement>(
539
+ `<div><template></template><a id="a"></a><b id="b"></b><i id="i"></i></div>`,
540
+ );
541
+ const a = el.querySelector("#a")!;
542
+ const i = el.querySelector("#i")!;
543
+ expect(dom.replaceNonTemplateChildren(el, [i, a])).to.equal(true);
544
+ expect(tags(el)).to.deep.equal(["template", "i#i", "a#a"]);
545
+ expect(el.querySelector("#b")).to.equal(null);
546
+ // no stray placeholder survives
547
+ expect(el.querySelectorAll("div").length).to.equal(0);
548
+ });
549
+
550
+ it("moves a non-element sibling node without the placeholder swap", () => {
551
+ const el = fixture<HTMLElement>(`<div><a id="a"></a>text</div>`);
552
+ const text = el.lastChild!;
553
+ expect(text.nodeType).to.equal(Node.TEXT_NODE);
554
+ expect(dom.replaceNonTemplateChildren(el, [text])).to.equal(true);
555
+ expect(el.childNodes.length).to.equal(1);
556
+ expect(el.firstChild).to.equal(text);
557
+ });
558
+ });
559
+
560
+ describe("dom: selection roots", () => {
561
+ afterEach(() => {
562
+ document.body.innerHTML = "";
563
+ });
564
+
565
+ it("returns null when the root selector matches nothing", () => {
566
+ document.body.innerHTML = `<span></span>`;
567
+ expect(dom.selectOne("span", { root: "#missing-root" })).to.equal(null);
568
+ expect(dom.selectAll("span", { root: "#missing-root" })).to.equal(null);
569
+ });
570
+
571
+ it("resolves the root as the closest ancestor of the scope", () => {
572
+ document.body.innerHTML = `
573
+ <section id="outer"><section id="inner"><a id="target"></a></section><b id="not-inside"></b></section>
574
+ `;
575
+ const target = document.querySelector("#target") as HTMLElement;
576
+ expect(
577
+ dom.selectOne("a", { root: "section", scope: target })?.id,
578
+ ).to.equal("target");
579
+ expect(dom.selectAll("b", { root: "#inner", scope: target })).to.deep.equal(
580
+ [],
581
+ );
582
+ // no ancestor matches the root selector
583
+ expect(dom.selectOne("a", { root: "article", scope: target })).to.equal(
584
+ null,
585
+ );
586
+ });
587
+
588
+ it("queries a detached tree from the scope's own root node", () => {
589
+ const detached = document.createElement("div");
590
+ detached.innerHTML = `<p id="p"><b id="b"></b></p>`;
591
+ const p = detached.querySelector("#p") as HTMLElement;
592
+ expect(dom.selectOne(":scope > b", { scope: p })?.id).to.equal("b");
593
+ expect(dom.selectAll("b", { scope: p })?.length).to.equal(1);
594
+ expect(p.attributes.length).to.equal(1);
595
+ });
596
+ });
597
+
598
+ describe("dom: Converter details", () => {
599
+ it("maps constructors and type names to converters", () => {
600
+ expect(dom.Converter.type(String)).to.equal(dom.Converter.string);
601
+ expect(dom.Converter.type(Boolean)).to.equal(dom.Converter.boolean);
602
+ expect(dom.Converter.type(Number)).to.equal(dom.Converter.number);
603
+ expect(dom.Converter.type(dom.TokenList)).to.equal(dom.Converter.tokens);
604
+ expect(dom.Converter.type("string")).to.equal(dom.Converter.string);
605
+ expect(dom.Converter.type("boolean")).to.equal(dom.Converter.boolean);
606
+ expect(dom.Converter.type("number")).to.equal(dom.Converter.number);
607
+ expect(dom.Converter.type("tokens")).to.equal(dom.Converter.tokens);
608
+ });
609
+
610
+ it("returns undefined for unknown types unless non-primitives are allowed", () => {
611
+ expect(dom.Converter.type("object")).to.equal(undefined);
612
+ expect(dom.Converter.type(Object)).to.equal(undefined);
613
+ expect(dom.Converter.type(Array, {})).to.equal(undefined);
614
+ expect(
615
+ dom.Converter.type("object", { convertNonPrimitives: true }),
616
+ ).to.equal(dom.Converter.nonPrimitive);
617
+ expect(
618
+ dom.Converter.type(Object, { convertNonPrimitives: true }),
619
+ ).to.equal(dom.Converter.nonPrimitive);
620
+ expect(dom.Converter.type(null, { convertNonPrimitives: true })).to.equal(
621
+ undefined,
622
+ );
623
+ expect(
624
+ dom.Converter.type(undefined, { convertNonPrimitives: true }),
625
+ ).to.equal(undefined);
626
+ });
627
+
628
+ it("boolean: truthiness and defaults", () => {
629
+ const { attr, prop } = dom.Converter.boolean;
630
+ expect(attr.isTruthy("")).to.equal(true);
631
+ expect(attr.isTruthy("false")).to.equal(true);
632
+ expect(attr.isTruthy(null)).to.equal(false);
633
+ expect(attr.defaultValue).to.equal(null);
634
+ expect(prop.isTruthy(true)).to.equal(true);
635
+ expect(prop.isTruthy(0)).to.equal(false);
636
+ expect(prop.defaultValue).to.equal(false);
637
+ // a string prop is written as a boolean attribute even when empty
638
+ expect(prop.convert("")).to.equal("");
639
+ expect(prop.convert("yes")).to.equal("");
640
+ expect(prop.convert(0)).to.equal(null);
641
+ expect(prop.convert(null)).to.equal(null);
642
+ });
643
+
644
+ it("number: truthiness, defaults and invalid input", () => {
645
+ const { attr, prop } = dom.Converter.number;
646
+ expect(attr.convert("abc")).to.equal(null);
647
+ expect(attr.convert(null)).to.equal(null);
648
+ expect(attr.convert("-2")).to.equal(-2);
649
+ expect(attr.isTruthy("0")).to.equal(true);
650
+ expect(attr.isTruthy("")).to.equal(false);
651
+ expect(attr.isTruthy(null)).to.equal(false);
652
+ expect(attr.defaultValue).to.equal(null);
653
+ expect(prop.convert(null)).to.equal(null);
654
+ expect(prop.convert(undefined)).to.equal(null);
655
+ expect(prop.convert("")).to.equal(null);
656
+ expect(prop.convert(0)).to.equal("0");
657
+ expect(prop.isTruthy(0)).to.equal(true);
658
+ expect(prop.isTruthy(NaN)).to.equal(false);
659
+ expect(prop.isTruthy("1")).to.equal(false);
660
+ expect(prop.defaultValue).to.equal(null);
661
+ });
662
+
663
+ it("string: truthiness and defaults", () => {
664
+ const { attr, prop } = dom.Converter.string;
665
+ expect(attr.isTruthy("")).to.equal(true);
666
+ expect(attr.isTruthy(null)).to.equal(false);
667
+ expect(attr.defaultValue).to.equal(null);
668
+ expect(prop.convert(12)).to.equal("12");
669
+ expect(prop.convert(undefined)).to.equal(null);
670
+ expect(prop.isTruthy("")).to.equal(true);
671
+ expect(prop.isTruthy(undefined)).to.equal(false);
672
+ expect(prop.defaultValue).to.equal(null);
673
+ });
674
+
675
+ it("tokens: trims, drops blanks and rejects non-arrays", () => {
676
+ const { attr, prop } = dom.Converter.tokens;
677
+ expect(attr.convert(" a b ")).to.deep.equal(["a", "b"]);
678
+ expect(attr.isTruthy("")).to.equal(true);
679
+ expect(attr.isTruthy(null)).to.equal(false);
680
+ expect(attr.defaultValue).to.equal(null);
681
+ expect(prop.convert(["a", null, "", undefined, "b"])).to.equal("a b");
682
+ expect(prop.convert("a b")).to.equal(null);
683
+ expect(prop.isTruthy([])).to.equal(true);
684
+ expect(prop.isTruthy("a")).to.equal(false);
685
+ expect(prop.defaultValue).to.equal(null);
686
+ });
687
+
688
+ it("nonPrimitive: cannot read attributes, counts arrays and objects", () => {
689
+ const { attr, prop } = dom.Converter.nonPrimitive;
690
+ expect(() => attr.convert("x")).to.throw(
691
+ "Cannot convert non-primitive to attribute",
692
+ );
693
+ expect(attr.isTruthy("")).to.equal(true);
694
+ expect(attr.isTruthy(null)).to.equal(false);
695
+ expect(attr.defaultValue).to.equal(null);
696
+ expect(prop.convert([1, 2, 3])).to.equal("3");
697
+ expect(prop.convert({ a: 1 })).to.equal("1");
698
+ expect(prop.convert(new Date())).to.equal("");
699
+ expect(prop.convert(0)).to.equal(null);
700
+ expect(prop.convert(null)).to.equal(null);
701
+ expect(prop.isTruthy({})).to.equal(true);
702
+ expect(prop.isTruthy(undefined)).to.equal(false);
703
+ expect(prop.defaultValue).to.equal(null);
704
+ });
705
+
706
+ it("nonPrimitive: falls back to presence when the value cannot be inspected", () => {
707
+ const hostile = new Proxy(
708
+ {},
709
+ {
710
+ ownKeys() {
711
+ throw new Error("no keys for you");
712
+ },
713
+ },
714
+ );
715
+ expect(dom.Converter.nonPrimitive.prop.convert(hostile)).to.equal("");
716
+ });
717
+ });
718
+
719
+ describe("dom: attribute helpers", () => {
720
+ it("setAttr: skips writes when the element already holds the value", () => {
721
+ const el = document.createElement("div");
722
+ el.setAttribute("data-x", "1");
723
+ const set = vi.spyOn(el, "setAttribute");
724
+ const remove = vi.spyOn(el, "removeAttribute");
725
+ dom.setAttr(el, "data-x", "1");
726
+ expect(set).not.toHaveBeenCalled();
727
+ dom.setAttr(el, "data-x", undefined);
728
+ expect(remove.mock.calls[0][0]).to.equal("data-x");
729
+ expect(el.hasAttribute("data-x")).to.equal(false);
730
+ });
731
+
732
+ it("setAttr/getAttr: work on a NamedNodeMap", () => {
733
+ const el = document.createElement("div");
734
+ const map = el.attributes;
735
+ expect(dom.getAttr(map, "data-x")).to.equal(null);
736
+ dom.setAttr(map, "data-x", "1");
737
+ expect(el.getAttribute("data-x")).to.equal("1");
738
+ expect(dom.getAttr(map, "data-x")).to.equal("1");
739
+ // unchanged value: no new attr node
740
+ const before = map.getNamedItem("data-x");
741
+ dom.setAttr(map, "data-x", "1");
742
+ expect(map.getNamedItem("data-x")).to.equal(before);
743
+ dom.setAttr(map, "data-x", "2");
744
+ expect(el.getAttribute("data-x")).to.equal("2");
745
+ expect(dom.getAttr(el, "data-x")).to.equal("2");
746
+ dom.setAttr(map, "data-x", null);
747
+ expect(el.hasAttribute("data-x")).to.equal(false);
748
+ expect(dom.getAttr(map, "data-x")).to.equal(null);
749
+ });
750
+
751
+ it("attributesToEntries/attributesToObject: filter by namespace and format", () => {
752
+ const el = document.createElement("div");
753
+ el.setAttribute("data-a", "1");
754
+ el.setAttribute("data-b", "2");
755
+ el.setAttribute("aria-label", "x");
756
+ expect(dom.attributesToEntries(el.attributes)).to.deep.equal([
757
+ ["data-a", "1"],
758
+ ["data-b", "2"],
759
+ ["aria-label", "x"],
760
+ ]);
761
+ expect(dom.attributesToObject(el.attributes, "data-")).to.deep.equal({
762
+ "data-a": "1",
763
+ "data-b": "2",
764
+ });
765
+ expect(
766
+ dom.attributesToObject(el.attributes, "data-", (a) => [
767
+ a.name.replace("data-", ""),
768
+ a.value + "!",
769
+ ]),
770
+ ).to.deep.equal({ a: "1!", b: "2!" });
771
+ expect(dom.attributesToEntries(el.attributes, "aria-", (a) => [
772
+ a.name,
773
+ a.value.toUpperCase(),
774
+ ])).to.deep.equal([["aria-label", "X"]]);
775
+ expect(
776
+ dom.attributesToObject(document.createElement("i").attributes),
777
+ ).to.deep.equal({});
778
+ });
779
+
780
+ it("objToAttrs: defaults to no prefix and drops non-primitives unless asked", () => {
781
+ expect(dom.objToAttrs({ fooBar: 1, isOn: true })).to.deep.equal({
782
+ "foo-bar": "1",
783
+ "is-on": "",
784
+ });
785
+ expect(dom.objToAttrs({ dataObj: { a: 1 } })).to.deep.equal({
786
+ "data-obj": null,
787
+ });
788
+ expect(
789
+ dom.objToAttrs({ dataObj: { a: 1 } }, { convertNonPrimitives: true }),
790
+ ).to.deep.equal({ "data-obj": "1" });
791
+ expect(
792
+ dom.objToAttrs({ fooBar: "x" }, { prefix: 5 as unknown as string }),
793
+ ).to.deep.equal({ "foo-bar": "x" });
794
+ expect(
795
+ dom.objToAttrs({ tokenList: ["a", undefined, "b"] }),
796
+ ).to.deep.equal({ "token-list": "a b" });
797
+ expect(dom.objToAttrs({ tokenList: ["a", 1] })).to.deep.equal({
798
+ "token-list": null,
799
+ });
800
+ });
801
+ });
802
+
803
+ describe("dom: predicates and casing", () => {
804
+ it("isNullish / isPrimitive / isPrimitiveConstructor", () => {
805
+ expect(dom.isNullish(null)).to.equal(true);
806
+ expect(dom.isNullish(undefined)).to.equal(true);
807
+ expect(dom.isNullish(0)).to.equal(false);
808
+ expect(dom.isNullish("")).to.equal(false);
809
+ expect(dom.isPrimitive("string")).to.equal(true);
810
+ expect(dom.isPrimitive("tokens")).to.equal(true);
811
+ expect(dom.isPrimitive("object")).to.equal(false);
812
+ expect(dom.isPrimitive(String)).to.equal(false);
813
+ expect(dom.isPrimitiveConstructor(String)).to.equal(true);
814
+ expect(dom.isPrimitiveConstructor(Number)).to.equal(true);
815
+ expect(dom.isPrimitiveConstructor(Boolean)).to.equal(true);
816
+ expect(dom.isPrimitiveConstructor(dom.TokenList)).to.equal(true);
817
+ expect(dom.isPrimitiveConstructor(Array)).to.equal(false);
818
+ expect(dom.isPrimitiveConstructor(Object)).to.equal(false);
819
+ expect(dom.isPrimitiveConstructor("string")).to.equal(false);
820
+ });
821
+
822
+ it("dashToCamel / camelToDash round-trip", () => {
823
+ expect(dom.dashToCamel("my-prop-name")).to.equal("myPropName");
824
+ expect(dom.dashToCamel("plain")).to.equal("plain");
825
+ expect(dom.camelToDash("myPropName")).to.equal("my-prop-name");
826
+ expect(dom.camelToDash("plain")).to.equal("plain");
827
+ expect(dom.dashToCamel(dom.camelToDash("someLongName"))).to.equal(
828
+ "someLongName",
829
+ );
830
+ });
831
+
832
+ it("TokenList is an Array marker type", () => {
833
+ expect(Object.getPrototypeOf(dom.TokenList)).to.equal(Array);
834
+ const list = dom.TokenList.from(["a", "b"]);
835
+ expect(Array.isArray(list)).to.equal(true);
836
+ expect([...list]).to.deep.equal(["a", "b"]);
837
+ expect(dom.TokenList).to.not.equal(Array);
838
+ });
839
+
840
+ it("PropSerializer: default is identity, weak wraps in WeakRef", () => {
841
+ const obj = { a: 1 };
842
+ expect(dom.PropSerializer.dfault.serialize(obj)).to.equal(obj);
843
+ expect(dom.PropSerializer.dfault.deserialize(obj)).to.equal(obj);
844
+ const ref = dom.PropSerializer.weak.serialize(obj);
845
+ expect(ref).to.be.instanceOf(WeakRef);
846
+ expect(dom.PropSerializer.weak.deserialize(ref)).to.equal(obj);
847
+ expect(dom.PropSerializer.weak.serialize(null)).to.equal(null);
848
+ expect(dom.PropSerializer.weak.serialize(undefined)).to.equal(undefined);
849
+ expect(dom.PropSerializer.weak.deserialize("plain")).to.equal("plain");
850
+ expect(dom.PropSerializer.weak.deserialize(null)).to.equal(null);
851
+ });
852
+ });