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>
75 lines
3.4 KiB
JavaScript
75 lines
3.4 KiB
JavaScript
/**
|
|
* `x-dialog-dividers`, on `<x-modal>`'s body — the one part of a dialog that scrolls: marks the
|
|
* `<dialog>` with `data-md-overflow-top` while the body is scrolled away from its top, and
|
|
* `data-md-overflow-bottom` while more of it is below. resources/css/components/modal.css draws
|
|
* 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>`, 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')
|
|
|
|
if (!dialog) {
|
|
return
|
|
}
|
|
|
|
// A pixel of slack: at a fractional device pixel ratio the end of a scroll can stop a
|
|
// fraction short of `scrollHeight`, which is rounded.
|
|
const measure = () => {
|
|
dialog.toggleAttribute('data-md-overflow-top', el.scrollTop >= 1)
|
|
dialog.toggleAttribute('data-md-overflow-bottom', el.scrollHeight - el.clientHeight - el.scrollTop >= 1)
|
|
}
|
|
|
|
const resizes = new ResizeObserver(measure)
|
|
resizes.observe(el)
|
|
|
|
const content = el.querySelector(':scope > [data-md-modal-content]')
|
|
|
|
if (content) {
|
|
resizes.observe(content)
|
|
}
|
|
|
|
el.addEventListener('scroll', measure, { passive: true })
|
|
|
|
cleanup(() => {
|
|
resizes.disconnect()
|
|
el.removeEventListener('scroll', measure)
|
|
dialog.removeAttribute('data-md-overflow-top')
|
|
dialog.removeAttribute('data-md-overflow-bottom')
|
|
})
|
|
})
|
|
})
|