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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 12:23:15 +02:00
co-authored by Claude Opus 5
parent b4a82a4dbe
commit 921c8cef04
5 changed files with 46 additions and 10 deletions
+6 -3
View File
@@ -13,7 +13,10 @@
`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.
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
@@ -93,7 +96,7 @@
@else
x-data="{ close() { this.open = false } }"
@endif
x-effect="open ? ($el.open || $el.showModal()) : ($el.open && $el.close())"
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
@@ -141,7 +144,7 @@
@endif
@if ($body)
<div id="{{ $id }}-body" data-md-modal-body x-dialog-dividers>
<div id="{{ $id }}-body" wire:ignore.self data-md-modal-body x-dialog-dividers>
<div data-md-modal-content>{{ $slot }}</div>
</div>
@endif