Files
livewire-material/tests/Browser/CommunicationTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 7db522826b 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>
2026-09-16 20:24:23 +02:00

315 lines
16 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component that renders again while a rich tooltip is open, from the tooltip's own
* action.
*/
class RichTooltipMorphProbe extends Component
{
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div style="padding: var(--md-sys-measurement-space200);">
<p id="outside">renders: <span id="renders">{{ $renders }}</span></p>
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time." persistent>
<x-button label="Details" data-test="details" />
<x-slot:actions><x-button label="Refresh" wire:click="touch" data-test="refresh" /></x-slot:actions>
</x-rich-tooltip>
<div style="margin-top: 200px">
<x-rich-tooltip text="Shown on hover.">
<x-button label="Hint" data-test="hint" />
</x-rich-tooltip>
</div>
</div>
BLADE;
}
}
// The live region is the host itself, in the page before any message is (actions.md ACT-02).
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"][aria-live]')";
const TOAST = "document.querySelector('[data-md-toast-snackbar]')";
it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
$page->assertScript(SNACKBAR."?.textContent.includes('First')")
->assertScript(SNACKBAR."?.getAttribute('role') === 'status'");
$page->wait(1.2);
$page->assertScript(SNACKBAR."?.textContent.includes('Second')")
->assertScript(SNACKBAR."?.getAttribute('role') === 'alert'");
});
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
// carries a detail object Firefox's sandbox will not let the page read.
$page->script("window.eval(\"Livewire.dispatch('toast', { type: 'info', title: 'Link copied', description: null, timeout: 4000 })\")");
$page->assertScript(SNACKBAR."?.textContent.includes('Link copied')");
});
it('runs a toast\'s action and dismisses it', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
$page->click('[x-data="materialSnackbar"] button:has-text("Undo")')
->assertScript('window.__undone === true')
->assertScript(TOAST.' === null')
->assertScript(SNACKBAR.' !== null');
});
it('does not let a toast dismissed early cut the next one short', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.eval(\"materialToast('Share deleted', { timeout: 800, action: { label: 'Undo' } }); materialToast('Link copied', { timeout: 2500 })\")");
// A click with no hover or focus before it, as a screen reader activates a button: nothing has
// paused the first toast's timer.
$page->script("document.querySelector('[data-md-toast-dismiss]').click()");
$page->assertScript(TOAST."?.textContent.includes('Link copied')")
->wait(1.2);
$page->assertScript(TOAST."?.textContent.includes('Link copied')");
});
it('keeps a sticky toast aside while a passing one shows, brings back the newest, and lets it go once dismissed', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// As an application dispatches it; built in the page's realm (see above).
$page->script("window.eval(\"window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, timeout: 300, action: { label: 'Reload', event: 'material:test-reload' } } }))\")");
$page->assertScript(TOAST."?.textContent.includes('A new version is ready')")
->wait(0.6);
$page->assertScript(TOAST."?.textContent.includes('A new version is ready')");
$page->script("window.eval(\"materialToast('Settings saved', { type: 'success', timeout: 900 }); materialToast('Version 2 is ready', { sticky: true })\")");
$page->assertScript(TOAST."?.textContent.includes('Settings saved')")
->wait(1.3);
$page->assertScript(TOAST."?.textContent.includes('Version 2 is ready')");
$page->click('[data-md-toast-dismiss]')
->assertScript(TOAST.' === null');
// Dismissed rather than left to time out: the pointer still rests where the snackbar appears,
// and hovering pauses it.
$page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")");
$page->assertScript(TOAST."?.textContent.includes('Link copied')")
->click('[data-md-toast-dismiss]')
->assertScript(TOAST.' === null');
});
it('dispatches a toast action\'s window event alongside its handler, and closes', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.eval(\"window.reloads = 0; window.handled = 0; window.addEventListener('material:test-reload', () => window.reloads++); materialToast('A new version is ready', { sticky: true, action: { label: 'Reload', event: 'material:test-reload', handler: () => window.handled++ } })\")");
$page->click('[data-md-toast-action]')
->assertScript(TOAST.' === null')
->assertScript("window.eval('window.reloads') === 1")
->assertScript("window.eval('window.handled') === 1");
});
it('keeps an actioned snackbar on screen past the default timeout, until it is acted on', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// No timeout written out: M3 forbids an actioned snackbar from auto-dismissing (ACT-03).
$page->script("materialToast('Share deleted', { action: { label: 'Undo', handler: () => {} } })");
$page->assertScript(TOAST."?.textContent.includes('Share deleted')")
->wait(4.5);
$page->assertScript(TOAST."?.textContent.includes('Share deleted')")
->click('[data-md-toast-dismiss]')
->assertScript(TOAST.' === null');
});
it('dismisses a focused snackbar with Escape', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('Share deleted', { action: { label: 'Undo', handler: () => {} } })");
$page->assertScript(TOAST."?.textContent.includes('Share deleted')");
$page->script("document.querySelector('[data-md-toast-dismiss]').focus()");
$page->keys(':focus', 'Escape')
->assertScript(TOAST.' === null');
});
it('keeps the snackbar\'s live region in the page, polite, before any message arrives', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->assertScript(SNACKBAR.' !== null')
->assertScript(SNACKBAR."?.getAttribute('aria-live') === 'polite'")
->assertScript(SNACKBAR."?.getAttribute('aria-atomic') === 'true'")
->assertScript(TOAST.' === null');
});
it('grows to M3\'s two-line height with a description, and Alt+G reaches its action from anywhere on the page', function () {
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('Upload paused', { description: 'The connection dropped at 64%.', action: { label: 'Retry now', handler: () => {} } })");
$page->assertScript(TOAST."?.textContent.includes('Upload paused')")
->assertScript('Math.round('.TOAST.'.getBoundingClientRect().height) === 68');
// Focused somewhere else on the page first: the shortcut has to reach the snackbar from
// wherever the keyboard already was, since a snackbar never takes the focus on its own.
$page->script("document.body.setAttribute('tabindex', '-1'); document.body.focus()");
$page->keys(':focus', 'Alt+g')
->assertScript('document.activeElement === '.TOAST."?.querySelector('[data-md-toast-action]')");
});
it('keeps a two-line snackbar\'s action on the title\'s row from 600px, wrapping under it only below that', function () {
$page = visit('/material/communication')->resize(400, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('Upload paused', { description: 'The connection dropped at 64%.', action: { label: 'Retry now', handler: () => {} } })");
$title = TOAST."?.querySelector('[data-md-toast-title]')";
$action = TOAST."?.querySelector('[data-md-toast-action]')";
// Below 600px M3's "two lines with longer action" wraps the action under both lines of text
// (toast.css's [data-md-toast-wrap]), so it starts only once the title's line has ended.
$page->assertScript("{$title}.textContent.includes('Upload paused')")
->assertScript("{$action}.getBoundingClientRect().top >= {$title}.getBoundingClientRect().bottom");
// From 600px it stays beside the title's line instead: the two boxes overlap vertically.
$page->resize(600, 800)
->assertScript("{$action}.getBoundingClientRect().top < {$title}.getBoundingClientRect().bottom")
->assertScript("{$action}.getBoundingClientRect().bottom > {$title}.getBoundingClientRect().top");
});
it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')");
});
it('closes a transient rich tooltip 1.5s after focus leaves, not at once', function () {
$trigger = '#communication button[aria-label="About expiry"]';
$bubble = "document.querySelector('#communication [role=\"tooltip\"][popover]')";
$page = visit('/material/communication')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// A key press first, so the browser is in keyboard modality, as a keyboard user would be.
$page->keys('#communication', 'Tab');
$page->script("document.querySelector('{$trigger}').focus()");
$page->assertScript("document.activeElement === document.querySelector('{$trigger}')")
->assertScript("{$bubble}.matches(':popover-open')");
$page->script("document.querySelector('{$trigger}').blur()");
// Still open shortly after focus leaves: it stands for 1.5s (rich-tooltip.js's
// LEAVE_GRACE_MS), not closing the instant focus does.
$page->wait(0.3)
->assertScript("{$bubble}.matches(':popover-open')");
$page->wait(1.4)
->assertScript("! {$bubble}.matches(':popover-open')");
});
it('keeps a rich tooltip open while its action renders the component, and opens it again after', function () {
Livewire::component('rich-tooltip-morph-probe', RichTooltipMorphProbe::class);
Route::middleware('web')->get('/rich-tooltip-morph-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body style="background-color: var(--md-sys-color-surface);">
<livewire:rich-tooltip-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
$persistent = "document.querySelector('[role=\"dialog\"][popover]')";
$transient = "document.querySelector('[role=\"tooltip\"][popover]')";
$page = visit('/rich-tooltip-morph-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->assertNoJavaScriptErrors();
$page->click('@details')
->assertScript("{$persistent}.matches(':popover-open')")
->click('@refresh')
->assertSeeIn('#renders', '1')
->assertScript("{$persistent}.matches(':popover-open')");
// Past the reopen guard (rich-tooltip.js's REOPEN_GUARD_MS), which takes a press this soon after
// a light dismiss for the press that dismissed it, as menu.js's does.
$page->click('#outside')
->assertScript("! {$persistent}.matches(':popover-open')")
->wait(0.3);
$page->click('@details')
->assertScript("{$persistent}.matches(':popover-open')");
$page->click('#outside')
->hover('@hint')
->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; })()");
});