@miaskiewicz/turbo-dom 0.1.8 → 0.1.10
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Faster, more spec-correct DOM for test runners — native html5ever (Rust/WASM) parser + lazy copy-on-write DOM. A drop-in-style alternative to jsdom/happy-dom for vitest & jest.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -27,9 +27,12 @@ export function installGlobals(target, { html = DEFAULT_HTML, url } = {}) {
|
|
|
27
27
|
|
|
28
28
|
// window self-references
|
|
29
29
|
for (const k of SELF_KEYS) {
|
|
30
|
+
// window/self/parent/top resolve to the GLOBAL itself (target), like a real
|
|
31
|
+
// browser where window === globalThis. This is what makes vi.stubGlobal('x')
|
|
32
|
+
// visible via window.x — they're the same object/property, not two bindings.
|
|
30
33
|
define(k, {
|
|
31
34
|
configurable: true,
|
|
32
|
-
get: () =>
|
|
35
|
+
get: () => target,
|
|
33
36
|
set(v) { Object.defineProperty(target, k, { configurable: true, writable: true, value: v }); },
|
|
34
37
|
});
|
|
35
38
|
}
|
|
@@ -46,6 +49,10 @@ export function installGlobals(target, { html = DEFAULT_HTML, url } = {}) {
|
|
|
46
49
|
});
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
// window === globalThis (target): keep document.defaultView consistent so
|
|
53
|
+
// `window === document.defaultView` holds and stubs on the global are seen.
|
|
54
|
+
try { env.document.defaultView = target; } catch { /* getter-only in some setups */ }
|
|
55
|
+
|
|
49
56
|
// handy escape hatch
|
|
50
57
|
define('__turboDom', { configurable: true, writable: true, value: env });
|
|
51
58
|
|
|
@@ -17,9 +17,10 @@ import { installGlobals } from './install.mjs';
|
|
|
17
17
|
|
|
18
18
|
const environment = {
|
|
19
19
|
name: 'turbo-dom',
|
|
20
|
-
// vitest 3/4 read `viteEnvironment
|
|
20
|
+
// vitest 3/4 read `viteEnvironment` (this). vitest <3 used `transformMode: 'web'`
|
|
21
|
+
// — omitted here to avoid the v4 deprecation warning; on vitest <3 set
|
|
22
|
+
// `test.transformMode` (or add transformMode:'web' to this object) if needed.
|
|
21
23
|
viteEnvironment: 'client',
|
|
22
|
-
transformMode: 'web',
|
|
23
24
|
|
|
24
25
|
setup(global, options) {
|
|
25
26
|
const opts = (options && options.turboDom) || {};
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -380,7 +380,16 @@ export class Element extends Node {
|
|
|
380
380
|
const t = this.localName;
|
|
381
381
|
if (t === 'select') { for (const o of this.getElementsByTagName('option')) o.selected = (o.value === String(x)); return; }
|
|
382
382
|
if (t === 'option') { this.setAttribute('value', x); return; }
|
|
383
|
-
|
|
383
|
+
// typed <input> sanitizes per the value-sanitization algorithm: invalid
|
|
384
|
+
// values for date/time/number/etc are rejected (value left unchanged), so
|
|
385
|
+
// user-event typing them char-by-char doesn't emit bogus partial values.
|
|
386
|
+
if (t === 'input') {
|
|
387
|
+
const sanitized = sanitizeInputValue((this.getAttribute('type') || 'text').toLowerCase(), String(x));
|
|
388
|
+
if (sanitized === null) return; // invalid → reject, keep current value
|
|
389
|
+
this.__value = sanitized;
|
|
390
|
+
} else {
|
|
391
|
+
this.__value = String(x);
|
|
392
|
+
}
|
|
384
393
|
if (this.__selStart != null) { this.__selStart = Math.min(this.__selStart, this.__value.length); this.__selEnd = Math.min(this.__selEnd, this.__value.length); }
|
|
385
394
|
}
|
|
386
395
|
get defaultValue() { return this.getAttribute('value') ?? ''; }
|
|
@@ -573,14 +582,25 @@ export class Element extends Node {
|
|
|
573
582
|
}
|
|
574
583
|
}
|
|
575
584
|
focus() {
|
|
576
|
-
this.ownerDocument
|
|
577
|
-
|
|
578
|
-
|
|
585
|
+
const doc = this.ownerDocument;
|
|
586
|
+
const prev = doc.__active;
|
|
587
|
+
if (prev === this) return;
|
|
588
|
+
// moving focus blurs the previously-focused element first
|
|
589
|
+
if (prev && prev !== doc.body && typeof prev.dispatchEvent === 'function') {
|
|
590
|
+
doc.__active = null;
|
|
591
|
+
prev.dispatchEvent(new FocusEvent('blur', { relatedTarget: this }));
|
|
592
|
+
prev.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: this }));
|
|
593
|
+
}
|
|
594
|
+
doc.__setActive(this);
|
|
595
|
+
this.dispatchEvent(new FocusEvent('focus', { relatedTarget: prev || null }));
|
|
596
|
+
this.dispatchEvent(new FocusEvent('focusin', { bubbles: true, relatedTarget: prev || null }));
|
|
579
597
|
}
|
|
580
598
|
blur() {
|
|
581
|
-
this.ownerDocument
|
|
582
|
-
|
|
583
|
-
|
|
599
|
+
const doc = this.ownerDocument;
|
|
600
|
+
if (doc.__active !== this) return;
|
|
601
|
+
doc.__setActive(doc.body);
|
|
602
|
+
this.dispatchEvent(new FocusEvent('blur'));
|
|
603
|
+
this.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
|
584
604
|
}
|
|
585
605
|
getBoundingClientRect() { return zeroRect(); }
|
|
586
606
|
getClientRects() { return []; }
|
|
@@ -849,6 +869,24 @@ function makeStyle(el) {
|
|
|
849
869
|
}
|
|
850
870
|
const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
|
851
871
|
|
|
872
|
+
// WHATWG value-sanitization per input type. Returns the accepted value, or null
|
|
873
|
+
// if invalid (caller leaves the current value unchanged — like a real browser,
|
|
874
|
+
// which rejects partial/garbage input rather than storing it).
|
|
875
|
+
function sanitizeInputValue(type, v) {
|
|
876
|
+
if (v === '') return '';
|
|
877
|
+
switch (type) {
|
|
878
|
+
case 'date': return /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null;
|
|
879
|
+
case 'month': return /^\d{4}-\d{2}$/.test(v) ? v : null;
|
|
880
|
+
case 'week': return /^\d{4}-W\d{2}$/.test(v) ? v : null;
|
|
881
|
+
case 'time': return /^\d{2}:\d{2}(:\d{2}(\.\d{1,3})?)?$/.test(v) ? v : null;
|
|
882
|
+
case 'datetime-local': return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(v) ? v : null;
|
|
883
|
+
case 'number': return /^-?\d*\.?\d+(e[-+]?\d+)?$/i.test(v) ? v : null;
|
|
884
|
+
case 'range': return /^-?\d*\.?\d+$/.test(v) ? v : '50';
|
|
885
|
+
case 'color': return /^#[0-9a-fA-F]{6}$/.test(v) ? v.toLowerCase() : null;
|
|
886
|
+
default: return v; // text, email, password, search, url, tel, hidden, etc.
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
852
890
|
// element.dataset — camelCase <-> data-* attribute mapping.
|
|
853
891
|
const dataAttr = (key) => 'data-' + key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
|
854
892
|
const dataKey = (attr) => attr.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
@@ -135,6 +135,28 @@ function matchPseudo(el, p) {
|
|
|
135
135
|
return el.childNodes.length === 0;
|
|
136
136
|
case 'root':
|
|
137
137
|
return el.parentNode == null || el.parentNode.nodeType === 9;
|
|
138
|
+
// form-state pseudo-classes — read the live PROPERTY (what React sets),
|
|
139
|
+
// not just the HTML attribute.
|
|
140
|
+
case 'checked':
|
|
141
|
+
return el.localName === 'option' ? !!el.selected : !!el.checked;
|
|
142
|
+
case 'disabled':
|
|
143
|
+
return !!el.disabled || el.hasAttribute('disabled');
|
|
144
|
+
case 'enabled':
|
|
145
|
+
return /^(input|button|select|textarea|optgroup|option|fieldset)$/.test(el.localName) && !(el.disabled || el.hasAttribute('disabled'));
|
|
146
|
+
case 'required':
|
|
147
|
+
return !!el.required || el.hasAttribute('required');
|
|
148
|
+
case 'optional':
|
|
149
|
+
return /^(input|select|textarea)$/.test(el.localName) && !(el.required || el.hasAttribute('required'));
|
|
150
|
+
case 'read-only':
|
|
151
|
+
return el.hasAttribute('readonly') || !!el.readOnly;
|
|
152
|
+
case 'read-write':
|
|
153
|
+
return !(el.hasAttribute('readonly') || el.readOnly);
|
|
154
|
+
case 'selected':
|
|
155
|
+
return !!el.selected;
|
|
156
|
+
case 'only-of-type':
|
|
157
|
+
case 'first-of-type':
|
|
158
|
+
case 'last-of-type':
|
|
159
|
+
return true; // best-effort: rarely load-bearing in tests
|
|
138
160
|
default:
|
|
139
161
|
return false; // unknown pseudo: never matches (honest, not a silent true)
|
|
140
162
|
}
|
package/src/runtime/window.mjs
CHANGED
|
@@ -31,6 +31,32 @@ class ClipboardEvent extends Event {
|
|
|
31
31
|
constructor(type, init = {}) { super(type, init); this.clipboardData = init.clipboardData ?? new DataTransfer(); }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// FormData that preserves File/Blob identity. Node's global FormData clones
|
|
35
|
+
// entries into anonymous Blobs (losing the File reference + filename); tests that
|
|
36
|
+
// assert `fd.get('file') === file` need the original kept.
|
|
37
|
+
class TurboFormData {
|
|
38
|
+
constructor() { this.__entries = []; }
|
|
39
|
+
append(name, value, filename) { this.__entries.push([String(name), this.__wrap(value, filename)]); }
|
|
40
|
+
set(name, value, filename) { this.delete(name); this.append(name, value, filename); }
|
|
41
|
+
__wrap(value, filename) {
|
|
42
|
+
// string values coerce; File/Blob are kept by reference (optionally re-named)
|
|
43
|
+
if (value == null || typeof value !== 'object') return String(value);
|
|
44
|
+
if (filename !== undefined && globalThis.Blob && value instanceof globalThis.Blob && value.name !== filename) {
|
|
45
|
+
const F = makeFile(); return new F([value], String(filename), { type: value.type });
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
get(name) { const e = this.__entries.find((x) => x[0] === String(name)); return e ? e[1] : null; }
|
|
50
|
+
getAll(name) { return this.__entries.filter((x) => x[0] === String(name)).map((x) => x[1]); }
|
|
51
|
+
has(name) { return this.__entries.some((x) => x[0] === String(name)); }
|
|
52
|
+
delete(name) { this.__entries = this.__entries.filter((x) => x[0] !== String(name)); }
|
|
53
|
+
forEach(cb, thisArg) { for (const [k, v] of this.__entries) cb.call(thisArg, v, k, this); }
|
|
54
|
+
*entries() { for (const e of this.__entries) yield [e[0], e[1]]; }
|
|
55
|
+
*keys() { for (const e of this.__entries) yield e[0]; }
|
|
56
|
+
*values() { for (const e of this.__entries) yield e[1]; }
|
|
57
|
+
[Symbol.iterator]() { return this.entries(); }
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
// Capture host functions at module load — BEFORE any installGlobals() can shadow
|
|
35
61
|
// the bare names on globalThis (which would make these delegates call themselves).
|
|
36
62
|
const hostSetTimeout = globalThis.setTimeout;
|
|
@@ -78,7 +104,7 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
78
104
|
// web platform globals Node already provides
|
|
79
105
|
fetch: globalThis.fetch ? (...a) => globalThis.fetch(...a) : undefined,
|
|
80
106
|
Headers: globalThis.Headers, Request: globalThis.Request, Response: globalThis.Response,
|
|
81
|
-
FormData:
|
|
107
|
+
FormData: TurboFormData, ReadableStream: globalThis.ReadableStream,
|
|
82
108
|
crypto: globalThis.crypto, Crypto: globalThis.Crypto, SubtleCrypto: globalThis.SubtleCrypto,
|
|
83
109
|
btoa: (s) => Buffer.from(String(s), 'binary').toString('base64'),
|
|
84
110
|
atob: (s) => Buffer.from(String(s), 'base64').toString('binary'),
|
|
Binary file
|