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>
213 lines
7.3 KiB
JavaScript
213 lines
7.3 KiB
JavaScript
/**
|
|
* `<x-collapse>`'s height, eased open and shut on the fast spatial spring in every engine.
|
|
*
|
|
* collapse.css used to animate `<details>`' `::details-content` from `block-size: 0` to `auto`:
|
|
* that takes `interpolate-size: allow-keywords` (Chrome only) and a `content-visibility` that holds
|
|
* through the close (not Firefox), so Firefox and Safari snapped open and shut. A script can do what
|
|
* the stylesheet cannot, the same way everywhere: the `<details>` itself runs a Web Animation of its
|
|
* `block-size`, from the height it is drawn at to the height it is going to, with `overflow: clip`
|
|
* for as long as it runs. Content below it moves with it; nothing is written into its `style`.
|
|
*
|
|
* - Open: `open` is set at once — the content is there, `toggle` fires, the chevron turns — and the
|
|
* height grows from where it was to the section's natural height.
|
|
* - Close: `open` stays set while the height shrinks to the summary's, so the content is still there
|
|
* to be clipped; `data-md-collapse-closing` turns the chevron back at the start, and `open` goes
|
|
* (and `toggle` fires) when the height has. `min-block-size` holds the summary whole while the
|
|
* spring overshoots.
|
|
* - A press mid-way turns it round from the height it has reached.
|
|
* - A `name` group closes its open member with the same animation: that member's `name` is lifted
|
|
* for the close, so the browser's own exclusivity does not shut it on the first frame, and put
|
|
* back once it has closed.
|
|
*
|
|
* Routes: a press on the summary (a pointer, Enter or Space, all a `click`) is taken over here; the
|
|
* Alpine and Livewire bindings call `materialCollapse(details, open)` from the view's `x-effect`.
|
|
* Anything else that sets `open` — find-in-page revealing a match, an application's own script —
|
|
* opens or closes it at once, as the browser does, and this follows along. Under reduced motion the
|
|
* duration token is zero and every route is the browser's own, instant.
|
|
*/
|
|
import { ms } from './util.js'
|
|
|
|
const COLLAPSE = 'details[data-md-collapse]'
|
|
const CLOSING = 'data-md-collapse-closing'
|
|
|
|
/** Where each collapse is headed (`true` open), its running animation, and a `name` lifted for its close. */
|
|
const targets = new WeakMap()
|
|
const animations = new WeakMap()
|
|
const lifted = new WeakMap()
|
|
|
|
/** The height the section is drawn at, closed: its summary, and its own padding and border. */
|
|
const closedHeight = (details) => {
|
|
const style = getComputedStyle(details)
|
|
const summary = details.querySelector(':scope > summary')
|
|
|
|
return (
|
|
(summary?.getBoundingClientRect().height ?? 0) +
|
|
parseFloat(style.paddingBlockStart) +
|
|
parseFloat(style.paddingBlockEnd) +
|
|
parseFloat(style.borderBlockStartWidth) +
|
|
parseFloat(style.borderBlockEndWidth)
|
|
)
|
|
}
|
|
|
|
const restoreName = (details) => {
|
|
if (lifted.has(details)) {
|
|
details.setAttribute('name', lifted.get(details))
|
|
lifted.delete(details)
|
|
}
|
|
}
|
|
|
|
/** Moves the height from where it is drawn now to `to`, then `done`. Returns false under reduced motion. */
|
|
const animate = (details, from, to, done) => {
|
|
const style = getComputedStyle(details)
|
|
const duration = ms(details, '--md-sys-motion-spatial-fast-duration') ?? 0
|
|
|
|
if (duration === 0) {
|
|
return false
|
|
}
|
|
|
|
const floor = `${closedHeight(details)}px`
|
|
const animation = details.animate(
|
|
[
|
|
{ blockSize: `${from}px`, minBlockSize: floor, overflow: 'clip' },
|
|
{ blockSize: `${to}px`, minBlockSize: floor, overflow: 'clip' },
|
|
],
|
|
{ duration, easing: style.getPropertyValue('--md-sys-motion-spatial-fast').trim() || 'ease', fill: 'forwards' },
|
|
)
|
|
|
|
animations.set(details, animation)
|
|
animation.onfinish = () => {
|
|
if (animations.get(details) !== animation) {
|
|
return
|
|
}
|
|
|
|
animations.delete(details)
|
|
done()
|
|
animation.cancel()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/** The height the section is drawn at this moment, then without whatever animation was running. */
|
|
const settle = (details) => {
|
|
const height = details.getBoundingClientRect().height
|
|
|
|
animations.get(details)?.cancel()
|
|
animations.delete(details)
|
|
|
|
return height
|
|
}
|
|
|
|
const open = (details) => {
|
|
targets.set(details, true)
|
|
|
|
const from = settle(details)
|
|
|
|
details.removeAttribute(CLOSING)
|
|
|
|
// The group's open member closes on the same spring, with its `name` lifted so the browser's
|
|
// exclusivity does not shut it the moment this one opens; this one's own name comes back
|
|
// after, when no other member of the group is open to be closed by it.
|
|
const name = details.getAttribute('name') ?? lifted.get(details)
|
|
|
|
if (name) {
|
|
details
|
|
.getRootNode()
|
|
.querySelectorAll(`${COLLAPSE}[open]`)
|
|
.forEach((other) => {
|
|
if (other !== details && (other.getAttribute('name') ?? lifted.get(other)) === name && targets.get(other) !== false) {
|
|
close(other)
|
|
}
|
|
})
|
|
}
|
|
|
|
restoreName(details)
|
|
details.open = true
|
|
|
|
animate(details, from, details.getBoundingClientRect().height, () => {})
|
|
}
|
|
|
|
const close = (details) => {
|
|
targets.set(details, false)
|
|
|
|
const from = settle(details)
|
|
const to = closedHeight(details)
|
|
|
|
const shut = () => {
|
|
details.removeAttribute(CLOSING)
|
|
details.open = false
|
|
restoreName(details)
|
|
}
|
|
|
|
if (details.hasAttribute('name')) {
|
|
lifted.set(details, details.getAttribute('name'))
|
|
details.removeAttribute('name')
|
|
}
|
|
|
|
details.setAttribute(CLOSING, '')
|
|
|
|
if (!animate(details, from, to, shut)) {
|
|
shut()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens (`true`) or closes a collapse on its spring; what the view's bindings call. The first call
|
|
* for a collapse, when Alpine starts and applies the bound value, sets it at once: nothing should
|
|
* move on page load.
|
|
*/
|
|
window.materialCollapse = (details, shouldOpen) => {
|
|
shouldOpen = Boolean(shouldOpen)
|
|
|
|
if (!targets.has(details)) {
|
|
targets.set(details, details.open)
|
|
|
|
if (details.open !== shouldOpen) {
|
|
details.open = shouldOpen
|
|
targets.set(details, shouldOpen)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if (targets.get(details) === shouldOpen) {
|
|
return
|
|
}
|
|
|
|
shouldOpen ? open(details) : close(details)
|
|
}
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const summary = event.target instanceof Element ? event.target.closest('summary') : null
|
|
const details = summary?.parentElement
|
|
|
|
if (event.defaultPrevented || !details?.matches(COLLAPSE) || summary !== details.querySelector(':scope > summary')) {
|
|
return
|
|
}
|
|
|
|
if ((ms(details, '--md-sys-motion-spatial-fast-duration') ?? 0) === 0) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
|
|
const headedOpen = targets.has(details) ? targets.get(details) : details.open
|
|
|
|
headedOpen ? close(details) : open(details)
|
|
})
|
|
|
|
// Whatever else sets `open` (find-in-page, a `name` group this script did not close, an
|
|
// application's own script) is followed, unless this script is the one mid-way through a change.
|
|
document.addEventListener(
|
|
'toggle',
|
|
(event) => {
|
|
const details = event.target
|
|
|
|
if (details instanceof HTMLDetailsElement && details.matches(COLLAPSE) && !animations.has(details)) {
|
|
targets.set(details, details.open)
|
|
details.removeAttribute(CLOSING)
|
|
}
|
|
},
|
|
true,
|
|
)
|