Give a closing standard side sheet's room back as it leaves

From `expanded` a standard side sheet sits in the layout beside the
content, and the view took it out of the layout the moment it closed:
`data-md-drawer-collapsed` (`display: none` on the root) followed `! open`
at once, so its fade was never seen and the content beside it took the
sheet's width, and the gap, in one jump — in every engine. M3's side
sheets: opening a standard sheet shrinks the body beside it, and closing
gives the room back.

drawer.css: the standard root is a clipping flex box whose `inline-size`
springs between none and the sheet's width, with a negative margin as
wide as its flex parent's gap (`--md-drawer-gap`), on the sheet's own
exit and entry timings; the sheet keeps its width at the root's far edge,
so it is uncovered from its inner edge while it fades. drawer.blade.php:
`settle()`, from `x-effect`, measures the gap on each change and holds
the collapse (`closing`) for the root's and the sheet's closing
durations, read a frame on; reopening lets a pending end go by. The
binding reads `collapsed`, which only `settle()` and the window's width
write: a binding reading `open` ran before `settle()` in the same flush
and, already queued, did not run again once `closing` changed.

ContainmentTest closes the showcase's sheet at 1000px and samples, in the
page, for the root part-way to none while the column beside it has grown
part of the way, then checks the collapse, the column at the row's full
width, and a reopen part-way through the exit ending fully open; it fails
on the previous code in Chrome, Firefox and Safari. OverlayTest pins the
root's declarations and the view's bindings.

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 db6023bf80
commit 99c18e01d8
4 changed files with 172 additions and 7 deletions
+56 -4
View File
@@ -23,7 +23,14 @@
modal sheet — M3 calls the standard sheet "supplementary surfaces mainly for medium to
expanded breakpoints" and the modal one "preferred at compact breakpoints", and the switch
sits at `expanded` rather than `medium` because M3 also caps a side sheet at 400dp and a
600px window has too little left beside one.
600px window has too little left beside one. Opening one shrinks the body beside it and closing
one gives the room back (M3's side sheets, "Adaptive"): the root's inline size, and a negative
margin as wide as its flex parent's gap, spring between none and the sheet's width while the
sheet fades, and `data-md-drawer-collapsed` — which takes the closed sheet out of the layout —
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.
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
@@ -78,20 +85,66 @@
x-data="{
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
wide: false,
collapsed: false,
closing: false,
closings: 0,
wasOpen: null,
close() { this.open = typeof this.open === 'boolean' ? false : null; },
settle(open) {
open = Boolean(open);
if (this.wasOpen === null || open === this.wasOpen) {
this.wasOpen = open;
return;
}
this.wasOpen = open;
const closing = ++this.closings;
const gap = parseFloat(getComputedStyle(this.$el.parentElement).columnGap);
this.$el.style.setProperty('--md-drawer-gap', Number.isNaN(gap) ? '0px' : gap + 'px');
this.closing = ! open;
if (open) {
this.collapsed = false;
return;
}
requestAnimationFrame(() => {
const ms = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000);
const longest = (element) => Math.max(0, ...getComputedStyle(element).transitionDuration.split(',').map(ms));
const duration = Math.max(longest(this.$el), ...[...this.$el.children].map(longest));
setTimeout(() => {
if (closing === this.closings) {
this.closing = false;
this.collapsed = this.wide;
}
}, duration);
});
},
@if ($standard)
init() {
const query = window.matchMedia('(width >= 840px)');
this.wide = query.matches;
query.addEventListener('change', (event) => this.wide = event.matches);
this.collapsed = ! this.open && this.wide;
query.addEventListener('change', (event) => {
this.wide = event.matches;
this.collapsed = ! this.open && this.wide && ! this.closing;
});
},
@endif
}"
@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="(! open && wide) ? '' : null"
x-bind:data-md-drawer-collapsed="collapsed ? '' : null"
x-effect="settle(open)"
data-md-drawer
@if ($standard) data-md-standard @endif
style="--sheet-width: {{ $sheetWidth }}"
>
{{-- `md-transition` is a class name only to turn on Alpine's CSS transition: `x-show` then keeps
the scrim and the sheet displayed for their computed transition-duration (drawer.css's
@@ -114,7 +167,6 @@
id="{{ $id }}"
data-md-drawer-sheet
data-md-side="{{ $side }}"
style="--sheet-width: {{ $sheetWidth }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id']) }}
>
@if (filled($title) || $closeButton)