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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 20:24:23 +02:00
co-authored by Claude Opus 5
parent 684d60efc8
commit 7db522826b
18 changed files with 331 additions and 60 deletions
+6 -11
View File
@@ -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);
+9 -11
View File
@@ -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;
}
+4 -8
View File
@@ -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;
}
+4 -8
View File
@@ -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;
}
+6 -6
View File
@@ -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 {
+6 -6
View File
@@ -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 {
+1
View File
@@ -9,6 +9,7 @@
*/
import './theme.js'
import './figure.js'
import './popover-exit.js'
import './tooltip.js'
import './menu.js'
import './fab.js'
+141
View File
@@ -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
* `<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,
)
@@ -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"
@@ -140,6 +140,7 @@
popover="auto"
role="menu"
data-md-submenu
data-md-popover-exit
aria-label="{{ $label }}"
tabindex="-1"
style="position-anchor: {{ $anchor }}"
@@ -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
@@ -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 }}"
>
@@ -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 }}</span>
+120
View File
@@ -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 150350ms exit, and the copy moves on the tokens as the popover would.
*/
function slowMotion(mixed $page, string $duration = '1500ms'): mixed
{
$page->script(<<<JS
document.head.insertAdjacentHTML('beforeend', '<style>: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}; }</style>')
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; })()");
});
+16
View File
@@ -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', '<style>:root { --md-sys-motion-effects-fast-duration: 1500ms; }</style>')");
$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; })()");
});
+1 -1
View File
@@ -75,7 +75,7 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a
->toMatch('/<button[^>]*aria-label="Star" aria-pressed="true"/')
// One overflow button per width, named and tooltipped.
->and(preg_match_all('/<button[^>]*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('/<a class="md-state-layer md-focus-ring" data-md-menu-item[^>]*role="menuitem" tabindex="-1" href="\/shares\/1\/share"/')
+6 -5
View File
@@ -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('<x-fab-menu icon="add" label="New"><x-fab-menu-item label="Upload" icon="upload" /></x-fab-menu>'))->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 () {
+6 -4
View File
@@ -10,7 +10,7 @@ it('wraps a trigger and anchors the bubble to it', function () {
expect($anchor)->not->toBeEmpty()
->and($html)
->toMatch('/<span data-md-tooltip-anchor style="anchor-name: --material-tooltip-[a-z0-9]+">\s*<button>Copy<\/button>/')
->toMatch('/<span\s+popover="manual"\s+aria-hidden="true"\s+x-data="materialTooltip"\s+data-md-tooltip\s+data-md-side="top"\s+style="position-anchor: '.preg_quote($anchor[1], '/').'"\s*>Copy link<\/span>/')
->toMatch('/<span\s+popover="manual"\s+aria-hidden="true"\s+x-data="materialTooltip"\s+data-md-tooltip\s+data-md-popover-exit\s+data-md-side="top"\s+style="position-anchor: '.preg_quote($anchor[1], '/').'"\s*>Copy link<\/span>/')
->not->toContain('class=');
});
@@ -44,12 +44,14 @@ it('draws M3\'s plain tooltip in the inverse surface, and fades on the effects s
'font' => 'var(--md-sys-typescale-body-sm)',
'pointer-events' => 'none',
'opacity' => '0',
'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',
])
->and($css->declarations('[data-md-tooltip]:popover-open'))->toBe(['opacity' => '1'])
->and($css->declarations('[data-md-tooltip]:popover-open', ['@starting-style']))->toBe(['opacity' => '0']);
// No `display`/`overlay` transition: the fade out is the exit copy's (popover-exit.js).
->not->toHaveKey('transition-behavior')
->and($css->declarations('[data-md-tooltip]:popover-open:not([data-md-popover-closing])'))->toBe(['opacity' => '1'])
->and($css->declarations('[data-md-tooltip]:popover-open:not([data-md-popover-closing])', ['@starting-style']))->toBe(['opacity' => '0']);
});
it('takes the caller\'s class and style on the standalone wrapper, its root', function () {