Files
livewire-material/resources/js/collapse.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

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,
)