tests / feature (8.4) (push) Successful in 1m50s
tests / feature (8.5) (push) Successful in 1m54s
tests / browser (chrome, chromium) (push) Successful in 7m52s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Successful in 12m35s
The browser plugin runs every call on a page again when its first attempt takes over a second, and on the runner a press can. For most presses that costs nothing. For two kinds it breaks the test: a press that opens a dialog, a sheet, a full-screen view or a modal rail over its own trigger, whose second press can never land, and a press that changes state a second one would change again — a menu trigger, a toggle, a chip, a range picker's day, a paging key, a Save. The bottom sheet with preset heights timed out on WebKit that way after the date picker had on Firefox. Every such press in the browser suite now goes through pressOnce(), 253 of them across sixteen files, not only the ones the runner happened to catch; focus moves inside an open view, Escape, links and plain "set" actions keep the retry, which is harmless for them. Two samples that were still racing the machine: - The standard side sheet's exit is caught half-way with its motion stretched, as every other mid-exit sample is, and fullSpeed() takes the stretch off again before the test times a reopen against the real exit. Under load on Linux WebKit it had failed two runs in five; it passes ten in ten. - The switch-and-checkbox row measures its widths once, so it now waits for the resize to land and the brand face to load before it does. Browser 300 passed on Firefox and WebKitGTK in a Linux container held to two busy cores, and on Chrome, Firefox and WebKit on macOS. Feature 1159 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
324 lines
14 KiB
PHP
324 lines
14 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.
|
|
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 = ready(visit('/material/communication'));
|
|
|
|
$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 = ready(visit('/material/communication'));
|
|
|
|
// 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 = ready(visit('/material/communication'));
|
|
|
|
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
|
|
|
|
// The Undo button vanishes once the toast is dismissed: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[x-data="materialSnackbar"] button:has-text("Undo")');
|
|
|
|
$page->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 = ready(visit('/material/communication'));
|
|
|
|
$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 = ready(visit('/material/communication'));
|
|
|
|
// 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')");
|
|
|
|
// The dismiss button vanishes with its toast: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-toast-dismiss]');
|
|
|
|
$page->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')");
|
|
|
|
pressOnce($page)->click('[data-md-toast-dismiss]');
|
|
|
|
$page->assertScript(TOAST.' === null');
|
|
});
|
|
|
|
it('dispatches a toast action\'s window event alongside its handler, and closes', function () {
|
|
$page = ready(visit('/material/communication'));
|
|
|
|
$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++ } })\")");
|
|
|
|
// The action closes the toast: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-toast-action]');
|
|
|
|
$page->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 = ready(visit('/material/communication'));
|
|
|
|
// No timeout written out: M3 forbids an actioned snackbar from auto-dismissing.
|
|
$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')");
|
|
|
|
// The dismiss button vanishes with its toast: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('[data-md-toast-dismiss]');
|
|
|
|
$page->assertScript(TOAST.' === null');
|
|
});
|
|
|
|
it('dismisses a focused snackbar with Escape', function () {
|
|
$page = ready(visit('/material/communication'));
|
|
|
|
$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 = ready(visit('/material/communication'));
|
|
|
|
$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 = ready(visit('/material/communication'));
|
|
|
|
$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 = ready(visit('/material/communication')->resize(400, 800));
|
|
|
|
$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]')";
|
|
|
|
$page = ready(visit('/material/communication'));
|
|
|
|
// Opens the persistent tooltip over the button that triggers it: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#communication button:has-text("Press for details")');
|
|
|
|
$page->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 = ready(visit('/material/communication'));
|
|
|
|
// 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>
|
|
<link rel="icon" href="data:,">
|
|
<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 = ready(visit('/rich-tooltip-morph-probe'))
|
|
->assertNoJavaScriptErrors();
|
|
|
|
// Opens the tooltip, then a counter a retry would double: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('@details');
|
|
|
|
$page->assertScript("{$persistent}.matches(':popover-open')");
|
|
|
|
pressOnce($page)->click('@refresh');
|
|
|
|
$page->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);
|
|
|
|
pressOnce($page)->click('@details');
|
|
|
|
$page->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 = ready(visit('/material/communication'));
|
|
|
|
// 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>')");
|
|
|
|
// The trigger toggles the tooltip, so each press is pressed once: pressOnce(), tests/Pest.php.
|
|
pressOnce($page)->click('#communication button:has-text("Press for details")');
|
|
|
|
$page->assertScript("{$bubble}.matches(':popover-open')");
|
|
|
|
pressOnce($page)->click('#communication button:has-text("Press for details")');
|
|
|
|
$page->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; })()");
|
|
});
|