<x-menu>, <x-fab-menu> and <x-rich-tooltip> gave their popover an id that is new with every render, and Livewire's morph matches an element without a wire:key by its id. So every render of the component around them swapped the popover for a closed copy: an open menu closed, its listeners stayed behind on the old element (Escape or a press outside then left aria-expanded="true" on the trigger and focus unreturned), a keep-open item's wire:click closed its menu when the response came, and a rich tooltip went on showing the detached bubble, so it never opened again. <x-carousel>'s row had the same kind of id: after a render it scrolled without its listeners, and its items stopped re-masking. The popover, the bubble and the row now carry a wire:key, so the morph patches them in place and changes the id and anchor name together with the trigger's, as it already did for everything else. The key goes through an attribute bag: Livewire compiles a wire:key written in a template into the key of the loop iteration around it, which would have given every child component after the menu in a row the same key. A morph also removes the menu button's ARIA attributes, which only script writes. menu.js now writes them again after every morph, so the button of a menu that stays open, and a FAB menu's close look, still say it is open, and aria-controls names the popover's new id. A second press on an open menu's button opened it again, render or not: the popover closes on the press, and the guard against the click that follows was timed from the toggle event, which is queued and arrives after that click. It is timed from beforetoggle now. Browser tests with Livewire probes in Chromium, Firefox and WebKit, and a render test for the keys and the keys of the child components after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
193 lines
8.7 KiB
PHP
193 lines
8.7 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 class="p-4">
|
|
<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;
|
|
}
|
|
}
|
|
|
|
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
|
|
|
|
const TOAST = "document.querySelector('[data-toast]')";
|
|
|
|
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(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-toast] button[aria-label=\"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-toast] button[aria-label="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-toast] button[aria-label="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-toast-action]')
|
|
->assertScript(TOAST.' === null')
|
|
->assertScript("window.eval('window.reloads') === 1")
|
|
->assertScript("window.eval('window.handled') === 1");
|
|
});
|
|
|
|
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('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 class="bg-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')");
|
|
|
|
$page->click('#outside')
|
|
->assertScript("! {$persistent}.matches(':popover-open')");
|
|
|
|
$page->click('@details')
|
|
->assertScript("{$persistent}.matches(':popover-open')");
|
|
|
|
$page->click('#outside')
|
|
->hover('@hint')
|
|
->assertScript("{$transient}.matches(':popover-open')")
|
|
->assertNoJavaScriptErrors();
|
|
});
|