Ease a collapse open and shut in Firefox and Safari too
collapse.css animated `<details>`' `::details-content` from `block-size: 0` to `auto`, which needs `interpolate-size: allow-keywords` (Chrome 129 and nothing else) and a `content-visibility` held through the close with `allow-discrete` (not Firefox), so in Firefox and Safari the section snapped open and shut. No stylesheet can do it there. resources/js/collapse.js animates the `<details>` itself, the same way in every engine: a Web Animation of its `block-size` from the height it is drawn at to the height it is going to, on the fast spatial spring's tokens, with `overflow: clip` and a `min-block-size` that keeps the summary whole through the spring's overshoot, all inside the keyframes, so nothing is left in `style`. Content below moves with it. Opening sets `open` at once and grows the height; closing keeps `open` while the height goes, marks `data-md-collapse-closing` so the chevron turns back at the start, and drops `open` (and fires `toggle`) when the height has. A press mid-way turns it round from the height it has reached. The CSS animation is gone, so Chrome takes the same path and never animates twice. Routes: a press on the summary (pointer, Enter, Space) is taken over; the Alpine and Livewire bindings call `materialCollapse()` from the view's `x-effect` (its first call, as Alpine starts, sets the state at once); a `name` group's open member closes on the same spring, its `name` lifted for the close so the browser's exclusivity does not shut it first and put back once closed. Anything else that sets `open` (find-in-page, an application's own script) stays instant, as the browser draws it, and is followed. Under reduced motion every route is the browser's own. Browser tests in Chrome, Firefox and Safari catch the height part-way open and shut (and what is under it moving) from a press and from the Alpine binding, the chevron turning at the start of a close, the clip while it moves, Enter on the summary, a reversal mid-way, a name group, and reduced motion. The four motion tests fail on main in Firefox and Safari. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
15e4fa7ed1
commit
9f46630352
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* `<x-collapse>`'s height, eased open and shut on the fast spatial spring in every engine.
|
||||
*
|
||||
* collapse.css used to animate `<details>`' `::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 `<details>` 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,
|
||||
)
|
||||
Reference in New Issue
Block a user