/** * 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 ``'s sheet below * `expanded`, ``'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 ``'s native ``, 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 ``, 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 `` 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. `` exposes its dialog the same way * (materialShowModal(), dialog.js); a modal `` 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() }) })) })