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:
Andreas Reinhold / reini
2026-09-16 21:00:23 +02:00
co-authored by Claude Opus 5
parent 15e4fa7ed1
commit 9f46630352
6 changed files with 486 additions and 28 deletions
+8 -23
View File
@@ -4,12 +4,13 @@
* state through a Livewire morph, and is announced as a disclosure by the browser. Judged against
* foundations rather than any M3 component spec (docs/audits/m3-alignment/containment.md, C-24).
*
* `interpolate-size: allow-keywords` makes `<details>`'s `0 -> auto` block-size an animatable pair,
* so `::details-content` (the pseudo-element that holds everything after the summary) can transition
* `block-size` on the fast spatial spring, the same one that turns the chevron; `content-visibility`
* goes with it, `allow-discrete`, so the content stays rendered while it closes rather than
* vanishing on the first frame. Under reduced motion the duration token is zero
* (tokens/motion.css), so the section snaps open with nothing else to do.
* The height eases open and shut on the fast spatial spring, the same one that turns the chevron,
* from resources/js/collapse.js: it animates the `<details>`' own `block-size`. This file cannot —
* `interpolate-size` (for `0 -> auto`) is Chrome's alone, and Firefox does not hold the content's
* `content-visibility` through a close — so Firefox and Safari used to snap open and shut. While a
* close runs, `open` is still set and `data-md-collapse-closing` turns the chevron back at its
* start. Under reduced motion the duration token is zero (tokens/motion.css), and the section
* snaps, as the browser draws it.
*
* `data-md-variant="filled"` is a surface-container tile with a large corner; the summary inherits
* it (`border-radius: inherit`) so the shared `md-state-layer`'s own `::before`, which also
@@ -27,27 +28,11 @@
@import './icon.css';
@layer material.components {
[data-md-collapse] {
interpolate-size: allow-keywords;
}
[data-md-collapse][data-md-variant='filled'] {
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-surface-container);
}
[data-md-collapse]::details-content {
block-size: 0;
overflow: hidden;
transition:
block-size var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete;
}
[data-md-collapse][open]::details-content {
block-size: auto;
}
[data-md-collapse-summary] {
display: flex;
min-block-size: var(--md-sys-measurement-space600);
@@ -82,7 +67,7 @@
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-md-collapse][open] > [data-md-collapse-summary] > [data-md-collapse-chevron] {
[data-md-collapse][open]:not([data-md-collapse-closing]) > [data-md-collapse-summary] > [data-md-collapse-chevron] {
rotate: 180deg;
}
+212
View File
@@ -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,
)
+1
View File
@@ -19,6 +19,7 @@ import './progress.js'
import './list-rows.js'
import './bottom-sheet.js'
import './dialog.js'
import './collapse.js'
import './carousel.js'
import './chips.js'
import './field.js'
@@ -4,8 +4,10 @@
state through a Livewire morph (`wire:ignore.self` stops the server's HTML from closing it),
and is announced as a disclosure. Drawn in M3's terms: a title-medium `title` (or a
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
and a height that eases open on the same spring
(`data-md-collapse`, resources/css/components/collapse.css). `open` starts it open.
and a height that eases open and shut on the same spring in every engine
(`data-md-collapse`, resources/css/components/collapse.css, and resources/js/collapse.js,
which animates the `<details>` itself because only Chrome can animate its content's height
from CSS). `open` starts it open.
`variant`: `plain` (the default, on the surface around it) or `filled` (a surface-container
tile with a large corner).
@@ -48,7 +50,7 @@
@if ($bound)
x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }"
@if ($model === null) x-modelable="collapseOpen" @endif
x-effect="$el.open = collapseOpen"
x-effect="materialCollapse($el, collapseOpen)"
x-on:toggle="collapseOpen = $el.open"
@endif
@if ($expanded) open @endif
+258
View File
@@ -873,3 +873,261 @@ it('keeps a selected segmented row\'s fill under the hover and focus tint', func
expect($selectedFocusBackground)->not->toBe($restBackground)
->and($selectedFocusBackground)->not->toBe($selectedHoverBackground);
});
/**
* Collapses to watch move: one on its own with a paragraph under it, one bound to Alpine, and a
* `name` group of two with the first open. Each body is tall enough for a height caught part-way
* to be told from both ends.
*/
function collapseMotionProbe(string $durations = ''): mixed
{
Route::middleware('web')->get('/collapse-motion-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<x-collapse id="plain" title="How long do links last?">
<p style="block-size: 240px">Until the expiry the sender chose, at most thirty days.</p>
</x-collapse>
<p id="below">Under the collapse.</p>
<div x-data="{ advanced: false }">
<x-collapse id="bound" title="Advanced" x-model="advanced">
<p style="block-size: 240px">Everything else.</p>
</x-collapse>
<button type="button" id="open-bound" x-on:click="advanced = true">Open from Alpine</button>
<button type="button" id="close-bound" x-on:click="advanced = false">Close from Alpine</button>
<output id="advanced" x-text="advanced"></output>
</div>
<x-collapse id="first" title="First" name="faq" open>
<p style="block-size: 240px">The first answer.</p>
</x-collapse>
<x-collapse id="second" title="Second" name="faq">
<p style="block-size: 240px">The second answer.</p>
</x-collapse>
@livewireScripts
</body>
</html>
BLADE));
$page = visit('/collapse-motion-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
if ($durations !== '') {
$page->script("document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-spatial-fast-duration: {$durations}; }</style>')");
}
return $page;
}
/**
* Runs `$act` in the page and samples `$details` every frame until its animation has run: whether
* its height was ever caught strictly between where it started and where it ended, what `open` and
* the closing mark said as it began, whether the chevron was caught turning, and how it ended. In one round trip, because a separate read
* costs about as long as the 350ms spring.
*/
function collapseMotion(mixed $page, string $act, string $details): array
{
return $page->script(<<<JS
(async () => {
const details = document.querySelector('{$details}')
const chevron = details.querySelector('[data-md-collapse-chevron]')
const below = details.nextElementSibling
const start = details.getBoundingClientRect().height
const belowStart = below?.getBoundingClientRect().top ?? 0
const heights = []
const belows = []
{$act}
// A binding reaches the collapse from Alpine's own flush, a microtask on; a task later
// both routes have begun, and the close's attribute and `open` hold for its whole run.
await new Promise((resolve) => setTimeout(resolve, 0))
const first = { open: details.open, closing: details.hasAttribute('data-md-collapse-closing') }
let chevronTurning = false
for (let i = 0; i < 120; i++) {
await new Promise((resolve) => requestAnimationFrame(resolve))
heights.push(details.getBoundingClientRect().height)
belows.push(below?.getBoundingClientRect().top ?? 0)
const rotate = getComputedStyle(chevron).rotate
chevronTurning ||= rotate !== 'none' && rotate !== '180deg' && rotate !== '0deg'
if (i > 5 && details.getAnimations().length === 0) break
}
const end = details.getBoundingClientRect().height
const low = Math.min(start, end)
const high = Math.max(start, end)
return {
between: heights.some((height) => height > low + 2 && height < high - 2),
belowMoved: belows.some((top) => top > Math.min(belowStart, belows.at(-1)) + 2 && top < Math.max(belowStart, belows.at(-1)) - 2),
first,
chevronTurning,
start,
end,
open: details.open,
closing: details.hasAttribute('data-md-collapse-closing'),
style: details.getAttribute('style'),
animations: details.getAnimations().length,
}
})()
JS);
}
it('eases a collapse open and shut in every engine, moving what is under it', function () {
$page = collapseMotionProbe();
$opened = collapseMotion($page, "details.querySelector('summary').click()", '#plain');
expect($opened['first']['open'])->toBeTrue()
->and($opened['between'])->toBeTrue()
->and($opened['belowMoved'])->toBeTrue()
->and($opened['open'])->toBeTrue()
->and($opened['end'])->toBeGreaterThan($opened['start'] + 200)
->and($opened['style'])->toBeNull()
->and($opened['animations'])->toBe(0);
$closed = collapseMotion($page, "details.querySelector('summary').click()", '#plain');
// Still open while the height goes, so there is content to clip; the chevron turns back at once.
expect($closed['first'])->toBe(['open' => true, 'closing' => true])
->and($closed['chevronTurning'])->toBeTrue()
->and($closed['between'])->toBeTrue()
->and($closed['belowMoved'])->toBeTrue()
->and($closed['open'])->toBeFalse()
->and($closed['closing'])->toBeFalse()
->and($closed['end'])->toBe($opened['start'])
->and($closed['style'])->toBeNull()
->and($closed['animations'])->toBe(0);
// Clipped while it moves, so the content never shows past the edge that is moving.
$clipped = $page->script(<<<'JS'
(async () => {
const details = document.querySelector('#plain')
details.querySelector('summary').click()
await new Promise((resolve) => requestAnimationFrame(resolve))
const overflow = getComputedStyle(details).overflowY
await Promise.all(details.getAnimations().map((animation) => animation.finished))
return overflow
})()
JS);
expect($clipped)->toBe('clip');
// From the keyboard: Enter on the summary is the same press, taken over the same way.
$page->script("document.querySelector('#plain summary').focus()");
$page->keys('#plain summary', 'Enter')
->assertScript("document.querySelector('#plain').open === false && document.querySelector('#plain').getAnimations().length === 0");
});
it('eases a collapse bound to Alpine open and shut, keeping the binding in step', function () {
$page = collapseMotionProbe();
$opened = collapseMotion($page, "document.querySelector('#open-bound').click()", '#bound');
expect($opened['between'])->toBeTrue()
->and($opened['open'])->toBeTrue();
$page->assertSeeIn('#advanced', 'true');
$closed = collapseMotion($page, "document.querySelector('#close-bound').click()", '#bound');
expect($closed['first']['closing'])->toBeTrue()
->and($closed['between'])->toBeTrue()
->and($closed['open'])->toBeFalse()
->and($closed['end'])->toBe($opened['start']);
$page->assertSeeIn('#advanced', 'false');
// A press on the summary tells the binding when the section has closed, and never loops.
$page->click('#bound summary')
->assertSeeIn('#advanced', 'true')
->assertScript("document.querySelector('#bound').open === true");
});
it('turns a collapse round from the height it has reached, ending as the last press asked', function () {
$page = collapseMotionProbe('1500ms');
$result = $page->script(<<<'JS'
(async () => {
const details = document.querySelector('#plain')
const summary = details.querySelector('summary')
const closed = details.getBoundingClientRect().height
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
summary.click()
await wait(300)
const midway = details.getBoundingClientRect().height
summary.click()
await new Promise((resolve) => requestAnimationFrame(resolve))
const turned = details.getBoundingClientRect().height
await wait(1700)
return { closed, midway, turned, end: details.getBoundingClientRect().height, open: details.open, style: details.getAttribute('style') }
})()
JS);
expect($result['midway'])->toBeGreaterThan($result['closed'] + 2)
// Turned round from where it was, not from either end.
->and(abs($result['turned'] - $result['midway']))->toBeLessThan(40)
->and($result['open'])->toBeFalse()
->and($result['end'])->toBe($result['closed'])
->and($result['style'])->toBeNull();
});
it('closes the open member of a name group on the same spring as the one opening', function () {
$page = collapseMotionProbe();
$first = "document.querySelector('#first')";
$result = $page->script(<<<'JS'
(async () => {
const first = document.querySelector('#first')
const second = document.querySelector('#second')
const start = first.getBoundingClientRect().height
let caught = false
second.querySelector('summary').click()
const stillOpen = first.open
for (let i = 0; i < 120; i++) {
await new Promise((resolve) => requestAnimationFrame(resolve))
const height = first.getBoundingClientRect().height
caught ||= first.open && height > 60 && height < start - 2
if (i > 5 && first.getAnimations().length === 0 && second.getAnimations().length === 0) break
}
return { stillOpen, caught, first: first.open, second: second.open, name: first.getAttribute('name') }
})()
JS);
expect($result)->toBe(['stillOpen' => true, 'caught' => true, 'first' => false, 'second' => true, 'name' => 'faq']);
// The group still keeps one open: the browser's own exclusivity, with the name back.
$page->script("document.querySelector('#first').open = true");
$page->assertScript("{$first}.open === true && document.querySelector('#second').open === false");
});
it('opens and closes a collapse at once under reduced motion, leaving nothing behind', function () {
$page = collapseMotionProbe('0ms');
$result = $page->script(<<<'JS'
(() => {
const details = document.querySelector('#plain')
details.querySelector('summary').click()
const opened = { open: details.open, animations: details.getAnimations().length }
details.querySelector('summary').click()
return { opened, open: details.open, animations: details.getAnimations().length, closing: details.hasAttribute('data-md-collapse-closing'), style: details.getAttribute('style') }
})()
JS);
expect($result)->toBe(['opened' => ['open' => true, 'animations' => 0], 'open' => false, 'animations' => 0, 'closing' => false, 'style' => null]);
});
+2 -2
View File
@@ -39,7 +39,7 @@ it('binds its open state through x-model, drawing the open prop until Alpine sta
->toMatch('/x-data="{ collapseOpen:\s*true\s*}"/')
->toContain('x-modelable="collapseOpen"')
->toContain('x-model="advanced"')
->toContain('x-effect="$el.open = collapseOpen"')
->toContain('x-effect="materialCollapse($el, collapseOpen)"')
->toContain('x-on:toggle="collapseOpen = $el.open"')
->toMatch('/\sopen\s/');
});
@@ -72,6 +72,6 @@ it('draws a filled collapse\'s padding and an open one\'s chevron on its own par
expect($css->declarations("[data-md-collapse][data-md-variant='filled'] > [data-md-collapse-summary]"))->toBe(['padding-inline' => 'var(--md-sys-measurement-space200)'])
->and($css->declarations("[data-md-collapse][data-md-variant='filled'] > [data-md-collapse-body]"))->toBe(['padding-inline' => 'var(--md-sys-measurement-space200)'])
->and($css->declarations('[data-md-collapse][open] > [data-md-collapse-summary] > [data-md-collapse-chevron]'))->toBe(['rotate' => '180deg'])
->and($css->declarations('[data-md-collapse][open]:not([data-md-collapse-closing]) > [data-md-collapse-summary] > [data-md-collapse-chevron]'))->toBe(['rotate' => '180deg'])
->and($css->declarations('[data-md-collapse-summary]'))->toHaveKey('gap', '12px');
});