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>
185 lines
7.3 KiB
JavaScript
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()
|
|
})
|
|
}))
|
|
})
|