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>
165 lines
8.4 KiB
PHP
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>
|