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:
co-authored by
Claude Opus 5
parent
b4a82a4dbe
commit
921c8cef04
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
|
||||
->toMatch('/<div id="[^"]+-body" data-md-modal-body x-dialog-dividers>\s*<div data-md-modal-content>Body<\/div>\s*<\/div>/')
|
||||
->toMatch('/<div id="[^"]+-body" wire:ignore.self data-md-modal-body x-dialog-dividers>\s*<div data-md-modal-content>Body<\/div>\s*<\/div>/')
|
||||
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user