@miaskiewicz/turbo-dom 0.1.61 → 0.2.0
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/README.md +26 -12
- package/package.json +1 -1
- package/src/runtime/cascade.mjs +29 -17
- package/src/runtime/color.mjs +160 -0
- package/src/runtime/dom.mjs +73 -9
- package/src/runtime/svg.mjs +70 -0
- package/src/runtime/window.mjs +2 -2
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/README.md
CHANGED
|
@@ -15,10 +15,14 @@ npm install -D @miaskiewicz/turbo-dom
|
|
|
15
15
|
- ✅ **More compatible than happy-dom** — 99.72% on html5lib-tests vs happy-dom's 37%.
|
|
16
16
|
Runs React Testing Library, `user-event`, downshift, Radix UI, and Headless UI unmodified.
|
|
17
17
|
- ⚡ **Faster than both** — ~130× jsdom / ~45× happy-dom on a realistic suite (parse-memoized repeated shells), ~8–44× faster HTML parsing on real pages, and ~2.7× happy-dom on repeated queries while staying 99.7% spec-correct.
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
- 🎨 **Real computed style** — `getComputedStyle` runs a **partial cascade**: it resolves real
|
|
19
|
+
injected `<style>` rules (emotion/MUI `.css-HASH{…}`) + inline styles with proper specificity/source
|
|
20
|
+
order, **inherits the standard inheritable properties down the tree** (a global `body{color}` reaches
|
|
21
|
+
descendants), and **canonicalizes colors to `rgb()/rgba()`** like a browser (so
|
|
22
|
+
`toHaveStyle({color:'#fff'})` matches a `#ffffff`/`white`/`rgb(255,255,255)` rule). It only ever
|
|
23
|
+
returns values a real rule set — never an invented one.
|
|
24
|
+
- 🎯 **Honest, not lying** — no fake layout numbers; `getBoundingClientRect()` is zeros and computed
|
|
25
|
+
style never invents layout. Geometry tests belong in a real browser.
|
|
22
26
|
|
|
23
27
|
## Quick start
|
|
24
28
|
|
|
@@ -124,7 +128,7 @@ to set up `document`/`window` on any global object.
|
|
|
124
128
|
| @testing-library/dom + user-event | ✅ | ✅ | ✅ |
|
|
125
129
|
| React + Radix / Headless UI / downshift | ✅ | partial | ✅ |
|
|
126
130
|
| Real layout | ❌ (honest stub) | partial | partial |
|
|
127
|
-
| `getComputedStyle` cascade | partial (real `<style>` + inline) | partial | partial |
|
|
131
|
+
| `getComputedStyle` cascade | partial (real `<style>` + inline + inheritance + `rgb()` colors) | partial | partial |
|
|
128
132
|
| Shadow DOM (attach, slots, event retargeting, scoped/`:host` CSS) | ✅ | ✅ | ✅ |
|
|
129
133
|
|
|
130
134
|
turbo-dom inherits Servo's tree constructor, so the "messy input" cases hand-rolled parsers
|
|
@@ -189,13 +193,23 @@ JS (chatty, fine-grained) but pays only for what a test touches. Full design not
|
|
|
189
193
|
- **No layout.** `getBoundingClientRect()` returns zeros; `getClientRects()` is empty.
|
|
190
194
|
- **`getComputedStyle` is a partial cascade** — it resolves REAL injected `<style>` rules
|
|
191
195
|
(emotion/MUI `.css-HASH{…}`) plus inline `style`, applying specificity and source order
|
|
192
|
-
(inline wins). It
|
|
193
|
-
`
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
196
|
+
(inline wins). It **inherits the standard inheritable properties** (`color`, `font*`,
|
|
197
|
+
`line-height`, `text-align`, `visibility`, …) down the flattened tree — a global rule on
|
|
198
|
+
`body`/`:root` reaches descendants, and inheritance crosses the shadow host boundary. It
|
|
199
|
+
**canonicalizes `<color>` values to `rgb()/rgba()`** (`#fff`/`white`/`hsl(...)` → `rgb(255, 255, 255)`)
|
|
200
|
+
exactly as browsers serialize computed style, so `@testing-library/jest-dom`'s `toHaveStyle`
|
|
201
|
+
color assertions compare equal regardless of how the rule authored the color. It expands common
|
|
202
|
+
shorthands to longhands (`margin`/`padding`/`border`/single-token `background`), serializes bare
|
|
203
|
+
`0` as `0px` for length props, and normalizes `font-family` comma spacing. Out of scope (returns
|
|
204
|
+
`''`): `@media`/`@supports`/`@keyframes`, `:hover`/state pseudo-classes, pseudo-elements, the
|
|
205
|
+
`inherit`/`initial`/`unset` keywords, CSS custom properties, and length-unit conversion
|
|
206
|
+
(`em`/`rem`→`px`). Only ever returns values from a matched, inline, or inherited declaration —
|
|
207
|
+
never an invented initial value. Style/geometry assertions belong in a real browser
|
|
208
|
+
(Playwright/WebDriver).
|
|
209
|
+
- **`<style>.textContent` reflects rules injected via `sheet.insertRule()`** — CSS-in-JS engines in
|
|
210
|
+
"speedy" mode (emotion/styled-components) inject rules straight into the CSSOM without writing the
|
|
211
|
+
node's text; turbo-dom serializes them back into `textContent` (as browsers/jsdom do), so tests
|
|
212
|
+
that scrape `querySelectorAll('style')` text see the injected CSS.
|
|
199
213
|
- Canvas, `<select>` rendering, and similar visual APIs are honest no-op stubs.
|
|
200
214
|
- **Shadow DOM** is supported and pay-for-what-you-use — every event/query/cascade hot path
|
|
201
215
|
is unchanged until the first `attachShadow` flips a per-document flag. Covered: `attachShadow`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miaskiewicz/turbo-dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
"license": "MIT",
|
|
6
6
|
"main": "index.js",
|
package/src/runtime/cascade.mjs
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
// added to any benchmarked path.
|
|
8
8
|
//
|
|
9
9
|
// HONEST: this only ever returns values that come from a REAL matched rule or inline
|
|
10
|
-
// style. A property no stylesheet/inline set
|
|
11
|
-
// layout/cascade numbers. Out of
|
|
12
|
-
// :hover & other stateful
|
|
13
|
-
//
|
|
10
|
+
// style (or inherited from one — see INHERITED). A property no stylesheet/inline set
|
|
11
|
+
// still reads '' — it never invents layout/cascade numbers or initial values. Out of
|
|
12
|
+
// scope (return ''): @media/@supports/@keyframes, :hover & other stateful
|
|
13
|
+
// pseudo-classes, pseudo-elements, the `inherit`/`initial`/`unset` keywords,
|
|
14
|
+
// CSS custom-property resolution. Colors ARE canonicalized to rgb()/rgba() (color.mjs)
|
|
15
|
+
// and bare-0 lengths to `0px`, matching what a browser serializes.
|
|
14
16
|
|
|
15
17
|
import { matchesSelector } from './selectors.mjs';
|
|
18
|
+
import { COLOR_PROPS, canonicalizeColor } from './color.mjs';
|
|
16
19
|
|
|
17
20
|
const kebab = (s) => s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
|
|
18
21
|
|
|
@@ -162,9 +165,10 @@ function collectMatched(el, rules, kind, into) {
|
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
167
|
|
|
165
|
-
// Inheritable properties we propagate
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
+
// Inheritable properties we propagate down the flattened tree (curated; matches
|
|
169
|
+
// the common inherited set). Honest partial: only these inherit. Covers both
|
|
170
|
+
// light-DOM "CSS globals" (a rule on html/body cascades to descendants) and
|
|
171
|
+
// crossing the shadow host boundary into shadow content.
|
|
168
172
|
const INHERITED = new Set([
|
|
169
173
|
'color', 'cursor', 'direction', 'font', 'font-family', 'font-size', 'font-style',
|
|
170
174
|
'font-variant', 'font-weight', 'letter-spacing', 'line-height', 'list-style',
|
|
@@ -199,6 +203,9 @@ function lookup(map, prop) {
|
|
|
199
203
|
// (emotion minifies to "a",b,c). Scoped to font-family so we don't rewrite commas
|
|
200
204
|
// inside rgb()/cubic-bezier() values, which browsers leave compact.
|
|
201
205
|
if (prop === 'font-family') return v.replace(/\s*,\s*/g, ', ');
|
|
206
|
+
// <color> properties: browsers serialize computed colors to rgb()/rgba() —
|
|
207
|
+
// names, hex, hsl all canonicalize (includeNamed=true). Unrecognized → passthrough.
|
|
208
|
+
if (COLOR_PROPS.has(prop)) { const c = canonicalizeColor(v, true); if (c) return c; }
|
|
202
209
|
return v;
|
|
203
210
|
}
|
|
204
211
|
|
|
@@ -255,16 +262,21 @@ export function makeGetComputedStyle() {
|
|
|
255
262
|
const inline = el.getAttribute && el.getAttribute('style');
|
|
256
263
|
if (inline) parseDecls(inline, map);
|
|
257
264
|
|
|
258
|
-
// Inheritance
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
265
|
+
// Inheritance: an element inherits unset inheritable props from its flattened
|
|
266
|
+
// parent (DOM parent, hopping shadow-root→host at the boundary). This carries
|
|
267
|
+
// "CSS globals" (a rule on html/body/:root) down the tree AND propagates into
|
|
268
|
+
// shadow content. Honest partial: only the curated INHERITED set inherits, only
|
|
269
|
+
// from values a REAL rule/inline actually set (pv truthy) — never an initial
|
|
270
|
+
// value. Recursion is memoized per element/version and terminates at the root
|
|
271
|
+
// (flattenedParent → null). Test-time only; hot paths never call gcs.
|
|
272
|
+
// `inShadow` is unused now that light-DOM inherits too, but the flattened-parent
|
|
273
|
+
// hop still crosses the host boundary correctly for shadow content.
|
|
274
|
+
void inShadow;
|
|
275
|
+
const parent = flattenedParent(el);
|
|
276
|
+
if (parent) {
|
|
277
|
+
const ps = gcs(parent);
|
|
278
|
+
for (const prop of INHERITED) {
|
|
279
|
+
if (!map.has(prop)) { const pv = ps.getPropertyValue(prop); if (pv) map.set(prop, pv); }
|
|
268
280
|
}
|
|
269
281
|
}
|
|
270
282
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Color canonicalization — browsers serialize computed/inline <color> values to
|
|
2
|
+
// rgb()/rgba(), never as the authored `#fff`/`white`. testing-library's
|
|
3
|
+
// `toHaveStyle` compares the element's computed value against the expected value
|
|
4
|
+
// AS STRINGS, after each is run through the DOM's own normalization — so to match
|
|
5
|
+
// a browser (and jsdom) both getComputedStyle (cascade.mjs) and inline style
|
|
6
|
+
// read-back (dom.mjs styleGet) must canonicalize. This module is the shared core.
|
|
7
|
+
//
|
|
8
|
+
// PERF: only ever called from getComputedStyle + el.style reads (test/app time),
|
|
9
|
+
// and only for properties in COLOR_PROPS. Parse/query/match/event hot paths never
|
|
10
|
+
// touch it. Returns null when the value isn't a recognized color → caller keeps
|
|
11
|
+
// the original string (honest passthrough for url()/gradients/var()/keywords).
|
|
12
|
+
//
|
|
13
|
+
// Two modes, matching real browsers:
|
|
14
|
+
// includeNamed=false (inline el.style): hex + rgb()/hsl() canonicalize; NAMED
|
|
15
|
+
// keywords (`red`, `transparent`) stay as authored — Chrome keeps them inline.
|
|
16
|
+
// includeNamed=true (computed getComputedStyle): names resolve to rgb() too.
|
|
17
|
+
|
|
18
|
+
// Properties whose value is a <color> (longhands + the single-token shorthands we
|
|
19
|
+
// already expand). `background` is included for the bare-color MUI case
|
|
20
|
+
// (`background:#fff`); a non-color background value just fails to parse → passthrough.
|
|
21
|
+
export const COLOR_PROPS = new Set([
|
|
22
|
+
'color', 'background-color', 'background', 'border-color',
|
|
23
|
+
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
|
24
|
+
'outline-color', 'text-decoration-color', 'column-rule-color', 'caret-color',
|
|
25
|
+
'fill', 'stroke', 'stop-color', 'flood-color', 'lighting-color',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// CSS named colors → #rrggbb (reused through hexToRgb). `transparent` is special
|
|
29
|
+
// (→ rgba(0,0,0,0)); both gate behind includeNamed.
|
|
30
|
+
const NAMED = {
|
|
31
|
+
aliceblue: 'f0f8ff', antiquewhite: 'faebd7', aqua: '00ffff', aquamarine: '7fffd4',
|
|
32
|
+
azure: 'f0ffff', beige: 'f5f5dc', bisque: 'ffe4c4', black: '000000',
|
|
33
|
+
blanchedalmond: 'ffebcd', blue: '0000ff', blueviolet: '8a2be2', brown: 'a52a2a',
|
|
34
|
+
burlywood: 'deb887', cadetblue: '5f9ea0', chartreuse: '7fff00', chocolate: 'd2691e',
|
|
35
|
+
coral: 'ff7f50', cornflowerblue: '6495ed', cornsilk: 'fff8dc', crimson: 'dc143c',
|
|
36
|
+
cyan: '00ffff', darkblue: '00008b', darkcyan: '008b8b', darkgoldenrod: 'b8860b',
|
|
37
|
+
darkgray: 'a9a9a9', darkgreen: '006400', darkgrey: 'a9a9a9', darkkhaki: 'bdb76b',
|
|
38
|
+
darkmagenta: '8b008b', darkolivegreen: '556b2f', darkorange: 'ff8c00', darkorchid: '9932cc',
|
|
39
|
+
darkred: '8b0000', darksalmon: 'e9967a', darkseagreen: '8fbc8f', darkslateblue: '483d8b',
|
|
40
|
+
darkslategray: '2f4f4f', darkslategrey: '2f4f4f', darkturquoise: '00ced1', darkviolet: '9400d3',
|
|
41
|
+
deeppink: 'ff1493', deepskyblue: '00bfff', dimgray: '696969', dimgrey: '696969',
|
|
42
|
+
dodgerblue: '1e90ff', firebrick: 'b22222', floralwhite: 'fffaf0', forestgreen: '228b22',
|
|
43
|
+
fuchsia: 'ff00ff', gainsboro: 'dcdcdc', ghostwhite: 'f8f8ff', gold: 'ffd700',
|
|
44
|
+
goldenrod: 'daa520', gray: '808080', green: '008000', greenyellow: 'adff2f',
|
|
45
|
+
grey: '808080', honeydew: 'f0fff0', hotpink: 'ff69b4', indianred: 'cd5c5c',
|
|
46
|
+
indigo: '4b0082', ivory: 'fffff0', khaki: 'f0e68c', lavender: 'e6e6fa',
|
|
47
|
+
lavenderblush: 'fff0f5', lawngreen: '7cfc00', lemonchiffon: 'fffacd', lightblue: 'add8e6',
|
|
48
|
+
lightcoral: 'f08080', lightcyan: 'e0ffff', lightgoldenrodyellow: 'fafad2', lightgray: 'd3d3d3',
|
|
49
|
+
lightgreen: '90ee90', lightgrey: 'd3d3d3', lightpink: 'ffb6c1', lightsalmon: 'ffa07a',
|
|
50
|
+
lightseagreen: '20b2aa', lightskyblue: '87cefa', lightslategray: '778899', lightslategrey: '778899',
|
|
51
|
+
lightsteelblue: 'b0c4de', lightyellow: 'ffffe0', lime: '00ff00', limegreen: '32cd32',
|
|
52
|
+
linen: 'faf0e6', magenta: 'ff00ff', maroon: '800000', mediumaquamarine: '66cdaa',
|
|
53
|
+
mediumblue: '0000cd', mediumorchid: 'ba55d3', mediumpurple: '9370db', mediumseagreen: '3cb371',
|
|
54
|
+
mediumslateblue: '7b68ee', mediumspringgreen: '00fa9a', mediumturquoise: '48d1cc', mediumvioletred: 'c71585',
|
|
55
|
+
midnightblue: '191970', mintcream: 'f5fffa', mistyrose: 'ffe4e1', moccasin: 'ffe4b5',
|
|
56
|
+
navajowhite: 'ffdead', navy: '000080', oldlace: 'fdf5e6', olive: '808000',
|
|
57
|
+
olivedrab: '6b8e23', orange: 'ffa500', orangered: 'ff4500', orchid: 'da70d6',
|
|
58
|
+
palegoldenrod: 'eee8aa', palegreen: '98fb98', paleturquoise: 'afeeee', palevioletred: 'db7093',
|
|
59
|
+
papayawhip: 'ffefd5', peachpuff: 'ffdab9', peru: 'cd853f', pink: 'ffc0cb',
|
|
60
|
+
plum: 'dda0dd', powderblue: 'b0e0e6', purple: '800080', rebeccapurple: '663399',
|
|
61
|
+
red: 'ff0000', rosybrown: 'bc8f8f', royalblue: '4169e1', saddlebrown: '8b4513',
|
|
62
|
+
salmon: 'fa8072', sandybrown: 'f4a460', seagreen: '2e8b57', seashell: 'fff5ee',
|
|
63
|
+
sienna: 'a0522d', silver: 'c0c0c0', skyblue: '87ceeb', slateblue: '6a5acd',
|
|
64
|
+
slategray: '708090', slategrey: '708090', snow: 'fffafa', springgreen: '00ff7f',
|
|
65
|
+
steelblue: '4682b4', tan: 'd2b48c', teal: '008080', thistle: 'd8bfd8',
|
|
66
|
+
tomato: 'ff6347', turquoise: '40e0d0', violet: 'ee82ee', wheat: 'f5deb3',
|
|
67
|
+
white: 'ffffff', whitesmoke: 'f5f5f5', yellow: 'ffff00', yellowgreen: '9acd32',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const clamp255 = (n) => (n < 0 ? 0 : n > 255 ? 255 : n) | 0;
|
|
71
|
+
|
|
72
|
+
// Serialize {r,g,b,a}. Browsers emit `rgb(r, g, b)` when fully opaque, else
|
|
73
|
+
// `rgba(r, g, b, a)` with the alpha trimmed (0.5, not 0.50). Both sides of every
|
|
74
|
+
// comparison route through here, so the exact alpha rounding only has to be
|
|
75
|
+
// internally consistent.
|
|
76
|
+
function rgb(r, g, b, a) {
|
|
77
|
+
r = clamp255(r); g = clamp255(g); b = clamp255(b);
|
|
78
|
+
if (a === undefined || a >= 1) return `rgb(${r}, ${g}, ${b})`;
|
|
79
|
+
if (a < 0) a = 0;
|
|
80
|
+
const as = String(Math.round(a * 1000) / 1000);
|
|
81
|
+
return `rgba(${r}, ${g}, ${b}, ${as})`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hexToRgb(v) {
|
|
85
|
+
const h = v.slice(1);
|
|
86
|
+
const n = h.length;
|
|
87
|
+
if (!/^[0-9a-fA-F]+$/.test(h)) return null;
|
|
88
|
+
if (n === 3 || n === 4) {
|
|
89
|
+
const r = parseInt(h[0] + h[0], 16), g = parseInt(h[1] + h[1], 16), b = parseInt(h[2] + h[2], 16);
|
|
90
|
+
const a = n === 4 ? parseInt(h[3] + h[3], 16) / 255 : undefined;
|
|
91
|
+
return rgb(r, g, b, a);
|
|
92
|
+
}
|
|
93
|
+
if (n === 6 || n === 8) {
|
|
94
|
+
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
|
95
|
+
const a = n === 8 ? parseInt(h.slice(6, 8), 16) / 255 : undefined;
|
|
96
|
+
return rgb(r, g, b, a);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Re-serialize rgb()/rgba() to canonical spacing. Percentage components (rare in
|
|
102
|
+
// CSS-in-JS output) → null (passthrough) rather than guessing.
|
|
103
|
+
function normalizeRgb(v) {
|
|
104
|
+
const open = v.indexOf('(');
|
|
105
|
+
if (open === -1 || v[v.length - 1] !== ')') return null;
|
|
106
|
+
const parts = v.slice(open + 1, -1).split(/[,\s/]+/).filter(Boolean);
|
|
107
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
108
|
+
const nums = [];
|
|
109
|
+
for (let i = 0; i < parts.length; i++) {
|
|
110
|
+
if (parts[i].indexOf('%') !== -1) return null;
|
|
111
|
+
const f = parseFloat(parts[i]);
|
|
112
|
+
if (Number.isNaN(f)) return null;
|
|
113
|
+
nums.push(f);
|
|
114
|
+
}
|
|
115
|
+
return rgb(nums[0], nums[1], nums[2], nums[3]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function hslToRgb(v) {
|
|
119
|
+
const open = v.indexOf('(');
|
|
120
|
+
if (open === -1 || v[v.length - 1] !== ')') return null;
|
|
121
|
+
const parts = v.slice(open + 1, -1).split(/[,\s/]+/).filter(Boolean);
|
|
122
|
+
if (parts.length < 3 || parts.length > 4) return null;
|
|
123
|
+
let h = parseFloat(parts[0]);
|
|
124
|
+
const s = parseFloat(parts[1]) / 100;
|
|
125
|
+
const l = parseFloat(parts[2]) / 100;
|
|
126
|
+
if (Number.isNaN(h) || Number.isNaN(s) || Number.isNaN(l)) return null;
|
|
127
|
+
const a = parts[3] !== undefined ? parseFloat(parts[3]) : undefined;
|
|
128
|
+
h = ((h % 360) + 360) % 360 / 360;
|
|
129
|
+
const hue = (p, q, t) => {
|
|
130
|
+
if (t < 0) t += 1; if (t > 1) t -= 1;
|
|
131
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
132
|
+
if (t < 1 / 2) return q;
|
|
133
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
134
|
+
return p;
|
|
135
|
+
};
|
|
136
|
+
let r, g, b;
|
|
137
|
+
if (s === 0) { r = g = b = l; }
|
|
138
|
+
else {
|
|
139
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
140
|
+
const p = 2 * l - q;
|
|
141
|
+
r = hue(p, q, h + 1 / 3); g = hue(p, q, h); b = hue(p, q, h - 1 / 3);
|
|
142
|
+
}
|
|
143
|
+
return rgb(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), a);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Canonicalize one color value, or null if unrecognized (→ caller passthrough).
|
|
147
|
+
export function canonicalizeColor(value, includeNamed) {
|
|
148
|
+
if (!value) return null;
|
|
149
|
+
const v = value.trim();
|
|
150
|
+
if (v[0] === '#') return hexToRgb(v);
|
|
151
|
+
const lower = v.toLowerCase();
|
|
152
|
+
if (lower.startsWith('rgb')) return normalizeRgb(v);
|
|
153
|
+
if (lower.startsWith('hsl')) return hslToRgb(v);
|
|
154
|
+
if (includeNamed) {
|
|
155
|
+
if (lower === 'transparent') return 'rgba(0, 0, 0, 0)';
|
|
156
|
+
const hex = NAMED[lower];
|
|
157
|
+
if (hex) return hexToRgb('#' + hex);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
package/src/runtime/dom.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import { liveNodeList, liveHTMLCollection } from './collections.mjs';
|
|
|
15
15
|
import { matchesSelector, querySelector as qsel, querySelectorAll as qselAll } from './selectors.mjs';
|
|
16
16
|
import { serializeInner, serializeOuter } from './html-serialize.mjs';
|
|
17
17
|
import { CSSStyleSheet, styleSheetList } from './cssom.mjs';
|
|
18
|
+
import { COLOR_PROPS, canonicalizeColor } from './color.mjs';
|
|
19
|
+
import { SVGAnimatedString, SVGAnimatedLength, SVGAnimatedRect, SVG_LENGTH_ATTRS } from './svg.mjs';
|
|
18
20
|
|
|
19
21
|
// Per-node query-result cache keyed by (selector, document version). querySelectorAll
|
|
20
22
|
// returns a STATIC list per spec, so caching is safe until the next mutation bumps
|
|
@@ -239,6 +241,20 @@ export class Node extends EventTarget {
|
|
|
239
241
|
}
|
|
240
242
|
|
|
241
243
|
get textContent() {
|
|
244
|
+
// <style> whose CSSOM sheet received insertRule() (emotion/styled-components
|
|
245
|
+
// "speedy" mode injects rules WITHOUT touching the node's text): reflect those
|
|
246
|
+
// rules in the serialized text, as browsers/jsdom do, so tests that scrape
|
|
247
|
+
// `querySelectorAll('style').map(s => s.textContent)` see the injected CSS.
|
|
248
|
+
// __sheet is only ever set on <style> elements (sheet getter gates on it), so
|
|
249
|
+
// this is one predicted-false read for every other element on the hot path.
|
|
250
|
+
const sheet = this.__sheet;
|
|
251
|
+
if (sheet && sheet.cssRules.length) {
|
|
252
|
+
const kids = this.__children();
|
|
253
|
+
let css = '';
|
|
254
|
+
for (let i = 0; i < kids.length; i++) if (kids[i].nodeType === TEXT_NODE) css += kids[i].data;
|
|
255
|
+
for (let i = 0; i < sheet.cssRules.length; i++) css += (css ? '\n' : '') + sheet.cssRules[i].cssText;
|
|
256
|
+
return css;
|
|
257
|
+
}
|
|
242
258
|
const kids = this.__children();
|
|
243
259
|
// hot leaf case (RTL getByText reads textContent on every element): a single
|
|
244
260
|
// text child → return its data directly, skipping the iterator + concat.
|
|
@@ -724,7 +740,7 @@ export class Element extends Node {
|
|
|
724
740
|
}
|
|
725
741
|
|
|
726
742
|
cloneNode(deep = false) {
|
|
727
|
-
const el =
|
|
743
|
+
const el = newElement(this.ownerDocument, this.localName, this.__ns);
|
|
728
744
|
el.__attrs = (this.__attrs ?? (this.__attrs = this.__buildAttrs())).map((a) => ({ ...a }));
|
|
729
745
|
if (deep) {
|
|
730
746
|
// build the cloned child array directly — the clone is detached, so per-child
|
|
@@ -935,6 +951,46 @@ export class Element extends Node {
|
|
|
935
951
|
}
|
|
936
952
|
|
|
937
953
|
|
|
954
|
+
// ------------------------------------------------------------- SVGElement ----
|
|
955
|
+
// SVG elements expose their geometry/identity attributes as SVGAnimated{String,Length,
|
|
956
|
+
// Rect} objects (el.className.baseVal, rect.width.baseVal.value, svg.viewBox.baseVal),
|
|
957
|
+
// not the plain strings/numbers HTML uses. A real subclass keeps these off every HTML
|
|
958
|
+
// element (zero regression, zero hot-path cost — the wrappers are built on access only).
|
|
959
|
+
// Instantiated by newElement() whenever the namespace is "svg".
|
|
960
|
+
export class SVGElement extends Element {
|
|
961
|
+
// className is a readonly SVGAnimatedString in the spec; we still allow assigning a
|
|
962
|
+
// string (common habit) by writing the class attribute, but reads return the wrapper.
|
|
963
|
+
get className() { return new SVGAnimatedString(this, 'class'); }
|
|
964
|
+
set className(v) { this.setAttribute('class', String(v)); }
|
|
965
|
+
|
|
966
|
+
get viewBox() { return new SVGAnimatedRect(this, 'viewBox'); }
|
|
967
|
+
|
|
968
|
+
// Honest geometry stubs (consistent with stubs.mjs — no layout engine, so zeros).
|
|
969
|
+
getBBox() { return { x: 0, y: 0, width: 0, height: 0 }; }
|
|
970
|
+
getCTM() { return null; }
|
|
971
|
+
getScreenCTM() { return null; }
|
|
972
|
+
get ownerSVGElement() {
|
|
973
|
+
let p = this.parentNode;
|
|
974
|
+
while (p && p.nodeType === ELEMENT_NODE) { if (p.localName === 'svg') return p; p = p.parentNode; }
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
// Length-valued geometry attributes → SVGAnimatedLength (built lazily per read).
|
|
979
|
+
for (const attr of SVG_LENGTH_ATTRS) {
|
|
980
|
+
Object.defineProperty(SVGElement.prototype, attr, {
|
|
981
|
+
configurable: true,
|
|
982
|
+
get() { return new SVGAnimatedLength(this, attr); },
|
|
983
|
+
set(v) { this.setAttribute(attr, v != null && v.baseVal ? String(v.baseVal.value) : String(v)); },
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Element factory: SVG namespace → SVGElement, everything else → Element. One place so
|
|
988
|
+
// every construction site (buffer inflation, innerHTML, cloneNode, createElementNS) agrees.
|
|
989
|
+
function newElement(doc, tag, ns) {
|
|
990
|
+
return ns === 'svg' ? new SVGElement(doc, tag, ns) : new Element(doc, tag, ns);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
|
|
938
994
|
// ---------------------------------------------------- DocumentFragment ----
|
|
939
995
|
export class DocumentFragment extends Node {
|
|
940
996
|
get nodeType() { return DOCUMENT_FRAGMENT_NODE; }
|
|
@@ -1300,11 +1356,19 @@ const SHORTHAND_OF = {
|
|
|
1300
1356
|
'padding-top': 'padding', 'padding-right': 'padding', 'padding-bottom': 'padding', 'padding-left': 'padding',
|
|
1301
1357
|
};
|
|
1302
1358
|
function styleGet(values, prop) {
|
|
1303
|
-
|
|
1304
|
-
if (
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1359
|
+
let v = values.get(prop);
|
|
1360
|
+
if (v === undefined) {
|
|
1361
|
+
const sh = SHORTHAND_OF[prop];
|
|
1362
|
+
if (sh) { const s = values.get(sh); if (s !== undefined && !/\s/.test(s.trim())) v = s; }
|
|
1363
|
+
}
|
|
1364
|
+
if (v === undefined) return '';
|
|
1365
|
+
// Inline CSSOM canonicalizes <color> values the way a browser does on read-back:
|
|
1366
|
+
// hex/rgb()/hsl() → rgb()/rgba(), but NAMED keywords stay as authored (includeNamed
|
|
1367
|
+
// =false) — `el.style.color='red'` reads back `'red'`, `'#fff'` reads back rgb().
|
|
1368
|
+
// This keeps el.style read-back consistent with getComputedStyle so jest-dom's
|
|
1369
|
+
// toHaveStyle (which normalizes the expected value via el.style) compares equal.
|
|
1370
|
+
if (COLOR_PROPS.has(prop)) { const c = canonicalizeColor(v, false); if (c) return c; }
|
|
1371
|
+
return v;
|
|
1308
1372
|
}
|
|
1309
1373
|
|
|
1310
1374
|
// WHATWG value-sanitization per input type. Returns the accepted value, or null
|
|
@@ -1473,7 +1537,7 @@ export class Document extends Node {
|
|
|
1473
1537
|
switch (nt) {
|
|
1474
1538
|
case ELEMENT_NODE: {
|
|
1475
1539
|
const tag = buf.tagName(idx); // read once (was read twice — ctor + template check)
|
|
1476
|
-
node =
|
|
1540
|
+
node = newElement(this, tag, buf.ns(idx));
|
|
1477
1541
|
node.__idx = idx;
|
|
1478
1542
|
node.__attrIdx = idx; // attrs lazy (constructor left __attrs undefined)
|
|
1479
1543
|
// template content fragment: a child node typed 11 named "content"
|
|
@@ -1505,7 +1569,7 @@ export class Document extends Node {
|
|
|
1505
1569
|
let node;
|
|
1506
1570
|
switch (raw.nodeType) {
|
|
1507
1571
|
case ELEMENT_NODE:
|
|
1508
|
-
node =
|
|
1572
|
+
node = newElement(this, raw.name, raw.namespace || '');
|
|
1509
1573
|
node.__attrs = raw.attrs.map((a) => ({ name: a.name, value: a.value, prefix: a.prefix || '' }));
|
|
1510
1574
|
if (raw.name === 'template') {
|
|
1511
1575
|
const contentRaw = raw.children.find((c) => c.nodeType === DOCUMENT_FRAGMENT_NODE && c.name === 'content');
|
|
@@ -1567,7 +1631,7 @@ export class Document extends Node {
|
|
|
1567
1631
|
createElementNS(ns, qualified) {
|
|
1568
1632
|
const short = ns === SVG_NS ? 'svg' : ns === MATHML_NS ? 'math' : '';
|
|
1569
1633
|
const local = qualified.includes(':') ? qualified.split(':')[1] : qualified;
|
|
1570
|
-
return
|
|
1634
|
+
return newElement(this, local, short);
|
|
1571
1635
|
}
|
|
1572
1636
|
createTextNode(data) { return new Text(this, String(data)); }
|
|
1573
1637
|
createComment(data) { return new Comment(this, String(data)); }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Minimal SVG DOM-property wrappers. SVG IDL attributes are not plain strings/numbers
|
|
2
|
+
// like HTML reflected attributes — they're SVGAnimated{String,Length,Rect} objects with
|
|
3
|
+
// .baseVal / .animVal. Component code (and the libraries that render SVG icons) reads
|
|
4
|
+
// `el.className.baseVal`, `rect.width.baseVal.value`, `svg.viewBox.baseVal`, etc.; without
|
|
5
|
+
// these wrappers those reads throw "Cannot read properties of undefined (reading 'baseVal')".
|
|
6
|
+
//
|
|
7
|
+
// HONEST + LIVE: every wrapper reads/writes the underlying attribute on demand (no cached
|
|
8
|
+
// snapshot), so it always reflects the current attribute and never invents a value. There's
|
|
9
|
+
// no animation engine — animVal === baseVal. Only attached to real SVGElement instances
|
|
10
|
+
// (see dom.mjs), so HTML elements are completely unaffected.
|
|
11
|
+
|
|
12
|
+
// SVGLength — the unit-bearing value behind an SVGAnimatedLength. valueOf() returns the
|
|
13
|
+
// number so `rect.width.baseVal` used in arithmetic/comparisons coerces correctly, while
|
|
14
|
+
// `rect.width.baseVal.value` reads the same number explicitly.
|
|
15
|
+
export class SVGLength {
|
|
16
|
+
constructor(host, attr) { this.__host = host; this.__attr = attr; }
|
|
17
|
+
get value() { const v = parseFloat(this.__host.getAttribute(this.__attr)); return Number.isNaN(v) ? 0 : v; }
|
|
18
|
+
set value(v) { this.__host.setAttribute(this.__attr, String(v)); }
|
|
19
|
+
get valueInSpecifiedUnits() { return this.value; }
|
|
20
|
+
get unitType() { return 1; } // SVG_LENGTHTYPE_NUMBER
|
|
21
|
+
get valueAsString() { return this.__host.getAttribute(this.__attr) || '0'; }
|
|
22
|
+
set valueAsString(s) { this.__host.setAttribute(this.__attr, String(s)); }
|
|
23
|
+
valueOf() { return this.value; }
|
|
24
|
+
toString() { return this.valueAsString; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SVGAnimatedLength {
|
|
28
|
+
constructor(host, attr) {
|
|
29
|
+
const base = new SVGLength(host, attr);
|
|
30
|
+
this.baseVal = base;
|
|
31
|
+
this.animVal = base; // no animation engine — animated value tracks the base value
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class SVGAnimatedString {
|
|
36
|
+
constructor(host, attr) { this.__host = host; this.__attr = attr; }
|
|
37
|
+
get baseVal() { return this.__host.getAttribute(this.__attr) || ''; }
|
|
38
|
+
set baseVal(v) { this.__host.setAttribute(this.__attr, String(v)); }
|
|
39
|
+
get animVal() { return this.baseVal; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// SVGRect / SVGAnimatedRect — backs `viewBox`. Parses the four-number list lazily.
|
|
43
|
+
export class SVGRect {
|
|
44
|
+
constructor(host, attr) { this.__host = host; this.__attr = attr; }
|
|
45
|
+
#nums() {
|
|
46
|
+
const raw = this.__host.getAttribute(this.__attr) || '';
|
|
47
|
+
const parts = raw.split(/[\s,]+/).map(parseFloat);
|
|
48
|
+
return [parts[0] || 0, parts[1] || 0, parts[2] || 0, parts[3] || 0];
|
|
49
|
+
}
|
|
50
|
+
get x() { return this.#nums()[0]; }
|
|
51
|
+
get y() { return this.#nums()[1]; }
|
|
52
|
+
get width() { return this.#nums()[2]; }
|
|
53
|
+
get height() { return this.#nums()[3]; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class SVGAnimatedRect {
|
|
57
|
+
constructor(host, attr) {
|
|
58
|
+
const base = new SVGRect(host, attr);
|
|
59
|
+
this.baseVal = base;
|
|
60
|
+
this.animVal = base;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Attributes that surface as SVGAnimatedLength on SVG elements. Covers the common
|
|
65
|
+
// geometry set (shapes, gradients, markers); any not listed still works via getAttribute.
|
|
66
|
+
export const SVG_LENGTH_ATTRS = [
|
|
67
|
+
'width', 'height', 'x', 'y', 'cx', 'cy', 'r', 'rx', 'ry',
|
|
68
|
+
'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'fx', 'fy', 'refX', 'refY',
|
|
69
|
+
'markerWidth', 'markerHeight', 'startOffset', 'textLength',
|
|
70
|
+
];
|
package/src/runtime/window.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { makeGetComputedStyle } from './cascade.mjs';
|
|
14
14
|
import { CSSStyleSheet } from './cssom.mjs';
|
|
15
15
|
import {
|
|
16
|
-
Node, Element, Text, Comment, Document, DocumentFragment, DocumentType, Event, CustomEvent,
|
|
16
|
+
Node, Element, SVGElement, Text, Comment, Document, DocumentFragment, DocumentType, Event, CustomEvent,
|
|
17
17
|
MutationObserver, DOMParser, XMLSerializer, ShadowRoot,
|
|
18
18
|
} from './dom.mjs';
|
|
19
19
|
import {
|
|
@@ -282,7 +282,7 @@ const STATIC_BASE = {
|
|
|
282
282
|
DataTransfer,
|
|
283
283
|
// every element is a plain Element → `el instanceof HTMLElement` is true.
|
|
284
284
|
// Tag-specific interfaces match by localName via Symbol.hasInstance.
|
|
285
|
-
HTMLElement: Element, SVGElement
|
|
285
|
+
HTMLElement: Element, SVGElement,
|
|
286
286
|
// SVG interface globals. Libs (and test setup mocks) do
|
|
287
287
|
// `Object.defineProperty(SVGSVGElement.prototype, 'viewBox', …)` and
|
|
288
288
|
// `el instanceof SVGSVGElement`; an undefined global is a hard ReferenceError.
|
|
Binary file
|