Files
livewire-material/tests/Browser/ActionsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 a83d7f62ea Hang a menu on its menu button rather than the trigger's wrapper
<x-menu> named its CSS anchor on the <span> around the trigger slot.
A trigger taken out of the flow, such as <x-button fab> fixed to the
bottom corner of a phone's window, left that span behind as an empty
box where the page put it, and the menu opened there. menu.js now moves
the name onto the menu button, beside any name the button already
carries for its tooltip, and moves it again whenever a Livewire morph
puts the server's attributes and a fresh name back. The wrapper keeps
the name until Alpine starts, or when there is no menu button.

A menu that fits neither below nor above its start edge now also tries
the opposite side and end together, so a default-position menu on a
FAB in the bottom-right corner opens above it, end-aligned. Before, it
fell back to its base position and overflowed the window.

This also applies to <x-fab-menu>, <x-account-menu>, <x-split-button>
and the <x-section-nav> picker, which share menu.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:18:39 +02:00

224 lines
9.3 KiB
PHP

<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
const MORE = '#menus [aria-label="More"]';
class MenuAnchorProbe extends Component
{
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div class="grid justify-items-start gap-6 p-4">
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-menu label="Share actions">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" data-test="more" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" />
<x-menu-item label="Download" icon="download" />
</x-menu>
<x-menu label="Create">
<x-slot:trigger>
<x-button fab icon="add" label="New plan" data-test="fab" />
</x-slot:trigger>
<x-menu-item label="Running plan" icon="directions_run" />
<x-menu-item label="Cycling plan" icon="directions_bike" />
</x-menu>
</div>
BLADE;
}
}
function showcase(string $section = 'buttons')
{
return visit("/material/{$section}")->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function focused(string $expression): string
{
return "document.activeElement.{$expression}";
}
it('opens a menu on the first item and walks it with the keyboard', function () {
$page = showcase('menus')
->assertNoJavaScriptErrors()
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('Copy link')"));
$page->keys(':focus', ['ArrowDown', 'ArrowDown'])
->assertScript(focused("textContent.trim().startsWith('Rename')"));
$page->keys(':focus', 'End')
->assertScript(focused("textContent.trim().startsWith('Delete')"));
$page->keys(':focus', 'ArrowDown')
->assertScript(focused("textContent.trim().startsWith('Copy link')"));
$page->keys(':focus', 'd')
->assertScript(focused("textContent.trim().startsWith('Download')"));
$page->keys(':focus', 'Escape')
->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript(focused("getAttribute('aria-label') === 'More'"));
});
it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
$page = showcase('menus');
$page->script("document.querySelector('".MORE."').focus()");
$page->keys(':focus', 'ArrowUp')
->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('Delete')"));
});
it('opens a menu the moment the page can be used', function () {
// The guard against a light-dismiss press reopening the menu once measured from the page's
// time origin, and swallowed every click in the first quarter second.
visit('/material/menus')
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true');
});
it('closes a menu when an item is chosen, but not one that keeps it open', function () {
$page = showcase('menus');
$page->click(MORE)
->click('#menus [role="menuitem"]:has-text("Download")')
->assertAttribute(MORE, 'aria-expanded', 'false');
$page->click('#menus button:has-text("Sort")')
->click('#menus [role="menuitemcheckbox"]:has-text("Largest")')
->assertScript("document.querySelector('#menus [role=\"menu\"][aria-label=\"Sort\"]').matches(':popover-open')");
});
it('shows a tooltip on keyboard focus and hides it on Escape', function () {
$tooltip = "document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]')";
$page = showcase();
// A key press first, so the browser is in keyboard modality, as a keyboard user would be —
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
// unless full keyboard access is on.
$page->keys('#content', 'Tab');
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
->assertScript("{$tooltip}.matches(':popover-open')");
$page->keys(':focus', 'Escape')
->assertScript("! {$tooltip}.matches(':popover-open')");
});
it('moves a connected group\'s choice with the arrow keys', function () {
$checked = fn (string $value): string => "document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"{$value}\"]').checked";
$page = showcase();
$page->script("document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"system\"]').focus()");
$page->keys(':focus', 'ArrowLeft')
->assertScript($checked('dark'))
->assertScript("getComputedStyle(document.querySelector('#buttons input[value=\"dark\"]').parentElement).borderTopLeftRadius === '9999px'");
});
it('rounds a split button\'s trailing half while its menu is open', function () {
$trailing = "document.querySelector('[data-split=\"trailing\"]')";
showcase()
->click('[data-split="trailing"] >> nth=0')
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === '9999px'");
});
it('turns the FAB into a close button while its menu is open', function () {
$fab = "document.querySelector('button[aria-label=\"New\"]')";
$page = showcase()
->click('button[aria-label="New"]')
->assertScript("{$fab}.getAttribute('aria-expanded') === 'true'")
->assertScript(focused("textContent.trim() === 'Upload files'"));
$page->keys(':focus', 'Escape')
->assertScript("{$fab}.getAttribute('aria-expanded') === 'false'")
->assertScript(focused("getAttribute('aria-label') === 'New'"));
});
it('animates the loading indicator in the browser', function () {
$clock = "document.querySelector('#buttons [role=\"progressbar\"] svg').getCurrentTime()";
showcase()->assertScript("{$clock} > 0.1");
});
/** A script giving the rects of a menu button (`control`) and of the menu it opens (`menu`). */
function menuAgainst(string $test, string $label): string
{
return "(() => { const control = document.querySelector('[data-test=\"{$test}\"]').getBoundingClientRect(); const menu = document.querySelector('[role=\"menu\"][aria-label=\"{$label}\"]').getBoundingClientRect(); return { control, menu }; })()";
}
it('hangs a menu on its menu button, even when the button is fixed to a corner of the window', function () {
Livewire::component('menu-anchor-probe', MenuAnchorProbe::class);
Route::middleware('web')->get('/menu-anchor-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-anchor-probe />
@livewireScripts
</body>
</html>
BLADE));
$placed = menuAgainst('fab', 'Create');
$above = "(({ control, menu }) => getComputedStyle(document.querySelector('[data-test=\"fab\"]')).position === 'fixed' && menu.bottom <= control.top && control.top - menu.bottom <= 16 && Math.abs(menu.right - control.right) <= 16 && menu.left >= 0 && menu.top >= 0)({$placed})";
$page = visit('/menu-anchor-probe')->resize(400, 800)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->click('@fab')
->assertAttribute('@fab', 'aria-expanded', 'true')
->assertScript($above);
$page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false');
// A Livewire render names the anchor afresh, on the wrapper again. Opened from the keyboard: a
// press this soon after the menu closed is taken for the light-dismiss press and ignored.
$page->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '1')
->script("document.querySelector('[data-test=\"fab\"]').focus()");
$page->keys(':focus', 'ArrowDown')
->assertAttribute('@fab', 'aria-expanded', 'true')
->assertScript($above);
// A button that also anchors its own tooltip keeps it, and the menu hangs under the button.
$page->resize(1024, 800)
->click('@more')
->assertAttribute('@more', 'aria-expanded', 'true')
->assertScript("(() => { const names = getComputedStyle(document.querySelector('[data-test=\"more\"]')).getPropertyValue('anchor-name'); return names.includes('--material-button-') && names.includes('--material-menu-'); })()")
->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
});