register/zlayer/anchor/motion/density are pure cores over plain data, so most of the suite runs with no DOM at all; jsdom covers only what is genuinely DOM. 100 tests, zero elements. These five are why v4 had 11 hand-built modals and 7 hand-built popovers: there was nothing shared to reuse. register makes an element exist only because a file defined it; portal gives every overlay one stacking parent; zlayer owns the only z-index in the app; dismiss keeps one stack so Escape pops the top and not the modal underneath; anchor measures, flips and clamps once. Three bugs the tests found: unmount() called removeChild on null when no portal existed yet, focusables() checked aria-hidden on the element instead of its ancestors so a hidden subtree stayed tab-reachable, and the density ratios v4 hand-tuned are now derived from the gap they were meant to allow for.
37 lines
1.4 KiB
JavaScript
37 lines
1.4 KiB
JavaScript
// Durations come from the tokens, so "how long" is answered in one place and
|
|
// the reduced-motion guard in tokens.css zeroes every one of them at once.
|
|
|
|
/** "120ms" | "0.12s" | "0" -> milliseconds. */
|
|
export function parseDuration(value) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return 0;
|
|
const match = /^(-?[\d.]+)(ms|s)?$/.exec(text);
|
|
if (!match) return 0;
|
|
const n = Number(match[1]);
|
|
if (!Number.isFinite(n)) return 0;
|
|
return Math.max(0, match[2] === "s" ? n * 1000 : n);
|
|
}
|
|
|
|
export function duration(step = 1) {
|
|
const value = getComputedStyle(document.documentElement).getPropertyValue(`--dur-${step}`);
|
|
return parseDuration(value);
|
|
}
|
|
|
|
export const prefersReducedMotion = () =>
|
|
Boolean(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
|
|
/**
|
|
* Run `fn` after the element's transition, or immediately when motion is off.
|
|
* Always fires: a transitionend that never arrives (the property did not change,
|
|
* the node was hidden) would otherwise strand a teardown forever.
|
|
*/
|
|
export function afterTransition(node, step, fn) {
|
|
const ms = duration(step);
|
|
if (!ms) { fn(); return () => {}; }
|
|
let done = false;
|
|
const finish = () => { if (done) return; done = true; node.removeEventListener("transitionend", finish); clearTimeout(timer); fn(); };
|
|
const timer = setTimeout(finish, ms + 50);
|
|
node.addEventListener("transitionend", finish);
|
|
return finish;
|
|
}
|