With nothing focusable inside, showModal() focuses the <dialog> itself. Chrome's scroll containers are keyboard-focusable, so there the scrolling body became the focus delegate and modal.css drew its inset ring; Firefox and WebKit focused the dialog, where the arrow keys scroll nothing, and WebKit never lets Tab reach a scroll container, so a keyboard could not read a long text-only dialog there at all. <x-modal> now opens through materialShowModal() (resources/js/dialog.js): showModal(), and when the dialog took the focus itself and its body overflows, the body gets tabindex="0" and the focus, as in Chrome. The tabindex goes when the dialog closes. The body is wire:ignore.self, like the dialog: a Livewire render would otherwise morph the tabindex away and WebKit dropped the focus out of the dialog. ContainmentTest now also checks the focus stays on the body through a render. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
8.2 KiB
PHP
162 lines
8.2 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. 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-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>
|