/** * 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. * * **The focus a layer moves.** A modal panel, `` and the modal rail move the focus to * their first control as they open (x-trap, `showModal()`), and on a page loaded with the layer * open, or opened from the keyboard, the browser counts that as `:focus-visible`. `openingFocus()` * tells that focus apart from a person's, so a tooltip does not stand over a sheet's close button * the moment the sheet appears (tooltip.js, rich-tooltip.js). */ /** 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 } /** The layers that move the focus into themselves as they open. */ const MODAL_LAYERS = 'dialog, [aria-modal="true"], [data-md-navigation-rail-panel]' /** * Whether a Tab is moving the focus: the browser moves it while the keydown is handled, and so does * focus-trap when it takes a Tab round a panel, so a focus that lands before the next task is the * Tab's. */ let tabbing = false document.addEventListener('keydown', (event) => { if (event.key === 'Tab') { tabbing = true setTimeout(() => (tabbing = false)) } }, true) /** * Whether a `focusin` is an open modal layer moving the focus into itself (see the header): the * focus arrives from outside the nearest open layer around its target, or from nowhere, and no Tab * is moving it. Focus moved within the layer, and a Tab that focus-trap brings back to its first * control, are a person's. */ export const openingFocus = (event) => { if (tabbing || !(event.target instanceof Element)) { return false } let layer = event.target.closest(MODAL_LAYERS) while (layer !== null && !isOpen(layer)) { layer = layer.parentElement?.closest(MODAL_LAYERS) ?? null } return layer !== null && !(event.relatedTarget instanceof Node && layer.contains(event.relatedTarget)) } /** 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() }) })) })