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
+5 -3
View File
@@ -30,9 +30,11 @@
* `> [data-md-modal-box] >`, as the divider marks do: a basic dialog opened from inside a
* full-screen dialog's body is a descendant of it, and must keep its own title and padding.
*
* `[data-md-modal-body]:focus-visible` is Chrome's own focusable scroll container catching the
* dialog's first focus when nothing inside can take it — M3's 3px secondary indicator, drawn
* inside the edge because the box's rounded, overflow-hidden corner would clip one drawn outside.
* `[data-md-modal-body]:focus-visible` is the scrolling body catching the dialog's first focus when
* nothing inside can take it — natively in Chrome, whose scroll containers are focusable, and
* through `materialShowModal()` (resources/js/dialog.js) in Firefox and WebKit — M3's 3px secondary
* indicator, drawn inside the edge because the box's rounded, overflow-hidden corner would clip one
* drawn outside.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
+27 -3
View File
@@ -5,15 +5,39 @@
* those as the dividers under the pinned header and over the pinned actions; a body that fits
* marks neither.
*
* The marks go on the `<dialog>` rather than the body because the dialog is `wire:ignore.self`: a
* Livewire morph hands the body back the server's attributes, which would wipe a mark until the
* next scroll, but it never touches the dialog's own.
* The marks go on the `<dialog>`, which is `wire:ignore.self`, so a Livewire morph never wipes one
* until the next scroll; the body is `wire:ignore.self` too, for `materialShowModal()`'s
* `tabindex`, but the dividers are the dialog's frame and modal.css reads them there.
*
* Measured on scroll, and whenever the body or what is in it changes size: the body when the window
* or the dialog does (opening, too — a closed dialog has no size, and the observer reports the
* one it opens to), and the element the component wraps around the slot when a morph, an image or
* a disclosure makes the content taller or shorter while the body stays at its cap.
*/
/**
* `materialShowModal(dialog)`, how `<x-modal>` opens: `showModal()`, and then the first focus Chrome
* gives a text-only dialog and Firefox and WebKit do not. With nothing focusable inside,
* `showModal()` focuses the `<dialog>` itself, the HTML fallback; Chrome's scroll containers are
* keyboard-focusable, so there the scrolling body is the focus delegate instead. A focused dialog
* leaves a long body unscrollable from the keyboard — the arrow keys scroll what has the focus, and
* WebKit never lets Tab reach a scroll container — so when the dialog took the focus itself and its
* body overflows, the body is made focusable and takes it, as in Chrome, where modal.css draws the
* inset ring. The `tabindex` goes when the dialog closes, so each opening decides afresh.
*/
window.materialShowModal = (dialog) => {
dialog.showModal()
const body = dialog.querySelector(':scope > [data-md-modal-box] > [data-md-modal-body]')
if (document.activeElement !== dialog || !body || body.scrollHeight - body.clientHeight < 1) {
return
}
body.tabIndex = 0
body.focus()
dialog.addEventListener('close', () => body.removeAttribute('tabindex'), { once: true })
}
document.addEventListener('alpine:init', () => {
window.Alpine.directive('dialog-dividers', (el, _, { cleanup }) => {
const dialog = el.closest('dialog')
+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