@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.8",
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: () => window,
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`; older versions read `transformMode`.
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) || {};
@@ -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
- this.__value = String(x);
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.__setActive(this);
577
- this.dispatchEvent(new Event('focus'));
578
- this.dispatchEvent(new Event('focusin', { bubbles: true }));
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.__setActive(this.ownerDocument.body);
582
- this.dispatchEvent(new Event('blur'));
583
- this.dispatchEvent(new Event('focusout', { bubbles: true }));
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
  }
@@ -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: globalThis.FormData, ReadableStream: globalThis.ReadableStream,
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'),