diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index 29a1b928..eddd83e5 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -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' +
+ + + + @for ($i = 1; $i <= 20; $i++) + + @endfor + +
+ 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' +
+

renders: {{ $renders }}

+ + + + + + + +
+ BLADE; + } +} + +function longMenuProbe() +{ + Livewire::component('long-menu-probe', LongMenuProbe::class); + + Route::middleware('web')->get('/long-menu-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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' +
+ + @for ($i = 1; $i <= 8; $i++) + + @endfor + +
+ BLADE; + } +} + +function longFabMenuProbe() +{ + Livewire::component('long-fab-menu-probe', LongFabMenuProbe::class); + + Route::middleware('web')->get('/long-fab-menu-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + 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"); +});