Draw a standard side sheet's first state without motion

Giving a closing standard sheet its room back made the root's inline
size a transition, with an `@starting-style` for the entry, so a sheet
that starts open grew in on every page load: its open state arrives
through Alpine, after the first paint of the closed root. Nothing should
move while a page loads.

The view now marks the root `data-md-drawer-settled` two frames after
the first `settle()` — once the state Alpine starts with has been
painted — and drawer.css gives the standard sheet's root and sheet no
transitions until then. The browser test loads a page with slowed motion
tokens and a sheet that starts open, and finds it standing at its full
width, shown, with nothing animating; closing it afterwards still
animates. It fails without the change in Chrome and Firefox and passes
in all three engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 21:02:48 +02:00
co-authored by Claude Opus 5
parent 99c18e01d8
commit 1ccf0b639e
3 changed files with 59 additions and 4 deletions
+7
View File
@@ -195,6 +195,13 @@
display: none;
}
/* Until the view settles (`data-md-drawer-settled`, two frames after Alpine starts), the
state the page loads with is drawn at once: a sheet that starts open does not grow in. */
[data-md-drawer][data-md-standard]:not([data-md-drawer-settled]),
[data-md-drawer][data-md-standard]:not([data-md-drawer-settled]) > [data-md-drawer-sheet] {
transition: none;
}
[data-md-drawer][data-md-standard] > [data-md-drawer-scrim] {
display: none;
}
+14 -2
View File
@@ -30,7 +30,9 @@
waits for that exit (`closing`, `settle()`) instead of cutting it off on its first frame. It
binds `collapsed`, which only `settle()` and the window's width write: a binding that read
`open` itself would run before `settle()` in the same flush and, already queued, not run again
once `closing` changed.
once `closing` changed. Nothing moves while the page loads: `data-md-drawer-settled` comes two
frames after the state Alpine starts with, and the standard sheet has no transitions until
then, so a sheet that starts open stands open rather than growing in.
For the second pane of a list-detail layout use `<x-list-detail>` instead (step 35's canonical
layout); the two are not the same thing — a pane shows what the list beside it selected, a
@@ -85,6 +87,7 @@
x-data="{
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
wide: false,
settled: false,
collapsed: false,
closing: false,
closings: 0,
@@ -93,9 +96,17 @@
settle(open) {
open = Boolean(open);
if (this.wasOpen === null || open === this.wasOpen) {
if (this.wasOpen === null) {
this.wasOpen = open;
// The state Alpine starts with is drawn, not animated: two frames on, once it has
// been painted, the sheet may move.
requestAnimationFrame(() => requestAnimationFrame(() => this.settled = true));
return;
}
if (open === this.wasOpen) {
return;
}
@@ -141,6 +152,7 @@
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
x-bind:data-md-open="open ? '' : null"
x-bind:data-md-drawer-collapsed="collapsed ? '' : null"
x-bind:data-md-drawer-settled="settled ? '' : null"
x-effect="settle(open)"
data-md-drawer
@if ($standard) data-md-standard @endif
+38 -2
View File
@@ -515,8 +515,8 @@ it('gives a closing standard side sheet\'s room back to the content beside it as
$column = "{$root}.previousElementSibling";
$toggle = "Array.from(document.querySelectorAll('#containment button')).find((button) => button.textContent.trim() === 'Show or hide the filters')";
// The example starts open, and grows in once Alpine opens it: its entry has run when the sheet
// stands at its full 400px and fully shown, with nothing on it still animating.
// The example starts open, and stands open from the first frame Alpine draws: full 400px, fully
// shown, nothing on it animating.
$page = containment()->resize(1000, 800)
->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1' && {$root}.getAnimations({ subtree: true }).length === 0");
@@ -565,6 +565,42 @@ it('gives a closing standard side sheet\'s room back to the content beside it as
->assertScript("{$root}.hasAttribute('data-md-open') && ! {$root}.hasAttribute('data-md-drawer-collapsed') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1 && getComputedStyle({$sheet}).opacity === '1'");
});
it('draws a standard side sheet that starts open standing open, without growing it in on load', function () {
// Slow tokens, so a load-time entry would still be running when the page is first read.
Route::middleware('web')->get('/standard-sheet-load-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
<style>:root { --md-sys-motion-spatial-default-duration: 3000ms; --md-sys-motion-effects-default-duration: 3000ms; }</style>
</head>
<body>
<x-row align="stretch" gap="space300" x-data="{ open: true }">
<x-stack style="flex: 1 1 0%; min-width: 0;">
<button type="button" id="toggle-sheet" x-on:click="open = ! open">Show or hide the filters</button>
</x-stack>
<x-drawer standard title="Filters">Filter the list</x-drawer>
</x-row>
@livewireScripts
</body>
</html>
BLADE));
$root = "document.querySelector('[data-md-drawer]')";
$page = visit('/standard-sheet-load-probe')->resize(1000, 800)->waitForEvent('networkidle')
->assertScript("typeof window.Alpine !== 'undefined' && {$root}.hasAttribute('data-md-drawer-settled')")
->assertScript("{$root}.hasAttribute('data-md-open') && Math.abs({$root}.getBoundingClientRect().width - 400) < 1")
->assertScript("{$root}.getAnimations({ subtree: true }).length === 0")
->assertScript("getComputedStyle({$root}.querySelector('[data-md-drawer-sheet]')).opacity === '1'");
// Settled, it still moves when someone closes it.
$page->script("document.getElementById('toggle-sheet').click()");
$page->assertScript("{$root}.getAnimations({ subtree: true }).length > 0");
});
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment();