electrobun 1.18.4-beta.19 → 1.18.4-beta.21

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 (53) hide show
  1. package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
  2. package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
  3. package/dist/api/browser/ui/dom.ts +490 -0
  4. package/dist/api/browser/ui/index.ts +44 -0
  5. package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
  6. package/dist/api/browser/ui/jsx-runtime.ts +56 -0
  7. package/dist/api/config/ElectrobunConfig.ts +33 -0
  8. package/dist/api/preload/.generated/compiled.ts +1 -1
  9. package/dist/api/preload/index.ts +2 -0
  10. package/dist/api/preload/uiTag.ts +45 -0
  11. package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
  12. package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
  13. package/dist/api/sdks/main/core/Utils.ts +52 -4
  14. package/dist/api/sdks/main/core/WGPUView.ts +9 -0
  15. package/dist/api/sdks/main/entries/ui.ts +1 -0
  16. package/dist/api/sdks/main/proc/native.ts +187 -0
  17. package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
  18. package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
  19. package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
  20. package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
  21. package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
  22. package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
  23. package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
  24. package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
  25. package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
  26. package/dist/api/sdks/main/ui/elements.ts +135 -0
  27. package/dist/api/sdks/main/ui/font.ts +168 -0
  28. package/dist/api/sdks/main/ui/hit.ts +46 -0
  29. package/dist/api/sdks/main/ui/index.ts +71 -0
  30. package/dist/api/sdks/main/ui/input.ts +268 -0
  31. package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
  32. package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
  33. package/dist/api/sdks/main/ui/keymap.ts +147 -0
  34. package/dist/api/sdks/main/ui/layout.ts +178 -0
  35. package/dist/api/sdks/main/ui/paint.ts +196 -0
  36. package/dist/api/sdks/main/ui/reactive.ts +4 -0
  37. package/dist/api/sdks/main/ui/renderer.ts +278 -0
  38. package/dist/api/sdks/main/ui/text.ts +175 -0
  39. package/dist/api/sdks/main/ui/textInput.ts +121 -0
  40. package/dist/api/sdks/main/ui/tree.ts +276 -0
  41. package/dist/api/sdks/main/ui/ui.ts +457 -0
  42. package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
  43. package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
  44. package/dist/api/shared/build-dependencies.test.ts +1 -1
  45. package/dist/api/shared/build-dependencies.ts +4 -4
  46. package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
  47. package/dist/api/shared/warren/jsx.ts +279 -0
  48. package/dist/api/shared/warren/reactive.ts +638 -0
  49. package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
  50. package/dist/preload-full.js +35 -0
  51. package/dist/zig-sdk/electrobun.zig +197 -162
  52. package/{dash.config.ts → hutch.config.ts} +3 -2
  53. package/package.json +11 -2
