Files
livewire-material/resources/js/dialog.js
T
Andreas Reinhold / reiniandClaude Opus 5 94a376a4fd Close only the topmost layer on Escape, and keep a layer opened over a sheet readable
A modal side sheet, bottom sheet or the modal rail closed on any Escape
the window heard, so a dialog opened from a sheet, a menu, select list
or searchable choice inside one, a sheet opened from a sheet, and a
sheet inside a dialog each closed two layers on one press. And a dialog
or a second sheet rendered elsewhere on the page sat inside the
`aria-hidden` the first sheet's `x-trap.inert` put on its siblings, so a
screen reader could not read it, while the sheet's focus trap took every
Tab inside the dialog back to the inert sheet.

resources/js/layers.js adds `x-layer`, on each of those panels beside
its `x-trap`. An Escape is the panel's only when nothing has handled it
and the nearest open layer around its target is the panel itself - not
an open dialog, popover or customizable select, nor a panel inside it;
the panel claims it with preventDefault(), which also keeps a dialog
around it from cancelling, and dispatches `material-escape`, which the
views close on. A panel that opens lifts `aria-hidden` from its own
ancestors and puts it back on close only where a panel still open hides
them; materialShowModal() does the same for `<x-modal>`, whose new
`x-trap.noautofocus.noreturn` pauses the sheet's focus trap while it is
open and moves no focus of its own. The searchable choice, the search
view and the supporting pane's sheet now preventDefault() the Escape
they act on, so the dialog or sheet around them stays.

Four browser tests stack the layers every way above and fail without
the change in Chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 05:59:32 +02:00

82 lines
3.7 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.
*/
import { expose } from './layers.js'
/**
* `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.
*
* A dialog opened from a modal sheet but rendered outside it sits inside what the sheet's
* `x-trap.inert` hid from assistive technology, so it lifts that for as long as it is open
* (layers.js).
*/
window.materialShowModal = (dialog) => {
dialog.addEventListener('close', expose(dialog), { once: true })
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')
})
})
})