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:
co-authored by
Claude Opus 5
parent
684d60efc8
commit
7db522826b
@@ -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(<<<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; })()");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user