@@ -0,0 +1,473 @@
1
+ // Warren DOM renderer against the stub DOM: structure, explicit reactivity,
2
+ // control flow with keyed reconciliation, events, Portal, and teardown.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { live, memo, setDevMode, signal, store } from "../../../shared/warren/reactive";
6
+ import {
7
+ For,
8
+ Fragment,
9
+ Match,
10
+ Portal,
11
+ Show,
12
+ Switch,
13
+ jsx,
14
+ render,
15
+ } from "../dom";
16
+ import { createStubRoot, StubComment, StubElement, StubText } from "./domStub";
17
+
18
+ function mount(app: () => unknown): {
19
+ container: StubElement;
20
+ document: ReturnType<typeof createStubRoot>["document"];
21
+ dispose: () => void;
22
+ } {
23
+ const { document, container } = createStubRoot();
24
+ const dispose = render(app as any, container as any);
25
+ return { container, document, dispose };
26
+ }
27
+
28
+ describe("DOM renderer", () => {
29
+ test("mounts an intrinsic tree with attributes, class, and style", () => {
30
+ const { container } = mount(() =>
31
+ jsx("div", {
32
+ id: "app",
33
+ class: "shell dark",
34
+ style: { color: "red", "--accent": "#fff" },
35
+ children: [
36
+ jsx("span", { children: "hello" }),
37
+ jsx("input", { type: "text", value: "abc", disabled: true }),
38
+ ],
39
+ }),
40
+ );
41
+ const [div] = container.children;
42
+ expect(div!.tagName).toBe("div");
43
+ expect(div!.getAttribute("id")).toBe("app");
44
+ expect(div!.className).toBe("shell dark");
45
+ expect(div!.style.color).toBe("red");
46
+ expect((div!.style as any)["--accent"]).toBe("#fff");
47
+ const [span, input] = div!.children;
48
+ expect(span!.textContent).toBe("hello");
49
+ expect(input!.value).toBe("abc"); // property, not attribute
50
+ expect(input!.hasAttribute("value")).toBe(false);
51
+ expect(input!.getAttribute("disabled")).toBe(""); // boolean attribute
52
+ });
53
+
54
+ test("static text and live text children", () => {
55
+ const [count, setCount] = signal(0);
56
+ const { container } = mount(() =>
57
+ jsx("p", { children: [live(() => `n=${count()}`), " (static)"] }),
58
+ );
59
+ const [p] = container.children;
60
+ expect(p!.textContent).toBe("n=0 (static)");
61
+ setCount(7);
62
+ expect(p!.textContent).toBe("n=7 (static)");
63
+ });
64
+
65
+ test("live props update in place; element identity is stable", () => {
66
+ const [cls, setCls] = signal("a");
67
+ const { container } = mount(() =>
68
+ jsx("div", { class: live(cls), title: live(() => cls().toUpperCase()) }),
69
+ );
70
+ const [div] = container.children;
71
+ expect(div!.className).toBe("a");
72
+ expect(div!.getAttribute("title")).toBe("A");
73
+ setCls("b");
74
+ expect(container.children[0]).toBe(div!);
75
+ expect(div!.className).toBe("b");
76
+ expect(div!.getAttribute("title")).toBe("B");
77
+ });
78
+
79
+ test("classList toggles classes from an object", () => {
80
+ const [active, setActive] = signal(false);
81
+ const { container } = mount(() =>
82
+ jsx("div", {
83
+ class: "base",
84
+ classList: live(() => ({ active: active(), muted: !active() })),
85
+ }),
86
+ );
87
+ const [div] = container.children;
88
+ expect(div!.classList.contains("base")).toBe(true);
89
+ expect(div!.classList.contains("muted")).toBe(true);
90
+ expect(div!.classList.contains("active")).toBe(false);
91
+ setActive(true);
92
+ expect(div!.classList.contains("active")).toBe(true);
93
+ expect(div!.classList.contains("muted")).toBe(false);
94
+ });
95
+
96
+ test("bare functions in value props throw loudly", () => {
97
+ const [x] = signal(1);
98
+ expect(() => mount(() => jsx("div", { title: x as any }))).toThrow(
99
+ /live\(/,
100
+ );
101
+ });
102
+
103
+ test("event handlers attach; ref sees the element", () => {
104
+ let clicks = 0;
105
+ let reffed: unknown = null;
106
+ const { container } = mount(() =>
107
+ jsx("button", {
108
+ ref: (el: unknown) => {
109
+ reffed = el;
110
+ },
111
+ onClick: () => clicks++,
112
+ children: "go",
113
+ }),
114
+ );
115
+ const [button] = container.children;
116
+ expect(reffed).toBe(button!);
117
+ button!.dispatch("click");
118
+ expect(clicks).toBe(1);
119
+ });
120
+
121
+ test("components run once; nested structure mounts", () => {
122
+ let calls = 0;
123
+ function Badge(props: Record<string, unknown>) {
124
+ calls++;
125
+ return jsx("em", { children: props.label as string });
126
+ }
127
+ const { container } = mount(() =>
128
+ jsx("div", {
129
+ children: [jsx(Badge, { label: "one" }), jsx(Badge, { label: "two" })],
130
+ }),
131
+ );
132
+ expect(calls).toBe(2);
133
+ expect(container.children[0]!.textContent).toBe("onetwo");
134
+ });
135
+
136
+ test("Show with live when swaps content and fallback", () => {
137
+ const [on, setOn] = signal(false);
138
+ const { container } = mount(() =>
139
+ jsx("div", {
140
+ children: Show({
141
+ when: live(on),
142
+ fallback: jsx("span", { class: "off", children: "off" }),
143
+ children: jsx("span", { class: "on", children: "on" }),
144
+ }),
145
+ }),
146
+ );
147
+ const [div] = container.children;
148
+ expect(div!.children[0]!.className).toBe("off");
149
+ setOn(true);
150
+ expect(div!.children[0]!.className).toBe("on");
151
+ setOn(false);
152
+ expect(div!.children[0]!.className).toBe("off");
153
+ });
154
+
155
+ test("Show with a plain value is a frozen snapshot", () => {
156
+ const [on, setOn] = signal(false);
157
+ const { container } = mount(() =>
158
+ jsx("div", {
159
+ children: Show({
160
+ when: on(), // evaluated once, not reactive
161
+ fallback: jsx("span", { children: "frozen-off" }),
162
+ children: jsx("span", { children: "frozen-on" }),
163
+ }),
164
+ }),
165
+ );
166
+ setOn(true);
167
+ expect(container.children[0]!.textContent).toBe("frozen-off");
168
+ });
169
+
170
+ test("For reconciles keyed rows: identity preserved across reorder", () => {
171
+ const [state, setState] = store({ items: ["a", "b", "c"] });
172
+ const { container } = mount(() =>
173
+ jsx("ul", {
174
+ children: For({
175
+ each: live(() => state.items.slice()),
176
+ key: (item: string) => item,
177
+ children: (item: string) => jsx("li", { children: item }),
178
+ }),
179
+ }),
180
+ );
181
+ const [ul] = container.children;
182
+ expect(ul!.children.map((li) => li.textContent)).toEqual(["a", "b", "c"]);
183
+ const [liA, liB, liC] = ul!.children;
184
+
185
+ setState((s) => s.items.reverse());
186
+ expect(ul!.children.map((li) => li.textContent)).toEqual(["c", "b", "a"]);
187
+ // Same DOM nodes, moved — not rebuilt.
188
+ expect(ul!.children[0]).toBe(liC!);
189
+ expect(ul!.children[1]).toBe(liB!);
190
+ expect(ul!.children[2]).toBe(liA!);
191
+ });
192
+
193
+ test("For adds and removes rows; fallback flips on empty", () => {
194
+ const [state, setState] = store({ items: ["x"] });
195
+ const { container } = mount(() =>
196
+ jsx("div", {
197
+ children: For({
198
+ each: live(() => state.items.slice()),
199
+ key: (item: string) => item,
200
+ fallback: jsx("i", { children: "empty" }),
201
+ children: (item: string) => jsx("b", { children: item }),
202
+ }),
203
+ }),
204
+ );
205
+ const [div] = container.children;
206
+ expect(div!.children[0]!.tagName).toBe("b");
207
+ setState((s) => {
208
+ s.items.length = 0;
209
+ });
210
+ expect(div!.children[0]!.tagName).toBe("i");
211
+ setState((s) => s.items.push("y", "z"));
212
+ expect(div!.children.map((el) => el.textContent)).toEqual(["y", "z"]);
213
+ });
214
+
215
+ test("For row index accessor is reactive", () => {
216
+ const [state, setState] = store({ items: ["a", "b"] });
217
+ const { container } = mount(() =>
218
+ jsx("div", {
219
+ children: For({
220
+ each: live(() => state.items.slice()),
221
+ key: (item: string) => item,
222
+ children: (item: string, index) =>
223
+ jsx("span", { children: live(() => `${item}${index()}`) }),
224
+ }),
225
+ }),
226
+ );
227
+ const [div] = container.children;
228
+ expect(div!.textContent).toBe("a0b1");
229
+ setState((s) => s.items.reverse());
230
+ expect(div!.textContent).toBe("b0a1");
231
+ });
232
+
233
+ test("JSX key attribute reaches For via the transform's third argument", () => {
234
+ // <For key={fn}> transpiles to jsx(For, props, fn) — key must land
235
+ // back in props or reconciliation silently degrades to item identity.
236
+ const [state, setState] = store({ items: [{ n: 1 }, { n: 2 }] });
237
+ const { container } = mount(() =>
238
+ jsx("div", {
239
+ children: jsx(
240
+ For as any,
241
+ {
242
+ each: live(() => state.items.map((it) => ({ ...it }))),
243
+ children: (item: { n: number }) =>
244
+ jsx("span", { children: String(item.n) }),
245
+ },
246
+ (item: { n: number }) => item.n, // key as third arg
247
+ ),
248
+ }),
249
+ );
250
+ const [div] = container.children;
251
+ const [span1] = div!.children;
252
+ setState((s) => s.items.reverse());
253
+ // Fresh objects each read: only a working key preserves identity.
254
+ expect(div!.children[1]).toBe(span1!);
255
+ });
256
+
257
+ test("Switch/Match picks the first live truthy branch", () => {
258
+ const [tab, setTab] = signal("home");
259
+ const { container } = mount(() =>
260
+ jsx("div", {
261
+ children: Switch({
262
+ fallback: jsx("span", { children: "none" }),
263
+ children: [
264
+ Match({
265
+ when: live(() => tab() === "home"),
266
+ children: jsx("span", { children: "home" }),
267
+ }),
268
+ Match({
269
+ when: live(() => tab() === "settings"),
270
+ children: jsx("span", { children: "settings" }),
271
+ }),
272
+ ],
273
+ }),
274
+ }),
275
+ );
276
+ const [div] = container.children;
277
+ expect(div!.textContent).toBe("home");
278
+ setTab("settings");
279
+ expect(div!.textContent).toBe("settings");
280
+ setTab("nope");
281
+ expect(div!.textContent).toBe("none");
282
+ });
283
+
284
+ test("memo feeds live bindings glitch-free", () => {
285
+ const [n, setN] = signal(2);
286
+ const double = memo(() => n() * 2);
287
+ const { container } = mount(() =>
288
+ jsx("output", { children: live(() => `${n()}:${double()}`) }),
289
+ );
290
+ const [output] = container.children;
291
+ expect(output!.textContent).toBe("2:4");
292
+ setN(5);
293
+ expect(output!.textContent).toBe("5:10");
294
+ });
295
+
296
+ test("live element children become dynamic regions (cond && <el/>)", () => {
297
+ const [open, setOpen] = signal(false);
298
+ const { container } = mount(() =>
299
+ jsx("div", {
300
+ children: live(() =>
301
+ open() ? jsx("span", { class: "panel", children: "open" }) : null,
302
+ ),
303
+ }),
304
+ );
305
+ const [div] = container.children;
306
+ expect(div!.children.length).toBe(0);
307
+ setOpen(true);
308
+ expect(div!.children[0]!.className).toBe("panel");
309
+ setOpen(false);
310
+ expect(div!.children.length).toBe(0);
311
+ });
312
+
313
+ test("live text children stay fine-grained text nodes", () => {
314
+ const [n, setN] = signal(1);
315
+ const { container } = mount(() =>
316
+ jsx("div", { children: live(() => `v${n()}`) }),
317
+ );
318
+ const [div] = container.children;
319
+ const textNode = div!.childNodes.find((c) => c instanceof StubText);
320
+ setN(2);
321
+ // Same text node updated in place — not a rebuilt region.
322
+ expect(div!.childNodes.find((c) => c instanceof StubText)).toBe(textNode!);
323
+ expect(div!.textContent).toBe("v2");
324
+ });
325
+
326
+ test("components created inside live regions keep inert bodies and deferred lives", () => {
327
+ const [open, setOpen] = signal(true);
328
+ const [label, setLabel] = signal("a");
329
+ let regionBuilds = 0;
330
+ let effectRuns = 0;
331
+ function Panel() {
332
+ // Statement live in a body mounted from a live region: must be
333
+ // deferred (declared-later variable is fine) and must NOT leak
334
+ // its reads into the region's dependencies.
335
+ live(() => {
336
+ effectRuns++;
337
+ laterDeclared();
338
+ });
339
+ const laterDeclared = () => label();
340
+ return jsx("span", { class: live(label), children: "panel" });
341
+ }
342
+ const { container } = mount(() =>
343
+ jsx("div", {
344
+ children: live(() => {
345
+ regionBuilds++;
346
+ return open() ? jsx(Panel, {}) : null;
347
+ }),
348
+ }),
349
+ );
350
+ const [div] = container.children;
351
+ expect(div!.children[0]!.className).toBe("a");
352
+ expect(effectRuns).toBe(1);
353
+ expect(regionBuilds).toBe(1);
354
+ // Inner signal change: value binding + effect update, region untouched.
355
+ setLabel("b");
356
+ expect(div!.children[0]!.className).toBe("b");
357
+ expect(effectRuns).toBe(2);
358
+ expect(regionBuilds).toBe(1);
359
+ // Region's own dependency still reconciles it.
360
+ setOpen(false);
361
+ expect(div!.children.length).toBe(0);
362
+ expect(regionBuilds).toBe(2);
363
+ });
364
+
365
+ test("Fragment and array children mount in order", () => {
366
+ const { container } = mount(() =>
367
+ Fragment({
368
+ children: [
369
+ jsx("i", { children: "1" }),
370
+ "mid",
371
+ jsx("b", { children: "2" }),
372
+ ],
373
+ }),
374
+ );
375
+ expect(container.textContent).toBe("1mid2");
376
+ });
377
+
378
+ test("bare function children run as escapes; returned elements mount", () => {
379
+ const { container } = mount(() =>
380
+ jsx("div", {
381
+ children: [() => jsx("span", { children: "escaped" }), () => {}],
382
+ }),
383
+ );
384
+ expect(container.children[0]!.textContent).toBe("escaped");
385
+ });
386
+
387
+ test("Portal renders into document.body and cleans up with its scope", () => {
388
+ const [open, setOpen] = signal(true);
389
+ const { container, document } = mount(() =>
390
+ jsx("div", {
391
+ children: Show({
392
+ when: live(open),
393
+ children: Portal({
394
+ children: jsx("dialog", { children: "modal" }),
395
+ }),
396
+ }),
397
+ }),
398
+ );
399
+ expect(container.textContent).toBe("");
400
+ expect(
401
+ document.body.children.some((el) => el.tagName === "dialog"),
402
+ ).toBe(true);
403
+ setOpen(false);
404
+ expect(
405
+ document.body.children.some((el) => el.tagName === "dialog"),
406
+ ).toBe(false);
407
+ setOpen(true);
408
+ expect(
409
+ document.body.children.some((el) => el.tagName === "dialog"),
410
+ ).toBe(true);
411
+ });
412
+
413
+ test("svg subtrees use the SVG namespace; class works via attribute", () => {
414
+ const { container } = mount(() =>
415
+ jsx("svg", {
416
+ viewBox: "0 0 10 10",
417
+ class: "icon",
418
+ children: jsx("path", { d: "M0 0L10 10" }),
419
+ }),
420
+ );
421
+ const [svg] = container.children;
422
+ expect(svg!.namespaceURI).toBe("http://www.w3.org/2000/svg");
423
+ expect(svg!.getAttribute("viewBox")).toBe("0 0 10 10");
424
+ expect(svg!.getAttribute("class")).toBe("icon");
425
+ expect(svg!.children[0]!.namespaceURI).toBe(
426
+ "http://www.w3.org/2000/svg",
427
+ );
428
+ });
429
+
430
+ test("dispose removes everything Warren created and stops updates", () => {
431
+ const [n, setN] = signal(0);
432
+ const { container, dispose } = mount(() =>
433
+ jsx("div", { children: live(() => `n=${n()}`) }),
434
+ );
435
+ expect(container.children.length).toBe(1);
436
+ dispose();
437
+ expect(container.childNodes.length).toBe(0);
438
+ // Writes after dispose must not throw or resurrect nodes.
439
+ setN(1);
440
+ expect(container.childNodes.length).toBe(0);
441
+ });
442
+
443
+ test("dynamic region rebuild tears down row state (no leaked comments)", () => {
444
+ setDevMode(false);
445
+ try {
446
+ const [state, setState] = store({ items: ["a", "b", "c"] });
447
+ const { container } = mount(() =>
448
+ jsx("div", {
449
+ children: For({
450
+ each: live(() => state.items.slice()),
451
+ key: (item: string) => item,
452
+ children: (item: string) => jsx("span", { children: item }),
453
+ }),
454
+ }),
455
+ );
456
+ const [div] = container.children;
457
+ const countComments = () =>
458
+ div!.childNodes.filter((n) => n instanceof StubComment).length;
459
+ const before = countComments();
460
+ // Shrinking to one row removes the other rows' anchors too.
461
+ setState((s) => {
462
+ s.items = ["b"];
463
+ });
464
+ expect(div!.textContent).toBe("b");
465
+ expect(countComments()).toBeLessThan(before);
466
+ // Text nodes from removed rows are gone.
467
+ const texts = div!.childNodes.filter((n) => n instanceof StubText);
468
+ expect(texts.length).toBe(0); // row text lives inside spans
469
+ } finally {
470
+ setDevMode(true);
471
+ }
472
+ });
473
+ });
@@ -0,0 +1,218 @@
1
+ // Minimal DOM implementation for headless renderer tests — no third-party
2
+ // dependency. Implements exactly the surface Warren's DOM renderer touches;
3
+ // if the renderer starts using more DOM, this stub fails loudly and the new
4
+ // surface gets added here consciously.
5
+
6
+ export class StubNode {
7
+ nodeType: number;
8
+ ownerDocument: StubDocument | null = null;
9
+ parentNode: StubNode | null = null;
10
+ childNodes: StubNode[] = [];
11
+
12
+ constructor(nodeType: number) {
13
+ this.nodeType = nodeType;
14
+ }
15
+
16
+ get nextSibling(): StubNode | null {
17
+ if (!this.parentNode) return null;
18
+ const siblings = this.parentNode.childNodes;
19
+ const idx = siblings.indexOf(this);
20
+ return idx >= 0 && idx + 1 < siblings.length ? siblings[idx + 1]! : null;
21
+ }
22
+
23
+ get firstChild(): StubNode | null {
24
+ return this.childNodes[0] ?? null;
25
+ }
26
+
27
+ insertBefore(node: StubNode, before: StubNode | null): StubNode {
28
+ if (node.parentNode) node.parentNode.removeChild(node);
29
+ if (before === null) {
30
+ this.childNodes.push(node);
31
+ } else {
32
+ const idx = this.childNodes.indexOf(before);
33
+ if (idx < 0) throw new Error("stub: insertBefore anchor not a child");
34
+ this.childNodes.splice(idx, 0, node);
35
+ }
36
+ node.parentNode = this;
37
+ return node;
38
+ }
39
+
40
+ appendChild(node: StubNode): StubNode {
41
+ return this.insertBefore(node, null);
42
+ }
43
+
44
+ removeChild(node: StubNode): StubNode {
45
+ const idx = this.childNodes.indexOf(node);
46
+ if (idx < 0) throw new Error("stub: removeChild of non-child");
47
+ this.childNodes.splice(idx, 1);
48
+ node.parentNode = null;
49
+ return node;
50
+ }
51
+ }
52
+
53
+ export class StubText extends StubNode {
54
+ data = "";
55
+ constructor() {
56
+ super(3);
57
+ }
58
+ get textContent(): string {
59
+ return this.data;
60
+ }
61
+ }
62
+
63
+ export class StubComment extends StubNode {
64
+ data = "";
65
+ constructor() {
66
+ super(8);
67
+ }
68
+ }
69
+
70
+ class StubClassList {
71
+ private owner: StubElement;
72
+ constructor(owner: StubElement) {
73
+ this.owner = owner;
74
+ }
75
+ private set(): Set<string> {
76
+ return new Set((this.owner.className ?? "").split(/\s+/).filter(Boolean));
77
+ }
78
+ private write(set: Set<string>): void {
79
+ this.owner.className = [...set].join(" ");
80
+ }
81
+ add(cls: string): void {
82
+ const s = this.set();
83
+ s.add(cls);
84
+ this.write(s);
85
+ }
86
+ remove(cls: string): void {
87
+ const s = this.set();
88
+ s.delete(cls);
89
+ this.write(s);
90
+ }
91
+ toggle(cls: string, force?: boolean): boolean {
92
+ const s = this.set();
93
+ const on = force ?? !s.has(cls);
94
+ if (on) s.add(cls);
95
+ else s.delete(cls);
96
+ this.write(s);
97
+ return on;
98
+ }
99
+ contains(cls: string): boolean {
100
+ return this.set().has(cls);
101
+ }
102
+ }
103
+
104
+ class StubStyle {
105
+ [key: string]: unknown;
106
+ cssText = "";
107
+ setProperty(name: string, value: string): void {
108
+ (this as Record<string, unknown>)[name] = value;
109
+ }
110
+ removeProperty(name: string): void {
111
+ delete (this as Record<string, unknown>)[name];
112
+ }
113
+ }
114
+
115
+ export class StubElement extends StubNode {
116
+ tagName: string;
117
+ namespaceURI: string;
118
+ className = "";
119
+ attributes = new Map<string, string>();
120
+ style = new StubStyle();
121
+ classList = new StubClassList(this);
122
+ listeners = new Map<string, Array<(event: unknown) => void>>();
123
+ // Property-set keys land as plain fields:
124
+ value: unknown;
125
+ checked: unknown;
126
+ innerHTML: unknown;
127
+
128
+ constructor(tagName: string, namespaceURI = "") {
129
+ super(1);
130
+ this.tagName = tagName;
131
+ this.namespaceURI = namespaceURI;
132
+ }
133
+
134
+ setAttribute(name: string, value: string): void {
135
+ this.attributes.set(name, value);
136
+ }
137
+ getAttribute(name: string): string | null {
138
+ return this.attributes.get(name) ?? null;
139
+ }
140
+ removeAttribute(name: string): void {
141
+ this.attributes.delete(name);
142
+ }
143
+ hasAttribute(name: string): boolean {
144
+ return this.attributes.has(name);
145
+ }
146
+
147
+ addEventListener(type: string, handler: (event: unknown) => void): void {
148
+ const list = this.listeners.get(type) ?? [];
149
+ list.push(handler);
150
+ this.listeners.set(type, list);
151
+ }
152
+ removeEventListener(type: string, handler: (event: unknown) => void): void {
153
+ const list = this.listeners.get(type);
154
+ if (!list) return;
155
+ const idx = list.indexOf(handler);
156
+ if (idx >= 0) list.splice(idx, 1);
157
+ }
158
+ dispatch(type: string, event: unknown = { type }): void {
159
+ for (const handler of this.listeners.get(type) ?? []) handler(event);
160
+ }
161
+
162
+ /** Test helper: concatenated text of the subtree. */
163
+ get textContent(): string {
164
+ let out = "";
165
+ for (const child of this.childNodes) {
166
+ if (child instanceof StubText) out += child.data;
167
+ else if (child instanceof StubElement) out += child.textContent;
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /** Test helper: element children only (comments/text skipped). */
173
+ get children(): StubElement[] {
174
+ return this.childNodes.filter(
175
+ (n): n is StubElement => n instanceof StubElement,
176
+ );
177
+ }
178
+ }
179
+
180
+ export class StubDocument {
181
+ body: StubElement;
182
+
183
+ constructor() {
184
+ this.body = this.createElement("body");
185
+ }
186
+
187
+ private adopt<T extends StubNode>(node: T): T {
188
+ node.ownerDocument = this;
189
+ return node;
190
+ }
191
+
192
+ createElement(tagName: string): StubElement {
193
+ return this.adopt(new StubElement(tagName));
194
+ }
195
+ createElementNS(ns: string, tagName: string): StubElement {
196
+ return this.adopt(new StubElement(tagName, ns));
197
+ }
198
+ createTextNode(data: string): StubText {
199
+ const node = this.adopt(new StubText());
200
+ node.data = data;
201
+ return node;
202
+ }
203
+ createComment(data: string): StubComment {
204
+ const node = this.adopt(new StubComment());
205
+ node.data = data;
206
+ return node;
207
+ }
208
+ }
209
+
210
+ /** A fresh document + detached container, typed loosely for render(). */
211
+ export function createStubRoot(): {
212
+ document: StubDocument;
213
+ container: StubElement;
214
+ } {
215
+ const document = new StubDocument();
216
+ const container = document.createElement("div");
217
+ return { document, container };
218
+ }