import type { Attachment } from "svelte/attachments"; /** * gives a closing popover or modal dialog the frames its exit transition needs. * * chromium transitions `display` and `overlay` as discrete properties, so the * popover-transition utility in app.css fades a closing panel out by itself. * firefox has no `overlay` yet, so a closing element leaves the top layer right * away and the UA's `display: none` for a hidden popover or a closed dialog lands * on the same frame — the exit never gets to run. this whole file goes away once * firefox ships `overlay`. * * holding the open `display` inline is what keeps the panel painting, but on its * own it paints in the wrong place. leaving the top layer costs the panel both * its anchor and its containing block: the implicit `popovertarget` anchor goes * with the top layer, and a `position: fixed` panel that resolved against the * viewport starts resolving against the nearest transformed ancestor instead. * either one drops the panel somewhere near the top of its trigger for the * length of the fade. so freeze the whole box instead — measure it while the * popover is still open, and pin it there until the transition is done. * * `onExiting` brackets the fade in both engines, for a panel that mounts its * contents lazily and would otherwise empty itself out mid-exit. */ const pinned = [ "display", "position", "position-area", "margin", "top", "left", "right", "bottom", "width", "height" ]; /** where a `position: fixed` box in this parent resolves its origin from. */ const fixedOrigin = (parent: HTMLElement) => { const probe = document.createElement("div"); probe.style.cssText = "position:fixed;top:0;left:0;width:0;height:0"; parent.appendChild(probe); const origin = probe.getBoundingClientRect(); probe.remove(); return origin; }; export const popoverExit = (onExiting?: (exiting: boolean) => void): Attachment => (node) => { let exiting = false; const settle = () => { if (!exiting) return; exiting = false; for (const property of pinned) node.style.removeProperty(property); onExiting?.(false); }; const freeze = () => { const parent = node.parentElement; if (!parent) return; const origin = fixedOrigin(parent); // read the box while the popover is still open — a frame later it is gone const box = node.getBoundingClientRect(); const pin: Record = { display: getComputedStyle(node).display, position: "fixed", // the anchor rules own the insets, so they have to stand down first "position-area": "none", margin: "0", top: `${box.top - origin.top}px`, left: `${box.left - origin.left}px`, right: "auto", bottom: "auto", width: `${box.width}px`, height: `${box.height}px` }; for (const [property, value] of Object.entries(pin)) node.style.setProperty(property, value); }; const onBeforeToggle = (event: ToggleEvent) => { // reopening mid-fade hands the panel back to `:popover-open` if (event.newState === "open") return settle(); exiting = true; onExiting?.(true); if (!CSS.supports("overlay", "auto")) freeze(); // the exit transition only exists once the state has flipped, so wait a // frame to find it. nothing to wait on — reduced motion, or no transition // at all — settles on the spot requestAnimationFrame(() => { const done = node.getAnimations().map((animation) => animation.finished); Promise.allSettled(done).then(settle); }); }; node.addEventListener("beforetoggle", onBeforeToggle); return () => { node.removeEventListener("beforetoggle", onBeforeToggle); settle(); }; };