A modal side sheet, bottom sheet and the modal rail move the focus to their first control as they open (x-trap), and `<x-modal>` does in `showModal()`. On a page loaded with the sheet already open (a `wire:model` set from `?workout=` in the URL) the browsers count that focus as `:focus-visible`, and so they do for a sheet or dialog opened from the keyboard, so tooltip.js showed the close button's "Close" tooltip the moment the layer appeared, over its title, with no keyboard use. Confirmed in Chrome, Firefox and Safari for the deep-linked sheet and a full-screen dialog opened with Enter; a deep-linked `<x-modal>`'s own focus was not counted as keyboard focus. M3 lets a tooltip appear on hover or focus and says nothing that asks for one on a focus the component moves itself. layers.js gains `openingFocus(event)`: a focus is a modal layer's own when the nearest open dialog, `aria-modal` panel or modal rail around its target is entered from outside it, or from nowhere, and no Tab is moving it (a Tab moves the focus while its keydown is handled, by the browser or by focus-trap wrapping round the panel). tooltip.js and rich-tooltip.js leave such a focus out; focus moved within the layer, a Tab onto the control and a focus returned to it from a menu inside still show the tooltip. The docblocks, the skill and UPGRADE.md say so. A browser test loads a page with a modal sheet open at 393px, opens a full-screen dialog and a dialog whose first control is a rich tooltip's trigger from the keyboard, and checks that no tooltip is up on the focused control, then that Tab wrapping round to the close button shows its tooltip; it fails without the change in Chrome, Firefox and Safari, each part on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
7.2 KiB
JavaScript
182 lines
7.2 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.
|
|
*
|
|
* **The focus a layer moves.** A modal panel, `<x-modal>` 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()
|
|
})
|
|
}))
|
|
})
|