/** * ``'s height, eased open and shut on the fast spatial spring in every engine. * * collapse.css used to animate `
`' `::details-content` from `block-size: 0` to `auto`: * that takes `interpolate-size: allow-keywords` (Chrome only) and a `content-visibility` that holds * through the close (not Firefox), so Firefox and Safari snapped open and shut. A script can do what * the stylesheet cannot, the same way everywhere: the `
` itself runs a Web Animation of its * `block-size`, from the height it is drawn at to the height it is going to, with `overflow: clip` * for as long as it runs. Content below it moves with it; nothing is written into its `style`. * * - Open: `open` is set at once — the content is there, `toggle` fires, the chevron turns — and the * height grows from where it was to the section's natural height. * - Close: `open` stays set while the height shrinks to the summary's, so the content is still there * to be clipped; `data-md-collapse-closing` turns the chevron back at the start, and `open` goes * (and `toggle` fires) when the height has. `min-block-size` holds the summary whole while the * spring overshoots. * - A press mid-way turns it round from the height it has reached. * - A `name` group closes its open member with the same animation: that member's `name` is lifted * for the close, so the browser's own exclusivity does not shut it on the first frame, and put * back once it has closed. * * Routes: a press on the summary (a pointer, Enter or Space, all a `click`) is taken over here; the * Alpine and Livewire bindings call `materialCollapse(details, open)` from the view's `x-effect`. * Anything else that sets `open` — find-in-page revealing a match, an application's own script — * opens or closes it at once, as the browser does, and this follows along. Under reduced motion the * duration token is zero and every route is the browser's own, instant. */ const COLLAPSE = 'details[data-md-collapse]' const CLOSING = 'data-md-collapse-closing' /** Where each collapse is headed (`true` open), its running animation, and a `name` lifted for its close. */ const targets = new WeakMap() const animations = new WeakMap() const lifted = new WeakMap() const milliseconds = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0 /** The height the section is drawn at, closed: its summary, and its own padding and border. */ const closedHeight = (details) => { const style = getComputedStyle(details) const summary = details.querySelector(':scope > summary') return ( (summary?.getBoundingClientRect().height ?? 0) + parseFloat(style.paddingBlockStart) + parseFloat(style.paddingBlockEnd) + parseFloat(style.borderBlockStartWidth) + parseFloat(style.borderBlockEndWidth) ) } const restoreName = (details) => { if (lifted.has(details)) { details.setAttribute('name', lifted.get(details)) lifted.delete(details) } } /** Moves the height from where it is drawn now to `to`, then `done`. Returns false under reduced motion. */ const animate = (details, from, to, done) => { const style = getComputedStyle(details) const duration = milliseconds(style.getPropertyValue('--md-sys-motion-spatial-fast-duration')) if (duration === 0) { return false } const floor = `${closedHeight(details)}px` const animation = details.animate( [ { blockSize: `${from}px`, minBlockSize: floor, overflow: 'clip' }, { blockSize: `${to}px`, minBlockSize: floor, overflow: 'clip' }, ], { duration, easing: style.getPropertyValue('--md-sys-motion-spatial-fast').trim() || 'ease', fill: 'forwards' }, ) animations.set(details, animation) animation.onfinish = () => { if (animations.get(details) !== animation) { return } animations.delete(details) done() animation.cancel() } return true } /** The height the section is drawn at this moment, then without whatever animation was running. */ const settle = (details) => { const height = details.getBoundingClientRect().height animations.get(details)?.cancel() animations.delete(details) return height } const open = (details) => { targets.set(details, true) const from = settle(details) details.removeAttribute(CLOSING) // The group's open member closes on the same spring, with its `name` lifted so the browser's // exclusivity does not shut it the moment this one opens; this one's own name comes back // after, when no other member of the group is open to be closed by it. const name = details.getAttribute('name') ?? lifted.get(details) if (name) { details .getRootNode() .querySelectorAll(`${COLLAPSE}[open]`) .forEach((other) => { if (other !== details && (other.getAttribute('name') ?? lifted.get(other)) === name && targets.get(other) !== false) { close(other) } }) } restoreName(details) details.open = true animate(details, from, details.getBoundingClientRect().height, () => {}) } const close = (details) => { targets.set(details, false) const from = settle(details) const to = closedHeight(details) const shut = () => { details.removeAttribute(CLOSING) details.open = false restoreName(details) } if (details.hasAttribute('name')) { lifted.set(details, details.getAttribute('name')) details.removeAttribute('name') } details.setAttribute(CLOSING, '') if (!animate(details, from, to, shut)) { shut() } } /** * Opens (`true`) or closes a collapse on its spring; what the view's bindings call. The first call * for a collapse, when Alpine starts and applies the bound value, sets it at once: nothing should * move on page load. */ window.materialCollapse = (details, shouldOpen) => { shouldOpen = Boolean(shouldOpen) if (!targets.has(details)) { targets.set(details, details.open) if (details.open !== shouldOpen) { details.open = shouldOpen targets.set(details, shouldOpen) } return } if (targets.get(details) === shouldOpen) { return } shouldOpen ? open(details) : close(details) } document.addEventListener('click', (event) => { const summary = event.target instanceof Element ? event.target.closest('summary') : null const details = summary?.parentElement if (event.defaultPrevented || !details?.matches(COLLAPSE) || summary !== details.querySelector(':scope > summary')) { return } if (milliseconds(getComputedStyle(details).getPropertyValue('--md-sys-motion-spatial-fast-duration')) === 0) { return } event.preventDefault() const headedOpen = targets.has(details) ? targets.get(details) : details.open headedOpen ? close(details) : open(details) }) // Whatever else sets `open` (find-in-page, a `name` group this script did not close, an // application's own script) is followed, unless this script is the one mid-way through a change. document.addEventListener( 'toggle', (event) => { const details = event.target if (details instanceof HTMLDetailsElement && details.matches(COLLAPSE) && !animations.has(details)) { targets.set(details, details.open) details.removeAttribute(CLOSING) } }, true, )