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>
224 lines
11 KiB
PHP
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>
|