Files
livewire-material/tests/Browser/ActionsTest.php
T
surtic86andClaude Opus 5 ad230e0f48
tests / feature (8.4) (push) Successful in 1m46s
tests / feature (8.5) (push) Successful in 1m53s
tests / browser (chrome, chromium) (push) Successful in 7m46s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Failing after 13m58s
Keep the full-screen range picker's months where they were as it grows
When the full-screen range picker adds months above the ones on screen,
extendMonths() puts the scroll back by however much the list grew. An
engine with scroll anchoring moved it back as well, so a reader paging up
landed half a year on, and a press meant for one day chose another. The
calendar now opts out of scroll anchoring (`overflow-anchor: none`) and
the picker's own correction is the only one, in every engine alike. A
test holds what sits under a fixed point of the list while it grows.

The browser plugin retries an action the way it retries an assertion:
when the page takes longer than a second to handle a press, it presses
again. On a loaded runner the full-screen picker, which re-renders every
day of every month it holds, took that long — so its toggle was pressed
a second time under the picker it had just opened, and never landed, and
a day pressed twice became the range's end as well as its start.
`pressOnce()` sends a press that must not be repeated exactly once, with
Playwright's own wait for the element; the picker's toggle, its paging
keys, its days and its Save go through it, and so do the menu triggers
that toggle.

Browser 300 passed on Chrome, Firefox and WebKit on macOS, and the date
picker and menu tests 61 passed on Linux Firefox and WebKitGTK held to
two busy cores. Feature 1159 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:45:51 +02:00

