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>
139 lines
5.5 KiB
JavaScript
139 lines
5.5 KiB
JavaScript
/**
|
|
* Layers: how the modal surfaces stack, so one Escape dismisses one layer and the layer on top is
|
|
* never hidden from assistive technology. The modal panels are `<x-drawer>`'s sheet below
|
|
* `expanded`, `<x-bottom-sheet>`'s and the navigation rail's while it is open over the page, each
|
|
* trapped with `x-trap.inert`; above or below them can be `<x-modal>`'s native `<dialog>`, and
|
|
* inside any of them a menu, a customizable select or another list.
|
|
*
|
|
* `x-layer="expression"` goes on a modal panel, beside its `x-trap`, and does two things while the
|
|
* expression is true.
|
|
*
|
|
* **Escape.** Each panel used to close on any Escape the window heard, so a dialog opened from a
|
|
* sheet, a sheet opened from a sheet, a sheet inside a dialog or a list inside a sheet closed two
|
|
* layers at once. An Escape is the panel's only when nothing has handled it yet
|
|
* (`defaultPrevented`: a searchable choice's list, the date picker, the search view, a panel on top)
|
|
* and the nearest open layer around its target is the panel itself — not an open `<dialog>`, a
|
|
* popover (a menu, which its own light dismiss closes) or a customizable select's list, and not
|
|
* another panel inside this one. A target in no layer at all (focus dropped to the body) belongs to
|
|
* the panel opened last. The panel then claims the Escape with `preventDefault()`, which also keeps
|
|
* a `<dialog>` around the panel from cancelling, and dispatches `material-escape` on itself for the
|
|
* view to close on; a view that keeps its panel open on Escape still claims it, so nothing under
|
|
* the panel closes in its place.
|
|
*
|
|
* **The accessibility tree.** `x-trap.inert` hides every sibling of the panel and of each of its
|
|
* ancestors (`aria-hidden`). That is right for the page under the panel and wrong for a layer
|
|
* opened above it from elsewhere in the document — a dialog rendered outside the sheet, or a second
|
|
* sheet beside the first — which sits inside one of those siblings. So a layer that opens lifts
|
|
* `aria-hidden` from its own ancestors (`expose()`), and when it closes puts it back only where a
|
|
* panel that is still open hides that element. `<x-modal>` exposes its dialog the same way
|
|
* (materialShowModal(), dialog.js); a modal `<dialog>` keeps the rest of the page from assistive
|
|
* technology itself, and its own `x-trap` pauses the focus trap of the panel under it, whose Tab
|
|
* would otherwise pull the focus back to an inert sheet.
|
|
*/
|
|
|
|
/** Everything that can hold an Escape before the window hears it, nearest first. */
|
|
const LAYERS = 'dialog, [popover], select, [aria-modal="true"], [data-md-navigation-rail-panel]'
|
|
|
|
/** The panels that are open, in the order they opened. */
|
|
const panels = []
|
|
|
|
const isOpen = (layer) => {
|
|
if (layer.matches('dialog')) {
|
|
return layer.open
|
|
}
|
|
|
|
if (layer.matches('[popover]')) {
|
|
return layer.matches(':popover-open')
|
|
}
|
|
|
|
if (layer.matches('select')) {
|
|
try {
|
|
return layer.matches(':open')
|
|
} catch {
|
|
// No customizable select, so no list of the page's own to hold the Escape.
|
|
return false
|
|
}
|
|
}
|
|
|
|
if (layer.matches('[data-md-navigation-rail-panel]')) {
|
|
return panels.includes(layer)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/** Whether an Escape keydown is this open panel's to act on (see the header). */
|
|
const owns = (panel, event) => {
|
|
if (event.defaultPrevented) {
|
|
return false
|
|
}
|
|
|
|
let layer = event.target instanceof Element ? event.target.closest(LAYERS) : null
|
|
|
|
while (layer !== null && !isOpen(layer)) {
|
|
layer = layer.parentElement?.closest(LAYERS) ?? null
|
|
}
|
|
|
|
return layer === null ? panels.at(-1) === panel : layer === panel
|
|
}
|
|
|
|
/**
|
|
* Lifts `aria-hidden` from `layer`'s ancestors; the function it returns puts it back on those a
|
|
* panel still open hides — a sibling of that panel or of one of its ancestors, as x-trap.inert
|
|
* hides them.
|
|
*/
|
|
export const expose = (layer) => {
|
|
const lifted = []
|
|
|
|
for (let element = layer.parentElement; element !== null && element !== document.body; element = element.parentElement) {
|
|
if (element.getAttribute('aria-hidden') === 'true') {
|
|
element.removeAttribute('aria-hidden')
|
|
lifted.push(element)
|
|
}
|
|
}
|
|
|
|
return () => lifted
|
|
.filter((element) => panels.some((panel) => panel !== layer && !element.contains(panel) && element.parentElement?.contains(panel)))
|
|
.forEach((element) => element.setAttribute('aria-hidden', 'true'))
|
|
}
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
window.Alpine.directive('layer', window.Alpine.skipDuringClone((el, { expression }, { effect, evaluateLater, cleanup }) => {
|
|
const active = evaluateLater(expression)
|
|
let restore = null
|
|
|
|
const release = () => {
|
|
if (restore === null) {
|
|
return
|
|
}
|
|
|
|
panels.splice(panels.indexOf(el), 1)
|
|
restore()
|
|
restore = null
|
|
}
|
|
|
|
const escape = (event) => {
|
|
if (restore !== null && event.key === 'Escape' && owns(el, event)) {
|
|
event.preventDefault()
|
|
el.dispatchEvent(new CustomEvent('material-escape'))
|
|
}
|
|
}
|
|
|
|
effect(() => active((value) => {
|
|
if (value && restore === null) {
|
|
panels.push(el)
|
|
restore = expose(el)
|
|
} else if (!value) {
|
|
release()
|
|
}
|
|
}))
|
|
|
|
window.addEventListener('keydown', escape)
|
|
|
|
cleanup(() => {
|
|
window.removeEventListener('keydown', escape)
|
|
release()
|
|
})
|
|
}))
|
|
})
|