~james/core

core/web/src/lib/popoverExit.ts -rw-r--r-- 3.9 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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();
		};
	};