diff --git a/resources/css/components/fab-menu-item.css b/resources/css/components/fab-menu-item.css index 1383b830..638af773 100644 --- a/resources/css/components/fab-menu-item.css +++ b/resources/css/components/fab-menu-item.css @@ -8,10 +8,9 @@ * (foundation/interaction.css); its 56px already meets M3's target, so it needs no * `md-touch-target`. * - * The entry is `@starting-style` on the item itself; the exit needs the state to be readable - * while the popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *` - * (fab-menu.css) drives from the popover's own open state — the popover keeps `display: flex` for - * the length of its own discrete transition, so the item has somewhere to animate to (ACT-33). + * The entry is `@starting-style` on the item itself; the exit is the popover's exit copy + * (resources/js/popover-exit.js), whose items fab-menu.css sets to the closed values, so the item + * has somewhere to animate to (ACT-33). */ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; @@ -42,13 +41,9 @@ user-select: none; translate: 0 0; opacity: 1; - transition-property: translate, opacity, display, overlay; - transition-duration: - var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration), - var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration); - transition-timing-function: - var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default), linear, linear; - transition-behavior: allow-discrete; + transition-property: translate, opacity; + transition-duration: var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration); + transition-timing-function: var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default); @starting-style { translate: 0 var(--md-sys-measurement-space100); diff --git a/resources/css/components/fab-menu.css b/resources/css/components/fab-menu.css index 4ef8819d..7917d8c0 100644 --- a/resources/css/components/fab-menu.css +++ b/resources/css/components/fab-menu.css @@ -12,13 +12,13 @@ * button, which stays fixed (ACT-32). * * The items (fab-menu-item.css) rise into place as the list opens and sink back as it closes: the - * entry is `@starting-style` on the item itself; the exit needs the state to be readable while the - * popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *` is — the - * popover keeps `display: flex` for the length of its own discrete transition, so the items have - * somewhere to animate to (ACT-33). The Tailwind-era rule sat outside the layers to beat the - * item's utilities; inside the layer it still beats fab-menu-item.css's resting `translate: 0 0` - * and `opacity: 1`, on specificity — an attribute and a pseudo-class against the item's one - * attribute — whichever order the two files are bundled in. + * entry is `@starting-style` on the item itself; the exit is the popover's exit copy + * (`data-md-popover-exit`, resources/js/popover-exit.js), which stays on screen after the popover + * has closed, so the items have somewhere to animate to (ACT-33) in every engine, Firefox included. + * `[data-md-fab-menu-popover]:not(:popover-open) > *` and the copy's `[data-md-popover-closing] > *` + * are the closed values; inside the layer they beat fab-menu-item.css's resting `translate: 0 0` + * and `opacity: 1` on specificity — two attributes, or an attribute and a pseudo-class, against the + * item's one attribute — whichever order the two files are bundled in. */ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; @@ -112,9 +112,6 @@ border-width: 0; background-color: transparent; padding: var(--md-sys-measurement-space100); - transition-property: display, overlay; - transition-duration: var(--md-sys-motion-spatial-fast-duration); - transition-behavior: allow-discrete; &:popover-open { display: flex; @@ -149,7 +146,8 @@ position-area: bottom span-right; } - [data-md-fab-menu-popover]:not(:popover-open) > * { + [data-md-fab-menu-popover]:not(:popover-open) > *, + [data-md-fab-menu-popover][data-md-popover-closing] > * { translate: 0 var(--md-sys-measurement-space100); opacity: 0; } diff --git a/resources/css/components/menu-item.css b/resources/css/components/menu-item.css index 2d942e90..13b9fd87 100644 --- a/resources/css/components/menu-item.css +++ b/resources/css/components/menu-item.css @@ -165,15 +165,11 @@ position-try-fallbacks: flip-inline; opacity: 0; scale: 0.95; - transition-property: opacity, scale, display, overlay; - transition-duration: - var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration), - var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration); - transition-timing-function: - var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear; - transition-behavior: allow-discrete; + transition-property: opacity, scale; + transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration); + transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast); - &:popover-open { + &:popover-open:not([data-md-popover-closing]) { opacity: 1; scale: 1; } diff --git a/resources/css/components/menu.css b/resources/css/components/menu.css index 6dc8df82..84ea6cf9 100644 --- a/resources/css/components/menu.css +++ b/resources/css/components/menu.css @@ -70,15 +70,11 @@ position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline; opacity: 0; scale: 0.95; - transition-property: opacity, scale, display, overlay; - transition-duration: - var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration), - var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration); - transition-timing-function: - var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear; - transition-behavior: allow-discrete; + transition-property: opacity, scale; + transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration); + transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast); - &:popover-open { + &:popover-open:not([data-md-popover-closing]) { opacity: 1; scale: 1; } diff --git a/resources/css/components/rich-tooltip.css b/resources/css/components/rich-tooltip.css index 02171a42..d2adfe98 100644 --- a/resources/css/components/rich-tooltip.css +++ b/resources/css/components/rich-tooltip.css @@ -10,9 +10,10 @@ * * Placed by CSS anchor positioning on `data-md-side`, corner-to-corner so the 312px bubble has * room to spread past a narrow trigger; `position-try-fallbacks` flips it when the window has no - * room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring, - * `allow-discrete` keeping `display` and `overlay` alive for the fade out and `@starting-style` - * giving the fade in a start. + * room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring: + * `@starting-style` gives the fade in a start, and the fade out is its exit copy's + * (`data-md-popover-exit`, resources/js/popover-exit.js), which `data-md-popover-closing` turns + * back to the closed opacity. * * resources/js/rich-tooltip.js shows and hides the bubble and keeps the trigger described with * `aria-describedby` — and, while `persistent`, `aria-haspopup`/`aria-expanded` (ACT-22) — none of @@ -41,12 +42,11 @@ text-align: start; white-space: normal; opacity: 0; - transition-property: opacity, display, overlay; + transition-property: opacity; transition-duration: var(--md-sys-motion-effects-fast-duration); transition-timing-function: var(--md-sys-motion-effects-fast); - transition-behavior: allow-discrete; - &:popover-open { + &:popover-open:not([data-md-popover-closing]) { opacity: 1; @starting-style { diff --git a/resources/css/components/tooltip.css b/resources/css/components/tooltip.css index 31398847..a94fbf9e 100644 --- a/resources/css/components/tooltip.css +++ b/resources/css/components/tooltip.css @@ -8,9 +8,10 @@ * text in `inverse-on-surface`, 8px either side and 4px above and below, 200px wide at most, 4px * from its anchor. It never takes the pointer, so it can stand over the control it names. * - * It fades in and out on the fast effects spring; `allow-discrete` keeps `display` and `overlay` - * alive until the fade out has been seen, and `@starting-style` gives the fade in a start. - * resources/js/tooltip.js shows and hides it. + * It fades in and out on the fast effects spring: `@starting-style` gives the fade in a start, and + * the fade out is its exit copy's (`data-md-popover-exit`, resources/js/popover-exit.js), which + * `data-md-popover-closing` turns back to the closed opacity. resources/js/tooltip.js shows and + * hides it. */ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; @@ -38,12 +39,11 @@ white-space: normal; pointer-events: none; opacity: 0; - transition-property: opacity, display, overlay; + transition-property: opacity; transition-duration: var(--md-sys-motion-effects-fast-duration); transition-timing-function: var(--md-sys-motion-effects-fast); - transition-behavior: allow-discrete; - &:popover-open { + &:popover-open:not([data-md-popover-closing]) { opacity: 1; @starting-style { diff --git a/resources/js/material.js b/resources/js/material.js index d7632585..48e45b8e 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -9,6 +9,7 @@ */ import './theme.js' import './figure.js' +import './popover-exit.js' import './tooltip.js' import './menu.js' import './fab.js' diff --git a/resources/js/popover-exit.js b/resources/js/popover-exit.js new file mode 100644 index 00000000..fdf645b9 --- /dev/null +++ b/resources/js/popover-exit.js @@ -0,0 +1,141 @@ +/** + * 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 + * `
`, 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, +) diff --git a/resources/views/components/fab-menu.blade.php b/resources/views/components/fab-menu.blade.php index a720a9ff..361a8d50 100644 --- a/resources/views/components/fab-menu.blade.php +++ b/resources/views/components/fab-menu.blade.php @@ -66,6 +66,7 @@ popover="auto" role="menu" data-md-fab-menu-popover + data-md-popover-exit data-md-position="{{ $position }}" aria-label="{{ $label }}" tabindex="-1" diff --git a/resources/views/components/menu-item.blade.php b/resources/views/components/menu-item.blade.php index 0a126dd6..4932e3e6 100644 --- a/resources/views/components/menu-item.blade.php +++ b/resources/views/components/menu-item.blade.php @@ -140,6 +140,7 @@ popover="auto" role="menu" data-md-submenu + data-md-popover-exit aria-label="{{ $label }}" tabindex="-1" style="position-anchor: {{ $anchor }}" diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php index f736e478..4af9e8fe 100644 --- a/resources/views/components/menu.blade.php +++ b/resources/views/components/menu.blade.php @@ -120,6 +120,7 @@ popover="auto" @unless ($filtering) role="menu" @endunless data-md-menu-popover + data-md-popover-exit data-md-position="{{ $position }}" @if ($vibrant) data-md-vibrant @endif @if ($label && ! $filtering) aria-label="{{ $label }}" @endif diff --git a/resources/views/components/rich-tooltip.blade.php b/resources/views/components/rich-tooltip.blade.php index cfd43c67..0c9d905e 100644 --- a/resources/views/components/rich-tooltip.blade.php +++ b/resources/views/components/rich-tooltip.blade.php @@ -55,6 +55,7 @@ role="{{ $persistent ? 'dialog' : 'tooltip' }}" @if ($title) aria-label="{{ $title }}" @endif data-md-rich-tooltip-bubble + data-md-popover-exit data-md-side="{{ $side }}" style="position-anchor: {{ $anchor }}" > diff --git a/resources/views/components/tooltip.blade.php b/resources/views/components/tooltip.blade.php index 31c3ab8e..b79661ab 100644 --- a/resources/views/components/tooltip.blade.php +++ b/resources/views/components/tooltip.blade.php @@ -45,6 +45,7 @@ aria-hidden="true" x-data="materialTooltip" data-md-tooltip + data-md-popover-exit data-md-side="{{ $side }}" style="position-anchor: {{ $anchor }}" >{{ $text }} diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index b1bdca9c..7abe5f84 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -931,3 +931,123 @@ it('scrolls the FAB menu\'s items on a short window, behind the close button, wh // The close button is not inside the scrolling list, so scrolling it never moves the button. $page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop"); }); + +/** + * Slows the page's motion tokens, so a popover's exit copy (resources/js/popover-exit.js) is + * still on screen after a separate round trip to the browser: a key press or a click already + * costs about as long as a 150–350ms exit, and the copy moves on the tokens as the popover would. + */ +function slowMotion(mixed $page, string $duration = '1500ms'): mixed +{ + $page->script(<<