From a6dc213e67f954211adce7bab9c6c175ea22bd01 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Thu, 17 Sep 2026 06:09:29 +0200 Subject: [PATCH] Keep a closed standard side sheet out of its row from the first paint A closed standard sheet is `inline-size: 0` with a negative margin as wide as its row's gap, but the view writes that gap (`--md-drawer-gap`) and `data-md-drawer-collapsed` only once Alpine runs. Until then each closed sheet was a zero-wide flex item that still cost the row one gap, and a sheet bound to an open Livewire property had no width at all, so the content beside them changed width when the script started - two closed sheets beside a column made it 48px narrower, then it jumped. Until the view has settled, drawer.css now takes a standard sheet that is not `data-md-open` out of the layout, and the view renders `data-md-open` on a standard sheet whose `wire:model` property is open (truthy as Alpine reads it), so it stands at its width from the first paint. Transitions still wait for `data-md-drawer-settled`. A browser test measures a column beside two closed sheets (one bound to Livewire, one to Alpine) and an open one before Alpine starts and once settled; it fails without the change. Co-Authored-By: Claude Opus 5 (1M context) --- UPGRADE.md | 6 +++ resources/css/components/drawer.css | 9 ++++ resources/views/components/drawer.blade.php | 15 +++++- tests/Browser/ContainmentTest.php | 58 +++++++++++++++++++++ 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/UPGRADE.md b/UPGRADE.md index 78da365f..fa247116 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -54,6 +54,12 @@ under an installed app's status bar as the page scrolls; inside `` it also sticks under a sticky `` in the `top` slot, which covered it. It is as tall as the window below that, and every side sheet keeps the bottom safe area inside its bottom padding. +- **A standard `` takes its place from the first paint.** Until Alpine started, a closed + standard sheet was a zero-wide item that still cost its row one gap, and a sheet bound to an open + Livewire property had no width yet, so the content beside them changed width when the script + ran. A standard sheet is now out of the row until then, and one whose `wire:model` property is + open is rendered open and stands at its width. One opened from an Alpine scope alone still + appears when Alpine starts, which only the script can know. ## From 2.0.0 to 2.1.0 diff --git a/resources/css/components/drawer.css b/resources/css/components/drawer.css index 7af556b8..d173c3fa 100644 --- a/resources/css/components/drawer.css +++ b/resources/css/components/drawer.css @@ -213,6 +213,15 @@ display: none; } + /* Before the view has settled, a sheet not rendered open is out of the layout already. The + view writes `data-md-drawer-collapsed` and measures the row's gap only once Alpine runs, + so until then a closed sheet would be a zero-wide flex item that still costs its row one + gap, and the content beside it would widen when the script started. A sheet whose + `wire:model` property is open is rendered `data-md-open` and stands at its width. */ + [data-md-drawer][data-md-standard]:not([data-md-open], [data-md-drawer-settled]) { + 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]), diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php index a78a5a06..76e2ad66 100644 --- a/resources/views/components/drawer.blade.php +++ b/resources/views/components/drawer.blade.php @@ -37,7 +37,10 @@ `open` itself would run before `settle()` in the same flush and, already queued, not run again 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. + then, so a sheet that starts open stands open rather than growing in. Before the script starts + at all, a standard sheet whose `wire:model` property is open is rendered `data-md-open` and + stands at its width, and any other is out of the row, so the content beside it keeps its width + when Alpine runs. For the second pane of a list-detail layout use `` instead (step 35's canonical layout); the two are not the same thing — a pane shows what the list beside it selected, a @@ -82,6 +85,15 @@ // a wider one is the modal sheet's to take, not the co-planar standard sheet's. $sheetWidth = $standard ? "min({$width}, 400px)" : $width; + // A standard sheet bound to Livewire is drawn open from the first paint when its property is + // (truthy as Alpine reads it), so it stands in the row before the script starts rather than + // growing into it; drawer.css keeps any other standard sheet out of the row until then. + $startsOpen = false; + + if ($standard && $model !== null && ($component = \Livewire\Livewire::current()) !== null) { + $startsOpen = ! in_array(data_get($component, $model), [null, false, 0, 0.0, ''], true); + } + // M3 requires a close affordance; the prop can only ever add one, never take away the last // way out of the sheet. A standard sheet keeps no scrim and no trap from `expanded`, and // Escape leaves it open there, so it always draws one. @@ -160,6 +172,7 @@ x-effect="settle(open)" data-md-drawer @if ($standard) data-md-standard @endif + @if ($startsOpen) data-md-open @endif style="--sheet-width: {{ $sheetWidth }}" > {{-- `md-transition` is a class name only to turn on Alpine's CSS transition: `x-show` then keeps diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 248a8bc8..afaec5cd 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -832,6 +832,64 @@ it('sticks a standard side sheet under the top safe area and a scaffold\'s stick } }); +class StandardSheetsProbe extends Component +{ + public bool $filters = false; + + public ?int $session = 7; + + public function render(): string + { + return <<<'BLADE' +
+ +

The session list

+ Narrow the list down + The open session + Nobody opened these +
+ +
+ BLADE; + } +} + +it('draws standard side sheets as the server renders them from the first paint, closed ones out of the row and an open one standing', function () { + Livewire::component('standard-sheets-probe', StandardSheetsProbe::class); + + Route::middleware('web')->get('/standard-sheets-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + // Playwright's window is 1280px wide from the first paint, in the band where the sheets stand + // in the row. The column measured before Alpine starts is the column once it has settled: two + // closed sheets (one bound to Livewire, one to Alpine) take no gap, and the open one its width. + $page = visit('/standard-sheets-probe')->waitForEvent('networkidle') + ->assertScript("window.eval('window.firstPaint.alpine') === 'undefined'") + ->assertScript("[...document.querySelectorAll('[data-md-drawer]')].every((root) => root.hasAttribute('data-md-drawer-settled'))"); + + $settled = $page->script("document.getElementById('sheets-column').getBoundingClientRect().width"); + + expect(abs($page->script('window.firstPaint.column') - $settled))->toBeLessThan(1) + ->and($page->script("Math.round([...document.querySelectorAll('[data-md-drawer]')][1].getBoundingClientRect().width)"))->toBe(400); +}); + it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () { $page = containment();