Files
livewire-material/resources/views/components/modal.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

165 lines
8.4 KiB
PHP

{{-- An M3 dialog, on the native `<dialog>` opened with `showModal()`.
Native because it gets the hard parts right on its own: the top layer above everything, the
rest of the page inert, focus moved in and handed back, Escape. Its `x-trap` moves no focus of
its own; it is there to pause the focus trap of a sheet the dialog opened over, which would
otherwise take every Tab back to the inert sheet (resources/js/layers.js). The open state is the Livewire
property in `wire:model` (entangled live) a flag (`$confirmingDelete`) or an id
(`$deletingShareId`) and closing writes back whichever "closed" means for it, `false` or
`null`. Without `wire:model` it reads and writes `open` in the Alpine scope around it:
<div x-data="{ open: false }">
<x-button label="Delete" x-on:click="open = true" />
<x-modal title="Delete this share?"></x-modal>
</div>
`wire:ignore.self`, because `showModal()` sets the `open` attribute, which the server's HTML
does not have: without it the next Livewire render morphs the attribute away and the dialog
shuts under the person using it. The contents still morph. The body is `wire:ignore.self` for
the same reason: `materialShowModal()` (resources/js/dialog.js) gives a text-only body the
`tabindex` that lets it hold the first focus, and a render that took it away would drop that
focus out of the dialog in WebKit.
M3's basic dialog (DialogTokens, androidx Compose Material 3, Apache-2.0): surface-container-
high, extra-large corner, elevation 3, a headline-small `title`, body-medium `subtitle` in
on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon
above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that
holds a form take the whole screen on a compact window (below `medium`, 600px M3 uses
full-screen dialogs "only in compact breakpoints", FullScreenDialogTokens), with a 56px
close-and-title bar clear of the notch (docs/audits/m3-alignment/containment.md, C-17).
`persistent` ignores Escape and the scrim, for a dialog that must be answered. It opens on the
fast spatial spring and closes at once, as M3's do.
"Dialog content generally shouldn't scroll; if it must, the title stays pinned at the top and
the buttons at the bottom" (docs/reference/m3/components-actions-communication-containment.md
§ Dialogs → Behaviour, C-06): the header and the action row are their own rows of the flex
column and only the body between them scrolls, each with the 24dp padding the box used to
carry.
Between that body and the rows pinned around it go M3's optional dividers (the 1dp "Divider"
in both dialogs' anatomy, § Dialogs → Anatomy, Specs), each only while content is hidden on
its far side: the rule under the header once the body is scrolled away from its top, the rule
over the actions while more of the body is below, so a body that fits draws neither. On a
phone a full-screen dialog's top rule lies under its close-and-title bar, unless a subtitle
or an icon keeps the header there, and its action bar's rule follows the scroll like any
other. `separator` draws both rules whatever the scroll. The gaps M3 gives are split around
the rules — 8px above and 8px below the top one (16dp title to body), 8px above and 16px below
the bottom one (24dp body to actions) — so the scrolling body keeps room inside its edges for
a floating label or a focus ring, and a rule that appears moves nothing. Without a body there
is nothing to divide, and no rule. resources/css/components/modal.css draws the rules, keyed
on `data-md-modal-divider` (only where a body exists to divide, the bar's own flag computed in
PHP because it alone can sit beside a header that is *also* on screen) and `data-md-separator`;
`x-dialog-dividers` (resources/js/dialog.js), on the body, watches it and the element wrapped
around the slot, so content a Livewire render adds counts, and marks the `<dialog>` with
`data-md-overflow-top`/`data-md-overflow-bottom`.
`alert` is M3's "on web, basic dialogs should have the alert dialog role" (C-20): it sets
`role="alertdialog"` and points `aria-describedby` at the body, for the dialog that
interrupts to say something important. It is opt-in, because ARIA-APG keeps `alertdialog`
for exactly that and a form dialog would over-announce with it. A `subtitle` is always the
dialog's description.
The root renders `data-md-modal` and `data-md-fullscreen`; its parts are `data-md-modal-*`,
drawn by resources/css/components/modal.css: `-box`, `-bar` (the phone-only header bar),
`-head`, `-title`, `-subtitle`, `-body`, `-content` (the resize-observed wrapper around the
slot), `-actions`. `box-class` lands on the box. --}}
@props([
'title' => null,
'subtitle' => null,
'icon' => null,
'separator' => false,
'persistent' => false,
'fullscreen' => false,
'alert' => false,
'boxClass' => null,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$id = $attributes->get('id') ?? 'material-dialog-'.substr(md5($model.'|'.$title), 0, 10);
$header = filled($title) || filled($subtitle) || $icon;
$body = $slot->isNotEmpty();
// On a phone a full-screen dialog's bar names it, and the header hides unless an icon or a subtitle keeps it.
$barAboveBody = $fullscreen && (! $header || (! $icon && blank($subtitle)));
$describedBy = implode(' ', array_filter([
filled($subtitle) ? $id.'-subtitle' : null,
$alert && $body ? $id.'-body' : null,
]));
@endphp
<dialog
wire:ignore.self
@if ($model !== null)
x-data="{
open: @entangle($attributes->wire('model')).live,
close() { this.open = typeof this.open === 'boolean' ? false : null },
}"
@else
x-data="{ close() { this.open = false } }"
@endif
x-effect="open ? ($el.open || materialShowModal($el)) : ($el.open && $el.close())"
x-trap.noautofocus.noreturn="open"
x-on:cancel.prevent="{{ $persistent ? '' : 'close()' }}"
x-on:close="if (open) close()"
@if (! $persistent) x-on:click.self="close()" @endif
@if ($alert) role="alertdialog" @endif
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id'])->merge(array_filter([
'id' => $id,
'data-md-modal' => true,
'data-md-fullscreen' => $fullscreen ? true : null,
], fn ($value): bool => $value !== null)) }}
>
<div data-md-modal-box @if (filled($boxClass)) class="{{ $boxClass }}" @endif>
@if ($fullscreen)
<div
data-md-modal-bar
@if ($body && $barAboveBody) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
>
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title))
<span data-md-modal-bar-title>{{ $title }}</span>
@endif
</div>
@endif
@if ($header)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div
data-md-modal-head
@if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
>
@if ($icon)
<x-livewire-material::icon :name="$icon" size="24" />
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" data-md-modal-title>{{ $title }}</h2>
@endif
@if (filled($subtitle))
<p id="{{ $id }}-subtitle" data-md-modal-subtitle>{{ $subtitle }}</p>
@endif
</div>
@endif
@if ($body)
<div id="{{ $id }}-body" wire:ignore.self data-md-modal-body x-dialog-dividers>
<div data-md-modal-content>{{ $slot }}</div>
</div>
@endif
@isset($actions)
<div
data-md-modal-actions
@if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
>
{{ $actions }}
</div>
@endisset
</div>
</dialog>