Newer
Older
navi-1 / webclient / src / utils / detailsAnimation.js
// 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'))
}