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.
*
* 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 fixedOrigin = (parent: HTMLElement) => {
if (CSS.supports("overlay", "auto")) return { top: 0, left: 0 };
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, pin = false): Attachment<HTMLElement> =>
(node) => {
let exiting = false;
let unpinned: [string, string][] = [];
const settle = () => {
if (!exiting) return;
exiting = false;
for (const [property, value] of unpinned) {
if (value) node.style.setProperty(property, value);
else node.style.removeProperty(property);
}
unpinned = [];
onExiting?.(false);
};
const freeze = () => {
const parent = node.parentElement;
if (!parent) return;
const origin = fixedOrigin(parent);
const box = node.getBoundingClientRect();
const pin: Record<string, string> = {
display: getComputedStyle(node).display,
position: "fixed",
// the anchor rules own the insets, so they have to stand down first
"position-anchor": "none",
"position-area": "none",
"position-try-fallbacks": "none",
"justify-self": "normal",
"align-self": "normal",
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`
};
unpinned = Object.keys(pin).map((property) => [
property,
node.style.getPropertyValue(property)
]);
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);
const still = getComputedStyle(node)
.transitionDuration.split(",")
.every((duration) => Number.parseFloat(duration) === 0);
if (!still && (pin || !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();
};
};