// Smooth open/close for <details>, in every engine.
//
// The browser toggles <details> in a single frame, and there is no portable
// pure-CSS way to animate it: `interpolate-size: allow-keywords` — the property
// that lets `::details-content` animate 0 → auto — shipped in Chromium alone, so
// Firefox and Safari always got a hard jump. Driving the element's own height
// works everywhere and leaves the native element intact: keyboard, screen
// readers and find-in-page behave exactly as before, we only add the motion.
const DURATION = 280 // same value as $motion-slow in styles/app.scss
const EASING = 'cubic-bezier(0.4, 0, 0.2, 1)'
// The run currently in charge of an element. Toggling mid-flight has to retarget
// the animation rather than fight it, so every step re-checks its token before
// touching the DOM, and the run that loses the race leaves no inline styles
// behind for the winner to inherit.
const runs = new WeakMap()
function prefersReducedMotion() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
}
/** Settle at the target in one frame, dropping everything that was pinned. */
function release(el, token, open) {
if (runs.get(el) !== token) return
runs.delete(el)
el.style.transition = ''
el.style.height = ''
el.style.overflow = ''
if (open) el.setAttribute('open', '')
else el.removeAttribute('open')
}
/**
* Open or close a <details> by animating its height.
* `animate: false` settles it in one frame — for the state the card is rendered
* with, where there is no motion to show.
*/
export function setDetailsOpen(el, open, { animate = true } = {}) {
if (!el) return
const token = Symbol('details-animation')
runs.set(el, token)
const summary = el.querySelector(':scope > summary')
const collapsed = summary ? summary.offsetHeight : 0
if (!animate || prefersReducedMotion() || !el.isConnected) {
release(el, token, open)
return
}
if (el.hasAttribute('open') === open && !el.style.height) {
runs.delete(el)
return
}
// Where we are right now: a retarget starts from the height on screen, not
// from whatever the previous run was aiming at.
const from = el.getBoundingClientRect().height
// Pin before opening, so content that appears with the attribute is clipped
// instead of flashing at full height for the frame it takes to measure it.
el.style.overflow = 'hidden'
el.style.height = `${from}px`
if (open) el.setAttribute('open', '')
// A lazily mounted body — ContentCard holds its iframes back until the first
// open — only lands after the open attribute has been handled, so measuring
// now would animate to a height that does not exist yet.
setTimeout(() => {
if (runs.get(el) !== token) return
// scrollHeight is the height the content wants, whatever height is pinned.
const to = open ? el.scrollHeight : collapsed
if (Math.abs(to - from) < 1) {
release(el, token, open)
return
}
void el.offsetHeight // flush, so the transition has a start value
el.style.transition = `height ${DURATION}ms ${EASING}`
el.style.height = `${to}px`
const finish = () => {
clearTimeout(timer)
el.removeEventListener('transitionend', onEnd)
release(el, token, open)
}
const onEnd = (event) => {
if (event.target !== el || event.propertyName !== 'height') return
finish()
}
el.addEventListener('transitionend', onEnd)
// A card that is off-screen, or inside a hidden pane, may never run the
// transition at all; the timer keeps it from staying pinned forever.
const timer = setTimeout(finish, DURATION + 100)
}, 0)
}
/**
* `@click.prevent` on a <summary> — the whole of the toggle wiring.
*
* The click is suppressed because letting it through would flip the attribute
* natively and leave nothing to animate. Changing the attribute ourselves still
* fires the `toggle` event, so a card that tracks its own open state (the
* `@toggle` in ContentCard) keeps working untouched.
*/
export function detailsToggle(event) {
const el = event.currentTarget?.parentElement
if (!el || el.tagName !== 'DETAILS') return
setDetailsOpen(el, !el.hasAttribute('open'))
}