Write the actions group's owed browser tests
From docs/plans/material-3-browser-tests.md § Actions: submenu keyboard (Right/Enter/Space open, Left/Escape close and return focus) and hover-open on a fine pointer; a filtering menu's typing, arrows, Enter and the clear on reopen; a sheet-at-compact menu's focus on its first item below 600px, its trigger switching between menu and dialog across 600px, a submenu opening in place inside it, its own filter, and a Livewire render keeping it open with the field's focus; a long menu scrolling while the keyboard keeps the reached item inside view; the FAB menu's list scrolling on a short window behind its close button, which stays put (plan step 36, actions). Written on the data-md-* hooks the group's five commits render; not run — the group's Chromium pass happens once every stream in the group has landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
c065d940af
commit
3680808ae8
@@ -534,3 +534,303 @@ it('toggles each choice of a multi selection group, down to none', function () {
|
||||
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false')
|
||||
->assertScript("{$marks} === 'none'");
|
||||
});
|
||||
|
||||
/**
|
||||
* Browser tests owed from docs/plans/material-3-browser-tests.md § Actions (Phase E), written on
|
||||
* the data-md-* hooks of plan step 36's actions stream: submenu keyboard and hover-open, the
|
||||
* filtering menu, the sheet-at-compact list, a long menu's scroll, and the FAB menu's own scroll
|
||||
* behind its fixed close button. Written but not run — the actions group's Chromium run happens
|
||||
* once every stream in the group has landed.
|
||||
*/
|
||||
|
||||
/** 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>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:long-menu-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/long-menu-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
||||
}
|
||||
|
||||
function sheetMenuProbe()
|
||||
{
|
||||
Livewire::component('sheet-menu-probe', SheetMenuProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/sheet-menu-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:sheet-menu-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/sheet-menu-probe')->resize(400, 800)->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
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 = '#menus [role="menuitem"]:has-text("Delete")';
|
||||
|
||||
$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"]';
|
||||
$ada = '#menus [role="menuitem"]:has-text("Ada Lovelace")';
|
||||
$grace = '#menus [role="menuitem"]:has-text("Grace Hopper")';
|
||||
|
||||
$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("[...document.querySelectorAll('#menus [role=\"menuitem\"]')].filter((item) => !item.hidden).length === 2")
|
||||
->assertScript("document.querySelector('{$field}').getAttribute('aria-activedescendant') === document.querySelector('{$ada}').id");
|
||||
|
||||
$page->keys($field, 'ArrowDown')
|
||||
->assertScript("document.querySelector('{$field}').getAttribute('aria-activedescendant') === document.querySelector('{$grace}').id");
|
||||
|
||||
$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("[...document.querySelectorAll('#menus [role=\"menuitem\"]')].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")';
|
||||
$dialog = "document.querySelector('[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')"))
|
||||
->assertScript("{$dialog}.matches(':popover-open')");
|
||||
|
||||
// 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')
|
||||
->assertScript("! {$dialog}.matches(':popover-open')")
|
||||
->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.
|
||||
$page->resize(900, 800)
|
||||
->click($trigger)
|
||||
->assertAttribute($trigger, 'aria-haspopup', 'menu')
|
||||
->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()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->assertScript("document.querySelector('[role=\"dialog\"][aria-label=\"Assign to\"]').matches(':popover-open')")
|
||||
->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>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:long-fab-menu-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/long-fab-menu-probe')->resize(320, 420)->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
|
||||
}
|
||||
|
||||
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.
|
||||
->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user