Keep open menus and rich tooltips open through a Livewire render
<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
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab7317faae
commit
8640d815c8
@@ -44,6 +44,88 @@ class MenuAnchorProbe extends Component
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Livewire component that renders again while its menus are open: `touch` from outside, a
|
||||
* `keep-open` item's action, or a FAB menu item's action.
|
||||
*/
|
||||
class MenuMorphProbe extends Component
|
||||
{
|
||||
public int $renders = 0;
|
||||
|
||||
public string $sort = 'newest';
|
||||
|
||||
public string $created = '';
|
||||
|
||||
public function touch(): void
|
||||
{
|
||||
$this->renders++;
|
||||
}
|
||||
|
||||
public function sortBy(string $sort): void
|
||||
{
|
||||
$this->sort = $sort;
|
||||
}
|
||||
|
||||
public function create(string $kind): void
|
||||
{
|
||||
$this->created = $kind;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="p-4">
|
||||
<p id="outside">renders: <span id="renders">{{ $renders }}</span>, created: <span id="created">{{ $created }}</span></p>
|
||||
|
||||
<x-menu label="Sort">
|
||||
<x-slot:trigger>
|
||||
<x-button label="Sort" data-test="sort" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="sortBy('newest')" keep-open />
|
||||
<x-menu-item label="Largest" :selected="$sort === 'largest'" wire:click="sortBy('largest')" keep-open />
|
||||
</x-menu>
|
||||
|
||||
<div style="position: fixed; right: 16px; bottom: 16px">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="create('files')" />
|
||||
<x-fab-menu-item label="Paste text" icon="content_paste" wire:click="create('text')" />
|
||||
</x-fab-menu>
|
||||
</div>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
const SORT_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"Sort\"]')";
|
||||
|
||||
const FAB_MENU = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')";
|
||||
|
||||
const NEW_FAB = 'button[aria-label="New"]';
|
||||
|
||||
function menuMorphProbe()
|
||||
{
|
||||
Livewire::component('menu-morph-probe', MenuMorphProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/menu-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:menu-morph-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/menu-morph-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
function showcase(string $section = 'buttons')
|
||||
{
|
||||
return visit("/material/{$section}")->waitForEvent('networkidle')
|
||||
@@ -251,3 +333,125 @@ it('paints a group\'s hint and a menu item\'s icon in the colour their classes n
|
||||
->assertScript($ink("document.querySelector('#chosen svg')")." === {$error}")
|
||||
->assertScript($ink("document.querySelector('#off svg')")." !== {$error}");
|
||||
});
|
||||
|
||||
it('keeps a menu open while the component around it renders, and closes it cleanly after', function () {
|
||||
$page = menuMorphProbe()->assertNoJavaScriptErrors();
|
||||
|
||||
$page->click('@sort')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->assertScript(SORT_MENU.".matches(':popover-open')")
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true')
|
||||
->assertScript("document.querySelector('[data-test=\"sort\"]').getAttribute('aria-controls') === ".SORT_MENU.'.id')
|
||||
->assertScript(focused("textContent.trim().startsWith('Newest')"));
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||
->assertScript('! '.SORT_MENU.".matches(':popover-open')")
|
||||
->assertScript(focused("dataset.test === 'sort'"));
|
||||
|
||||
// Opened from the keyboard: a press this soon after the menu closed is taken for the
|
||||
// light-dismiss press and ignored.
|
||||
$page->keys('@sort', 'ArrowDown')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '2')
|
||||
->click('#outside')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('closes a menu on a second press of its menu button, and after the component renders', function () {
|
||||
$page = menuMorphProbe();
|
||||
|
||||
// The press closes the menu before its click reaches the button: the guard against that click
|
||||
// opening it again once waited for the queued toggle event, which comes after the click.
|
||||
$page->click('@sort')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true')
|
||||
->click('@sort')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||
|
||||
$page->keys('@sort', 'ArrowDown')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
// Past the reopen guard of the close above, so the second press is on its own.
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->wait(0.3);
|
||||
|
||||
$page->click('@sort')
|
||||
->assertAttribute('@sort', 'aria-expanded', 'false')
|
||||
->assertScript('! '.SORT_MENU.".matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('keeps a menu open while a keep-open item\'s action runs', function () {
|
||||
$page = menuMorphProbe();
|
||||
|
||||
$page->click('@sort')
|
||||
->click('[role="menuitemcheckbox"]:has-text("Largest")')
|
||||
->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true')
|
||||
->assertScript(SORT_MENU.".matches(':popover-open')")
|
||||
->assertAttribute('@sort', 'aria-expanded', 'true');
|
||||
|
||||
$page->click('[role="menuitemcheckbox"]:has-text("Newest")')
|
||||
->assertAttribute('[role="menuitemcheckbox"]:has-text("Newest")', 'aria-checked', 'true')
|
||||
->assertScript(SORT_MENU.".matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('keeps a FAB menu open while the component around it renders, and closes it cleanly after', function () {
|
||||
$page = menuMorphProbe();
|
||||
|
||||
$page->click(NEW_FAB)
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->assertScript(FAB_MENU.".matches(':popover-open')")
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
|
||||
->assertScript("document.querySelector('".NEW_FAB."').getAttribute('aria-controls') === ".FAB_MENU.'.id');
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||||
|
||||
$page->keys(NEW_FAB, 'ArrowDown')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
// Past the reopen guard of the Escape above, so the second press is on its own.
|
||||
$page->assertSeeIn('#renders', '2')
|
||||
->wait(0.3);
|
||||
|
||||
$page->click(NEW_FAB)
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||
->assertScript('! '.FAB_MENU.".matches(':popover-open')");
|
||||
});
|
||||
|
||||
it('closes a FAB menu when an item\'s action runs, and opens and closes it cleanly after', function () {
|
||||
$page = menuMorphProbe();
|
||||
|
||||
$page->click(NEW_FAB)
|
||||
->click('[role="menuitem"]:has-text("Upload files")')
|
||||
->assertSeeIn('#created', 'files')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false');
|
||||
|
||||
$page->script("document.querySelector('".NEW_FAB."').focus()");
|
||||
|
||||
$page->keys(NEW_FAB, 'ArrowDown')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
|
||||
->assertScript(focused("textContent.trim() === 'Upload files'"));
|
||||
|
||||
$page->keys(':focus', 'Escape')
|
||||
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
|
||||
->assertScript('! '.FAB_MENU.".matches(':popover-open')")
|
||||
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ use Livewire\Livewire;
|
||||
*/
|
||||
class CarouselMorphProbe extends Component
|
||||
{
|
||||
public int $count = 3;
|
||||
public int $count = 6;
|
||||
|
||||
public function add(): void
|
||||
{
|
||||
@@ -219,10 +219,19 @@ it('measures itself again after a Livewire morph adds an item', function () {
|
||||
|
||||
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertScript($masked, 3)
|
||||
->assertAttribute('[data-material-carousel-item] >> nth=2', 'aria-label', '3 of 3');
|
||||
->assertScript($masked, 6)
|
||||
->assertAttribute('[data-material-carousel-item] >> nth=5', 'aria-label', '6 of 6');
|
||||
|
||||
$page->click('Add')
|
||||
->assertScript($masked, 4)
|
||||
->assertAttribute('[data-material-carousel-item] >> nth=3', 'aria-label', '4 of 4');
|
||||
->assertScript($masked, 7)
|
||||
->assertAttribute('[data-material-carousel-item] >> nth=6', 'aria-label', '7 of 7');
|
||||
|
||||
// Scrolled once the morph has settled, so only the row's own scroll listener can re-mask the
|
||||
// items: a row the morph swapped for a copy scrolls with its items' masks left as they were.
|
||||
$page->script(onCarousel(0, "await pause(300); scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })", 'body'));
|
||||
|
||||
$page->assertScript(onCarousel(0, <<<'JS'
|
||||
await pause(50)
|
||||
return inset(0) > 0.5 && inset(2) < 0.5
|
||||
JS, 'body'));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
<?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]')";
|
||||
@@ -108,3 +147,46 @@ it('opens a persistent rich tooltip on press', function () {
|
||||
->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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/** The child component in each row of `KeyedRowsProbe`. */
|
||||
class KeyedRowChildProbe extends Component
|
||||
{
|
||||
public function render(): string
|
||||
{
|
||||
return '<p data-row-child>Row</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A list whose rows each hold `$markup`, then a child component. Livewire keys a child from the
|
||||
* loop around it, and takes a `wire:key` written in any template rendered in the row for the row's
|
||||
* key: a component that wrote its own that way would key every row's child alike.
|
||||
*/
|
||||
class KeyedRowsProbe extends Component
|
||||
{
|
||||
public static string $markup = '';
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return '<div>@foreach ([1, 2] as $row)<div wire:key="row-{{ $row }}">'.static::$markup.'<livewire:keyed-row-child-probe /></div>@endforeach</div>';
|
||||
}
|
||||
}
|
||||
|
||||
it('keys what a morph must patch in place, without keying the child components after it', function (string $markup, string $key) {
|
||||
Livewire::component('keyed-row-child-probe', KeyedRowChildProbe::class);
|
||||
Livewire::component('keyed-rows-probe', KeyedRowsProbe::class);
|
||||
|
||||
KeyedRowsProbe::$markup = $markup;
|
||||
|
||||
$html = Livewire::test('keyed-rows-probe')->html();
|
||||
|
||||
preg_match_all('/<p\b[^>]*\bwire:key="([^"]+)"[^>]*>Row<\/p>/', $html, $children);
|
||||
|
||||
expect(substr_count($html, "wire:key=\"{$key}\""))->toBe(2)
|
||||
->and($children[1])->toHaveCount(2)
|
||||
->and(array_unique($children[1]))->toHaveCount(2);
|
||||
})->with([
|
||||
'menu' => ['<x-menu label="Row actions"><x-slot:trigger><button>More</button></x-slot:trigger><x-menu-item label="Delete" /></x-menu>', 'material-menu'],
|
||||
'FAB menu' => ['<x-fab-menu label="New"><x-fab-menu-item label="Upload" /></x-fab-menu>', 'material-fab-menu'],
|
||||
'rich tooltip' => ['<x-rich-tooltip text="Details"><button>i</button></x-rich-tooltip>', 'material-rich-tooltip'],
|
||||
'carousel' => ['<x-carousel label="Photos"><x-carousel-item><div class="size-full"></div></x-carousel-item></x-carousel>', 'material-carousel'],
|
||||
]);
|
||||
Reference in New Issue
Block a user