diff --git a/resources/css/components/modal.css b/resources/css/components/modal.css index 465dc001..9ad531c5 100644 --- a/resources/css/components/modal.css +++ b/resources/css/components/modal.css @@ -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; diff --git a/resources/js/dialog.js b/resources/js/dialog.js index 28a3bfbb..51248f4b 100644 --- a/resources/js/dialog.js +++ b/resources/js/dialog.js @@ -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 `` 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 ``, 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 `` 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 `` 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') diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php index 622fb601..7207458c 100644 --- a/resources/views/components/modal.blade.php +++ b/resources/views/components/modal.blade.php @@ -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) -
+
{{ $slot }}
@endif diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 53f432de..dfdcd911 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -422,6 +422,13 @@ it('gives the scrolling body an inset focus ring when a text-only dialog opens f ->assertScript("getComputedStyle({$body}).outlineStyle === 'solid'") ->assertScript("getComputedStyle({$body}).outlineWidth === '3px'") ->assertScript("getComputedStyle({$body}).outlineOffset === '-3px'"); + + // Outside Chrome the body holds the focus through a tabindex materialShowModal() gives it; a + // Livewire render keeps it (the body is wire:ignore.self), so the focus stays in the dialog. + $page->script('window.eval("Livewire.first().$refresh()")'); + + $page->assertScript("document.querySelector('dialog[open]') !== null") + ->assertScript("document.activeElement.matches('[data-md-modal-body]')"); }); it('cycles a bottom sheet\'s preset heights from its handle, announcing each, and closes from the last', function () { diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 1d128740..2d09babe 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -75,7 +75,7 @@ it('pins a dialog\'s headline and actions and scrolls only the body between them expect($html) ->toContain('data-md-modal-box') ->toMatch('//') - ->toMatch('/
\s*
Body<\/div>\s*<\/div>/') + ->toMatch('/
\s*
Body<\/div>\s*<\/div>/') ->toMatch('//'); });