@derivesome/tree-web 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.package.json.~undo-tree~ +10 -0
- package/.tsconfig.json.~undo-tree~ +6 -0
- package/dist/cjs/css.d.ts +5 -0
- package/dist/cjs/css.d.ts.map +1 -0
- package/dist/cjs/css.js +14 -0
- package/dist/cjs/css.js.map +1 -0
- package/dist/cjs/index.d.ts +5 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +21 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/jsx-globals.d.ts +826 -0
- package/dist/cjs/jsx-globals.d.ts.map +1 -0
- package/dist/cjs/jsx-globals.js +18 -0
- package/dist/cjs/jsx-globals.js.map +1 -0
- package/dist/cjs/mount.d.ts +2 -0
- package/dist/cjs/mount.d.ts.map +1 -0
- package/dist/cjs/mount.js +9 -0
- package/dist/cjs/mount.js.map +1 -0
- package/dist/cjs/renderer.d.ts +3 -0
- package/dist/cjs/renderer.d.ts.map +1 -0
- package/dist/cjs/renderer.js +123 -0
- package/dist/cjs/renderer.js.map +1 -0
- package/dist/esm/css.d.ts +5 -0
- package/dist/esm/css.d.ts.map +1 -0
- package/dist/esm/css.js +14 -0
- package/dist/esm/css.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +21 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/jsx-globals.d.ts +826 -0
- package/dist/esm/jsx-globals.d.ts.map +1 -0
- package/dist/esm/jsx-globals.js +18 -0
- package/dist/esm/jsx-globals.js.map +1 -0
- package/dist/esm/mount.d.ts +2 -0
- package/dist/esm/mount.d.ts.map +1 -0
- package/dist/esm/mount.js +9 -0
- package/dist/esm/mount.js.map +1 -0
- package/dist/esm/renderer.d.ts +3 -0
- package/dist/esm/renderer.d.ts.map +1 -0
- package/dist/esm/renderer.js +123 -0
- package/dist/esm/renderer.js.map +1 -0
- package/package.json +47 -0
- package/package.json~ +53 -0
- package/src/.context.ts.~undo-tree~ +6 -0
- package/src/.css.ts.~undo-tree~ +6 -0
- package/src/.index.ts.~undo-tree~ +8 -0
- package/src/.jsx-globals.ts.~undo-tree~ +192 -0
- package/src/.jsx-types.ts.~undo-tree~ +23 -0
- package/src/.mount.test.ts.~undo-tree~ +438 -0
- package/src/.mount.ts.~undo-tree~ +16 -0
- package/src/.renderer.ts.~undo-tree~ +96 -0
- package/src/.tree.ts.~undo-tree~ +489 -0
- package/src/.velement.ts.~undo-tree~ +1738 -0
- package/src/context.ts~ +0 -0
- package/src/css.ts +13 -0
- package/src/css.ts~ +13 -0
- package/src/index.ts +4 -0
- package/src/index.ts~ +4 -0
- package/src/jsx-globals.ts +861 -0
- package/src/jsx-globals.ts~ +854 -0
- package/src/jsx-types.ts~ +772 -0
- package/src/mount.test.ts~ +375 -0
- package/src/mount.ts +6 -0
- package/src/mount.ts~ +10 -0
- package/src/renderer.ts +133 -0
- package/src/renderer.ts~ +133 -0
- package/src/tree.ts~ +212 -0
- package/src/velement.ts~ +856 -0
- package/tsconfig.cjs.json +10 -0
- package/tsconfig.esm.json +10 -0
- package/tsconfig.json +23 -0
- package/tsconfig.json~ +23 -0
package/src/velement.ts~
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
// virtual-dom.ts
|
|
2
|
+
// A lightweight virtual DOM implementation for unit testing browser code without a real browser.
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Node type constants (mirrors the real DOM)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
export const enum NodeType {
|
|
8
|
+
ELEMENT_NODE = 1,
|
|
9
|
+
TEXT_NODE = 3,
|
|
10
|
+
COMMENT_NODE = 8,
|
|
11
|
+
DOCUMENT_NODE = 9,
|
|
12
|
+
DOCUMENT_TYPE_NODE = 10,
|
|
13
|
+
DOCUMENT_FRAGMENT_NODE = 11,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// VNode — abstract base for every node
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
export abstract class VNode {
|
|
20
|
+
abstract readonly nodeType: NodeType;
|
|
21
|
+
abstract readonly nodeName: string;
|
|
22
|
+
abstract textContent: string | null;
|
|
23
|
+
|
|
24
|
+
parentNode: VNode | null = null;
|
|
25
|
+
childNodes: VNode[] = [];
|
|
26
|
+
ownerDocument: VDocument | null = null;
|
|
27
|
+
|
|
28
|
+
get firstChild(): VNode | null {
|
|
29
|
+
return this.childNodes[0] ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get lastChild(): VNode | null {
|
|
33
|
+
return this.childNodes[this.childNodes.length - 1] ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get nextSibling(): VNode | null {
|
|
37
|
+
if (!this.parentNode) return null;
|
|
38
|
+
const siblings = this.parentNode.childNodes;
|
|
39
|
+
const idx = siblings.indexOf(this);
|
|
40
|
+
return siblings[idx + 1] ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get previousSibling(): VNode | null {
|
|
44
|
+
if (!this.parentNode) return null;
|
|
45
|
+
const siblings = this.parentNode.childNodes;
|
|
46
|
+
const idx = siblings.indexOf(this);
|
|
47
|
+
return idx > 0 ? (siblings[idx - 1] ?? null) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get parentElement(): VElement | null {
|
|
51
|
+
return this.parentNode instanceof VElement ? this.parentNode : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- Mutation helpers ----
|
|
55
|
+
|
|
56
|
+
appendChild<T extends VNode>(child: T): T {
|
|
57
|
+
this._removeFromParent(child);
|
|
58
|
+
child.parentNode = this;
|
|
59
|
+
child.ownerDocument = this.ownerDocument;
|
|
60
|
+
this.childNodes.push(child);
|
|
61
|
+
return child;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
insertBefore<T extends VNode>(newChild: T, refChild: VNode | null): T {
|
|
65
|
+
if (refChild === null) return this.appendChild(newChild);
|
|
66
|
+
const idx = this.childNodes.indexOf(refChild);
|
|
67
|
+
if (idx === -1) throw new Error("refChild is not a child of this node");
|
|
68
|
+
this._removeFromParent(newChild);
|
|
69
|
+
newChild.parentNode = this;
|
|
70
|
+
newChild.ownerDocument = this.ownerDocument;
|
|
71
|
+
this.childNodes.splice(idx, 0, newChild);
|
|
72
|
+
return newChild;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
removeChild<T extends VNode>(child: T): T {
|
|
76
|
+
const idx = this.childNodes.indexOf(child);
|
|
77
|
+
if (idx === -1) throw new Error("Node is not a child of this node");
|
|
78
|
+
this.childNodes.splice(idx, 1);
|
|
79
|
+
child.parentNode = null;
|
|
80
|
+
return child;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
replaceChild<T extends VNode>(newChild: VNode, oldChild: T): T {
|
|
84
|
+
const idx = this.childNodes.indexOf(oldChild);
|
|
85
|
+
if (idx === -1) throw new Error("oldChild is not a child of this node");
|
|
86
|
+
this._removeFromParent(newChild);
|
|
87
|
+
newChild.parentNode = this;
|
|
88
|
+
newChild.ownerDocument = this.ownerDocument;
|
|
89
|
+
this.childNodes.splice(idx, 1, newChild);
|
|
90
|
+
oldChild.parentNode = null;
|
|
91
|
+
return oldChild;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
hasChildNodes(): boolean {
|
|
95
|
+
return this.childNodes.length > 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
contains(other: VNode | null): boolean {
|
|
99
|
+
if (!other) return false;
|
|
100
|
+
let current: VNode | null = other;
|
|
101
|
+
while (current) {
|
|
102
|
+
if (current === this) return true;
|
|
103
|
+
current = current.parentNode;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
cloneNode(deep: boolean = false): VNode {
|
|
109
|
+
return this._clone(deep);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
protected abstract _clone(deep: boolean): VNode;
|
|
113
|
+
|
|
114
|
+
/** Remove a node from its current parent before re-parenting. */
|
|
115
|
+
protected _removeFromParent(child: VNode): void {
|
|
116
|
+
if (child.parentNode) {
|
|
117
|
+
child.parentNode.removeChild(child);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// VText
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
export class VText extends VNode {
|
|
126
|
+
readonly nodeType = NodeType.TEXT_NODE as const;
|
|
127
|
+
readonly nodeName = "#text";
|
|
128
|
+
|
|
129
|
+
constructor(public data: string) {
|
|
130
|
+
super();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get textContent(): string {
|
|
134
|
+
return this.data;
|
|
135
|
+
}
|
|
136
|
+
set textContent(value: string | null) {
|
|
137
|
+
this.data = value ?? "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get nodeValue(): string {
|
|
141
|
+
return this.data;
|
|
142
|
+
}
|
|
143
|
+
set nodeValue(value: string | null) {
|
|
144
|
+
this.data = value ?? "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
protected _clone(_deep: boolean): VText {
|
|
148
|
+
return new VText(this.data);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// VComment
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
export class VComment extends VNode {
|
|
156
|
+
readonly nodeType = NodeType.COMMENT_NODE as const;
|
|
157
|
+
readonly nodeName = "#comment";
|
|
158
|
+
|
|
159
|
+
constructor(public data: string) {
|
|
160
|
+
super();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get textContent(): string {
|
|
164
|
+
return this.data;
|
|
165
|
+
}
|
|
166
|
+
set textContent(value: string | null) {
|
|
167
|
+
this.data = value ?? "";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get nodeValue(): string {
|
|
171
|
+
return this.data;
|
|
172
|
+
}
|
|
173
|
+
set nodeValue(value: string | null) {
|
|
174
|
+
this.data = value ?? "";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
protected _clone(_deep: boolean): VComment {
|
|
178
|
+
return new VComment(this.data);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Attr map — case-insensitive for HTML-like behaviour
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
class AttributeMap {
|
|
186
|
+
private _map = new Map<string, string>();
|
|
187
|
+
|
|
188
|
+
get(name: string): string | null {
|
|
189
|
+
return this._map.get(name.toLowerCase()) ?? null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
set(name: string, value: string): void {
|
|
193
|
+
this._map.set(name.toLowerCase(), value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
has(name: string): boolean {
|
|
197
|
+
return this._map.has(name.toLowerCase());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
remove(name: string): boolean {
|
|
201
|
+
return this._map.delete(name.toLowerCase());
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
entries(): IterableIterator<[string, string]> {
|
|
205
|
+
return this._map.entries();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
get size(): number {
|
|
209
|
+
return this._map.size;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
clone(): AttributeMap {
|
|
213
|
+
const copy = new AttributeMap();
|
|
214
|
+
for (const [k, v] of this._map) copy._map.set(k, v);
|
|
215
|
+
return copy;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// ClassList — minimal DOMTokenList stand-in
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
export class VClassList {
|
|
223
|
+
constructor(private _el: VElement) {}
|
|
224
|
+
|
|
225
|
+
private _tokens(): string[] {
|
|
226
|
+
const raw = this._el.getAttribute("class") ?? "";
|
|
227
|
+
return raw
|
|
228
|
+
.split(/\s+/)
|
|
229
|
+
.filter(Boolean);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private _write(tokens: string[]): void {
|
|
233
|
+
this._el.setAttribute("class", tokens.join(" "));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
add(...tokens: string[]): void {
|
|
237
|
+
const set = new Set(this._tokens());
|
|
238
|
+
for (const t of tokens) set.add(t);
|
|
239
|
+
this._write([...set]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
remove(...tokens: string[]): void {
|
|
243
|
+
const set = new Set(this._tokens());
|
|
244
|
+
for (const t of tokens) set.delete(t);
|
|
245
|
+
this._write([...set]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
toggle(token: string, force?: boolean): boolean {
|
|
249
|
+
const set = new Set(this._tokens());
|
|
250
|
+
const shouldAdd = force !== undefined ? force : !set.has(token);
|
|
251
|
+
if (shouldAdd) {
|
|
252
|
+
set.add(token);
|
|
253
|
+
} else {
|
|
254
|
+
set.delete(token);
|
|
255
|
+
}
|
|
256
|
+
this._write([...set]);
|
|
257
|
+
return shouldAdd;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
contains(token: string): boolean {
|
|
261
|
+
return this._tokens().includes(token);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
replace(oldToken: string, newToken: string): boolean {
|
|
265
|
+
const tokens = this._tokens();
|
|
266
|
+
const idx = tokens.indexOf(oldToken);
|
|
267
|
+
if (idx === -1) return false;
|
|
268
|
+
tokens.splice(idx, 1, newToken);
|
|
269
|
+
this._write(tokens);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
get length(): number {
|
|
274
|
+
return this._tokens().length;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
toString(): string {
|
|
278
|
+
return this._tokens().join(" ");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Style — minimal CSSStyleDeclaration stand-in
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
export class VStyle {
|
|
286
|
+
[prop: string]: string | ((...args: any[]) => any);
|
|
287
|
+
|
|
288
|
+
private _props = new Map<string, string>();
|
|
289
|
+
|
|
290
|
+
getPropertyValue(name: string): string {
|
|
291
|
+
return this._props.get(name) ?? "";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
setProperty(name: string, value: string | null): void {
|
|
295
|
+
if (value === null || value === "") {
|
|
296
|
+
this._props.delete(name);
|
|
297
|
+
} else {
|
|
298
|
+
this._props.set(name, value);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
removeProperty(name: string): string {
|
|
303
|
+
const old = this._props.get(name) ?? "";
|
|
304
|
+
this._props.delete(name);
|
|
305
|
+
return old;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
get cssText(): string {
|
|
309
|
+
return [...this._props.entries()]
|
|
310
|
+
.map(([k, v]) => `${k}: ${v};`)
|
|
311
|
+
.join(" ");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
set cssText(value: string) {
|
|
315
|
+
this._props.clear();
|
|
316
|
+
if (!value) return;
|
|
317
|
+
for (const decl of value.split(";")) {
|
|
318
|
+
const colon = decl.indexOf(":");
|
|
319
|
+
if (colon === -1) continue;
|
|
320
|
+
const prop = decl.slice(0, colon).trim();
|
|
321
|
+
const val = decl.slice(colon + 1).trim();
|
|
322
|
+
if (prop) this._props.set(prop, val);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// VElement
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
export class VElement extends VNode {
|
|
331
|
+
readonly nodeType = NodeType.ELEMENT_NODE as const;
|
|
332
|
+
readonly tagName: string;
|
|
333
|
+
readonly classList: VClassList;
|
|
334
|
+
readonly style: VStyle;
|
|
335
|
+
|
|
336
|
+
private _attrs = new AttributeMap();
|
|
337
|
+
private _eventListeners = new Map<string, Array<VEventListenerOrObject>>();
|
|
338
|
+
private _dataset: Record<string, string> = {};
|
|
339
|
+
|
|
340
|
+
constructor(tagName: string) {
|
|
341
|
+
super();
|
|
342
|
+
this.tagName = tagName.toUpperCase();
|
|
343
|
+
this.classList = new VClassList(this);
|
|
344
|
+
this.style = new VStyle();
|
|
345
|
+
|
|
346
|
+
// Return a proxy so element.id, element.className etc. work naturally
|
|
347
|
+
return new Proxy(this, {
|
|
348
|
+
get(target, prop, receiver) {
|
|
349
|
+
// Built-in properties first
|
|
350
|
+
if (prop in target || typeof prop === "symbol") {
|
|
351
|
+
return Reflect.get(target, prop, receiver);
|
|
352
|
+
}
|
|
353
|
+
// Fallback: treat as an attribute shorthand (e.g. el.id, el.href)
|
|
354
|
+
if (typeof prop === "string") {
|
|
355
|
+
return target.getAttribute(prop) ?? undefined;
|
|
356
|
+
}
|
|
357
|
+
return undefined;
|
|
358
|
+
},
|
|
359
|
+
set(target, prop, value, receiver) {
|
|
360
|
+
if (prop in target || typeof prop === "symbol") {
|
|
361
|
+
return Reflect.set(target, prop, value, receiver);
|
|
362
|
+
}
|
|
363
|
+
if (typeof prop === "string") {
|
|
364
|
+
target.setAttribute(prop, String(value));
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
get nodeName(): string {
|
|
373
|
+
return this.tagName;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---- id / className shortcuts ----
|
|
377
|
+
|
|
378
|
+
get id(): string {
|
|
379
|
+
return this.getAttribute("id") ?? "";
|
|
380
|
+
}
|
|
381
|
+
set id(value: string) {
|
|
382
|
+
this.setAttribute("id", value);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
get className(): string {
|
|
386
|
+
return this.getAttribute("class") ?? "";
|
|
387
|
+
}
|
|
388
|
+
set className(value: string) {
|
|
389
|
+
this.setAttribute("class", value);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---- dataset ----
|
|
393
|
+
|
|
394
|
+
get dataset(): Record<string, string> {
|
|
395
|
+
return this._dataset;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---- textContent / innerHTML ----
|
|
399
|
+
|
|
400
|
+
get textContent(): string {
|
|
401
|
+
return this.childNodes.map((c) => c.textContent ?? "").join("");
|
|
402
|
+
}
|
|
403
|
+
set textContent(value: string | null) {
|
|
404
|
+
this.childNodes = [];
|
|
405
|
+
if (value) {
|
|
406
|
+
this.appendChild(new VText(value));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
get innerHTML(): string {
|
|
411
|
+
return this.childNodes.map((c) => serializeNode(c)).join("");
|
|
412
|
+
}
|
|
413
|
+
set innerHTML(html: string) {
|
|
414
|
+
// Minimal: just set the raw text. A real implementation would parse HTML.
|
|
415
|
+
this.childNodes = [];
|
|
416
|
+
if (html) {
|
|
417
|
+
this.appendChild(new VText(html));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
get outerHTML(): string {
|
|
422
|
+
return serializeNode(this);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---- Attributes ----
|
|
426
|
+
|
|
427
|
+
getAttribute(name: string): string | null {
|
|
428
|
+
return this._attrs.get(name);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
setAttribute(name: string, value: string): void {
|
|
432
|
+
this._attrs.set(name, value);
|
|
433
|
+
// Keep dataset in sync
|
|
434
|
+
if (name.startsWith("data-")) {
|
|
435
|
+
this._dataset[camelCase(name.slice(5))] = value;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
removeAttribute(name: string): void {
|
|
440
|
+
this._attrs.remove(name);
|
|
441
|
+
if (name.startsWith("data-")) {
|
|
442
|
+
delete this._dataset[camelCase(name.slice(5))];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
hasAttribute(name: string): boolean {
|
|
447
|
+
return this._attrs.has(name);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
getAttributeNames(): string[] {
|
|
451
|
+
return [...this._attrs.entries()].map(([k]) => k);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---- Query helpers ----
|
|
455
|
+
|
|
456
|
+
getElementById(id: string): VElement | null {
|
|
457
|
+
return this._query((el) => el.getAttribute("id") === id);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
getElementsByClassName(className: string): VElement[] {
|
|
461
|
+
const target = className.trim();
|
|
462
|
+
return this._queryAll((el) => el.classList.contains(target));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
getElementsByTagName(tag: string): VElement[] {
|
|
466
|
+
const upper = tag.toUpperCase();
|
|
467
|
+
return this._queryAll((el) => upper === "*" || el.tagName === upper);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
querySelector(selector: string): VElement | null {
|
|
471
|
+
return this._query(makeSimpleMatcher(selector));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
querySelectorAll(selector: string): VElement[] {
|
|
475
|
+
return this._queryAll(makeSimpleMatcher(selector));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
closest(selector: string): VElement | null {
|
|
479
|
+
const matcher = makeSimpleMatcher(selector);
|
|
480
|
+
let current: VNode | null = this as VNode;
|
|
481
|
+
while (current) {
|
|
482
|
+
if (current instanceof VElement && matcher(current)) {
|
|
483
|
+
return current;
|
|
484
|
+
}
|
|
485
|
+
current = current.parentNode;
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
matches(selector: string): boolean {
|
|
491
|
+
return makeSimpleMatcher(selector)(this);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---- Events (record & replay for testing) ----
|
|
495
|
+
|
|
496
|
+
addEventListener(
|
|
497
|
+
type: string,
|
|
498
|
+
listener: VEventListenerOrObject,
|
|
499
|
+
): void {
|
|
500
|
+
let list = this._eventListeners.get(type);
|
|
501
|
+
if (!list) {
|
|
502
|
+
list = [];
|
|
503
|
+
this._eventListeners.set(type, list);
|
|
504
|
+
}
|
|
505
|
+
list.push(listener);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
removeEventListener(
|
|
509
|
+
type: string,
|
|
510
|
+
listener: VEventListenerOrObject,
|
|
511
|
+
): void {
|
|
512
|
+
const arr = this._eventListeners.get(type);
|
|
513
|
+
if (!arr) return;
|
|
514
|
+
const idx = arr.indexOf(listener);
|
|
515
|
+
if (idx !== -1) arr.splice(idx, 1);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
dispatchEvent(event: VEvent): boolean {
|
|
519
|
+
const listeners = this._eventListeners.get(event.type) ?? [];
|
|
520
|
+
for (const l of listeners) {
|
|
521
|
+
if (typeof l === "function") l(event);
|
|
522
|
+
else l.handleEvent(event);
|
|
523
|
+
}
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Click shorthand — dispatches a "click" VEvent. */
|
|
528
|
+
click(): void {
|
|
529
|
+
this.dispatchEvent(new VEvent("click"));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---- Children helpers ----
|
|
533
|
+
|
|
534
|
+
get children(): VElement[] {
|
|
535
|
+
return this.childNodes.filter((c): c is VElement => c instanceof VElement);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
get childElementCount(): number {
|
|
539
|
+
return this.children.length;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
get firstElementChild(): VElement | null {
|
|
543
|
+
return this.children[0] ?? null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
get lastElementChild(): VElement | null {
|
|
547
|
+
const ch = this.children;
|
|
548
|
+
return ch[ch.length - 1] ?? null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---- Clone ----
|
|
552
|
+
|
|
553
|
+
protected _clone(deep: boolean): VElement {
|
|
554
|
+
const el = new VElement(this.tagName);
|
|
555
|
+
el._attrs = this._attrs.clone();
|
|
556
|
+
el._dataset = { ...this._dataset };
|
|
557
|
+
el.style.cssText = this.style.cssText;
|
|
558
|
+
if (deep) {
|
|
559
|
+
for (const child of this.childNodes) {
|
|
560
|
+
el.appendChild(child.cloneNode(true));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return el;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ---- Internal query helpers ----
|
|
567
|
+
|
|
568
|
+
private _query(pred: (el: VElement) => boolean): VElement | null {
|
|
569
|
+
for (const child of this.childNodes) {
|
|
570
|
+
if (child instanceof VElement) {
|
|
571
|
+
if (pred(child)) return child;
|
|
572
|
+
const found = child._query(pred);
|
|
573
|
+
if (found) return found;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private _queryAll(pred: (el: VElement) => boolean): VElement[] {
|
|
580
|
+
const results: VElement[] = [];
|
|
581
|
+
for (const child of this.childNodes) {
|
|
582
|
+
if (child instanceof VElement) {
|
|
583
|
+
if (pred(child)) results.push(child);
|
|
584
|
+
results.push(...child._queryAll(pred));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return results;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
// VDocumentFragment
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
export class VDocumentFragment extends VNode {
|
|
595
|
+
readonly nodeType = NodeType.DOCUMENT_FRAGMENT_NODE as const;
|
|
596
|
+
readonly nodeName = "#document-fragment";
|
|
597
|
+
|
|
598
|
+
get textContent(): string {
|
|
599
|
+
return this.childNodes.map((c) => c.textContent ?? "").join("");
|
|
600
|
+
}
|
|
601
|
+
set textContent(value: string | null) {
|
|
602
|
+
this.childNodes = [];
|
|
603
|
+
if (value) this.appendChild(new VText(value));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
protected _clone(deep: boolean): VDocumentFragment {
|
|
607
|
+
const frag = new VDocumentFragment();
|
|
608
|
+
if (deep) {
|
|
609
|
+
for (const child of this.childNodes) {
|
|
610
|
+
frag.appendChild(child.cloneNode(true));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return frag;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
// VDocument
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
export class VDocument extends VNode {
|
|
621
|
+
readonly nodeType = NodeType.DOCUMENT_NODE as const;
|
|
622
|
+
readonly nodeName = "#document";
|
|
623
|
+
|
|
624
|
+
body: VElement;
|
|
625
|
+
head: VElement;
|
|
626
|
+
documentElement: VElement;
|
|
627
|
+
|
|
628
|
+
constructor() {
|
|
629
|
+
super();
|
|
630
|
+
this.ownerDocument = this;
|
|
631
|
+
this.documentElement = this._make("html");
|
|
632
|
+
this.head = this._make("head");
|
|
633
|
+
this.body = this._make("body");
|
|
634
|
+
this.documentElement.appendChild(this.head);
|
|
635
|
+
this.documentElement.appendChild(this.body);
|
|
636
|
+
this.appendChild(this.documentElement);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
get textContent(): string | null {
|
|
640
|
+
return null; // matches real DOM behaviour
|
|
641
|
+
}
|
|
642
|
+
set textContent(_value: string | null) {
|
|
643
|
+
// no-op for Document, matches real DOM
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
createElement(tagName: string): VElement {
|
|
647
|
+
const el = new VElement(tagName);
|
|
648
|
+
el.ownerDocument = this;
|
|
649
|
+
return el;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
createTextNode(data: string): VText {
|
|
653
|
+
const t = new VText(data);
|
|
654
|
+
t.ownerDocument = this;
|
|
655
|
+
return t;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
createComment(data: string): VComment {
|
|
659
|
+
const c = new VComment(data);
|
|
660
|
+
c.ownerDocument = this;
|
|
661
|
+
return c;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
createDocumentFragment(): VDocumentFragment {
|
|
665
|
+
const f = new VDocumentFragment();
|
|
666
|
+
f.ownerDocument = this;
|
|
667
|
+
return f;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
getElementById(id: string): VElement | null {
|
|
671
|
+
return this.documentElement.getElementById(id);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
getElementsByClassName(className: string): VElement[] {
|
|
675
|
+
return this.documentElement.getElementsByClassName(className);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
getElementsByTagName(tag: string): VElement[] {
|
|
679
|
+
return this.documentElement.getElementsByTagName(tag);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
querySelector(selector: string): VElement | null {
|
|
683
|
+
return this.documentElement.querySelector(selector);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
querySelectorAll(selector: string): VElement[] {
|
|
687
|
+
return this.documentElement.querySelectorAll(selector);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
protected _clone(deep: boolean): VDocument {
|
|
691
|
+
const doc = new VDocument();
|
|
692
|
+
if (deep) {
|
|
693
|
+
// Already has html > head + body structure; skip re-creating
|
|
694
|
+
}
|
|
695
|
+
return doc;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private _make(tag: string): VElement {
|
|
699
|
+
const el = new VElement(tag);
|
|
700
|
+
el.ownerDocument = this;
|
|
701
|
+
return el;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
// VEvent — lightweight Event stand-in
|
|
707
|
+
// ---------------------------------------------------------------------------
|
|
708
|
+
export class VEvent {
|
|
709
|
+
readonly type: string;
|
|
710
|
+
bubbles: boolean;
|
|
711
|
+
cancelable: boolean;
|
|
712
|
+
defaultPrevented = false;
|
|
713
|
+
target: VNode | null = null;
|
|
714
|
+
currentTarget: VNode | null = null;
|
|
715
|
+
|
|
716
|
+
constructor(
|
|
717
|
+
type: string,
|
|
718
|
+
opts: { bubbles?: boolean; cancelable?: boolean } = {},
|
|
719
|
+
) {
|
|
720
|
+
this.type = type;
|
|
721
|
+
this.bubbles = opts.bubbles ?? false;
|
|
722
|
+
this.cancelable = opts.cancelable ?? false;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
preventDefault(): void {
|
|
726
|
+
if (this.cancelable) this.defaultPrevented = true;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
stopPropagation(): void {
|
|
730
|
+
/* noop for now */
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
stopImmediatePropagation(): void {
|
|
734
|
+
/* noop for now */
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
// Event listener types (self-contained, no lib.dom dependency)
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
export type VEventListener = (event: VEvent) => void;
|
|
742
|
+
|
|
743
|
+
export interface VEventListenerObject {
|
|
744
|
+
handleEvent(event: VEvent): void;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export type VEventListenerOrObject = VEventListener | VEventListenerObject;
|
|
748
|
+
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
// Serialization helper (outerHTML / innerHTML)
|
|
751
|
+
// ---------------------------------------------------------------------------
|
|
752
|
+
const VOID_ELEMENTS = new Set([
|
|
753
|
+
"AREA", "BASE", "BR", "COL", "EMBED", "HR", "IMG",
|
|
754
|
+
"INPUT", "LINK", "META", "PARAM", "SOURCE", "TRACK", "WBR",
|
|
755
|
+
]);
|
|
756
|
+
|
|
757
|
+
function serializeNode(node: VNode): string {
|
|
758
|
+
if (node instanceof VText) return escapeHtml(node.data);
|
|
759
|
+
if (node instanceof VComment) return `<!--${node.data}-->`;
|
|
760
|
+
if (node instanceof VElement) {
|
|
761
|
+
const tag = node.tagName.toLowerCase();
|
|
762
|
+
let attrs = "";
|
|
763
|
+
for (const name of node.getAttributeNames()) {
|
|
764
|
+
const val = node.getAttribute(name) ?? "";
|
|
765
|
+
attrs += ` ${name}="${escapeAttr(val)}"`;
|
|
766
|
+
}
|
|
767
|
+
if (VOID_ELEMENTS.has(node.tagName)) {
|
|
768
|
+
return `<${tag}${attrs} />`;
|
|
769
|
+
}
|
|
770
|
+
const inner = node.childNodes.map(serializeNode).join("");
|
|
771
|
+
return `<${tag}${attrs}>${inner}</${tag}>`;
|
|
772
|
+
}
|
|
773
|
+
if (node instanceof VDocumentFragment) {
|
|
774
|
+
return node.childNodes.map(serializeNode).join("");
|
|
775
|
+
}
|
|
776
|
+
return "";
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function escapeHtml(s: string): string {
|
|
780
|
+
return s
|
|
781
|
+
.replace(/&/g, "&")
|
|
782
|
+
.replace(/</g, "<")
|
|
783
|
+
.replace(/>/g, ">");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function escapeAttr(s: string): string {
|
|
787
|
+
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Very small CSS-selector matcher (covers the common cases)
|
|
792
|
+
// Supports: tag, .class, #id, [attr], [attr=value], and combos thereof
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
function makeSimpleMatcher(selector: string): (el: VElement) => boolean {
|
|
795
|
+
// Split on commas for ,‑separated selectors
|
|
796
|
+
const parts = selector.split(",").map((s) => s.trim());
|
|
797
|
+
const matchers = parts.map(parseSingleSelector);
|
|
798
|
+
return (el) => matchers.some((m) => m(el));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function parseSingleSelector(sel: string): (el: VElement) => boolean {
|
|
802
|
+
const checks: Array<(el: VElement) => boolean> = [];
|
|
803
|
+
|
|
804
|
+
// Tag name (must be at the start, before any # . or [)
|
|
805
|
+
const tagMatch = sel.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
|
|
806
|
+
if (tagMatch) {
|
|
807
|
+
const tag = tagMatch[1]!.toUpperCase();
|
|
808
|
+
checks.push((el) => el.tagName === tag);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// #id
|
|
812
|
+
for (const m of sel.matchAll(/#([a-zA-Z0-9_-]+)/g)) {
|
|
813
|
+
const id = m[1]!;
|
|
814
|
+
checks.push((el) => el.getAttribute("id") === id);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// .class
|
|
818
|
+
for (const m of sel.matchAll(/\.([a-zA-Z0-9_-]+)/g)) {
|
|
819
|
+
const cls = m[1]!;
|
|
820
|
+
checks.push((el) => el.classList.contains(cls));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// [attr] and [attr=value] and [attr="value"]
|
|
824
|
+
for (const m of sel.matchAll(/\[([a-zA-Z0-9_-]+)(?:=["']?([^"'\]]*)["']?)?\]/g)) {
|
|
825
|
+
const attr = m[1]!;
|
|
826
|
+
const val = m[2];
|
|
827
|
+
if (val !== undefined) {
|
|
828
|
+
checks.push((el) => el.getAttribute(attr) === val);
|
|
829
|
+
} else {
|
|
830
|
+
checks.push((el) => el.hasAttribute(attr));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (checks.length === 0) {
|
|
835
|
+
return () => false;
|
|
836
|
+
}
|
|
837
|
+
return (el) => checks.every((fn) => fn(el));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
// Utility
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
function camelCase(s: string): string {
|
|
844
|
+
return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ---------------------------------------------------------------------------
|
|
848
|
+
// Convenience factory (drop-in for tests)
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
|
|
851
|
+
/** Create a fresh virtual document, ready to use like `window.document`. */
|
|
852
|
+
export function createDocument(): VDocument {
|
|
853
|
+
return new VDocument();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export const toHTML = (node: VNode): string => serializeNode(node);
|