Files
livewire-material/resources/js/popover-exit.js
T
Andreas Reinhold / reiniandClaude Opus 5 7db522826b Fade and shrink popovers out in every engine, Firefox included
Menus, submenus, tooltips, rich tooltips and the FAB menu held their
exit with `transition-behavior: allow-discrete` on `display` and
`overlay`. Firefox transitions neither (MDN browser-compat-data,
`display.is_transitionable`: Chrome 117, Safari 18, Firefox none), so
every one of them vanished on its first frame there. A script cannot
hold a popover open instead: `beforetoggle` is not cancellable on the
way out, and the browser's own light dismiss (Escape, a press outside)
never asks.

resources/js/popover-exit.js: a popover marked `data-md-popover-exit`
closes for real at once — focus, aria-expanded and toggle stay the
browser's — and a copy taken in `beforetoggle`, while it is still drawn,
stands in for the exit. The copy is decoration: a manual popover in the
top layer (closing no other popover), inert, aria-hidden, without ids or
nested popovers, `x-ignore`d so Alpine starts nothing in it, pinned to
the popover's box with its resolved colours. It is shown with its
transitions off, so `@starting-style` does not replay the entry, then
marked `data-md-popover-closing`, which each stylesheet turns into its
closed values (`:popover-open:not([data-md-popover-closing])`, and the
FAB menu's items' sink), so it moves on the component's own tokens. It
is removed once the longest of them has run, and opening the popover
again takes it away. Under reduced motion every duration is zero and no
copy is made. `display`, `overlay` and `allow-discrete` leave the
transitions, so Chrome and Safari take the same path.

Browser tests in Chrome, Firefox and Safari slow the motion tokens so a
round trip still finds the exit on screen: a menu after Escape and after
a press outside (the real menu closed and focus back on its button, the
copy inert, fading, with no Alpine state, and gone after), a reopen
part-way through, reduced motion, a submenu while its menu stays open, a
tooltip, the FAB menu's items part-way down their sink, and a persistent
rich tooltip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:24:23 +02:00

142 lines
5.7 KiB
JavaScript

/**
* Popover exits in every engine: a popover marked `data-md-popover-exit` fades or shrinks out on
* its own transitions, however it closes — `hidePopover()` from a component's script, Escape, a
* press outside it, or another popover opening.
*
* A closed popover leaves the top layer and takes `display: none` at once. Chrome and Safari could
* hold both for a transition (`transition-behavior: allow-discrete` on `display` and `overlay`);
* Firefox transitions neither (MDN browser-compat-data, `display.is_transitionable`), so there every
* exit vanished on its first frame. Nor can a script hold the popover open: `beforetoggle` cannot be
* cancelled on the way out, and the browser's own light dismiss never asks.
*
* So the popover closes for real, at once — its focus, `aria-expanded` and `toggle` event are the
* browser's as before — and a copy of it, taken in `beforetoggle` while it is still drawn, stands
* in its place for the exit. The copy is decoration: `popover="manual"` (the top layer, above what
* the popover was above, without closing any other popover), inert, hidden from assistive
* technology, without ids or nested popovers, and `x-ignore`, so Alpine does not start the
* components it holds. It is pinned to the popover's box, shown with its transitions off (its
* `@starting-style` would otherwise replay the entry), and then marked `data-md-popover-closing`,
* which the component's stylesheet turns into its closed values (`:popover-open:not(…)`), so the
* copy moves on the component's own durations and springs. It is removed when the longest of them
* has run; under reduced motion they are zero, and there is no copy at all.
*/
const EXIT = '[data-md-popover-exit]'
const GHOST = 'data-md-popover-ghost'
const CLOSING = 'data-md-popover-closing'
/** The copy each popover's exit is showing, so opening the popover again takes it away. */
const ghosts = new WeakMap()
/** The longest `transition-duration` + `transition-delay` pair on an element, in ms. */
const longestTransition = (element) => {
const style = getComputedStyle(element)
const durations = style.transitionDuration.split(',').map((value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000))
const delays = style.transitionDelay.split(',').map((value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000))
return Math.max(0, ...durations.map((duration, index) => duration + (delays[index % delays.length] || 0)))
}
/** Out of the DOM, which takes it off the top layer without a `beforetoggle` or `toggle` of its own. */
const remove = (ghost) => ghost.remove()
/**
* A still copy of the popover, pinned where it is drawn. Colours come from custom properties an
* ancestor may set (a vibrant menu's `--material-menu-surface`), which the copy loses at the end of
* `<body>`, so the root's resolved colours travel with it.
*/
const copyOf = (popover) => {
const box = popover.getBoundingClientRect()
const style = getComputedStyle(popover)
const ghost = popover.cloneNode(true)
ghost.querySelectorAll('[popover]').forEach((nested) => nested.remove())
ghost.querySelectorAll('[id]').forEach((element) => element.removeAttribute('id'))
ghost.removeAttribute('id')
ghost.removeAttribute(EXIT.slice(1, -1))
ghost.setAttribute('popover', 'manual')
ghost.setAttribute(GHOST, '')
ghost.setAttribute('x-ignore', '')
ghost.setAttribute('aria-hidden', 'true')
ghost.inert = true
Object.assign(ghost.style, {
position: 'fixed',
inset: 'auto',
left: `${box.left}px`,
top: `${box.top}px`,
width: `${box.width}px`,
height: `${box.height}px`,
margin: '0',
positionAnchor: 'none',
positionArea: 'none',
positionTryFallbacks: 'none',
pointerEvents: 'none',
backgroundColor: style.backgroundColor,
color: style.color,
})
return { ghost, scrollTop: popover.scrollTop }
}
const exit = ({ ghost, scrollTop }, popover) => {
const still = [ghost, ...ghost.querySelectorAll('*')]
const transitions = still.map((element) => element.style.transition)
still.forEach((element) => (element.style.transition = 'none'))
document.body.append(ghost)
ghost.showPopover()
ghost.scrollTop = scrollTop
// Resolve the open values with no transition, then give the transitions back and close.
still.forEach((element) => getComputedStyle(element).opacity)
still.forEach((element, index) => (element.style.transition = transitions[index]))
ghost.setAttribute(CLOSING, '')
const duration = Math.max(...still.map(longestTransition))
ghosts.set(popover, ghost)
setTimeout(() => {
remove(ghost)
if (ghosts.get(popover) === ghost) {
ghosts.delete(popover)
}
}, duration)
}
document.addEventListener(
'beforetoggle',
(event) => {
const popover = event.target
if (!(popover instanceof HTMLElement) || !popover.matches(EXIT)) {
return
}
const previous = ghosts.get(popover)
if (previous) {
ghosts.delete(popover)
remove(previous)
}
if (event.newState !== 'closed') {
return
}
// Under reduced motion every duration token is zero: nothing to show on the way out.
if (Math.max(...[popover, ...popover.querySelectorAll('*')].map(longestTransition)) === 0) {
return
}
// Taken now, while the popover is still drawn; shown in the next frame, before it is
// painted without the popover, so neither `showPopover()` runs inside the browser's own
// hiding nor a frame goes by with nothing on screen.
const copy = copyOf(popover)
requestAnimationFrame(() => exit(copy, popover))
},
true,
)