@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.6",
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",
@@ -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) { this.__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 === 'checkbox') this.checked = !this.checked;
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 map = new Map();
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
- const val = decl.slice(i + 1).trim();
774
- if (prop) map.set(prop, val);
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 map;
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 === 'setProperty') return (p, v) => { const m = parse(); m.set(p, v); write(m); };
783
- if (key === 'removeProperty') return (p) => { const m = parse(); const v = m.get(p) ?? ''; m.delete(p); write(m); return v; };
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 m = parse(); m.set(kebab(key), String(value)); write(m); return true;
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());
@@ -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
- // default actions (checkbox toggle, label→control, etc.) unless prevented
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
  }
@@ -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),