From 383823a48e7e5a5b3d13a87b3b81475bf06d4994 Mon Sep 17 00:00:00 2001
From: Fuwn <contact@fuwn.net>
Date: Tue, 19 May 2026 02:19:44 +0000
Subject: feat(details): animate details open/close via Web Animations API

A document-level click delegate intercepts <summary> clicks and
animates the parent <details> height instead of relying on the
browser's instant snap.

Initial attempt used the CSS pseudo-element approach
(::details-content + interpolate-size: allow-keywords). Both features
have shipped at different times across browsers (Safari < 18.2 has
neither, Safari 18.2 to 25 only has the pseudo) and degrade in
distinct broken ways. JS via WAAPI works in every browser that has
shipped Web Animations.

Closed height is computed from the summary's offsetHeight plus the
details element's vertical padding and border (read from
getComputedStyle), so the animation end-state matches the natural
collapsed height regardless of per-element padding tweaks
(details-unstyled, card variants, etc). Earlier draft animated to
summary.offsetHeight only, which undershot by 2 * padding and caused
the element to clip text before snapping back to its resting height.

Respects prefers-reduced-motion (bypass to native toggle). Uses a
WeakMap so rapid toggles cancel the in-flight animation cleanly.
Duration 240ms / cubic-bezier(0.22,1,0.36,1) matches the panel-class
motion token used elsewhere.
---
 src/routes/+layout.svelte | 51 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 51 insertions(+)

(limited to 'src/routes/+layout.svelte')

diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 625f58b1..8c36bb0f 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -101,6 +101,55 @@ const handleScroll = () => {
 	if (isMenuOpen) isMenuOpen = false;
 };
 
+const detailsAnimations = new WeakMap<HTMLDetailsElement, Animation>();
+
+const animateDetails = (e: MouseEvent) => {
+	const summary = (e.target as HTMLElement | null)?.closest("summary");
+	if (!summary) return;
+	const details = summary.parentElement as HTMLDetailsElement | null;
+	if (!details || details.tagName !== "DETAILS") return;
+	if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+
+	e.preventDefault();
+	detailsAnimations.get(details)?.cancel();
+
+	const detailsStyle = getComputedStyle(details);
+	const closedHeight =
+		summary.offsetHeight +
+		parseFloat(detailsStyle.paddingTop) +
+		parseFloat(detailsStyle.paddingBottom) +
+		parseFloat(detailsStyle.borderTopWidth) +
+		parseFloat(detailsStyle.borderBottomWidth);
+	const startHeight = details.offsetHeight;
+
+	if (details.open) {
+		details.style.overflow = "hidden";
+		const animation = details.animate(
+			{ height: [`${startHeight}px`, `${closedHeight}px`] },
+			{ duration: 240, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
+		);
+		detailsAnimations.set(details, animation);
+		animation.onfinish = () => {
+			details.open = false;
+			details.style.overflow = "";
+			detailsAnimations.delete(details);
+		};
+	} else {
+		details.style.overflow = "hidden";
+		details.open = true;
+		const fullHeight = details.offsetHeight;
+		const animation = details.animate(
+			{ height: [`${closedHeight}px`, `${fullHeight}px`] },
+			{ duration: 240, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
+		);
+		detailsAnimations.set(details, animation);
+		animation.onfinish = () => {
+			details.style.overflow = "";
+			detailsAnimations.delete(details);
+		};
+	}
+};
+
 onMount(async () => {
 	if (browser) {
 		if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
@@ -223,6 +272,8 @@ $: {
   on:keydown={(e) => { if (e.key === 'Escape' && isMenuOpen) isMenuOpen = false; }}
   on:click={(e) => {
     if (isMenuOpen && !(e.target as HTMLElement).closest('.header')) isMenuOpen = false;
+
+    animateDetails(e);
   }}
 />
 
-- 
cgit v1.2.3

