Files
livewire-material/resources/js/layers.js
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

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

185 lines
7.3 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)
/** The nearest layer matching `selector` around `target` that is open, walking out past shut ones. */
const nearestOpen = (target, selector) => {
let layer = target instanceof Element ? target.closest(selector) : null
while (layer !== null && !isOpen(layer)) {
layer = layer.parentElement?.closest(selector) ?? null
}
return layer
}
/**
* 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
}
const layer = nearestOpen(event.target, MODAL_LAYERS)
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
}
const layer = nearestOpen(event.target, LAYERS)
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()
})
}))
})