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) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 06:09:29 +02:00
co-authored by Claude Opus 5
parent 207270a23d
commit a6dc213e67
4 changed files with 87 additions and 1 deletions
+6
View File
@@ -54,6 +54,12 @@
under an installed app's status bar as the page scrolls; inside `<x-scaffold>` it also sticks under an installed app's status bar as the page scrolls; inside `<x-scaffold>` it also sticks
under a sticky `<x-app-bar>` in the `top` slot, which covered it. It is as tall as the window under a sticky `<x-app-bar>` 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. below that, and every side sheet keeps the bottom safe area inside its bottom padding.
- **A standard `<x-drawer>` 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 ## From 2.0.0 to 2.1.0
+9
View File
@@ -213,6 +213,15 @@
display: none; 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 /* 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. */ 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]),
+14 -1
View File
@@ -37,7 +37,10 @@
`open` itself would run before `settle()` in the same flush and, already queued, not run again `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 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 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 `<x-list-detail>` instead (step 35's canonical 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 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. // a wider one is the modal sheet's to take, not the co-planar standard sheet's.
$sheetWidth = $standard ? "min({$width}, 400px)" : $width; $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 // 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 // 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. // Escape leaves it open there, so it always draws one.
@@ -160,6 +172,7 @@
x-effect="settle(open)" x-effect="settle(open)"
data-md-drawer data-md-drawer
@if ($standard) data-md-standard @endif @if ($standard) data-md-standard @endif
@if ($startsOpen) data-md-open @endif
style="--sheet-width: {{ $sheetWidth }}" style="--sheet-width: {{ $sheetWidth }}"
> >
{{-- `md-transition` is a class name only to turn on Alpine's CSS transition: `x-show` then keeps {{-- `md-transition` is a class name only to turn on Alpine's CSS transition: `x-show` then keeps
+58
View File
@@ -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'
<div>
<x-row id="sheets-row" align="stretch" gap="space300" x-data="{ open: false }">
<x-stack id="sheets-column" style="flex: 1 1 0%; min-width: 0;"><p>The session list</p></x-stack>
<x-drawer standard wire:model="filters" title="Filters">Narrow the list down</x-drawer>
<x-drawer standard wire:model="session" title="Session">The open session</x-drawer>
<x-drawer standard title="Notes">Nobody opened these</x-drawer>
</x-row>
<script>
window.firstPaint = {
column: document.getElementById('sheets-column').getBoundingClientRect().width,
alpine: typeof window.Alpine,
};
</script>
</div>
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'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body>
<livewire:standard-sheets-probe />
@livewireScripts
</body>
</html>
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 () { it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment(); $page = containment();