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>
239 lines
12 KiB
PHP
239 lines
12 KiB
PHP
{{-- An M3 side sheet: detail or controls that slide in from the edge over a scrim; on a phone it
|
|
is the whole screen.
|
|
|
|
The open state is the Livewire property in `wire:model` (entangled live, so a detail held in
|
|
the URL follows a close) — a flag or an id — and closing writes back `false` or `null`.
|
|
Without `wire:model` it reads and writes `open` in the Alpine scope around it. `close()` is in
|
|
scope for anything inside the sheet, so it can draw its own close button.
|
|
|
|
As a sheet it is modal: the page inert and still (`x-trap.inert.noscroll`), and Escape closes
|
|
only the topmost layer — a dialog, menu or list open above or inside the sheet takes its own,
|
|
and a sheet or dialog opened from this one stays readable to a screen reader
|
|
(`x-layer`, resources/js/layers.js). It enters on
|
|
emphasized decelerate rather than a spring — a sheet anchored to the edge that overshot would
|
|
open a gap. M3's modal side sheet (docs/reference/m3/components-actions-communication-
|
|
containment.md § Side sheets; Compose has no side-sheet token file): surface-container-low, a
|
|
large corner on its inner edge, elevation 1; `side` `end` (the default) or `start`, mirrored
|
|
in a right-to-left document; `width` from `medium`, 400px (M3's 400dp cap) unless it says
|
|
otherwise — a compact window gets the full-bleed sheet.
|
|
|
|
As a **standard** side sheet (`standard`, from `expanded`) it is M3's other variant: co-planar
|
|
with the content rather than over it — no scrim, no focus trap, nothing inert, 0dp elevation
|
|
(material-components-android's `SideSheet.md`: "standard side sheet elevation = 0dp,
|
|
coplanar"), `surface` rather than surface-container-low, no corner, and an outline-variant
|
|
divider down its inner edge in place of the scrim. It sits in the page flow beside the
|
|
content, sticks under the top safe area — and under `<x-scaffold>`'s sticky `top` app bar —
|
|
as tall as the window below that, with the bottom safe area inside its padding, and scrolls on
|
|
its own; below `expanded` (840px) it is the
|
|
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. 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. 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. 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
|
|
layout); the two are not the same thing — a pane shows what the list beside it selected, a
|
|
standard sheet is supplementary content (filters, details, a list of actions) beside the
|
|
primary content, divided from it by a rule rather than being part of its selection.
|
|
|
|
M3 **requires** a close affordance on a side sheet — without one nobody can predict the
|
|
sheet's open/close flow or tell whether it is transient or permanent
|
|
(docs/reference/m3/components-actions-communication-containment.md § Side sheets →
|
|
Accessibility, C-04) — so `with-close-button` is on by default, and
|
|
`:with-close-button="false"` is ignored where nothing else closes the sheet: Escape off, the
|
|
scrim off, or `standard`, which has neither from `expanded`.
|
|
|
|
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
|
|
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot.
|
|
|
|
The root renders `data-md-drawer`, `data-md-open` and `data-md-standard`; the sheet is
|
|
`data-md-drawer-sheet` with `data-md-side`, its parts `data-md-drawer-head`/-head-row/
|
|
-heading/-title/-subtitle/-close/-body/-actions — drawn by resources/css/components/
|
|
drawer.css, which imports button.css and divider.css for what the view renders. --}}
|
|
|
|
@props([
|
|
'title' => null,
|
|
'subtitle' => null,
|
|
'separator' => false,
|
|
'side' => 'end',
|
|
'right' => true,
|
|
'withCloseButton' => true,
|
|
'closeOnEscape' => true,
|
|
'withoutBackdropClose' => false,
|
|
'width' => '400px',
|
|
'standard' => false,
|
|
])
|
|
|
|
@php
|
|
$model = $attributes->wire('model')->value() ?: null;
|
|
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
|
|
$side = $side === 'start' ? 'start' : 'end';
|
|
$standard = (bool) $standard;
|
|
|
|
// The side-sheet specs table caps the sheet at 400dp, which is where `width` already starts;
|
|
// 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.
|
|
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || $standard;
|
|
@endphp
|
|
|
|
<div
|
|
x-data="{
|
|
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
|
wide: false,
|
|
settled: 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) {
|
|
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;
|
|
}
|
|
|
|
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;
|
|
this.collapsed = ! this.open && this.wide;
|
|
query.addEventListener('change', (event) => {
|
|
this.wide = event.matches;
|
|
this.collapsed = ! this.open && this.wide && ! this.closing;
|
|
});
|
|
},
|
|
@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
|
|
@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
|
|
the scrim and the sheet displayed for their computed transition-duration (drawer.css's
|
|
tokens) before hiding them, so they fade and slide out rather than vanish — Firefox does not
|
|
transition `display`, even with `allow-discrete`. Both stages, so reopening during the exit
|
|
cancels the pending hide. Nothing styles the class. --}}
|
|
<div data-md-drawer-scrim x-cloak x-show="open" x-transition:enter="md-transition" x-transition:leave="md-transition" @if (! $withoutBackdropClose) x-on:click="close()" @endif aria-hidden="true"></div>
|
|
|
|
<aside
|
|
x-cloak
|
|
x-show="open"
|
|
x-transition:enter="md-transition"
|
|
x-transition:leave="md-transition"
|
|
x-trap.inert.noscroll="open && ! wide"
|
|
x-layer="open && ! wide"
|
|
@if ($closeOnEscape) x-on:material-escape="close()" @endif
|
|
x-bind:role="wide ? 'region' : 'dialog'"
|
|
x-bind:aria-modal="wide ? null : 'true'"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
|
id="{{ $id }}"
|
|
data-md-drawer-sheet
|
|
data-md-side="{{ $side }}"
|
|
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id']) }}
|
|
>
|
|
@if (filled($title) || $closeButton)
|
|
<div data-md-drawer-head>
|
|
<div data-md-drawer-head-row>
|
|
<div data-md-drawer-heading>
|
|
@if (filled($title))
|
|
<h2 id="{{ $id }}-title" data-md-drawer-title>{{ $title }}</h2>
|
|
@endif
|
|
|
|
@if (filled($subtitle))
|
|
<p data-md-drawer-subtitle>{{ $subtitle }}</p>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($closeButton)
|
|
<span data-md-drawer-close>
|
|
<x-livewire-material::button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
|
|
</span>
|
|
@endif
|
|
</div>
|
|
|
|
@if ($separator)
|
|
<x-livewire-material::divider />
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
<div data-md-drawer-body>{{ $slot }}</div>
|
|
|
|
@isset($actions)
|
|
{{-- The side-sheet specs table: bottom actions 72dp tall, 16dp above them, 24dp below
|
|
(the sheet's own padding), aligned **left** — a dialog's are trailing-aligned, and
|
|
M3 specifies the two differently (C-10). --}}
|
|
<div data-md-drawer-actions>{{ $actions }}</div>
|
|
@endisset
|
|
</aside>
|
|
</div>
|