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
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user