1118 lines
49 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 style="display: grid; justify-items: start; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
<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 style="padding: var(--md-sys-measurement-space200);">
<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>
<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:menu-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/menu-morph-probe'));
}
function showcase(string $section = 'buttons')
{
return ready(visit("/material/{$section}"));
}
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();
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
pressOnce($page)->click(MORE);
$page->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('shows no state layer on a disabled menu item even when keyboard focus reaches it', function () {
$trigger = '#menus button:has-text("Sort")';
$reset = '#menus [role="menuitem"]:has-text("Reset")';
$page = showcase('menus')->click($trigger);
// M3 keeps a disabled item reachable so a person can find out it is there, but
// .md-state-layer withholds the layer itself from [aria-disabled="true"] (interaction.css).
$page->keys(':focus', 'End')
->assertScript(focused("textContent.trim().startsWith('Reset')"))
->assertAttribute($reset, 'aria-disabled', 'true')
->assertScript("getComputedStyle(document.activeElement, '::before').display === 'none'");
});
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-md-split=\"trailing\"]')";
showcase()
->click('[data-md-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-md-button-group=\"connected\"] > *, [data-md-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");
});
it('draws the loading indicator at the size it is given, container and shape in proportion', function () {
$box = fn (string $selector): string => "document.querySelector('#buttons {$selector}').getBoundingClientRect()";
showcase()
->assertScript("(({ width, height }) => width === 96 && height === 96)({$box('[aria-label=\"Uploading\"]')})")
->assertScript("(({ width, height }) => width === 96 && height === 96)({$box('[aria-label=\"Uploading\"] > [data-md-loading-animated]')})")
->assertScript("(({ width, height }) => width === 32 && height === 32)({$box('[data-md-loading][data-md-contained][style]')})");
});
/** 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>
<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: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 = ready(visit('/menu-anchor-probe')->resize(400, 800));
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
pressOnce($page)->click('@fab');
$page->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>
<link rel="icon" href="data:,">
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
</head>
<body style="background-color: var(--md-sys-color-surface);">
<span id="error-ink" class="md-ink-error">Reference</span>
<div id="terrain">
<x-group name="terrain" hint="No elevation data here" hint-class="md-ink-error" :options="[['id' => 'flat', 'name' => 'Flat']]" />
</div>
<x-menu-item id="run" label="Running plan" icon="directions_run" icon-class="md-ink-error" />
<x-menu-item id="chosen" label="Cycling plan" icon="directions_bike" icon-class="md-ink-error" :selected="true" />
<x-menu-item id="off" label="Swimming plan" icon="pool" icon-class="md-ink-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'"));
});
it('collapses an extended FAB to a FAB while the page scrolls down, and extends it on the way back', function () {
$fab = "document.querySelector('#buttons [data-md-collapse-on-scroll]')";
// Reduced motion takes the spring's duration to zero, so the width is final at once.
$page = visit('/material/buttons', ['reducedMotion' => 'reduce'])
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'")
->assertScript("! {$fab}.hasAttribute('data-md-collapsed') && {$fab}.getBoundingClientRect().width > 56");
$page->script('window.scrollTo(0, 600)');
$page->assertScript("{$fab}.hasAttribute('data-md-collapsed')")
->assertScript("Math.round({$fab}.getBoundingClientRect().width) === 56")
// Clipped, not removed: the collapsed FAB keeps the extended one's name.
->assertScript("{$fab}.textContent.trim() === 'Compose'");
$page->script('window.scrollTo(0, 200)');
$page->assertScript("! {$fab}.hasAttribute('data-md-collapsed')")
->assertScript("{$fab}.getBoundingClientRect().width > 56");
});
class DisabledFabProbe extends Component
{
public int $created = 0;
public function create(): void
{
usleep(800_000);
$this->created++;
}
public function render(): string
{
return <<<'BLADE'
<div>
<p>created: <span id="created">{{ $created }}</span></p>
<x-button id="enabled-fab" fab icon="add" label="New plan" wire:click="create" spinner />
<x-button id="disabled-fab" fab icon="add" label="New route" disabled />
<x-button id="disabled-link-fab" fab icon="add" label="New course" link="/courses/new" disabled />
</div>
BLADE;
}
}
it('hides a disabled fab button below 600px, as M3 removes a FAB whose action is unavailable, and keeps the disabled button from 600px', function () {
Livewire::component('disabled-fab-probe', DisabledFabProbe::class);
Route::middleware('web')->get('/disabled-fab-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:disabled-fab-probe />
@livewireScripts
</body>
</html>
BLADE));
// [rendered, fixed as a FAB]: `display: none` draws nothing and takes the button out of the
// accessibility tree and the Tab order with it.
$state = fn (string $id): string => "(() => {
const button = document.getElementById('{$id}');
return [getComputedStyle(button).display !== 'none' && button.checkVisibility(), getComputedStyle(button).position === 'fixed'];
})()";
$page = ready(visit('/disabled-fab-probe')->resize(393, 800), alpine: false);
expect($page->script($state('enabled-fab')))->toBe([true, true])
->and($page->script($state('disabled-fab')))->toBe([false, true])
->and($page->script($state('disabled-link-fab')))->toBe([false, true]);
// A spinner disables its button while the action runs; that FAB is busy, not unavailable, and
// stays on screen with its loading indicator.
$page->click('#enabled-fab')
->assertScript("(() => { const button = document.getElementById('enabled-fab'); return button.hasAttribute('data-loading') && button.disabled && button.checkVisibility() && button.querySelector('[data-md-button-spinner]').checkVisibility(); })()")
->assertSeeIn('#created', '1');
$page->resize(600, 800)->assertScript("getComputedStyle(document.getElementById('enabled-fab')).position !== 'fixed'");
expect($page->script($state('disabled-fab')))->toBe([true, false])
->and($page->script($state('disabled-link-fab')))->toBe([true, false])
// The disabled filled button it is from medium: on-surface at 10% behind 38% ink.
->and($page->script("getComputedStyle(document.getElementById('disabled-fab')).backgroundColor"))->not->toBe($page->script("getComputedStyle(document.getElementById('enabled-fab')).backgroundColor"));
});
it('takes a press on a small connected segment at its 48px edge, past what it draws', function () {
// M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width".
showcase()->assertScript(<<<'JS'
(() => {
const segment = document.querySelector('#buttons input[name="showcase-theme"][value="light"]').parentElement
segment.scrollIntoView({ block: 'center' })
const box = segment.getBoundingClientRect()
const x = box.left + box.width / 2
return box.height === 40 && box.width >= 48
&& document.elementFromPoint(x, box.top - 3) === segment
&& document.elementFromPoint(x, box.bottom + 3) === segment
})()
JS);
});
class GroupSizeProbe extends Component
{
public function render(): string
{
return <<<'BLADE'
<div style="display: grid; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
<x-group name="xs-group" size="xs" :options="[['id' => 'a', 'name' => 'A'], ['id' => 'b', 'name' => 'B']]" />
<x-group name="sm-group" size="sm" :options="[['id' => 'a', 'name' => 'A'], ['id' => 'b', 'name' => 'B']]" />
</div>
BLADE;
}
}
function groupSizeProbe()
{
Livewire::component('group-size-probe', GroupSizeProbe::class);
Route::middleware('web')->get('/group-size-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:group-size-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/group-size-probe'), livewire: false);
}
it('takes a press at an xs or sm group segment\'s 48px edge, past what it draws, from the shared classes', function () {
// M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width" —
// group.blade.php reaches it with md-touch-target, not a rule of its own.
groupSizeProbe()
->assertScript(<<<'JS'
(() => {
const segment = document.querySelector('input[name="xs-group"][value="a"]').parentElement
const box = segment.getBoundingClientRect()
const x = box.left + box.width / 2
return box.height < 48
&& document.elementFromPoint(x, box.top - 3) === segment
&& document.elementFromPoint(x, box.bottom + 3) === segment
})()
JS)
->assertScript(<<<'JS'
(() => {
const segment = document.querySelector('input[name="sm-group"][value="a"]').parentElement
const box = segment.getBoundingClientRect()
const x = box.left + box.width / 2
return box.height < 48
&& document.elementFromPoint(x, box.top - 3) === segment
&& document.elementFromPoint(x, box.bottom + 3) === segment
})()
JS);
});
it('keeps one choice in a single, required selection group', function () {
$group = '#buttons [data-md-selection="single"]';
$view = "document.querySelector('{$group}').parentElement.querySelector('code').textContent";
$page = showcase();
$page->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'true')
->click("{$group} button:has-text(\"Day\")")
->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true')
->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'false')
->assertScript("{$view} === 'day'");
// Required: pressing the chosen button again leaves it chosen.
$page->click("{$group} button:has-text(\"Day\")")
->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true')
->assertScript("{$view} === 'day'");
});
it('toggles each choice of a multi selection group, down to none', function () {
$group = '#buttons [data-md-selection="multi"]';
$marks = "document.querySelector('{$group}').parentElement.querySelectorAll('code')[1].textContent";
showcase()
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true')
->click("{$group} [aria-label=\"Italic\"]")
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true')
->assertScript("{$marks} === 'bold, italic'")
->click("{$group} [aria-label=\"Bold\"]")
->click("{$group} [aria-label=\"Italic\"]")
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'false')
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false')
->assertScript("{$marks} === 'none'");
});
/**
* The tests below: a submenu opened and closed by keyboard or hover, a menu's filter field, its
* sheet-at-compact form below 600px, a long menu's scroll and the FAB menu's own scroll behind
* its fixed close button. `LongMenuProbe` and `SheetMenuProbe` back the last three of those.
*/
/** A Livewire component whose menu has more items than the popover's 288px cap can show. */
class LongMenuProbe extends Component
{
public function render(): string
{
return <<<'BLADE'
<div style="padding: 4rem">
<x-menu label="Long list">
<x-slot:trigger><button>Open</button></x-slot:trigger>
@for ($i = 1; $i <= 20; $i++)
<x-menu-item label="Item {{ $i }}" />
@endfor
</x-menu>
</div>
BLADE;
}
}
/**
* A Livewire component whose `sheet-at-compact` menu also filters, so the compact window's field
* can be typed into and a render around an open sheet can be checked for a focus kept in it.
*/
class SheetMenuProbe extends Component
{
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-menu label="Assign to" filter="Find a person" sheet-at-compact>
<x-slot:trigger><button>Assign</button></x-slot:trigger>
<x-menu-item label="Ada Lovelace" />
<x-menu-item label="Grace Hopper" />
</x-menu>
</div>
BLADE;
}
}
function longMenuProbe()
{
Livewire::component('long-menu-probe', LongMenuProbe::class);
Route::middleware('web')->get('/long-menu-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:long-menu-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/long-menu-probe'), livewire: false);
}
function sheetMenuProbe()
{
Livewire::component('sheet-menu-probe', SheetMenuProbe::class);
Route::middleware('web')->get('/sheet-menu-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:sheet-menu-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/sheet-menu-probe')->resize(400, 800));
}
it('opens a submenu with Right, Enter or Space, and closes it with Left or Escape, back on the item', function () {
$trigger = '#menus button:has-text("Share")';
$sendTo = '#menus [role="menuitem"]:has-text("Send to")';
$page = showcase('menus')
->click($trigger)
->assertAttribute($trigger, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('Copy link')"));
$page->keys(':focus', 'ArrowDown')
->assertScript(focused("textContent.trim().startsWith('Send to')"))
->assertAttribute($sendTo, 'aria-expanded', 'false');
$page->keys(':focus', 'ArrowRight')
->assertAttribute($sendTo, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('A person')"));
$page->keys(':focus', 'ArrowLeft')
->assertAttribute($sendTo, 'aria-expanded', 'false')
->assertScript(focused("textContent.trim().startsWith('Send to')"));
// Enter and Space, the button's own activation keys, open it exactly as Right does.
$page->keys(':focus', 'Enter')
->assertAttribute($sendTo, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('A person')"));
$page->keys(':focus', 'Escape')
->assertAttribute($sendTo, 'aria-expanded', 'false')
->assertScript(focused("textContent.trim().startsWith('Send to')"))
// Escape closed only the submenu: the outer menu is still open.
->assertAttribute($trigger, 'aria-expanded', 'true');
});
it('opens a submenu on hover for a fine pointer, and closes it once the pointer truly leaves', function () {
$trigger = '#menus button:has-text("Share")';
$sendTo = '#menus [role="menuitem"]:has-text("Send to")';
// "Delete" is an item in five menus on the page; only the open one's is visible.
$delete = '#menus [role="menuitem"]:has-text("Delete"):visible';
$page = showcase('menus')->click($trigger);
$page->hover($sendTo)
->wait(0.3)
->assertAttribute($sendTo, 'aria-expanded', 'true');
// The submenu is a DOM child of the item's own wrapper, so leaving for elsewhere in the list
// is what closes it — crossing into it would not be a leave at all.
$page->hover($delete)
->wait(0.4)
->assertAttribute($sendTo, 'aria-expanded', 'false');
});
it('filters a menu\'s items as its field is typed into, moves the highlight with arrows, and clears on reopen', function () {
$trigger = '#menus button:has-text("Assign to")';
$field = '#menus [aria-label="Find a person"]';
// Scoped to this menu's own filtered list: every other menu on the page has "menuitem"s too,
// and :has-text() is a Playwright locator extension — invalid inside a real
// document.querySelector, which the id lookups below need to be.
$list = "[...document.querySelectorAll('#menus [role=\"menu\"][aria-label=\"Assign to\"] [role=\"menuitem\"]')]";
$ada = "{$list}.find((item) => item.textContent.trim().startsWith('Ada Lovelace')).id";
$grace = "{$list}.find((item) => item.textContent.trim().startsWith('Grace Hopper')).id";
$page = showcase('menus')
->click($trigger)
->assertScript(focused("getAttribute('aria-label') === 'Find a person'"));
// Ada Lovelace and Grace Hopper are both "Engineering"; the rest are Research or Networks.
$page->type($field, 'engineering')
->assertScript("{$list}.filter((item) => !item.hidden).length === 2")
->assertScript("document.querySelector('{$field}').getAttribute('aria-activedescendant') === ({$ada})");
$page->keys($field, 'ArrowDown')
->assertScript("document.querySelector('{$field}').getAttribute('aria-activedescendant') === ({$grace})");
$page->keys($field, 'Enter')
->assertAttribute($trigger, 'aria-expanded', 'false');
// Closing clears the query, so a reopen shows the whole list again.
$page->click($trigger)
->assertScript("document.querySelector('{$field}').value === ''")
->assertScript("{$list}.every((item) => !item.hidden)");
});
it('opens a sheet-at-compact menu below 600px, focused on its first item, and the popover from 600px', function () {
$trigger = '#menus button:has-text("Photo")';
// The sheet is <x-bottom-sheet>, still x-show-driven — its CSS now animates open/close
// through @starting-style and transition-behavior: allow-discrete rather than Alpine's
// x-transition classes, but x-show still owns the element's display, so its openness shows
// as visibility, not the Popover API's :popover-open.
$dialog = '[role="dialog"][aria-label="Photo actions"]';
$addToAlbum = '#menus [role="menuitem"]:has-text("Add to album")';
$page = showcase('menus')->resize(400, 800);
$page->click($trigger)
->assertAttribute($trigger, 'aria-haspopup', 'dialog')
->assertScript(focused("textContent.trim().startsWith('Set as wallpaper')"))
->assertVisible($dialog);
// A submenu opens in place, under its item, instead of beside it as the popover's would.
$page->keys(':focus', 'ArrowDown')
->keys(':focus', 'ArrowRight')
->assertScript(focused("textContent.trim().startsWith('Holidays')"));
$page->keys(':focus', 'Escape')
->assertScript(focused("textContent.trim().startsWith('Add to album')"))
->assertAttribute($addToAlbum, 'aria-expanded', 'false');
$page->keys(':focus', 'Escape')
->assertAttribute($trigger, 'aria-expanded', 'false')
->assertMissing($dialog)
->assertScript(focused("textContent.trim() === 'Photo'"));
// From 600px the trigger opens the popover instead, and a resize across it while one is open
// closes whichever was shown. The trigger says which it opens now before it is pressed — and
// pressed once, as it toggles: pressOnce() in tests/Pest.php.
$page->resize(900, 800)
->assertAttribute($trigger, 'aria-haspopup', 'menu');
pressOnce($page)->click($trigger);
$page->assertAttribute($trigger, 'aria-expanded', 'true');
$page->resize(400, 800)
->assertAttribute($trigger, 'aria-expanded', 'false');
});
it('filters in the sheet too, and a Livewire render keeps it open with the field\'s focus', function () {
$page = sheetMenuProbe();
$page->click('button:has-text("Assign")')
->assertScript(focused("getAttribute('aria-label') === 'Find a person'"));
$page->type(':focus', 'grace')
->assertScript("[...document.querySelectorAll('[role=\"dialog\"] [role=\"menuitem\"]')].filter((item) => !item.hidden).length === 1");
$page->script('window.eval("Livewire.first().touch()")');
// The sheet is <x-bottom-sheet>, still x-show-driven (see the note above), so open shows as
// visible, not :popover-open.
$page->assertSeeIn('#renders', '1')
->assertVisible('[role="dialog"][aria-label="Assign to"]')
->assertScript(focused("getAttribute('aria-label') === 'Find a person'"))
->assertScript(focused("value === 'grace'"));
});
it('scrolls a long menu and keeps the item the keyboard reaches inside its visible box', function () {
$popover = "document.querySelector('[role=\"menu\"][aria-label=\"Long list\"]')";
$page = longMenuProbe()
->click('button:has-text("Open")')
->assertScript("{$popover}.scrollHeight > {$popover}.clientHeight");
$page->keys(':focus', 'End')
->assertScript(focused("textContent.trim() === 'Item 20'"))
->assertScript("(() => { const item = document.activeElement.getBoundingClientRect(); const box = {$popover}.getBoundingClientRect(); return item.top >= box.top - 1 && item.bottom <= box.bottom + 1; })()");
});
class LongFabMenuProbe extends Component
{
public function render(): string
{
return <<<'BLADE'
<div style="position: fixed; right: 16px; bottom: 16px">
<x-fab-menu label="New">
@for ($i = 1; $i <= 8; $i++)
<x-fab-menu-item label="Item {{ $i }}" icon="star" />
@endfor
</x-fab-menu>
</div>
BLADE;
}
}
function longFabMenuProbe()
{
Livewire::component('long-fab-menu-probe', LongFabMenuProbe::class);
Route::middleware('web')->get('/long-fab-menu-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:long-fab-menu-probe />
@livewireScripts
</body>
</html>
BLADE));
return ready(visit('/long-fab-menu-probe')->resize(320, 420), livewire: false);
}
it('scrolls the FAB menu\'s items on a short window, behind the close button, which stays put', function () {
$trigger = "document.querySelector('button[aria-label=\"New\"]')";
$list = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')";
$page = longFabMenuProbe()
->click('button[aria-label="New"]')
->assertAttribute('button[aria-label="New"]', 'aria-expanded', 'true')
->assertScript("{$list}.scrollHeight > {$list}.clientHeight");
$page->script("window.__fabTop = {$trigger}.getBoundingClientRect().top; {$list}.scrollTop = 40");
// The close button is not inside the scrolling list, so scrolling it never moves the button.
$page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop");
});
/** The exit copy on screen: shown, inert, hidden from assistive technology, with no ids of its own. */
const EXIT_COPY = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); return copy !== null && copy.matches(':popover-open') && copy.inert && copy.getAttribute('aria-hidden') === 'true' && ! copy.hasAttribute('id') && copy.querySelector('[id], [popover]:not([data-md-popover-ghost])') === null; })()";
/** Whether the exit copy is caught part-way to its closed opacity. */
const EXIT_COPY_FADING = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); const opacity = copy && parseFloat(getComputedStyle(copy).opacity); return opacity > 0.02 && opacity < 0.98; })()";
it('fades a menu out after the browser has closed it, on Escape or a press outside, in every engine', function () {
$menu = 'document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\'))';
$page = slowMotion(showcase('menus'))
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true');
// Escape: the browser's own light dismiss, which no script can hold open.
$page->keys(':focus', 'Escape')
->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript("! {$menu}.matches(':popover-open')")
->assertScript(focused("getAttribute('aria-label') === 'More'"))
->assertScript(EXIT_COPY)
->assertScript(EXIT_COPY_FADING)
// The copy is decoration: Alpine starts nothing inside it.
->assertScript("window.eval(\"[...document.querySelectorAll('[data-md-popover-ghost], [data-md-popover-ghost] *')].every((element) => element._x_dataStack === undefined)\")")
// Gone once the slowest of its transitions has run: assertScript's own retry waits for it,
// where sleeping for the exit's length inside the page outlived the plugin's 1000ms budget
// for one script attempt and had the whole expectation run again until it timed out.
->assertScript('document.querySelector("[data-md-popover-ghost]") === null');
// A press outside it.
$page->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true')
->click('#menus')
->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript(EXIT_COPY)
->assertScript(EXIT_COPY_FADING)
->assertNoJavaScriptErrors();
});
it('takes a menu\'s exit copy away when the menu opens again part-way through it', function () {
$page = slowMotion(showcase('menus'))
->click(MORE);
$page->keys(':focus', 'Escape')
->assertScript(EXIT_COPY)
// Past menu.js's reopen guard (250ms), which takes a press this soon after a light dismiss
// for the press that dismissed it; the copy is still fading.
->wait(0.3)
->assertScript(EXIT_COPY);
$page->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript("document.querySelector('[data-md-popover-ghost]') === null")
->assertScript('document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\')).matches(\':popover-open\')');
});
it('leaves no exit copy under reduced motion, where every duration token is zero', function () {
$page = slowMotion(showcase('menus'), '0ms')
->click(MORE);
$page->keys(':focus', 'Escape')
->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript('new Promise((resolve) => { let seen = false; const look = () => { seen ||= document.querySelector("[data-md-popover-ghost]") !== null; }; const timer = setInterval(look, 5); setTimeout(() => { clearInterval(timer); resolve(! seen); }, 300); })');
});
it('fades a submenu out on its own, while its menu stays open', function () {
$trigger = '#menus button:has-text("Share")';
$sendTo = '#menus [role="menuitem"]:has-text("Send to")';
$page = slowMotion(showcase('menus'))
->click($trigger);
$page->keys(':focus', 'ArrowDown');
$page->keys(':focus', 'ArrowRight')
->assertAttribute($sendTo, 'aria-expanded', 'true');
$page->keys(':focus', 'Escape')
->assertAttribute($sendTo, 'aria-expanded', 'false')
->assertAttribute($trigger, 'aria-expanded', 'true')
->assertScript(EXIT_COPY)
->assertScript("document.querySelectorAll('[data-md-popover-ghost]').length === 1 && document.querySelector('[data-md-popover-ghost]').hasAttribute('data-md-submenu')")
->assertScript(EXIT_COPY_FADING);
});
it('fades a tooltip out after Escape hides it', function () {
$page = slowMotion(showcase());
$page->keys('#content', 'Tab');
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
$page->assertScript("document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]').matches(':popover-open')");
$page->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]').matches(':popover-open')")
->assertScript(EXIT_COPY)
->assertScript("document.querySelector('[data-md-popover-ghost]').hasAttribute('data-md-tooltip')")
->assertScript(EXIT_COPY_FADING);
});
it('sinks a FAB menu\'s items back after the menu has closed', function () {
$page = slowMotion(showcase())
->click('button[aria-label="New"]')
->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'true'");
$page->keys(':focus', 'Escape')
->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'false'")
->assertScript(EXIT_COPY)
// The first item part-way down its 8px sink and its fade.
->assertScript("(() => { const item = document.querySelector('[data-md-popover-ghost] [data-md-fab-menu-item]'); const style = getComputedStyle(item); const drop = parseFloat(style.translate.split(' ')[1] ?? '0'); const opacity = parseFloat(style.opacity); return drop > 0.1 && drop < 7.9 && opacity > 0.02 && opacity < 0.98; })()");
});