From 7db522826b589c8e92c6f372a8314edbaccf5b5a Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Wed, 16 Sep 2026 20:24:23 +0200 Subject: [PATCH] Fade and shrink popovers out in every engine, Firefox included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- resources/css/components/fab-menu-item.css | 17 +-- resources/css/components/fab-menu.css | 20 ++- resources/css/components/menu-item.css | 12 +- resources/css/components/menu.css | 12 +- resources/css/components/rich-tooltip.css | 12 +- resources/css/components/tooltip.css | 12 +- resources/js/material.js | 1 + resources/js/popover-exit.js | 141 ++++++++++++++++++ resources/views/components/fab-menu.blade.php | 1 + .../views/components/menu-item.blade.php | 1 + resources/views/components/menu.blade.php | 1 + .../views/components/rich-tooltip.blade.php | 1 + resources/views/components/tooltip.blade.php | 1 + tests/Browser/ActionsTest.php | 120 +++++++++++++++ tests/Browser/CommunicationTest.php | 16 ++ tests/Feature/Components/AppBarTest.php | 2 +- tests/Feature/Components/FabMenuTest.php | 11 +- tests/Feature/Components/TooltipTest.php | 10 +- 18 files changed, 331 insertions(+), 60 deletions(-) create mode 100644 resources/js/popover-exit.js 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(<<:root { --md-sys-motion-effects-fast-duration: {$duration}; --md-sys-motion-effects-default-duration: {$duration}; --md-sys-motion-spatial-fast-duration: {$duration}; --md-sys-motion-spatial-default-duration: {$duration}; }') + JS); + + return $page; +} + +/** The exit copy on screen: shown, inert, hidden from assistive technology, with no ids of its own. */ +const EXIT_COPY = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); return copy !== null && copy.matches(':popover-open') && copy.inert && copy.getAttribute('aria-hidden') === 'true' && ! copy.hasAttribute('id') && copy.querySelector('[id], [popover]:not([data-md-popover-ghost])') === null; })()"; + +/** Whether the exit copy is caught part-way to its closed opacity. */ +const EXIT_COPY_FADING = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); const opacity = copy && parseFloat(getComputedStyle(copy).opacity); return opacity > 0.02 && opacity < 0.98; })()"; + +it('fades a menu out after the browser has closed it, on Escape or a press outside, in every engine', function () { + $menu = 'document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\'))'; + + $page = slowMotion(showcase('menus')) + ->click(MORE) + ->assertAttribute(MORE, 'aria-expanded', 'true'); + + // Escape: the browser's own light dismiss, which no script can hold open. + $page->keys(':focus', 'Escape') + ->assertAttribute(MORE, 'aria-expanded', 'false') + ->assertScript("! {$menu}.matches(':popover-open')") + ->assertScript(focused("getAttribute('aria-label') === 'More'")) + ->assertScript(EXIT_COPY) + ->assertScript(EXIT_COPY_FADING) + // The copy is decoration: Alpine starts nothing inside it. + ->assertScript("window.eval(\"[...document.querySelectorAll('[data-md-popover-ghost], [data-md-popover-ghost] *')].every((element) => element._x_dataStack === undefined)\")") + // Gone once the slowest of its transitions has run. + ->assertScript('new Promise((resolve) => setTimeout(() => resolve(document.querySelector("[data-md-popover-ghost]") === null), 1800))'); + + // A press outside it. + $page->click(MORE) + ->assertAttribute(MORE, 'aria-expanded', 'true') + ->click('#menus') + ->assertAttribute(MORE, 'aria-expanded', 'false') + ->assertScript(EXIT_COPY) + ->assertScript(EXIT_COPY_FADING) + ->assertNoJavaScriptErrors(); +}); + +it('takes a menu\'s exit copy away when the menu opens again part-way through it', function () { + $page = slowMotion(showcase('menus')) + ->click(MORE); + + $page->keys(':focus', 'Escape') + ->assertScript(EXIT_COPY) + // Past menu.js's reopen guard (250ms), which takes a press this soon after a light dismiss + // for the press that dismissed it; the copy is still fading. + ->wait(0.3) + ->assertScript(EXIT_COPY); + + $page->click(MORE) + ->assertAttribute(MORE, 'aria-expanded', 'true') + ->assertScript("document.querySelector('[data-md-popover-ghost]') === null") + ->assertScript('document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\')).matches(\':popover-open\')'); +}); + +it('leaves no exit copy under reduced motion, where every duration token is zero', function () { + $page = slowMotion(showcase('menus'), '0ms') + ->click(MORE); + + $page->keys(':focus', 'Escape') + ->assertAttribute(MORE, 'aria-expanded', 'false') + ->assertScript('new Promise((resolve) => { let seen = false; const look = () => { seen ||= document.querySelector("[data-md-popover-ghost]") !== null; }; const timer = setInterval(look, 5); setTimeout(() => { clearInterval(timer); resolve(! seen); }, 300); })'); +}); + +it('fades a submenu out on its own, while its menu stays open', function () { + $trigger = '#menus button:has-text("Share")'; + $sendTo = '#menus [role="menuitem"]:has-text("Send to")'; + + $page = slowMotion(showcase('menus')) + ->click($trigger); + + $page->keys(':focus', 'ArrowDown'); + $page->keys(':focus', 'ArrowRight') + ->assertAttribute($sendTo, 'aria-expanded', 'true'); + + $page->keys(':focus', 'Escape') + ->assertAttribute($sendTo, 'aria-expanded', 'false') + ->assertAttribute($trigger, 'aria-expanded', 'true') + ->assertScript(EXIT_COPY) + ->assertScript("document.querySelectorAll('[data-md-popover-ghost]').length === 1 && document.querySelector('[data-md-popover-ghost]').hasAttribute('data-md-submenu')") + ->assertScript(EXIT_COPY_FADING); +}); + +it('fades a tooltip out after Escape hides it', function () { + $page = slowMotion(showcase()); + + $page->keys('#content', 'Tab'); + $page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()"); + $page->assertScript("document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]').matches(':popover-open')"); + + $page->keys(':focus', 'Escape') + ->assertScript("! document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]').matches(':popover-open')") + ->assertScript(EXIT_COPY) + ->assertScript("document.querySelector('[data-md-popover-ghost]').hasAttribute('data-md-tooltip')") + ->assertScript(EXIT_COPY_FADING); +}); + +it('sinks a FAB menu\'s items back after the menu has closed', function () { + $page = slowMotion(showcase()) + ->click('button[aria-label="New"]') + ->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'true'"); + + $page->keys(':focus', 'Escape') + ->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'false'") + ->assertScript(EXIT_COPY) + // The first item part-way down its 8px sink and its fade. + ->assertScript("(() => { const item = document.querySelector('[data-md-popover-ghost] [data-md-fab-menu-item]'); const style = getComputedStyle(item); const drop = parseFloat(style.translate.split(' ')[1] ?? '0'); const opacity = parseFloat(style.opacity); return drop > 0.1 && drop < 7.9 && opacity > 0.02 && opacity < 0.98; })()"); +}); diff --git a/tests/Browser/CommunicationTest.php b/tests/Browser/CommunicationTest.php index 06f906fe..521c0276 100644 --- a/tests/Browser/CommunicationTest.php +++ b/tests/Browser/CommunicationTest.php @@ -296,3 +296,19 @@ it('keeps a rich tooltip open while its action renders the component, and opens ->assertScript("{$transient}.matches(':popover-open')") ->assertNoJavaScriptErrors(); }); + +it('fades a persistent rich tooltip out when a second press closes it', function () { + $bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')"; + + $page = visit('/material/communication')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); + + // Slow tokens, so the exit copy (resources/js/popover-exit.js) is still on screen after the round trip. + $page->script("document.head.insertAdjacentHTML('beforeend', '')"); + + $page->click('#communication button:has-text("Press for details")') + ->assertScript("{$bubble}.matches(':popover-open')") + ->click('#communication button:has-text("Press for details")') + ->assertScript("! {$bubble}.matches(':popover-open')") + ->assertScript("(() => { const copy = document.querySelector('[data-md-popover-ghost]'); const opacity = copy && parseFloat(getComputedStyle(copy).opacity); return copy !== null && copy.hasAttribute('data-md-rich-tooltip-bubble') && copy.inert && opacity > 0.02 && opacity < 0.98; })()"); +}); diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index 838235ed..005d2e34 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -75,7 +75,7 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a ->toMatch('/]*aria-label="Star" aria-pressed="true"/') // One overflow button per width, named and tooltipped. ->and(preg_match_all('/]*data-md-size="sm"[^>]*aria-label="More options"/', $html))->toBe(2) - ->and(preg_match_all('/role="menu"\s+data-md-menu-popover\s+data-md-position="bottom-end"\s+aria-label="More options"/', $html))->toBe(2) + ->and(preg_match_all('/role="menu"\s+data-md-menu-popover\s+data-md-popover-exit\s+data-md-position="bottom-end"\s+aria-label="More options"/', $html))->toBe(2) // Below medium: all but the first. ->and($compact) ->toMatch('/]*role="menuitem" tabindex="-1" href="\/shares\/1\/share"/') diff --git a/tests/Feature/Components/FabMenuTest.php b/tests/Feature/Components/FabMenuTest.php index c6270f11..d1ca46e3 100644 --- a/tests/Feature/Components/FabMenuTest.php +++ b/tests/Feature/Components/FabMenuTest.php @@ -79,11 +79,12 @@ it('opens at the corner asked for, secondary and tertiary in their own colours', ]); }); -it('sinks the items back while the list closes, from the popover\'s own open state', function () { - expect(ComponentStylesheet::read('fab-menu')->declarations('[data-md-fab-menu-popover]:not(:popover-open) > *'))->toBe([ - 'translate' => '0 var(--md-sys-measurement-space100)', - 'opacity' => '0', - ]); +it('sinks the items back while the list closes, in the popover and in its exit copy', function () { + expect((string) $this->blade(''))->toContain('data-md-popover-exit') + ->and(ComponentStylesheet::read('fab-menu')->declarations('[data-md-fab-menu-popover]:not(:popover-open) > *, [data-md-fab-menu-popover][data-md-popover-closing] > *'))->toBe([ + 'translate' => '0 var(--md-sys-measurement-space100)', + 'opacity' => '0', + ]); }); it('gives an item its icon, colour and the exit fab-menu.css drives from the popover', function () { diff --git a/tests/Feature/Components/TooltipTest.php b/tests/Feature/Components/TooltipTest.php index 4475a6e3..f19b25d1 100644 --- a/tests/Feature/Components/TooltipTest.php +++ b/tests/Feature/Components/TooltipTest.php @@ -10,7 +10,7 @@ it('wraps a trigger and anchors the bubble to it', function () { expect($anchor)->not->toBeEmpty() ->and($html) ->toMatch('/\s*