Files
livewire-material/tests/Browser/ActionsTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 b1fc0c9cfa
tests / feature (8.4) (push) Successful in 1m17s
tests / feature (8.5) (push) Successful in 1m21s
tests / browser (chrome, chromium) (push) Successful in 3m55s
tests / browser (firefox, firefox) (push) Successful in 5m34s
tests / lint (push) Successful in 1m5s
tests / browser (safari, webkit) (push) Successful in 5m54s
Keep a connected group's inner corners round
Connected segments and split halves wrote their full corners as
--md-sys-shape-corner-full, 9999px, beside 8px inner corners on the same
element. A box whose radii add up to more than its side has every radius
scaled by one factor, so the inner corners shrank to a hundredth of a pixel
and drew square. Full is now half the size's height (--group-full), which no
side can overflow. A browser test fails on any radius in a group or split
button larger than half its height.

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

467 lines
20 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;
}
}
/**
* 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')
->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("(el => getComputedStyle(el).borderTopLeftRadius === (el.offsetHeight / 2) + 'px')(document.querySelector('#buttons input[value=\"dark\"]').parentElement)");
});
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 === ({$trailing}.offsetHeight / 2) + 'px'");
});
it('keeps a connected segment\'s small inner corners, which a 9999px outer corner would scale away', function () {
// When a box's radii add up to more than a side, CSS shrinks every radius by the same
// factor: a full corner written as 9999px drew the 8px inner corners square. Every radius
// in a connected group or a split button stays within half its height, so none is scaled.
showcase()
->assertScript("[...document.querySelectorAll('[data-button-group=\"connected\"] > *, [data-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
->assertScript("(el => getComputedStyle(el).borderTopRightRadius === '8px')(document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"light\"]').parentElement)");
});
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').')');
});
it('paints a group\'s hint and a menu item\'s icon in the colour their classes name', function () {
Route::middleware('web')->get('/colour-class-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
</head>
<body class="bg-surface">
<span id="error-ink" class="text-error">Reference</span>
<div id="terrain">
<x-group name="terrain" hint="No elevation data here" hint-class="text-error" :options="[['id' => 'flat', 'name' => 'Flat']]" />
</div>
<x-menu-item id="run" label="Running plan" icon="directions_run" icon-class="text-error" />
<x-menu-item id="chosen" label="Cycling plan" icon="directions_bike" icon-class="text-error" :selected="true" />
<x-menu-item id="off" label="Swimming plan" icon="pool" icon-class="text-error" disabled />
</body>
</html>
BLADE));
$ink = fn (string $element): string => "getComputedStyle({$element}).color";
$error = $ink("document.querySelector('#error-ink')");
visit('/colour-class-probe')->waitForEvent('networkidle')
->assertScript($ink("document.querySelector('#terrain p')")." === {$error}")
->assertScript($ink("document.querySelector('#run svg')")." === {$error}")
->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'"));
});