renders++; } public function render(): string { return <<<'BLADE'

renders: {{ $renders }}

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'

renders: {{ $renders }}, created: {{ $created }}

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' @vite(config('livewire-material.showcase.vite')) @livewireStyles @livewireScripts 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('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"); }); /** 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' @vite(config('livewire-material.showcase.vite')) @livewireStyles @livewireScripts 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' @vite(config('livewire-material.showcase.vite')) Reference
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"); }); 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'
BLADE; } } function groupSizeProbe() { Livewire::component('group-size-probe', GroupSizeProbe::class); Route::middleware('web')->get('/group-size-probe', fn () => Blade::render(<<<'BLADE' @vite(config('livewire-material.showcase.vite')) @livewireStyles @livewireScripts BLADE)); return visit('/group-size-probe')->waitForEvent('networkidle') ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'"); } 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'"); }); /** * 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" 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 , still x-show-based (not rewritten in this batch), 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. $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()")'); // The sheet is , still x-show-based, 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'
@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. $page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop"); });