Files
livewire-material/resources/views/components/modal.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 921c8cef04 Give a text-only dialog's scrolling body the first focus in every engine
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>
2026-09-16 12:23:15 +02:00

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>