@miaskiewicz/turbo-dom 0.1.6 → 0.1.8
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.8",
|
|
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",
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -385,6 +385,10 @@ export class Element extends Node {
|
|
|
385
385
|
}
|
|
386
386
|
get defaultValue() { return this.getAttribute('value') ?? ''; }
|
|
387
387
|
set defaultValue(v) { this.setAttribute('value', v); }
|
|
388
|
+
get valueAsNumber() { const v = this.value; return v === '' || v == null ? NaN : Number(v); }
|
|
389
|
+
set valueAsNumber(n) { this.value = String(n); }
|
|
390
|
+
get valueAsDate() { const v = this.value; const d = v ? new Date(v) : null; return d && !isNaN(d) ? d : null; }
|
|
391
|
+
set valueAsDate(d) { this.value = d instanceof Date ? d.toISOString().slice(0, 10) : ''; }
|
|
388
392
|
|
|
389
393
|
get selectionStart() { return this.__selStart ?? null; }
|
|
390
394
|
set selectionStart(v) { this.__selStart = v; }
|
|
@@ -424,7 +428,17 @@ export class Element extends Node {
|
|
|
424
428
|
|
|
425
429
|
// option
|
|
426
430
|
get selected() { return this.__selected !== undefined ? this.__selected : this.hasAttribute('selected'); }
|
|
427
|
-
set selected(x) {
|
|
431
|
+
set selected(x) {
|
|
432
|
+
this.__selected = !!x;
|
|
433
|
+
// single-select: selecting one option deselects the others (exclusive)
|
|
434
|
+
if (x && this.localName === 'option') {
|
|
435
|
+
let sel = this.parentNode;
|
|
436
|
+
while (sel && sel.localName !== 'select') sel = sel.parentNode;
|
|
437
|
+
if (sel && !sel.multiple) {
|
|
438
|
+
for (const o of sel.getElementsByTagName('option')) if (o !== this) o.__selected = false;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
428
442
|
get defaultSelected() { return this.hasAttribute('selected'); }
|
|
429
443
|
get text() { return this.textContent; }
|
|
430
444
|
set text(v) { this.textContent = v; }
|
|
@@ -513,14 +527,43 @@ export class Element extends Node {
|
|
|
513
527
|
}
|
|
514
528
|
|
|
515
529
|
click() { this.dispatchEvent(new Event('click', { bubbles: true, cancelable: true })); }
|
|
530
|
+
// pre-click activation (runs BEFORE click listeners; undone if preventDefault)
|
|
531
|
+
__preClickActivation() {
|
|
532
|
+
if (this.localName !== 'input') return null;
|
|
533
|
+
const t = (this.getAttribute('type') || 'text').toLowerCase();
|
|
534
|
+
// Write the internal field directly — NOT via the `checked` setter, which
|
|
535
|
+
// React wraps with its value-tracker. Going through the setter would update
|
|
536
|
+
// React's tracked value too, hiding the change and suppressing onChange.
|
|
537
|
+
if (t === 'checkbox') {
|
|
538
|
+
const old = this.checked; this.__checked = !old;
|
|
539
|
+
return { undo: () => { this.__checked = old; } };
|
|
540
|
+
}
|
|
541
|
+
if (t === 'radio') {
|
|
542
|
+
if (this.checked) return null;
|
|
543
|
+
const group = this.__radioGroup();
|
|
544
|
+
const prev = group.find((r) => r.checked) || null;
|
|
545
|
+
group.forEach((r) => { r.__checked = false; });
|
|
546
|
+
this.__checked = true;
|
|
547
|
+
return { undo: () => { this.__checked = false; if (prev) prev.__checked = true; } };
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
__radioGroup() {
|
|
552
|
+
const name = this.getAttribute('name');
|
|
553
|
+
let root = this; while (root.parentNode) root = root.parentNode;
|
|
554
|
+
const form = this.closest && this.closest('form');
|
|
555
|
+
const scope = form || root;
|
|
556
|
+
if (!name) return [this];
|
|
557
|
+
return (scope.getElementsByTagName ? Array.from(scope.getElementsByTagName('input')) : [])
|
|
558
|
+
.filter((i) => i.localName === 'input' && (i.getAttribute('type') || '').toLowerCase() === 'radio' && i.getAttribute('name') === name);
|
|
559
|
+
}
|
|
560
|
+
|
|
516
561
|
// default actions applied post-dispatch when not preventDefault'd
|
|
517
562
|
__runDefaultAction(e) {
|
|
518
563
|
if (e.type !== 'click') return;
|
|
519
564
|
if (this.localName === 'input') {
|
|
520
565
|
const t = (this.getAttribute('type') || 'text').toLowerCase();
|
|
521
|
-
if (t === '
|
|
522
|
-
else if (t === 'radio') this.checked = true;
|
|
523
|
-
else if (t === 'submit') { const f = this.closest('form'); if (f) f.requestSubmit(); }
|
|
566
|
+
if (t === 'submit') { const f = this.closest('form'); if (f) f.requestSubmit(); }
|
|
524
567
|
} else if (this.localName === 'button') {
|
|
525
568
|
const t = (this.getAttribute('type') || 'submit').toLowerCase();
|
|
526
569
|
if (t === 'submit') { const f = this.closest('form'); if (f) f.requestSubmit(); }
|
|
@@ -764,31 +807,44 @@ export class XMLSerializer {
|
|
|
764
807
|
|
|
765
808
|
// minimal inline-style CSSOM (honest: only inline + explicitly set props)
|
|
766
809
|
function makeStyle(el) {
|
|
810
|
+
// parse → { values: Map<prop,value>, prio: Map<prop,'important'|''> } (handles !important)
|
|
767
811
|
const parse = () => {
|
|
768
|
-
const
|
|
812
|
+
const values = new Map();
|
|
813
|
+
const prio = new Map();
|
|
769
814
|
for (const decl of (el.getAttribute('style') || '').split(';')) {
|
|
770
815
|
const i = decl.indexOf(':');
|
|
771
816
|
if (i === -1) continue;
|
|
772
817
|
const prop = decl.slice(0, i).trim();
|
|
773
|
-
|
|
774
|
-
if (prop)
|
|
818
|
+
let val = decl.slice(i + 1).trim();
|
|
819
|
+
if (!prop) continue;
|
|
820
|
+
const m = /\s*!\s*important\s*$/i.exec(val);
|
|
821
|
+
if (m) { val = val.slice(0, m.index).trim(); prio.set(prop, 'important'); }
|
|
822
|
+
values.set(prop, val);
|
|
775
823
|
}
|
|
776
|
-
return
|
|
824
|
+
return { values, prio };
|
|
825
|
+
};
|
|
826
|
+
const write = ({ values, prio }) => {
|
|
827
|
+
el.setAttribute('style', [...values].map(([k, v]) => `${k}: ${v}${prio.get(k) === 'important' ? ' !important' : ''}`).join('; '));
|
|
777
828
|
};
|
|
778
|
-
const write = (map) => el.setAttribute('style', [...map].map(([k, v]) => `${k}: ${v}`).join('; '));
|
|
779
829
|
return new Proxy({}, {
|
|
780
830
|
get(_t, key) {
|
|
781
|
-
if (key === 'getPropertyValue') return (p) => parse().get(p) ?? '';
|
|
782
|
-
if (key === '
|
|
783
|
-
if (key === '
|
|
831
|
+
if (key === 'getPropertyValue') return (p) => parse().values.get(p) ?? '';
|
|
832
|
+
if (key === 'getPropertyPriority') return (p) => parse().prio.get(p) ?? '';
|
|
833
|
+
if (key === 'setProperty') return (p, v, priority) => { const s = parse(); s.values.set(p, String(v)); if (priority) s.prio.set(p, 'important'); else s.prio.delete(p); write(s); };
|
|
834
|
+
if (key === 'removeProperty') return (p) => { const s = parse(); const v = s.values.get(p) ?? ''; s.values.delete(p); s.prio.delete(p); write(s); return v; };
|
|
835
|
+
if (key === 'item') return (i) => [...parse().values.keys()][i] ?? '';
|
|
836
|
+
if (key === 'length') return parse().values.size;
|
|
784
837
|
if (key === 'cssText') return el.getAttribute('style') || '';
|
|
838
|
+
if (key === 'cssFloat') return parse().values.get('float') ?? '';
|
|
839
|
+
if (key === Symbol.iterator) { const keys = [...parse().values.keys()]; return keys[Symbol.iterator].bind(keys); }
|
|
785
840
|
if (typeof key !== 'string') return undefined;
|
|
786
|
-
return parse().get(kebab(key)) ?? '';
|
|
841
|
+
return parse().values.get(kebab(key)) ?? '';
|
|
787
842
|
},
|
|
788
843
|
set(_t, key, value) {
|
|
789
844
|
if (key === 'cssText') { el.setAttribute('style', String(value)); return true; }
|
|
790
|
-
const
|
|
845
|
+
const s = parse(); s.values.set(kebab(key), String(value)); write(s); return true;
|
|
791
846
|
},
|
|
847
|
+
has(_t, key) { return typeof key === 'string'; },
|
|
792
848
|
});
|
|
793
849
|
}
|
|
794
850
|
const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
package/src/runtime/events.mjs
CHANGED
|
@@ -153,6 +153,13 @@ export class EventTarget {
|
|
|
153
153
|
event._path = this.__eventPath();
|
|
154
154
|
const path = event._path;
|
|
155
155
|
|
|
156
|
+
// pre-click activation (WHATWG): checkbox/radio toggle BEFORE click listeners
|
|
157
|
+
// run, so React's change detection sees the new value. Undone if preventDefault.
|
|
158
|
+
let activation = null;
|
|
159
|
+
if (event.type === 'click' && typeof this.__preClickActivation === 'function') {
|
|
160
|
+
activation = this.__preClickActivation();
|
|
161
|
+
}
|
|
162
|
+
|
|
156
163
|
const invoke = (node, phase) => {
|
|
157
164
|
const list = node.__listeners && node.__listeners.get(event.type);
|
|
158
165
|
if (!list || list.length === 0) return;
|
|
@@ -196,7 +203,10 @@ export class EventTarget {
|
|
|
196
203
|
event.eventPhase = PHASE_NONE;
|
|
197
204
|
event.currentTarget = null;
|
|
198
205
|
|
|
199
|
-
//
|
|
206
|
+
// canceled activation: undo the pre-click toggle if default was prevented
|
|
207
|
+
if (activation && event.defaultPrevented) activation.undo();
|
|
208
|
+
|
|
209
|
+
// remaining default actions (label→control, form submit) unless prevented
|
|
200
210
|
if (!event.defaultPrevented && typeof this.__runDefaultAction === 'function') {
|
|
201
211
|
this.__runDefaultAction(event);
|
|
202
212
|
}
|
package/src/runtime/window.mjs
CHANGED
|
@@ -102,6 +102,9 @@ export function createWindow(document, { url = 'http://localhost/' } = {}) {
|
|
|
102
102
|
structuredClone: (...a) => hostStructuredClone(...a),
|
|
103
103
|
getSelection: () => document.getSelection(),
|
|
104
104
|
scrollTo() {}, scroll() {}, scrollBy() {},
|
|
105
|
+
// window methods libraries/tests spy on — must exist as own props for vi.spyOn
|
|
106
|
+
open: () => null, close() {}, stop() {}, print() {}, focus() {}, blur() {},
|
|
107
|
+
moveTo() {}, moveBy() {}, resizeTo() {}, resizeBy() {},
|
|
105
108
|
alert() {}, confirm: () => false, prompt: () => null,
|
|
106
109
|
dispatchEvent: (e) => document.dispatchEvent(e),
|
|
107
110
|
addEventListener: (...a) => document.addEventListener(...a),
|
|
Binary file
|