Files
livewire-material/resources/views/components/drawer.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 94a376a4fd Close only the topmost layer on Escape, and keep a layer opened over a sheet readable
A modal side sheet, bottom sheet or the modal rail closed on any Escape
the window heard, so a dialog opened from a sheet, a menu, select list
or searchable choice inside one, a sheet opened from a sheet, and a
sheet inside a dialog each closed two layers on one press. And a dialog
or a second sheet rendered elsewhere on the page sat inside the
`aria-hidden` the first sheet's `x-trap.inert` put on its siblings, so a
screen reader could not read it, while the sheet's focus trap took every
Tab inside the dialog back to the inert sheet.

resources/js/layers.js adds `x-layer`, on each of those panels beside
its `x-trap`. An Escape is the panel's only when nothing has handled it
and the nearest open layer around its target is the panel itself - not
an open dialog, popover or customizable select, nor a panel inside it;
the panel claims it with preventDefault(), which also keeps a dialog
around it from cancelling, and dispatches `material-escape`, which the
views close on. A panel that opens lifts `aria-hidden` from its own
ancestors and puts it back on close only where a panel still open hides
them; materialShowModal() does the same for `<x-modal>`, whose new
`x-trap.noautofocus.noreturn` pauses the sheet's focus trap while it is
open and moves no focus of its own. The searchable choice, the search
view and the supporting pane's sheet now preventDefault() the Escape
they act on, so the dialog or sheet around them stays.

Four browser tests stack the layers every way above and fail without
the change in Chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 05:59:32 +02:00

224 lines
11 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, spans the window's height 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.
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;
// 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
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>