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*