diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index 09c4a71f..00eaf016 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -167,8 +167,10 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function () $page->script("document.querySelector('".MORE."').focus()"); - $page->keys(':focus', 'ArrowUp') - ->assertAttribute(MORE, 'aria-expanded', 'true') + // ArrowUp on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(':focus', 'ArrowUp'); + + $page->assertAttribute(MORE, 'aria-expanded', 'true') ->assertScript(focused("textContent.trim().startsWith('Delete')")); }); @@ -176,7 +178,10 @@ it('shows no state layer on a disabled menu item even when keyboard focus reache $trigger = '#menus button:has-text("Sort")'; $reset = '#menus [role="menuitem"]:has-text("Reset")'; - $page = showcase('menus')->click($trigger); + $page = showcase('menus'); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->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). @@ -189,21 +194,28 @@ it('shows no state layer on a disabled menu item even when keyboard focus reache 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'); + $page = visit('/material/menus'); + + // 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'); }); 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'); + // Opens the menu, then an item closes it: pressOnce(), tests/Pest.php. + pressOnce($page)->click(MORE) + ->click('#menus [role="menuitem"]:has-text("Download")'); - $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')"); + $page->assertAttribute(MORE, 'aria-expanded', 'false'); + + // Opens the menu, then a checkbox item toggles: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#menus button:has-text("Sort")') + ->click('#menus [role="menuitemcheckbox"]:has-text("Largest")'); + + $page->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 () { @@ -239,9 +251,13 @@ it('moves a connected group\'s choice with the arrow keys', function () { 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'") + $page = showcase(); + + // Opens the split button's menu over the trailing half that triggers it: pressOnce(), + // tests/Pest.php. + pressOnce($page)->click('[data-md-split="trailing"] >> nth=0'); + + $page->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'") ->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'"); }); @@ -257,9 +273,12 @@ it('keeps a connected segment\'s small inner corners, which a 9999px outer corne 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'") + $page = showcase(); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('button[aria-label="New"]'); + + $page->assertScript("{$fab}.getAttribute('aria-expanded') === 'true'") ->assertScript(focused("textContent.trim() === 'Upload files'")); $page->keys(':focus', 'Escape') @@ -327,14 +346,19 @@ it('hangs a menu on its menu button, even when the button is fixed to a corner o $page->assertSeeIn('#renders', '1') ->script("document.querySelector('[data-test=\"fab\"]').focus()"); - $page->keys(':focus', 'ArrowDown') - ->assertAttribute('@fab', 'aria-expanded', 'true') + // ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(':focus', 'ArrowDown'); + + $page->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') + $page->resize(1024, 800); + + // 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("(() => { 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').')'); }); @@ -373,8 +397,10 @@ it('paints a group\'s hint and a menu item\'s icon in the colour their classes n 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'); + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('@sort'); + + $page->assertAttribute('@sort', 'aria-expanded', 'true'); $page->script('window.eval("Livewire.first().touch()")'); @@ -390,9 +416,11 @@ it('keeps a menu open while the component around it renders, and closes it clean ->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'); + // light-dismiss press and ignored. ArrowDown on the trigger opens the menu, same as clicking + // it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('@sort', 'ArrowDown'); + + $page->assertAttribute('@sort', 'aria-expanded', 'true'); $page->script('window.eval("Livewire.first().touch()")'); @@ -406,15 +434,20 @@ it('closes a menu on a second press of its menu button, and after the component $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') + // opening it again once waited for the queued toggle event, which comes after the click. Each + // press toggles it, so each is pressed once: pressOnce(), tests/Pest.php. + pressOnce($page)->click('@sort'); + + $page->assertAttribute('@sort', 'aria-expanded', 'true'); + + pressOnce($page)->click('@sort'); + + $page->assertAttribute('@sort', 'aria-expanded', 'false') ->assertScript('! '.SORT_MENU.".matches(':popover-open')"); - $page->keys('@sort', 'ArrowDown') - ->assertAttribute('@sort', 'aria-expanded', 'true'); + pressOnce($page)->keys('@sort', 'ArrowDown'); + + $page->assertAttribute('@sort', 'aria-expanded', 'true'); $page->script('window.eval("Livewire.first().touch()")'); @@ -422,30 +455,36 @@ it('closes a menu on a second press of its menu button, and after the component $page->assertSeeIn('#renders', '1') ->wait(0.3); - $page->click('@sort') - ->assertAttribute('@sort', 'aria-expanded', 'false') + pressOnce($page)->click('@sort'); + + $page->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') + // Opens the menu, then a checkbox item toggles: pressOnce(), tests/Pest.php. + pressOnce($page)->click('@sort') + ->click('[role="menuitemcheckbox"]:has-text("Largest")'); + + $page->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') + pressOnce($page)->click('[role="menuitemcheckbox"]:has-text("Newest")'); + + $page->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'); + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click(NEW_FAB); + + $page->assertAttribute(NEW_FAB, 'aria-expanded', 'true'); $page->script('window.eval("Livewire.first().touch()")'); @@ -458,8 +497,10 @@ it('keeps a FAB menu open while the component around it renders, and closes it c ->assertAttribute(NEW_FAB, 'aria-expanded', 'false') ->assertScript(focused("getAttribute('aria-label') === 'New'")); - $page->keys(NEW_FAB, 'ArrowDown') - ->assertAttribute(NEW_FAB, 'aria-expanded', 'true'); + // ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(NEW_FAB, 'ArrowDown'); + + $page->assertAttribute(NEW_FAB, 'aria-expanded', 'true'); $page->script('window.eval("Livewire.first().touch()")'); @@ -467,23 +508,28 @@ it('keeps a FAB menu open while the component around it renders, and closes it c $page->assertSeeIn('#renders', '2') ->wait(0.3); - $page->click(NEW_FAB) - ->assertAttribute(NEW_FAB, 'aria-expanded', 'false') + pressOnce($page)->click(NEW_FAB); + + $page->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') + // Opens the FAB menu, then an item closes it: pressOnce(), tests/Pest.php. + pressOnce($page)->click(NEW_FAB) + ->click('[role="menuitem"]:has-text("Upload files")'); + + $page->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') + // ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(NEW_FAB, 'ArrowDown'); + + $page->assertAttribute(NEW_FAB, 'aria-expanded', 'true') ->assertScript(focused("textContent.trim() === 'Upload files'")); $page->keys(':focus', 'Escape') @@ -571,9 +617,11 @@ it('hides a disabled fab button below 600px, as M3 removes a FAB whose action is ->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(); })()") + // stays on screen with its loading indicator. A counter a retry would double: pressOnce(), + // tests/Pest.php. + pressOnce($page)->click('#enabled-fab'); + + $page->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'"); @@ -683,14 +731,19 @@ 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') + $page = showcase() + ->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true'); + + // Each press toggles the choice: pressOnce(), tests/Pest.php. + pressOnce($page)->click("{$group} [aria-label=\"Italic\"]"); + + $page->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true') + ->assertScript("{$marks} === 'bold, italic'"); + + pressOnce($page)->click("{$group} [aria-label=\"Bold\"]") + ->click("{$group} [aria-label=\"Italic\"]"); + + $page->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'false') ->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false') ->assertScript("{$marks} === 'none'"); }); @@ -800,9 +853,12 @@ it('opens a submenu with Right, Enter or Space, and closes it with Left or Escap $trigger = '#menus button:has-text("Share")'; $sendTo = '#menus [role="menuitem"]:has-text("Send to")'; - $page = showcase('menus') - ->click($trigger) - ->assertAttribute($trigger, 'aria-expanded', 'true') + $page = showcase('menus'); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click($trigger); + + $page->assertAttribute($trigger, 'aria-expanded', 'true') ->assertScript(focused("textContent.trim().startsWith('Copy link')")); $page->keys(':focus', 'ArrowDown') @@ -835,7 +891,10 @@ it('opens a submenu on hover for a fine pointer, and closes it once the pointer // "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 = showcase('menus'); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click($trigger); $page->hover($sendTo) ->wait(0.3) @@ -858,9 +917,12 @@ it('filters a menu\'s items as its field is typed into, moves the highlight with $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'")); + $page = showcase('menus'); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click($trigger); + + $page->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') @@ -874,8 +936,9 @@ it('filters a menu\'s items as its field is typed into, moves the highlight with ->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 === ''") + pressOnce($page)->click($trigger); + + $page->assertScript("document.querySelector('{$field}').value === ''") ->assertScript("{$list}.every((item) => !item.hidden)"); }); @@ -890,8 +953,10 @@ it('opens a sheet-at-compact menu below 600px, focused on its first item, and th $page = showcase('menus')->resize(400, 800); - $page->click($trigger) - ->assertAttribute($trigger, 'aria-haspopup', 'dialog') + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click($trigger); + + $page->assertAttribute($trigger, 'aria-haspopup', 'dialog') ->assertScript(focused("textContent.trim().startsWith('Set as wallpaper')")) ->assertVisible($dialog); @@ -926,8 +991,10 @@ it('opens a sheet-at-compact menu below 600px, focused on its first item, and th 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'")); + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('button:has-text("Assign")'); + + $page->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"); @@ -945,9 +1012,12 @@ it('filters in the sheet too, and a Livewire render keeps it open with the field 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 = longMenuProbe(); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('button:has-text("Open")'); + + $page->assertScript("{$popover}.scrollHeight > {$popover}.clientHeight"); $page->keys(':focus', 'End') ->assertScript(focused("textContent.trim() === 'Item 20'")) @@ -997,9 +1067,12 @@ it('scrolls the FAB menu\'s items on a short window, behind the close button, wh $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') + $page = longFabMenuProbe(); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('button[aria-label="New"]'); + + $page->assertAttribute('button[aria-label="New"]', 'aria-expanded', 'true') ->assertScript("{$list}.scrollHeight > {$list}.clientHeight"); $page->script("window.__fabTop = {$trigger}.getBoundingClientRect().top; {$list}.scrollTop = 40"); @@ -1017,9 +1090,12 @@ const EXIT_COPY_FADING = "(() => { const copy = document.querySelector('[data-md 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'); + $page = slowMotion(showcase('menus')); + + // 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'); // Escape: the browser's own light dismiss, which no script can hold open. $page->keys(':focus', 'Escape') @@ -1036,9 +1112,11 @@ it('fades a menu out after the browser has closed it, on Escape or a press outsi ->assertScript('document.querySelector("[data-md-popover-ghost]") === null'); // A press outside it. - $page->click(MORE) - ->assertAttribute(MORE, 'aria-expanded', 'true') - ->click('#menus') + pressOnce($page)->click(MORE); + + $page->assertAttribute(MORE, 'aria-expanded', 'true'); + + $page->click('#menus') ->assertAttribute(MORE, 'aria-expanded', 'false') ->assertScript(EXIT_COPY) ->assertScript(EXIT_COPY_FADING) @@ -1046,8 +1124,10 @@ it('fades a menu out after the browser has closed it, on Escape or a press outsi }); 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 = slowMotion(showcase('menus')); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click(MORE); $page->keys(':focus', 'Escape') ->assertScript(EXIT_COPY) @@ -1056,15 +1136,18 @@ it('takes a menu\'s exit copy away when the menu opens again part-way through it ->wait(0.3) ->assertScript(EXIT_COPY); - $page->click(MORE) - ->assertAttribute(MORE, 'aria-expanded', 'true') + pressOnce($page)->click(MORE); + + $page->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 = slowMotion(showcase('menus'), '0ms'); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click(MORE); $page->keys(':focus', 'Escape') ->assertAttribute(MORE, 'aria-expanded', 'false') @@ -1075,8 +1158,10 @@ 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 = slowMotion(showcase('menus')); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click($trigger); $page->keys(':focus', 'ArrowDown'); $page->keys(':focus', 'ArrowRight') @@ -1105,9 +1190,12 @@ it('fades a tooltip out after Escape hides it', function () { }); 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 = slowMotion(showcase()); + + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('button[aria-label="New"]'); + + $page->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'") diff --git a/tests/Browser/BarsTest.php b/tests/Browser/BarsTest.php index 54ecf9cf..f59ea8fc 100644 --- a/tests/Browser/BarsTest.php +++ b/tests/Browser/BarsTest.php @@ -94,8 +94,11 @@ it('moves between tabs with the arrow keys, skipping disabled ones, and tells Li ->assertAttribute('#share-people', 'tabindex', '0') ->assertAttribute('#share-files', 'tabindex', '-1'); - $page->keys('#share-people', 'ArrowRight') - ->assertSeeIn('#tab', 'activity') + // Each arrow key sends its own wire:model.live request and moves one tab further: pressOnce(), + // tests/Pest.php. + pressOnce($page)->keys('#share-people', 'ArrowRight'); + + $page->assertSeeIn('#tab', 'activity') ->assertScript("document.activeElement.id === 'share-activity'") ->assertScript("getComputedStyle(document.querySelector('#share-activity [data-md-tab-indicator]')).opacity === '1'"); @@ -103,8 +106,9 @@ it('moves between tabs with the arrow keys, skipping disabled ones, and tells Li ->assertSeeIn('#tab', 'files') ->assertScript("document.activeElement.id === 'share-files'"); - $page->keys('#share-files', 'ArrowLeft') - ->assertSeeIn('#tab', 'activity'); + pressOnce($page)->keys('#share-files', 'ArrowLeft'); + + $page->assertSeeIn('#tab', 'activity'); }); it('sends a request per click with wire:model.live, but nothing until then with plain wire:model', function () { @@ -166,23 +170,32 @@ it('switches the theme from a toggle, a cycle and a picker', function () { ->assertScript("document.documentElement.dataset.theme === 'light'") ->assertAttribute('#toggle', 'aria-pressed', 'false'); - $page->click('#toggle') - ->assertScript("document.documentElement.dataset.theme === 'dark'") + // A theme toggle flips on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#toggle'); + + $page->assertScript("document.documentElement.dataset.theme === 'dark'") ->assertAttribute('#toggle', 'aria-pressed', 'true') ->assertScript('document.querySelector(\'input[name="material-theme"][value="dark"]\').checked'); - $page->click('#cycle') - ->assertScript("document.documentElement.dataset.themeChoice === 'system'") - ->click('#cycle') - ->assertScript("document.documentElement.dataset.themeChoice === 'light'"); + // A cycling toggle a retry would cycle twice: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#cycle'); + + $page->assertScript("document.documentElement.dataset.themeChoice === 'system'"); + + pressOnce($page)->click('#cycle'); + + $page->assertScript("document.documentElement.dataset.themeChoice === 'light'"); }); it('turns section tabs into a picker on a phone', function () { - barsProbe() + $page = barsProbe() ->resize(400, 800) - ->assertScript("getComputedStyle(document.querySelector('[data-md-section-nav] nav')).display === 'none'") - ->click('[data-md-section-nav-picker] button') - ->assertScript("document.querySelector('[data-md-section-nav-picker] [popover]').matches(':popover-open')") + ->assertScript("getComputedStyle(document.querySelector('[data-md-section-nav] nav')).display === 'none'"); + + // Opens the picker menu over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-section-nav-picker] button'); + + $page->assertScript("document.querySelector('[data-md-section-nav-picker] [popover]').matches(':popover-open')") // A menu of places: the current section is the page, not a checked choice. ->assertAttribute('[data-md-section-nav-picker] [role="menuitem"][href="#profile"]', 'aria-current', 'page') ->assertScript("! document.querySelector('[data-md-section-nav-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')"); @@ -308,8 +321,10 @@ it('shows the app bar overflow at exactly 599 and 600px', function () { it('moves the keyboard through an app bar\'s overflow menu, and closes it once the width no longer shows it', function () { $page = appBarOverflowProbe()->resize(599, 800); - $page->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button') - ->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')") + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button'); + + $page->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')") ->keys(':focus', 'ArrowDown') ->assertScript("document.activeElement.textContent.includes('Star')") ->keys(':focus', 'ArrowDown') @@ -322,8 +337,10 @@ it('moves the keyboard through an app bar\'s overflow menu, and closes it once t // Reopen it, then resize across 600px: the compact menu's trigger is no longer shown, so the // menu it opened closes rather than staying open anchored to nothing. - $page->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button') - ->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')") + // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php. + pressOnce($page)->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button'); + + $page->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')") ->resize(900, 800) ->assertScript("! document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')") ->assertNoJavaScriptErrors(); @@ -332,13 +349,18 @@ it('moves the keyboard through an app bar\'s overflow menu, and closes it once t it('runs a wire:click action from either the icon button or its overflow menu item', function () { $page = appBarOverflowProbe()->resize(900, 800); - $page->click('#bar [data-md-app-bar-action] [aria-label="Star"]') - ->assertSeeIn('#stars', '1'); + // A counter a retry would double: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#bar [data-md-app-bar-action] [aria-label="Star"]'); - $page->resize(599, 800) - ->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button') - ->click('[role="menuitem"]:has-text("Star")') - ->assertSeeIn('#stars', '2'); + $page->assertSeeIn('#stars', '1'); + + $page->resize(599, 800); + + // Opens the overflow menu, then a counter a retry would double: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button') + ->click('[role="menuitem"]:has-text("Star")'); + + $page->assertSeeIn('#stars', '2'); }); function toolbarScaffoldProbe() diff --git a/tests/Browser/CarouselTest.php b/tests/Browser/CarouselTest.php index db66fde8..6191e610 100644 --- a/tests/Browser/CarouselTest.php +++ b/tests/Browser/CarouselTest.php @@ -107,29 +107,37 @@ it('moves one item with the next and previous buttons', function () { $page = carouselShowcase() ->assertScript(onCarousel(0, 'return at(0) && root.querySelector(\'[aria-label="Previous"]\').disabled')); - $page->click('#carousel button[aria-label="Next"] >> nth=0') - ->assertScript(onCarousel(0, 'return at(1) && inset(1) < 0.5 && !root.querySelector(\'[aria-label="Previous"]\').disabled')); + // A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#carousel button[aria-label="Next"] >> nth=0'); - $page->click('#carousel button[aria-label="Next"] >> nth=0') - ->assertScript(onCarousel(0, 'return at(2)')); + $page->assertScript(onCarousel(0, 'return at(1) && inset(1) < 0.5 && !root.querySelector(\'[aria-label="Previous"]\').disabled')); - $page->click('#carousel button[aria-label="Previous"] >> nth=0') - ->assertScript(onCarousel(0, 'return at(1)')); + pressOnce($page)->click('#carousel button[aria-label="Next"] >> nth=0'); + + $page->assertScript(onCarousel(0, 'return at(2)')); + + pressOnce($page)->click('#carousel button[aria-label="Previous"] >> nth=0'); + + $page->assertScript(onCarousel(0, 'return at(1)')); }); it('moves one item with the arrow keys, and to the ends with Home and End', function () { $page = carouselShowcase(); // M3 puts the tab stop on the item, so the keys are the focused item's and each one moves - // focus to the item it scrolls to. - $page->keys(FIRST_ITEM, 'ArrowRight') - ->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]')); + // focus to the item it scrolls to. A retried press drifts the carousel one further item each + // time: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(FIRST_ITEM, 'ArrowRight'); - $page->keys(':focus', 'ArrowRight') - ->assertScript(onCarousel(0, 'return at(2)')); + $page->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]')); - $page->keys(':focus', 'ArrowLeft') - ->assertScript(onCarousel(0, 'return at(1)')); + pressOnce($page)->keys(':focus', 'ArrowRight'); + + $page->assertScript(onCarousel(0, 'return at(2)')); + + pressOnce($page)->keys(':focus', 'ArrowLeft'); + + $page->assertScript(onCarousel(0, 'return at(1)')); $page->keys(':focus', 'End') ->assertScript(onCarousel(0, 'return Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5 && open(items.length - 1) && root.querySelector(\'[aria-label="Next"]\').disabled')); @@ -234,17 +242,21 @@ it('mirrors in a right-to-left page', function () { ->assertNoJavaScriptErrors() ->assertScript(onCarousel(0, 'return size > 0 && at(0) && open(0) && ! open(items.length - 1)', 'body')); - $page->keys('[data-md-carousel-item] >> nth=0', 'ArrowLeft') - ->assertScript(onCarousel(0, <<<'JS' + // A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('[data-md-carousel-item] >> nth=0', 'ArrowLeft'); + + $page->assertScript(onCarousel(0, <<<'JS' return scroller.scrollLeft < -1 && at(1) && ! open(0) && open(1) && parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0 JS, 'body')); - $page->click('button[aria-label="Next"]') - ->assertScript(onCarousel(0, 'return at(2)', 'body')); + pressOnce($page)->click('button[aria-label="Next"]'); - $page->keys('[data-md-carousel-item] >> nth=2', 'ArrowRight') - ->assertScript(onCarousel(0, 'return at(1)', 'body')); + $page->assertScript(onCarousel(0, 'return at(2)', 'body')); + + pressOnce($page)->keys('[data-md-carousel-item] >> nth=2', 'ArrowRight'); + + $page->assertScript(onCarousel(0, 'return at(1)', 'body')); }); it('measures itself again after a Livewire morph adds an item', function () { @@ -273,8 +285,10 @@ it('measures itself again after a Livewire morph adds an item', function () { ->assertScript($masked, 6) ->assertAttribute('[data-md-carousel-item] >> nth=5', 'aria-label', '6 of 6'); - $page->click('Add') - ->assertScript($masked, 7) + // A counter a retry would double: pressOnce(), tests/Pest.php. + pressOnce($page)->click('Add'); + + $page->assertScript($masked, 7) ->assertAttribute('[data-md-carousel-item] >> nth=6', 'aria-label', '7 of 7'); // Scrolled once the morph has settled, so only the row's own scroll listener can re-mask the @@ -316,26 +330,31 @@ it('moves the multi-aspect carousel one item at a time with the buttons, arrows, ->assertNoJavaScriptErrors() ->assertScript(onMultiAspectCarousel('return scroller.scrollLeft === 0 && items.length > 2')); - $page->click(MULTI_ASPECT_SCOPE.' button[aria-label="Next"]') - ->assertScript(onMultiAspectCarousel(<<<'JS' + // A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php. + pressOnce($page)->click(MULTI_ASPECT_SCOPE.' button[aria-label="Next"]'); + + $page->assertScript(onMultiAspectCarousel(<<<'JS' await pause(400) return scroller.scrollLeft > 0 JS)); - $page->click(MULTI_ASPECT_SCOPE.' button[aria-label="Previous"]') - ->assertScript(onMultiAspectCarousel(<<<'JS' + pressOnce($page)->click(MULTI_ASPECT_SCOPE.' button[aria-label="Previous"]'); + + $page->assertScript(onMultiAspectCarousel(<<<'JS' await pause(400) return scroller.scrollLeft === 0 JS)); - $page->keys(MULTI_ASPECT_SCOPE.' [data-md-carousel-item] >> nth=0', 'ArrowRight') - ->assertScript(onMultiAspectCarousel(<<<'JS' + pressOnce($page)->keys(MULTI_ASPECT_SCOPE.' [data-md-carousel-item] >> nth=0', 'ArrowRight'); + + $page->assertScript(onMultiAspectCarousel(<<<'JS' await pause(400) return scroller.scrollLeft > 0 && document.activeElement === items[1] JS)); - $page->keys(':focus', 'ArrowLeft') - ->assertScript(onMultiAspectCarousel(<<<'JS' + pressOnce($page)->keys(':focus', 'ArrowLeft'); + + $page->assertScript(onMultiAspectCarousel(<<<'JS' await pause(400) return scroller.scrollLeft === 0 && document.activeElement === items[0] JS)); diff --git a/tests/Browser/CascadeTest.php b/tests/Browser/CascadeTest.php index 6dbe6873..c60007e8 100644 --- a/tests/Browser/CascadeTest.php +++ b/tests/Browser/CascadeTest.php @@ -150,12 +150,15 @@ it('keeps a data-md-* attribute through a Livewire $refresh', function () { $page = cascadeReady(visit('/cascade-variant-probe')) ->assertScript("{$button}.getAttribute('data-md-variant') === 'filled'"); - $page->click('[data-test="button"]') - ->assertScript("{$button}.getAttribute('data-md-variant') === 'outlined'"); + // A second press flips the variant back: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-test="button"]'); + + $page->assertScript("{$button}.getAttribute('data-md-variant') === 'outlined'"); // Twice, so the morph is proven both ways round, not just once off the initial paint. - $page->click('[data-test="button"]') - ->assertScript("{$button}.getAttribute('data-md-variant') === 'filled'"); + pressOnce($page)->click('[data-test="button"]'); + + $page->assertScript("{$button}.getAttribute('data-md-variant') === 'filled'"); }); it('mirrors an icon in a right-to-left document, not in left-to-right', function (string $dir, string $transform) { diff --git a/tests/Browser/ChipsTest.php b/tests/Browser/ChipsTest.php index 1558f671..00e822c5 100644 --- a/tests/Browser/ChipsTest.php +++ b/tests/Browser/ChipsTest.php @@ -90,8 +90,10 @@ it('toggles a filter chip with a click and with Space, and grows its check in', $page = chipShowcase()->assertNoJavaScriptErrors(); - $page->click('#chips label:has-text("Documents")') - ->assertScript(filterInput('documents').'.checked') + // A filter chip toggles on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#chips label:has-text("Documents")'); + + $page->assertScript(filterInput('documents').'.checked') ->assertScript("Math.round({$check('documents')}) === 18") ->assertScript('getComputedStyle('.filterInput('documents').".parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'") ->assertScript("window.eval(\"Alpine.\$data(document.querySelector('#chips input[value=documents]')).kinds.join(',')\") === 'photos,documents'"); @@ -100,12 +102,16 @@ it('toggles a filter chip with a click and with Space, and grows its check in', // because WebKit leaves form controls out of the Tab order unless full keyboard access is on. $page->keys('#content', 'Tab'); $page->script(filterInput('archives').'.focus()'); - $page->keys(':focus', 'Space') - ->assertScript(filterInput('archives').'.checked') + + // Space toggles a filter chip on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(':focus', 'Space'); + + $page->assertScript(filterInput('archives').'.checked') ->assertScript("Math.round({$check('archives')}) === 18"); - $page->keys(':focus', 'Space') - ->assertScript('! '.filterInput('archives').'.checked') + pressOnce($page)->keys(':focus', 'Space'); + + $page->assertScript('! '.filterInput('archives').'.checked') ->assertScript("Math.round({$check('archives')}) === 0"); }); @@ -116,8 +122,10 @@ it('binds filter chips to a Livewire array, and a server change reaches them aft $page->assertScript("{$input('photos')}.checked && ! {$input('documents')}.checked"); - $page->click('label:has-text("Documents")') - ->assertSeeIn('#kinds', 'photos,documents'); + // A filter chip toggles on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('label:has-text("Documents")'); + + $page->assertSeeIn('#kinds', 'photos,documents'); $page->click('button:has-text("Only video")') ->assertSeeIn('#kinds', 'video') @@ -129,14 +137,18 @@ it('binds filter chips to a Livewire array, and a server change reaches them aft it('removes a Livewire input chip with its button and with Delete, focusing the chip after it', function () { $page = chipProbe(); - $page->click('button[aria-label="Remove js"]') - ->assertSeeIn('#tags', 'php,css') + // The remove button vanishes once its chip is gone: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button[aria-label="Remove js"]'); + + $page->assertSeeIn('#tags', 'php,css') ->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null"); $page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()"); - $page->keys(':focus', 'Delete') - ->assertScript("document.querySelector('#tags').textContent === 'css'") + // Removes the focused chip, whose button then vanishes: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(':focus', 'Delete'); + + $page->assertScript("document.querySelector('#tags').textContent === 'css'") ->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null") ->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'"); }); @@ -150,8 +162,10 @@ it('removes an Alpine input chip with Backspace, focusing the chip before it', f $page->assertScript("{$remove('ben')} !== null"); $page->script("{$remove('ben')}.focus()"); - $page->keys(':focus', 'Backspace') - ->assertScript("{$remove('ben')} === null") + // Removes the focused chip, whose button then vanishes: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(':focus', 'Backspace'); + + $page->assertScript("{$remove('ben')} === null") ->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null") ->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'"); }); @@ -245,8 +259,10 @@ it('shows a scroll button only on the edge the row can still scroll towards, in ->assertScript("{$row}.scrollWidth > {$row}.clientWidth") ->assertScript("! ({$shown('start')}) && ({$shown('end')})"); - $page->click('[data-md-chip-scroll="end"]') - ->wait(0.8) + // A paging key a retry would page twice: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-chip-scroll="end"]'); + + $page->wait(0.8) ->assertScript("Math.abs({$row}.scrollLeft) > 0") ->assertScript("({$shown('start')})"); diff --git a/tests/Browser/ColourProfilesTest.php b/tests/Browser/ColourProfilesTest.php index 1c7596e1..034d8842 100644 --- a/tests/Browser/ColourProfilesTest.php +++ b/tests/Browser/ColourProfilesTest.php @@ -68,9 +68,11 @@ it('previews a profile from the picker and the showcase menu, and keeps it throu ->assertScript(pagePrimary()." === '{$this->profiles['rose']['light']['primary']}'") ->assertScript("window.eval(\"Alpine.store('theme').scheme\") === 'rose'"); - $page->click('[data-test="showcase-profiles"] [aria-haspopup="menu"]') - ->click('[data-scheme-preview="teal"]') - ->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'"); + // Opens the menu, then an item closes it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-test="showcase-profiles"] [aria-haspopup="menu"]') + ->click('[data-scheme-preview="teal"]'); + + $page->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'"); $page->click('[data-md-navigation-rail-panel] a[href$="/material/buttons"]') ->assertScript("location.pathname.endsWith('/material/buttons')") diff --git a/tests/Browser/CommunicationTest.php b/tests/Browser/CommunicationTest.php index 97fce29c..474c030f 100644 --- a/tests/Browser/CommunicationTest.php +++ b/tests/Browser/CommunicationTest.php @@ -73,8 +73,10 @@ it('runs a toast\'s action and dismisses it', function () { $page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })"); - $page->click('[x-data="materialSnackbar"] button:has-text("Undo")') - ->assertScript('window.__undone === true') + // The Undo button vanishes once the toast is dismissed: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[x-data="materialSnackbar"] button:has-text("Undo")'); + + $page->assertScript('window.__undone === true') ->assertScript(TOAST.' === null') ->assertScript(SNACKBAR.' !== null'); }); @@ -112,16 +114,20 @@ it('keeps a sticky toast aside while a passing one shows, brings back the newest $page->assertScript(TOAST."?.textContent.includes('Version 2 is ready')"); - $page->click('[data-md-toast-dismiss]') - ->assertScript(TOAST.' === null'); + // The dismiss button vanishes with its toast: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-toast-dismiss]'); + + $page->assertScript(TOAST.' === null'); // Dismissed rather than left to time out: the pointer still rests where the snackbar appears, // and hovering pauses it. $page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")"); - $page->assertScript(TOAST."?.textContent.includes('Link copied')") - ->click('[data-md-toast-dismiss]') - ->assertScript(TOAST.' === null'); + $page->assertScript(TOAST."?.textContent.includes('Link copied')"); + + pressOnce($page)->click('[data-md-toast-dismiss]'); + + $page->assertScript(TOAST.' === null'); }); it('dispatches a toast action\'s window event alongside its handler, and closes', function () { @@ -129,8 +135,10 @@ it('dispatches a toast action\'s window event alongside its handler, and closes' $page->script("window.eval(\"window.reloads = 0; window.handled = 0; window.addEventListener('material:test-reload', () => window.reloads++); materialToast('A new version is ready', { sticky: true, action: { label: 'Reload', event: 'material:test-reload', handler: () => window.handled++ } })\")"); - $page->click('[data-md-toast-action]') - ->assertScript(TOAST.' === null') + // The action closes the toast: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-toast-action]'); + + $page->assertScript(TOAST.' === null') ->assertScript("window.eval('window.reloads') === 1") ->assertScript("window.eval('window.handled') === 1"); }); @@ -144,9 +152,12 @@ it('keeps an actioned snackbar on screen past the default timeout, until it is a $page->assertScript(TOAST."?.textContent.includes('Share deleted')") ->wait(4.5); - $page->assertScript(TOAST."?.textContent.includes('Share deleted')") - ->click('[data-md-toast-dismiss]') - ->assertScript(TOAST.' === null'); + $page->assertScript(TOAST."?.textContent.includes('Share deleted')"); + + // The dismiss button vanishes with its toast: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-toast-dismiss]'); + + $page->assertScript(TOAST.' === null'); }); it('dismisses a focused snackbar with Escape', function () { @@ -209,9 +220,12 @@ it('keeps a two-line snackbar\'s action on the title\'s row from 600px, wrapping it('opens a persistent rich tooltip on press', function () { $bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')"; - ready(visit('/material/communication')) - ->click('#communication button:has-text("Press for details")') - ->assertScript("{$bubble}.matches(':popover-open')"); + $page = ready(visit('/material/communication')); + + // Opens the persistent tooltip over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#communication button:has-text("Press for details")'); + + $page->assertScript("{$bubble}.matches(':popover-open')"); }); it('closes a transient rich tooltip 1.5s after focus leaves, not at once', function () { @@ -263,10 +277,14 @@ it('keeps a rich tooltip open while its action renders the component, and opens $page = ready(visit('/rich-tooltip-morph-probe')) ->assertNoJavaScriptErrors(); - $page->click('@details') - ->assertScript("{$persistent}.matches(':popover-open')") - ->click('@refresh') - ->assertSeeIn('#renders', '1') + // Opens the tooltip, then a counter a retry would double: pressOnce(), tests/Pest.php. + pressOnce($page)->click('@details'); + + $page->assertScript("{$persistent}.matches(':popover-open')"); + + pressOnce($page)->click('@refresh'); + + $page->assertSeeIn('#renders', '1') ->assertScript("{$persistent}.matches(':popover-open')"); // Past the reopen guard (rich-tooltip.js's REOPEN_GUARD_MS), which takes a press this soon after @@ -275,8 +293,9 @@ it('keeps a rich tooltip open while its action renders the component, and opens ->assertScript("! {$persistent}.matches(':popover-open')") ->wait(0.3); - $page->click('@details') - ->assertScript("{$persistent}.matches(':popover-open')"); + pressOnce($page)->click('@details'); + + $page->assertScript("{$persistent}.matches(':popover-open')"); $page->click('#outside') ->hover('@hint') @@ -292,9 +311,13 @@ it('fades a persistent rich tooltip out when a second press closes it', function // Slow tokens, so the exit copy (resources/js/popover-exit.js) is still on screen after the round trip. $page->script("document.head.insertAdjacentHTML('beforeend', '')"); - $page->click('#communication button:has-text("Press for details")') - ->assertScript("{$bubble}.matches(':popover-open')") - ->click('#communication button:has-text("Press for details")') - ->assertScript("! {$bubble}.matches(':popover-open')") + // The trigger toggles the tooltip, so each press is pressed once: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#communication button:has-text("Press for details")'); + + $page->assertScript("{$bubble}.matches(':popover-open')"); + + pressOnce($page)->click('#communication button:has-text("Press for details")'); + + $page->assertScript("! {$bubble}.matches(':popover-open')") ->assertScript("(() => { const copy = document.querySelector('[data-md-popover-ghost]'); const opacity = copy && parseFloat(getComputedStyle(copy).opacity); return copy !== null && copy.hasAttribute('data-md-rich-tooltip-bubble') && copy.inert && opacity > 0.02 && opacity < 0.98; })()"); }); diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 44a5d7e9..b065bcb5 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -139,19 +139,25 @@ function containment() it('writes back what closed means: false for a flag, null for an id', function () { $page = overlayProbe(); - $page->click('button:has-text("Confirm")') - ->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))"); + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Confirm")'); - $page->click('dialog[open] button:has-text("Re-render")') - ->assertSeeIn('#renders', '1') + $page->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))"); + + // A counter a retry would double: pressOnce(), tests/Pest.php. + pressOnce($page)->click('dialog[open] button:has-text("Re-render")'); + + $page->assertSeeIn('#renders', '1') ->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))"); $page->keys('dialog[open] button:has-text("Re-render")', 'Escape') ->assertScript("! [...document.querySelectorAll('dialog')].some((d) => d.open)") ->assertSeeIn('#confirming', 'false'); - $page->click('button:has-text("Delete 7")') - ->assertSeeIn('#deleting', '7') + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Delete 7")'); + + $page->assertSeeIn('#deleting', '7') ->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Delete share?'))"); $page->script("document.querySelector('dialog[open]').dispatchEvent(new Event('cancel', { cancelable: true }))"); @@ -166,8 +172,11 @@ it('closes a showcase dialog on Escape and gives focus back to its trigger', fun $page->script("[...document.querySelectorAll('#containment button')].find((b) => b.textContent.trim() === 'Basic dialog').focus()"); - $page->keys(':focus', 'Enter') - ->assertScript("document.querySelector('#containment dialog').open"); + // Enter on the focused button is a native click on it, opening the dialog over it: pressOnce(), + // tests/Pest.php. + pressOnce($page)->keys(':focus', 'Enter'); + + $page->assertScript("document.querySelector('#containment dialog').open"); $page->keys('#containment dialog[open] button:has-text("Cancel")', 'Escape') ->assertScript("! document.querySelector('#containment dialog').open") @@ -177,9 +186,12 @@ it('closes a showcase dialog on Escape and gives focus back to its trigger', fun it('slides a side sheet in, traps the page, and closes on the scrim', function () { $sheet = "document.querySelector('#containment aside[role=\"dialog\"]')"; - $page = containment() - ->click('#containment button:has-text("Side sheet")') - ->assertScript("getComputedStyle({$sheet}).display !== 'none'") + $page = containment(); + + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#containment button:has-text("Side sheet")'); + + $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") ->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null"); // This sheet's own scrim: the standard side sheet above it on the page is a `[data-md-drawer]` too. @@ -192,9 +204,12 @@ it('slides a side sheet in, traps the page, and closes on the scrim', function ( it('dismisses a bottom sheet dragged down past a quarter of its height', function () { $sheet = "document.querySelector('#containment section[role=\"dialog\"]')"; - $page = containment() - ->click('#containment button:has(> span:text-is("Bottom sheet"))') - ->assertScript("getComputedStyle({$sheet}).display !== 'none'") + $page = containment(); + + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#containment button:has(> span:text-is("Bottom sheet"))'); + + $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") ->wait(0.5); $page->script(<<resize(320, 300); - $page->click('button:has-text("Open outer")'); + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Open outer")'); // Matched by each dialog's own title element, not its full (recursive) textContent: the // outer dialog's textContent also contains the nested inner dialog's text, "Inner" included, @@ -284,7 +300,8 @@ it('marks a scrolling dialog\'s divider at the edge with more content, and a nes // Scrolled to the top: nothing hidden above, so the head's rule stays off. $page->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'"); - $page->click('button:has-text("Open inner")'); + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Open inner")'); $inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Inner')"; $innerHead = "{$inner}.querySelector('[data-md-modal-head]')"; @@ -311,7 +328,8 @@ it('shows a full-screen dialog\'s 56px phone bar below 600px, and the basic dial // CSS on the fullscreen flag, so which comes first makes no difference to what is asserted. $page = containment(); - $page->click('#containment button:has-text("Full screen on a phone")'); + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#containment button:has-text("Full screen on a phone")'); $page->resize(400, 800); @@ -328,7 +346,8 @@ it('shows a full-screen dialog\'s 56px phone bar below 600px, and the basic dial it('draws separator dividers whatever the scroll, on a dialog that fits without scrolling', function () { $page = containment(); - $page->click('#containment button:has-text("Always divided (separator)")'); + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#containment button:has-text("Always divided (separator)")'); $dialog = "document.querySelector('#containment dialog[open]')"; $head = "{$dialog}.querySelector('[data-md-modal-head]')"; @@ -348,7 +367,8 @@ it('opens a dialog with no fade under reduced motion, but a real one without it' $normal = containment() ->assertScript("! window.matchMedia('(prefers-reduced-motion: reduce)').matches"); - $normal->click('#containment button:has-text("Basic dialog")'); + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($normal)->click('#containment button:has-text("Basic dialog")'); $normal->assertScript("parseFloat(getComputedStyle({$dialog}).transitionDuration) > 0"); @@ -358,7 +378,8 @@ it('opens a dialog with no fade under reduced motion, but a real one without it' $reduced = ready(visit('/material/containment', ['reducedMotion' => 'reduce'])) ->assertScript("window.matchMedia('(prefers-reduced-motion: reduce)').matches"); - $reduced->click('#containment button:has-text("Basic dialog")'); + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($reduced)->click('#containment button:has-text("Basic dialog")'); $reduced->assertScript("parseFloat(getComputedStyle({$dialog}).transitionDuration) === 0") ->assertScript("getComputedStyle({$dialog}).opacity === '1'"); @@ -412,7 +433,9 @@ it('gives the scrolling body an inset focus ring when a text-only dialog opens f $page->script("document.querySelector('button').focus()"); - $page->keys(':focus', 'Enter'); + // Enter on the focused button is a native click on it, opening the dialog over it: pressOnce(), + // tests/Pest.php. + pressOnce($page)->keys(':focus', 'Enter'); $body = "document.querySelector('dialog[open] [data-md-modal-body]')"; @@ -432,7 +455,8 @@ it('gives the scrolling body an inset focus ring when a text-only dialog opens f it('cycles a bottom sheet\'s preset heights from its handle, announcing each, and closes from the last', function () { $page = containment(); - $page->click('#containment button:has-text("Bottom sheet with preset heights")'); + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#containment button:has-text("Bottom sheet with preset heights")'); $sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')"; $handle = "{$sheet}.querySelector('[data-md-bottom-sheet-grip]')"; @@ -466,7 +490,8 @@ it('cycles a bottom sheet\'s preset heights from its handle, announcing each, an it('settles a dragged bottom sheet on the nearest preset height', function () { $page = containment(); - $page->click('#containment button:has-text("Bottom sheet with preset heights")'); + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#containment button:has-text("Bottom sheet with preset heights")'); $sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')"; @@ -503,13 +528,15 @@ it('is a standard side sheet from 840px, without a scrim or a focus trap, and th ->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null") ->assertScript("! {$sheet}.hasAttribute('inert')"); - $page->click($toggle); + // The toggle button opens or closes the sheet on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click($toggle); $page->assertScript("getComputedStyle({$sheet}).opacity === '0'"); // Below 840px the same sheet becomes the modal one: fixed, with a scrim, and the page inert. - $page->resize(700, 800) - ->click($toggle); + $page->resize(700, 800); + + pressOnce($page)->click($toggle); $page->assertScript("getComputedStyle({$sheet}).position === 'fixed'") ->assertScript("getComputedStyle({$scrim}).display !== 'none'") @@ -534,7 +561,10 @@ it('gives a closing standard side sheet\'s room back to the content beside it as // Closed and sampled in one round trip: the sheet's root caught part-way between its width // and none, still in the layout, while the column beside it has grown part of the way — not - // the whole sheet and its gap handed back in one jump at the end. + // the whole sheet and its gap handed back in one jump at the end. Stretched, as every other + // mid-exit sample is: a loaded engine can paint too few frames in the real exit to see it. + slowMotion($page); + $midExit = $page->script(caughtMidExit( <<assertScript("{$root}.hasAttribute('data-md-drawer-collapsed') && getComputedStyle({$root}).display === 'none'") ->assertScript("Math.abs({$column}.getBoundingClientRect().width - {$root}.parentElement.getBoundingClientRect().width) < 1"); + // What follows times a reopen against the real exit. + fullSpeed($page); + // Reopened part-way through its exit, it ends open, not collapsed. Each script stays well under // the browser plugin's one-second call timeout, past which it runs the script a second time. $page->script("{$toggle}.click()"); @@ -655,8 +688,11 @@ function hiddenFromAssistiveTech(string $id): string it('closes only a dialog opened over a modal sheet on Escape, readable and tabbable while it is open', function () { $page = layersProbe(); - $page->click('#open-sheet')->assertScript(sheetOpen('sheet')); - $page->click('#open-dialog')->assertScript("document.getElementById('dialog').open"); + // Opens the sheet, then the dialog over it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-sheet'); + $page->assertScript(sheetOpen('sheet')); + pressOnce($page)->click('#open-dialog'); + $page->assertScript("document.getElementById('dialog').open"); // Rendered outside the sheet, the dialog is not inside what the sheet hides, and the sheet's // focus trap does not take the Tab back to the sheet. @@ -681,14 +717,20 @@ it('closes only a dialog opened over a modal sheet on Escape, readable and tabba it('closes only the menu, select list or searchable choice open inside a modal sheet on Escape', function () { $page = layersProbe(); - $page->click('#open-sheet')->assertScript(sheetOpen('sheet')); + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-sheet'); + $page->assertScript(sheetOpen('sheet')); - $page->click('#open-menu')->assertScript("document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')"); + // Opens the menu over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-menu'); + $page->assertScript("document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')"); $page->keys(':focus', 'Escape') ->assertScript("! document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')") ->assertScript(sheetOpen('sheet')); - $page->click('#sheet-choices')->assertScript("document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')"); + // Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#sheet-choices'); + $page->assertScript("document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')"); $page->keys('#sheet-choices', 'Escape') ->assertScript("! document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')") ->assertScript(sheetOpen('sheet')); @@ -696,7 +738,9 @@ it('closes only the menu, select list or searchable choice open inside a modal s // A customizable select's list, where the browser has one: elsewhere there is no list of the // page's own open over the sheet. if ($page->script("CSS.supports('appearance', 'base-select')") === true) { - $page->click('#sheet-select')->assertScript("document.getElementById('sheet-select').matches(':open')"); + // Opens the native select's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#sheet-select'); + $page->assertScript("document.getElementById('sheet-select').matches(':open')"); $page->keys(':focus', 'Escape') ->assertScript("! document.getElementById('sheet-select').matches(':open')") ->assertScript(sheetOpen('sheet')); @@ -708,15 +752,19 @@ it('closes only the menu, select list or searchable choice open inside a modal s it('closes only the sheet on top on Escape, inside the first or beside it, and keeps the one beside it readable', function () { $page = layersProbe(); - $page->click('#open-sheet')->assertScript(sheetOpen('sheet')); + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-sheet'); + $page->assertScript(sheetOpen('sheet')); - $page->click('#open-inner')->assertScript(sheetOpen('inner-sheet')); + pressOnce($page)->click('#open-inner'); + $page->assertScript(sheetOpen('inner-sheet')); $page->click('#inner-field'); $page->keys('#inner-field', 'Escape') ->assertScript('! '.sheetOpen('inner-sheet')) ->assertScript(sheetOpen('sheet')); - $page->click('#open-beside')->assertScript(sheetOpen('beside-sheet')) + pressOnce($page)->click('#open-beside'); + $page->assertScript(sheetOpen('beside-sheet')) ->assertScript('! '.hiddenFromAssistiveTech('beside-sheet')); $page->click('#beside-field'); $page->keys('#beside-field', 'Escape') @@ -732,24 +780,32 @@ it('closes only the sheet, searchable choice, menu or select list open inside a $page->script("window.dispatchEvent(new CustomEvent('open-probe-dialog'))"); $page->assertScript("document.getElementById('dialog').open"); - $page->click('#open-dialog-sheet')->assertScript(sheetOpen('dialog-sheet')); + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-dialog-sheet'); + $page->assertScript(sheetOpen('dialog-sheet')); $page->click('#dialog-sheet-field'); $page->keys('#dialog-sheet-field', 'Escape') ->assertScript('! '.sheetOpen('dialog-sheet')) ->assertScript("document.getElementById('dialog').open"); - $page->click('#dialog-choices')->assertScript("document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')"); + // Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#dialog-choices'); + $page->assertScript("document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')"); $page->keys('#dialog-choices', 'Escape') ->assertScript("! document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')") ->assertScript("document.getElementById('dialog').open"); - $page->click('#open-dialog-menu')->assertScript("document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')"); + // Opens the menu over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-dialog-menu'); + $page->assertScript("document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')"); $page->keys(':focus', 'Escape') ->assertScript("! document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')") ->assertScript("document.getElementById('dialog').open"); if ($page->script("CSS.supports('appearance', 'base-select')") === true) { - $page->click('#dialog-select')->assertScript("document.getElementById('dialog-select').matches(':open')"); + // Opens the native select's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#dialog-select'); + $page->assertScript("document.getElementById('dialog-select').matches(':open')"); $page->keys(':focus', 'Escape') ->assertScript("! document.getElementById('dialog-select').matches(':open')") ->assertScript("document.getElementById('dialog').open"); @@ -911,8 +967,10 @@ it('opens a row\'s opener from a press anywhere on the row, but not from its own $page->script("window.__opened = 0; document.querySelector('{$row} [data-md-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })"); - $page->click("{$row} p") - ->assertScript('window.__opened === 1'); + // A counter a retry would double: pressOnce(), tests/Pest.php. + pressOnce($page)->click("{$row} p"); + + $page->assertScript('window.__opened === 1'); $page->click("{$row} button:has-text(\"Copy link\")") ->assertScript('window.__opened === 1'); @@ -929,8 +987,10 @@ it('makes a directly actionable card the one tab stop, answering Enter', functio $page->assertScript("document.activeElement.matches('{$card}')"); - $page->keys($card, 'Enter') - ->assertScript('window.__opened === 1'); + // A counter a retry would double: pressOnce(), tests/Pest.php. + pressOnce($page)->keys($card, 'Enter'); + + $page->assertScript('window.__opened === 1'); }); it('binds a collapse to a Livewire property both ways', function () { @@ -939,10 +999,15 @@ it('binds a collapse to a Livewire property both ways', function () { $page = collapseProbe() ->assertScript("{$collapse}.open === false"); - $page->click('#fine-tuning-collapse summary') - ->assertScript("{$collapse}.open === true") - ->click('button:has-text("Re-render")') - ->assertSeeIn('#renders', '1') + // A native click toggles the collapse: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#fine-tuning-collapse summary'); + + $page->assertScript("{$collapse}.open === true"); + + // A counter a retry would double: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Re-render")'); + + $page->assertSeeIn('#renders', '1') ->assertSeeIn('#fine-tuning', 'true') ->assertScript("{$collapse}.open === true"); @@ -961,8 +1026,10 @@ it('binds a collapse to an Alpine property both ways', function () { $page = collapseProbe() ->assertScript("{$collapse}.open === false"); - $page->click('#advanced-collapse summary') - ->assertScript("{$collapse}.open === true") + // A native click toggles the collapse: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#advanced-collapse summary'); + + $page->assertScript("{$collapse}.open === true") ->assertSeeIn('#advanced', 'true'); $page->click('button:has-text("Close from Alpine")') @@ -1001,9 +1068,12 @@ dataset('containment sheets', [ it('fades the sheet\'s scrim out on close, rather than making it vanish', function (string $sheet, string $trigger, string $scrimAttr) { $scrim = "{$sheet}.parentElement.querySelector(':scope > [{$scrimAttr}]')"; - $page = containment() - ->click($trigger) - ->assertScript("getComputedStyle({$sheet}).display !== 'none'") + $page = containment(); + + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click($trigger); + + $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") ->assertScript("getComputedStyle({$scrim}).opacity === '1'") // Settled, not still fading in: an engine reads a transition's end value until its next // refresh tick, so an opacity of 1 alone does not say the entry has run — and a close @@ -1033,9 +1103,12 @@ it('slides the sheet out on close, rather than making it vanish', function (stri $offset = "(parseFloat(getComputedStyle({$sheet}).translate{$axis}) || 0)"; $sample = $absolute ? "Math.abs(parseFloat(style.translate{$axis}) || 0)" : "(parseFloat(style.translate{$axis}) || 0)"; - $page = containment() - ->click($trigger) - ->assertScript("getComputedStyle({$sheet}).display !== 'none'") + $page = containment(); + + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click($trigger); + + $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") ->assertScript("Math.abs({$offset}) < 0.5") // Settled, not still sliding in: see the scrim's fade above. ->assertScript(settled($sheet)); @@ -1092,7 +1165,8 @@ it('slides the side sheet in from its own edge in a right-to-left page', functio $sheet = "document.querySelector('aside[data-md-side]')"; - $page->click('button:has-text("Open")'); + // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Open")'); // `side="end"` (the default) is the trailing edge — the *left* of a right-to-left page — so it // slides in from off the left, not the right (the CSS mirror reads `[dir='rtl']` rather than @@ -1156,7 +1230,8 @@ it('keeps a basic dialog\'s own title and padding when it is nested inside a ful $page = ready(visit('/nested-fullscreen-dialog-probe')->resize(400, 800)); - $page->click('button:has-text("Open settings")'); + // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Open settings")'); $outerBar = "document.querySelector('dialog[data-md-fullscreen][open] [data-md-modal-bar]')"; @@ -1165,7 +1240,8 @@ it('keeps a basic dialog\'s own title and padding when it is nested inside a ful // must keep its own head regardless. $page->assertScript("getComputedStyle({$outerBar}).display !== 'none'"); - $page->click('button:has-text("Delete the share")'); + // Opens the nested dialog over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Delete the share")'); $inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Delete this share?')"; $innerHead = "{$inner}.querySelector('[data-md-modal-head]')"; @@ -1184,12 +1260,13 @@ it('shows the 16% dragged state layer on the showcase card being dragged', funct $page->assertScript("getComputedStyle({$card}, '::before').opacity === '0'"); - $page->click('#containment button:has-text("Pick it up or put it down")'); + // Toggles the drag state on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#containment button:has-text("Pick it up or put it down")'); $page->assertScript("{$card}.hasAttribute('data-md-dragged')") ->assertScript("getComputedStyle({$card}, '::before').opacity === '0.16'"); - $page->click('#containment button:has-text("Pick it up or put it down")'); + pressOnce($page)->click('#containment button:has-text("Pick it up or put it down")'); $page->assertScript("! {$card}.hasAttribute('data-md-dragged')") ->assertScript("getComputedStyle({$card}, '::before').opacity === '0'"); @@ -1464,10 +1541,12 @@ it('eases a collapse open and shut in every engine, moving what is under it', fu expect($clipped)->toBe('clip'); - // From the keyboard: Enter on the summary is the same press, taken over the same way. + // From the keyboard: Enter on the summary is the same press, taken over the same way, and it + // toggles: pressOnce(), tests/Pest.php. $page->script("document.querySelector('#plain summary').focus()"); - $page->keys('#plain summary', 'Enter') - ->assertScript("document.querySelector('#plain').open === false && document.querySelector('#plain').getAnimations().length === 0"); + pressOnce($page)->keys('#plain summary', 'Enter'); + + $page->assertScript("document.querySelector('#plain').open === false && document.querySelector('#plain').getAnimations().length === 0"); }); it('eases a collapse bound to Alpine open and shut, keeping the binding in step', function () { @@ -1489,9 +1568,11 @@ it('eases a collapse bound to Alpine open and shut, keeping the binding in step' $page->assertSeeIn('#advanced', 'false'); - // A press on the summary tells the binding when the section has closed, and never loops. - $page->click('#bound summary') - ->assertSeeIn('#advanced', 'true') + // A press on the summary tells the binding when the section has closed, and never loops; a + // native click toggles the collapse: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#bound summary'); + + $page->assertSeeIn('#advanced', 'true') ->assertScript("document.querySelector('#bound').open === true"); }); @@ -1655,8 +1736,10 @@ it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but // A full-screen dialog opened from the keyboard moves the focus to its bar's ✕ in showModal(). $page = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false); - $page->keys('#open-setback', 'Enter') - ->assertScript("document.querySelector('{$dialog}').open && document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')") + // Enter on the focused button opens the dialog over it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#open-setback', 'Enter'); + + $page->assertScript("document.querySelector('{$dialog}').open && document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')") ->wait(0.3) ->assertScript('! '.closeTooltipOpen($dialog)); @@ -1667,8 +1750,10 @@ it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but // A rich tooltip's trigger as the first control of a dialog opened from the keyboard. $page = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false); - $page->keys('#open-about', 'Enter') - ->assertScript("document.activeElement.id === 'about-help'") + // Enter on the focused button opens the dialog over it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#open-about', 'Enter'); + + $page->assertScript("document.activeElement.id === 'about-help'") ->wait(0.3) ->assertScript("! document.querySelector('[data-md-rich-tooltip-bubble]').matches(':popover-open')"); }); diff --git a/tests/Browser/DataTest.php b/tests/Browser/DataTest.php index 71a83607..b06ffb3d 100644 --- a/tests/Browser/DataTest.php +++ b/tests/Browser/DataTest.php @@ -87,12 +87,15 @@ it('sorts by a column and flips the direction on a second press', function () { ->assertAttribute('#by-name', 'aria-sort', 'ascending') ->assertScript("{$first} === 'file-01.zip'"); - $page->click('#by-name button') - ->assertAttribute('#by-name', 'aria-sort', 'descending') + // A second press on the same header flips its direction again: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#by-name button'); + + $page->assertAttribute('#by-name', 'aria-sort', 'descending') ->assertScript("{$first} === 'file-25.zip'"); - $page->click('#by-size button') - ->assertAttribute('#by-size', 'aria-sort', 'ascending') + pressOnce($page)->click('#by-size button'); + + $page->assertAttribute('#by-size', 'aria-sort', 'ascending') ->assertScript("! document.querySelector('#by-name').hasAttribute('aria-sort')"); }); @@ -104,8 +107,10 @@ it('pages through Livewire results and marks the current page', function () { ->assertSee('21–25 of 25') ->assertSeeIn('[data-md-pagination] [aria-current="page"]', '3'); - $page->click('[data-md-pagination] button[aria-label="Previous"]') - ->assertSee('11–20 of 25'); + // A relative paging key a retry would page twice: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-pagination] button[aria-label="Previous"]'); + + $page->assertSee('11–20 of 25'); $page->resize(400, 800) ->assertSee('Page 2 of 3'); diff --git a/tests/Browser/DatepickerTest.php b/tests/Browser/DatepickerTest.php index 02c91001..15e3ecfd 100644 --- a/tests/Browser/DatepickerTest.php +++ b/tests/Browser/DatepickerTest.php @@ -179,9 +179,12 @@ function focusedDay(string $date): string it('opens the docked picker under its field and walks the grid from the keyboard', function () { $picker = "document.querySelector('#expires-field-picker')"; - $page = dateProbe() - ->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]') - ->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.mdPresentation === 'docked'") + $page = dateProbe(); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.mdPresentation === 'docked'") ->assertAttribute('#expires-field', 'aria-expanded', 'true') ->assertScript("(() => { const field = document.querySelector('#expires-field').closest('[data-md-field-box]').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()") ->assertScript(focusedDay('2026-09-13')); @@ -204,9 +207,12 @@ it('opens the docked picker under its field and walks the grid from the keyboard }); it('keeps focus and choice inside min and max', function () { - $page = dateProbe() - ->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]') - ->assertScript(focusedDay('2026-09-13')); + $page = dateProbe(); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript(focusedDay('2026-09-13')); // Playwright will not press an aria-disabled cell, so the press is dispatched. $page->assertAttribute(day('expires-field', '2026-09-09'), 'aria-disabled', 'true') @@ -242,9 +248,12 @@ it('takes a date typed into the docked field once it is whole, and says when it it('opens the modal picker, jumps through the year grid and confirms with OK', function () { $dialog = "document.querySelector('#birthday-field-picker')"; - $page = dateProbe() - ->click('#birthday-field') - ->assertScript("{$dialog}.matches(':modal')") + $page = dateProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#birthday-field'); + + $page->assertScript("{$dialog}.matches(':modal')") ->assertSeeIn('#birthday-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'May 17, 2000') ->click('#birthday-field-picker [data-md-datepicker-nav]:not([data-md-docked]) [data-md-datepicker-menu-button]') ->assertScript("getComputedStyle(document.querySelector('#birthday-field-picker [data-md-datepicker-years]')).display !== 'none'") @@ -256,8 +265,10 @@ it('opens the modal picker, jumps through the year grid and confirms with OK', f ->assertSeeIn('#birthday-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'May 3, 1990') ->assertSeeIn('#birthday', '2000-05-17'); - $page->click('#birthday-field-picker [data-md-datepicker-confirm]') - ->assertSeeIn('#birthday', '1990-05-03') + // The confirm button closes the dialog: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#birthday-field-picker [data-md-datepicker-confirm]'); + + $page->assertSeeIn('#birthday', '1990-05-03') ->assertScript("! {$dialog}.open"); }); @@ -268,8 +279,10 @@ it('closes on Escape without keeping the draft and hands focus back to the field $page->script("document.querySelector('#birthday-field').focus()"); - $page->keys('#birthday-field', 'Enter') - ->assertScript("{$dialog}.open") + // Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#birthday-field', 'Enter'); + + $page->assertScript("{$dialog}.open") ->assertScript(focusedDay('2000-05-17')); $page->keys(':focus', 'ArrowRight') @@ -284,8 +297,11 @@ it('closes on Escape without keeping the draft and hands focus back to the field // The docked picker too. $page->script("document.querySelector('[aria-controls=\"expires-field-picker\"][data-md-datepicker-toggle]').focus()"); - $page->keys(':focus', 'Enter') - ->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')") + // Enter on the focused toggle button is a native click on it, and it toggles: pressOnce(), + // tests/Pest.php. A retried Enter would land on the day it opened onto instead, closing it. + pressOnce($page)->keys(':focus', 'Enter'); + + $page->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')") ->assertScript(focusedDay('2026-09-13')); $page->keys(':focus', 'Escape') @@ -296,15 +312,22 @@ it('closes on Escape without keeping the draft and hands focus back to the field it('reads the modal input in the locale\'s format and explains a date it cannot take', function () { $dialog = "document.querySelector('#delivery-field-picker')"; - $page = dateProbe() - ->click('#delivery-field') - ->assertScript("{$dialog}.matches(':modal')") + $page = dateProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#delivery-field'); + + $page->assertScript("{$dialog}.matches(':modal')") ->assertScript("document.activeElement.id === 'delivery-field-entry'") ->assertSeeIn('#delivery-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'Entered date'); - $page->type('#delivery-field-entry', '2026-03-07') - ->click('#delivery-field-picker [data-md-datepicker-confirm]') - ->assertSeeIn('#delivery-field-entry-support', 'Date does not match expected pattern: MM/DD/YYYY') + $page->type('#delivery-field-entry', '2026-03-07'); + + // The confirm button closes the dialog when the date is valid; here it stays open (invalid), + // but pressed once anyway: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#delivery-field-picker [data-md-datepicker-confirm]'); + + $page->assertSeeIn('#delivery-field-entry-support', 'Date does not match expected pattern: MM/DD/YYYY') ->assertAttribute('#delivery-field-entry', 'aria-invalid', 'true') ->assertScript("{$dialog}.open"); @@ -322,21 +345,29 @@ it('reads the modal input in the locale\'s format and explains a date it cannot }); it('picks a range: a start, an end, and the days between', function () { - $page = dateProbe() - ->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]') - ->assertAttribute(day('trip-field', '2026-09-14'), 'aria-selected', 'true'); + $page = dateProbe(); - $page->click(day('trip-field', '2026-09-20')) - ->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-md-selected')") + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]'); + + $page->assertAttribute(day('trip-field', '2026-09-14'), 'aria-selected', 'true'); + + // A day pressed twice ends the range it began: pressOnce(), tests/Pest.php. + pressOnce($page)->click(day('trip-field', '2026-09-20')); + + $page->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-md-selected')") ->assertScript("! document.querySelector('".day('trip-field', '2026-09-14')."').hasAttribute('data-md-between')"); - $page->click(day('trip-field', '2026-09-24')) - ->assertScript("[...document.querySelectorAll('#trip-field-picker [data-md-between]')].map((cell) => cell.dataset.mdValue).join() === '2026-09-21,2026-09-22,2026-09-23'") + pressOnce($page)->click(day('trip-field', '2026-09-24')); + + $page->assertScript("[...document.querySelectorAll('#trip-field-picker [data-md-between]')].map((cell) => cell.dataset.mdValue).join() === '2026-09-21,2026-09-22,2026-09-23'") ->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-md-start') && document.querySelector('".day('trip-field', '2026-09-24')."').hasAttribute('data-md-end')") ->assertSeeIn('#trip', '2026-09-15'); - $page->click('#trip-field-picker [data-md-datepicker-confirm]') - ->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}') + // The confirm button closes the dialog: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#trip-field-picker [data-md-datepicker-confirm]'); + + $page->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}') ->assertValue('#trip-field', '09/20/2026 – 09/24/2026'); }); @@ -344,9 +375,12 @@ it('follows the locale: German weeks start on Monday, American ones on Sunday', $firstWeekday = "document.querySelector('#expires-field-picker thead th').getAttribute('abbr')"; $page = dateProbe('de') - ->assertValue('#expires-field', '13.09.2026') - ->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]') - ->assertScript("{$firstWeekday} === 'Montag'") + ->assertValue('#expires-field', '13.09.2026'); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript("{$firstWeekday} === 'Montag'") ->assertScript("document.querySelector('#expires-field-month').textContent === 'September 2026'") ->assertScript(focusedDay('2026-09-13')); @@ -357,15 +391,20 @@ it('follows the locale: German weeks start on Monday, American ones on Sunday', ->assertSeeIn('#expires', '2026-09-20') ->assertAttribute('#expires-field', 'placeholder', 'DD.MM.YYYY'); - dateProbe('en_US') - ->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]') - ->assertScript("{$firstWeekday} === 'Sunday'"); + $page = dateProbe('en_US'); + + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript("{$firstWeekday} === 'Sunday'"); }); it('stays open, where it was, through a Livewire render', function () { - $page = dateProbe() - ->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]') - ->assertScript(focusedDay('2026-09-13')); + $page = dateProbe(); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript(focusedDay('2026-09-13')); $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14')); @@ -378,18 +417,23 @@ it('stays open, where it was, through a Livewire render', function () { $page->keys(day('expires-field', '2026-09-14'), 'Enter')->assertSeeIn('#expires', '2026-09-14'); - $page->click('#birthday-field') - ->script('window.eval("Livewire.first().touch()")'); + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#birthday-field'); + + $page->script('window.eval("Livewire.first().touch()")'); $page->assertSeeIn('#renders', '2') ->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')"); }); it('opens a docked picker as a modal one on a compact window', function () { - dateProbe() - ->resize(400, 800) - ->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]') - ->assertScript("document.querySelector('#expires-field-picker').matches(':modal')") + $page = dateProbe() + ->resize(400, 800); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript("document.querySelector('#expires-field-picker').matches(':modal')") ->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-md-datepicker-header]')).display !== 'none'"); }); @@ -397,22 +441,28 @@ it('empties a date, or both ends of a range, with its clear button', function () $page = dateProbe() ->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('[data-md-field]').querySelector('[data-md-field-clear]')).display !== 'none'"); - $page->click('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]') - ->assertScript("document.querySelector('#expires').textContent === ''") + // The clear button vanishes once its field is empty: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]'); + + $page->assertScript("document.querySelector('#expires').textContent === ''") ->assertValue('#expires-field', '') ->assertScript("document.activeElement.id === 'expires-field'") ->assertScript("getComputedStyle(document.querySelector('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'"); - $page->click('[data-md-datepicker]:has(#trip-field) [data-md-field-clear]') - ->assertSeeIn('#trip', '{"start":null,"end":null}') + pressOnce($page)->click('[data-md-datepicker]:has(#trip-field) [data-md-field-clear]'); + + $page->assertSeeIn('#trip', '{"start":null,"end":null}') ->assertValue('#trip-field', ''); }); it('starts the week on the day week-start names, whatever the locale says', function () { $page = dateFormatProbe('de') - ->assertValue('#sunday-field', '13.09.2026') - ->click('[aria-controls="sunday-field-picker"][data-md-datepicker-toggle]') - ->assertScript("document.querySelector('#sunday-field-picker thead th').getAttribute('abbr') === 'Sonntag'") + ->assertValue('#sunday-field', '13.09.2026'); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="sunday-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript("document.querySelector('#sunday-field-picker thead th').getAttribute('abbr') === 'Sonntag'") ->assertScript("document.querySelector('#sunday-field-picker tbody td').dataset.mdValue === '2026-08-30'") ->assertScript(focusedDay('2026-09-13')); @@ -436,8 +486,10 @@ it('shows and reads a year-first format and still binds Y-m-d', function () { ->assertSee('Date does not match expected pattern: YYYY-MM-DD') ->assertSeeIn('#iso', '2026-10-01'); - $page->click('[aria-controls="iso-field-picker"][data-md-datepicker-toggle]') - ->assertScript(focusedDay('2026-10-01')); + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="iso-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript(focusedDay('2026-10-01')); $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-10-02')); @@ -450,9 +502,12 @@ it('reads the dialog\'s text field in the given format, not the locale\'s', func $dialog = "document.querySelector('#dotted-field-picker')"; $page = dateFormatProbe() - ->assertValue('#dotted-field', '13.09.2026') - ->click('#dotted-field') - ->assertScript("{$dialog}.matches(':modal')") + ->assertValue('#dotted-field', '13.09.2026'); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#dotted-field'); + + $page->assertScript("{$dialog}.matches(':modal')") ->assertScript("document.activeElement.id === 'dotted-field-entry'") ->assertValue('#dotted-field-entry', '13.09.2026') ->assertAttribute('#dotted-field-entry', 'placeholder', 'DD.MM.YYYY'); @@ -477,27 +532,36 @@ it('lays out and shows a range in the given first day and format', function () { $page->script("document.querySelector('#span-field').focus()"); - $page->keys('#span-field', 'Enter') - ->assertScript("{$dialog}.matches(':modal')") + // Enter on the readonly field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#span-field', 'Enter'); + + $page->assertScript("{$dialog}.matches(':modal')") ->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'") ->assertScript(focusedDay('2026-09-13')); $page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12')); $page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18')); - $page->click(day('span-field', '2026-09-20')) - ->click(day('span-field', '2026-09-24')) - ->click('#span-field-picker [data-md-datepicker-confirm]') - ->assertSeeIn('#span', '{"start":"2026-09-20","end":"2026-09-24"}') + // A day pressed twice ends the range it began: pressOnce(), tests/Pest.php. + pressOnce($page)->click(day('span-field', '2026-09-20')) + ->click(day('span-field', '2026-09-24')); + + // The confirm button closes the dialog: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#span-field-picker [data-md-datepicker-confirm]'); + + $page->assertSeeIn('#span', '{"start":"2026-09-20","end":"2026-09-24"}') ->assertValue('#span-field', '20/09/2026 – 24/09/2026'); }); it('keeps each picker\'s own settings inside a page\'s outer x-data scope', function () { // Settings assigned in init() without being declared would land on the outermost scope, // where the last picker's null min and Monday start would overwrite the first's. - $page = scopedDateProbe() - ->click('[aria-controls="early-field-picker"][data-md-datepicker-toggle]') - ->assertScript(focusedDay('2026-09-13')); + $page = scopedDateProbe(); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="early-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript(focusedDay('2026-09-13')); $page->assertAttribute(day('early-field', '2026-09-09'), 'aria-disabled', 'true') ->assertScript('! (\'min\' in Alpine.$data(document.querySelector(\'[x-data="{ step: 1 }"]\')))'); @@ -666,9 +730,12 @@ it('closes the full-screen range picker from its app bar close button without ke it('reaches the month and year dropdowns with Shift+M and Shift+Y', function () { $toggle = '[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'; - $page = dateProbe() - ->click($toggle) - ->assertScript(focusedDay('2026-09-13')); + $page = dateProbe(); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click($toggle); + + $page->assertScript(focusedDay('2026-09-13')); $page->keys(':focus', 'Shift+M') ->assertScript("document.activeElement.dataset.mdDatepickerMenuButton === 'months'"); @@ -676,8 +743,9 @@ it('reaches the month and year dropdowns with Shift+M and Shift+Y', function () $page->keys(':focus', 'Escape') ->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')"); - $page->click($toggle) - ->assertScript(focusedDay('2026-09-13')); + pressOnce($page)->click($toggle); + + $page->assertScript(focusedDay('2026-09-13')); $page->keys(':focus', 'Shift+Y') ->assertScript("document.activeElement.dataset.mdDatepickerMenuButton === 'years'"); @@ -692,9 +760,12 @@ it('draws the shared state layer and focus ring on the menu buttons, the year an $hasBoth = fn (string $expr): string => "({$expr}).classList.contains('md-state-layer') && ({$expr}).classList.contains('md-focus-ring')"; - $page = dateProbe() - ->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]') - ->assertScript($hasBoth("document.querySelector('{$monthButton}')")) + $page = dateProbe(); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript($hasBoth("document.querySelector('{$monthButton}')")) ->assertScript($hasBoth("document.querySelector('{$yearButton}')")) ->click($monthButton) // toggleView() scrolls the selected option into view and focuses it once settled. @@ -711,8 +782,10 @@ it('draws the shared state layer and focus ring on the menu buttons, the year an $page->keys(':focus', 'Escape') ->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')"); - $page->click('#birthday-field') - ->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')") + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#birthday-field'); + + $page->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')") ->click($modalYearButton) ->assertScript($hasBoth("document.querySelector('{$modalYearButton}')")) ->assertScript("document.activeElement === {$selectedYear}") @@ -725,9 +798,12 @@ it('shows a state layer over a selected day\'s primary fill on hover, and none o $disabledDay = day('expires-field', '2026-09-09'); $disabledSpan = "document.querySelector('{$disabledDay} > span')"; - $page = dateProbe() - ->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]') - ->assertScript("document.querySelector('{$selectedDay}').hasAttribute('data-md-selected')") + $page = dateProbe(); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + + $page->assertScript("document.querySelector('{$selectedDay}').hasAttribute('data-md-selected')") ->assertAttribute($disabledDay, 'aria-disabled', 'true') // The day's own fill transitions in when the popover opens: let it settle before reading // a resting value, or the "unchanged by hover" comparison below race against that instead. @@ -754,8 +830,10 @@ it('shows a state layer over a selected day\'s primary fill on hover, and none o // A blank cell (no date, outside the shown month in modal presentation) can't be focused at // all — the same rule hides its layer regardless. - $page->click('#birthday-field') - ->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')") + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#birthday-field'); + + $page->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')") ->hover('#birthday-field-picker [data-md-blank] > span >> nth=0') ->assertScript("getComputedStyle([...document.querySelectorAll('#birthday-field-picker [data-md-blank] > span')][0], '::before').display === 'none'"); }); @@ -766,7 +844,10 @@ it('keeps the day indicator its fixed 40px size at a 20px root font size, growin $font = "getComputedStyle({$daySpan}).fontSize"; $picker = "document.querySelector('#expires-field-picker')"; - $page = dateProbe()->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); + $page = dateProbe(); + + // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'); $restingWidth = (int) $page->script($width); $restingFont = $page->script($font); diff --git a/tests/Browser/FieldsTest.php b/tests/Browser/FieldsTest.php index 7ab61259..4d00c3b6 100644 --- a/tests/Browser/FieldsTest.php +++ b/tests/Browser/FieldsTest.php @@ -103,11 +103,14 @@ it('floats the label while the field has focus or holds a value', function () { }); it('clears a field and tells Livewire', function () { - fieldProbe() + $page = fieldProbe() ->type('#name-field', 'Holiday') - ->assertSeeIn('#name', 'Holiday') - ->click('[data-md-field-clear]') - ->assertScript("document.querySelector('#name-field').value === ''") + ->assertSeeIn('#name', 'Holiday'); + + // The clear button vanishes once its field is empty: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-field-clear]'); + + $page->assertScript("document.querySelector('#name-field').value === ''") ->assertScript("document.querySelector('#name').textContent === ''") ->assertScript("document.activeElement.id === 'name-field'"); }); @@ -123,13 +126,18 @@ it('copies a field\'s value and says so', function () { }); it('shows and hides a password', function () { - fieldProbe() - ->type('#password-field', 'secret') - ->click('[data-md-field-reveal]') - ->assertScript("document.querySelector('#password-field').type === 'text'") - ->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password') - ->click('[data-md-field-reveal]') - ->assertScript("document.querySelector('#password-field').type === 'password'"); + $page = fieldProbe() + ->type('#password-field', 'secret'); + + // The reveal button toggles on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-field-reveal]'); + + $page->assertScript("document.querySelector('#password-field').type === 'text'") + ->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password'); + + pressOnce($page)->click('[data-md-field-reveal]'); + + $page->assertScript("document.querySelector('#password-field').type === 'password'"); }); it('shows the server\'s error in place of the hint', function () { @@ -162,8 +170,10 @@ it('opens the customizable select picker as M3\'s menu, where the browser suppor $page->assertScript("getComputedStyle({$select}).appearance === 'base-select'"); - $page->click('#hours-field') - ->assertScript("{$select}.matches(':open')") + // Opens the customizable select's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#hours-field'); + + $page->assertScript("{$select}.matches(':open')") // M3's menu row height (menu-item.css's 48px), not the browser's own tiny option row. ->assertScript("parseFloat(getComputedStyle({$option}).minBlockSize) === 48"); }); @@ -250,30 +260,42 @@ it('keeps a partly ticked checkbox in step with the server', function () { $page = fieldProbe()->assertScript("! {$all}.indeterminate"); - $page->click('label[for="file-a"]') - ->assertSeeIn('#files', 'a') + // A checkbox toggles on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('label[for="file-a"]'); + + $page->assertSeeIn('#files', 'a') ->assertScript("{$all}.hasAttribute('data-md-indeterminate') && {$all}.indeterminate"); - $page->click('label[for="file-b"]') - ->click('label[for="file-c"]') - ->assertSeeIn('#files', 'a,b,c') + pressOnce($page)->click('label[for="file-b"]') + ->click('label[for="file-c"]'); + + $page->assertSeeIn('#files', 'a,b,c') ->assertScript("! {$all}.hasAttribute('data-md-indeterminate') && ! {$all}.indeterminate"); }); it('moves between radio buttons with the arrow keys', function () { - fieldProbe() - ->keys('input[type="radio"][value="anyone"]', 'ArrowDown') - ->assertSeeIn('#audience', 'team') + $page = fieldProbe(); + + // A two-option radio group: a second press of the same arrow cycles back: pressOnce(), + // tests/Pest.php. + pressOnce($page)->keys('input[type="radio"][value="anyone"]', 'ArrowDown'); + + $page->assertSeeIn('#audience', 'team') ->assertScript("document.querySelector('input[type=\"radio\"][value=\"team\"]').checked"); }); it('turns a switch on from the keyboard and from its label', function () { - $page = fieldProbe() - ->keys('#public', 'Space') - ->assertSeeIn('#public-state', 'true'); + $page = fieldProbe(); - $page->click('label[for="public"]') - ->assertSeeIn('#public-state', 'false') + // Space toggles a switch on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#public', 'Space'); + + $page->assertSeeIn('#public-state', 'true'); + + // A switch's label toggles it, same as the switch itself: pressOnce(), tests/Pest.php. + pressOnce($page)->click('label[for="public"]'); + + $page->assertSeeIn('#public-state', 'false') ->assertAttribute('#public', 'role', 'switch'); }); @@ -394,13 +416,19 @@ it('keeps a focused field\'s focus edge under the pointer, as M3 layers focus ov // A customizable select reads as focused while its menu is open, though focus is then on an // option in the top layer: in primary under the pointer, and in error when the field is. if ($page->script("CSS.supports('appearance', 'base-select')") === true) { - $page->click('#select-field')->assertScript("document.getElementById('select-field').matches(':open')"); + // Opens the customizable select's list over the field that triggers it: pressOnce(), + // tests/Pest.php. + pressOnce($page)->click('#select-field'); + + $page->assertScript("document.getElementById('select-field').matches(':open')"); expect($page->script(settledEdge('select-field')))->toBe('2px '.$role('primary')); $page->keys('#select-field', 'Escape')->assertScript("! document.getElementById('select-field').matches(':open')"); - $page->click('#invalid-select-field')->assertScript("document.getElementById('invalid-select-field').matches(':open')"); + pressOnce($page)->click('#invalid-select-field'); + + $page->assertScript("document.getElementById('invalid-select-field').matches(':open')"); expect($page->script(settledEdge('invalid-select-field')))->toBe('2px '.$role('error')); } @@ -513,8 +541,10 @@ it('draws the switch\'s handle at SwitchTokens\' sizes and centres, off and on', $page = fieldProbe()->assertScript("{$handle} === '52,32,16,16,16'"); - $page->keys('#public', 'Space') - ->assertSeeIn('#public-state', 'true') + // Space toggles a switch on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#public', 'Space'); + + $page->assertSeeIn('#public-state', 'true') ->wait(0.7) ->assertScript("{$handle} === '52,32,24,36,16'"); }); @@ -560,7 +590,11 @@ it('keeps a switch\'s track and a checkbox\'s box whole beside long text in a ro })()"; // A phone's window: the row is 329px across, less than the text beside each control would take. - $page = ready(visit('/selection-row-probe')->resize(393, 800), alpine: false, livewire: false); + $page = ready(visit('/selection-row-probe')->resize(393, 800), alpine: false, livewire: false) + // Measured once, so measured only when the layout is final: the resize has landed and the + // brand face has replaced its fallback, either of which moves every width below. + ->assertScript("window.innerWidth === 393 && document.fonts.status === 'loaded'") + ->assertScript(settled('document.documentElement', subtree: true)); expect($page->script($control('row-bare-switch', '[data-md-switch]', '[data-md-toggle]')))->toBe([52, true, true]) ->and($page->script($control('row-labelled-switch', '[data-md-switch]', '[data-md-toggle]')))->toBe([52, true, true]) diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php index 6d964465..dd3b72ad 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -250,8 +250,11 @@ it('shows the list alone below expanded, and the detail with a back button once $page = listDetailPage(599); $page->script("document.querySelector('a[href=\"#message-2\"]').focus()"); - $page->keys('a[href="#message-2"]', 'Enter') - ->assertSeeIn('#selected', '2') + + // The selected item's link is hidden once the detail pane covers it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('a[href="#message-2"]', 'Enter'); + + $page->assertSeeIn('#selected', '2') ->assertSeeIn('#detail-text', 'The message') ->assertScript('! '.listDetailVisible(LIST_PANE)) ->assertScript(listDetailVisible(DETAIL_PANE)) @@ -259,8 +262,10 @@ it('shows the list alone below expanded, and the detail with a back button once ->assertScript("document.activeElement === document.querySelector('".DETAIL_PANE."')") ->assertScript("document.querySelector('".DETAIL_PANE." [data-md-list-detail-back] button').checkVisibility()"); - $page->click(DETAIL_PANE.' [data-md-list-detail-back] button') - ->assertSeeIn('#selected', 'NULL') + // The back button closes the detail pane: pressOnce(), tests/Pest.php. + pressOnce($page)->click(DETAIL_PANE.' [data-md-list-detail-back] button'); + + $page->assertSeeIn('#selected', 'NULL') ->assertScript(listDetailVisible(LIST_PANE)) ->assertScript('! '.listDetailVisible(DETAIL_PANE)) // And back to the item it came from. @@ -303,8 +308,10 @@ it('mirrors the list-detail in a right-to-left document', function () { $page = listDetailPage(599, 'rtl'); - $page->click('a[href="#message-1"]') - ->assertSeeIn('#selected', '1') + // The selected item's link is hidden once the detail pane covers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('a[href="#message-1"]'); + + $page->assertSeeIn('#selected', '1') ->assertScript("getComputedStyle(document.querySelector('".DETAIL_PANE." [data-md-list-detail-back] svg')).transform !== 'none'"); }); @@ -321,14 +328,18 @@ it('selects from Alpine through x-model, with the layout\'s own back row', funct $page = layoutPage($body, 599); - $page->click('#pick') - ->assertSeeIn('#chosen', 'b') + // The list pane is hidden once the button that picks it is covered: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#pick'); + + $page->assertSeeIn('#chosen', 'b') ->assertSeeIn('#shown', 'b') ->assertScript('! '.listDetailVisible(LIST_PANE)) ->assertScript("document.querySelector('[data-md-list-detail-back-row]').checkVisibility()"); - $page->click('[data-md-list-detail-back-row] button') - ->assertSeeIn('#chosen', 'null') + // The back button closes the detail pane: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-list-detail-back-row] button'); + + $page->assertSeeIn('#chosen', 'null') ->assertScript(listDetailVisible(LIST_PANE)) ->assertScript("document.activeElement === document.querySelector('#pick')"); }); @@ -386,8 +397,10 @@ it('docks the supporting pane as a bottom sheet below expanded, opened and close ->assertScript(layoutRect(SUPPORTING_SIDE, 'bottom').' === window.innerHeight') ->assertScript(layoutStyle(SUPPORTING_SIDE, 'borderTopLeftRadius')." === '28px'"); - $page->click($handle) - ->assertAttribute($handle, 'aria-expanded', 'true') + // The handle toggles the sheet on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click($handle); + + $page->assertAttribute($handle, 'aria-expanded', 'true') ->assertScript("document.querySelector('#comment').checkVisibility()") ->assertScript(layoutRect(SUPPORTING_SIDE, 'bottom').' === window.innerHeight'); @@ -560,8 +573,10 @@ it('shows each canonical layout working on its own Layout page', function () { $compactListDetail = layoutReady(visit('/material/layout/list-detail')->resize(599, 900)); - $compactListDetail->click('#example-list-detail a:has-text("Chiara Rossi")') - ->assertSeeIn('#example-list-detail [data-md-list-detail-pane="detail"]', 'Train times for Friday') + // The selected item's link is hidden once the detail pane covers it: pressOnce(), tests/Pest.php. + pressOnce($compactListDetail)->click('#example-list-detail a:has-text("Chiara Rossi")'); + + $compactListDetail->assertSeeIn('#example-list-detail [data-md-list-detail-pane="detail"]', 'Train times for Friday') ->assertScript("! document.querySelector('#example-list-detail [data-md-list-detail-pane=\"list\"]').checkVisibility()") ->assertNoJavaScriptErrors(); diff --git a/tests/Browser/NavigationTest.php b/tests/Browser/NavigationTest.php index 0c4a3db0..966256f5 100644 --- a/tests/Browser/NavigationTest.php +++ b/tests/Browser/NavigationTest.php @@ -102,8 +102,10 @@ it('starts the rail collapsed across the expanded class and expands it in the la ->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'") ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false'); - $page->click('[data-md-navigation-rail-menu]') - ->assertScript(railWidth(256)) + // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-navigation-rail-menu]'); + + $page->assertScript(railWidth(256)) ->assertScript('! '.RAIL.".hasAttribute('data-md-open')") ->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-scrim]')).display === 'none'") ->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null") @@ -194,7 +196,10 @@ it('morphs the rail header FAB between collapsed (icon only) and extended (with ->assertScript("getComputedStyle({$label}).maxWidth === '0px'") ->assertScript("Math.round({$fab}.getBoundingClientRect().width) === Math.round({$fab}.getBoundingClientRect().height)"); - $page->click('[data-md-navigation-rail-menu]') + // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-navigation-rail-menu]'); + + $page // The label's width and the FAB's own shape both spring on the rail's spatial-default // transition; wait past it for the settled, extended end state. ->wait(0.6) @@ -216,8 +221,10 @@ it('expands the rail from large, and keeps it collapsed across a reload from the ->assertScript("window.eval('window.firstPaint.width') === '256px'") ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true'); - $page->click('[data-md-navigation-rail-menu]') - ->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'") + // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-navigation-rail-menu]'); + + $page->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'") ->assertScript("localStorage.getItem('material-rail') === 'collapsed'") ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false') ->assertScript(railWidth(96)); @@ -249,8 +256,12 @@ it('opens the modal rail from its menu button below expanded, holding focus unti // From the keyboard: WebKit neither focuses a button on click nor returns focus to one that never had it. $page->script("document.querySelector('[data-md-navigation-rail-menu]').focus()"); - $page->keys('[data-md-navigation-rail-menu]', 'Enter') - ->assertScript(RAIL.".hasAttribute('data-md-open')") + + // Enter on the focused button is a native click on it, and it toggles the rail: pressOnce(), + // tests/Pest.php. + pressOnce($page)->keys('[data-md-navigation-rail-menu]', 'Enter'); + + $page->assertScript(RAIL.".hasAttribute('data-md-open')") ->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256') ->assertScript(railWidth(96)) ->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-heading]')).display !== 'none'") @@ -263,19 +274,26 @@ it('opens the modal rail from its menu button below expanded, holding focus unti ->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null") ->assertScript("document.activeElement === document.querySelector('[data-md-navigation-rail-menu]')"); - $page->keys('[data-md-navigation-rail-menu]', 'Enter') - ->assertScript(RAIL.".hasAttribute('data-md-open')"); + // Enter on the focused button is a native click on it, and it toggles the rail: pressOnce(), + // tests/Pest.php. + pressOnce($page)->keys('[data-md-navigation-rail-menu]', 'Enter'); - $page->click('[data-md-navigation-rail-scrim]') - ->assertScript('! '.RAIL.".hasAttribute('data-md-open')") + $page->assertScript(RAIL.".hasAttribute('data-md-open')"); + + // The scrim closes the modal rail: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-navigation-rail-scrim]'); + + $page->assertScript('! '.RAIL.".hasAttribute('data-md-open')") ->assertScript("localStorage.getItem('material-rail') === null"); }); it('slides the modal rail in on a compact window from the app bar\'s menu button', function () { $page = shellPage(599, 860); - $page->click('@shell-menu') - ->assertScript(RAIL.".hasAttribute('data-md-open')") + // Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('@shell-menu'); + + $page->assertScript(RAIL.".hasAttribute('data-md-open')") ->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'flex'") ->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0') ->assertScript(RAIL_PANEL.'.contains(document.activeElement)'); @@ -288,8 +306,10 @@ it('slides the modal rail in on a compact window from the app bar\'s menu button it('keeps the rail and the theme through wire:navigate and moves aria-current', function () { $page = shellPage(1200); - $page->click('[data-md-navigation-rail-menu]') - ->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'"); + // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-navigation-rail-menu]'); + + $page->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'"); $page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")"); @@ -454,14 +474,19 @@ it('takes a rail out of the layout entirely when it hides collapsed, and back ov $rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')"; $railWidth = "Math.round({$rail}.getBoundingClientRect().width)"; - $page->assertScript("{$railWidth} > 200") - ->click('#hiding-rail [data-md-navigation-rail-menu]') - ->assertScript("{$railWidth} === 0") + $page->assertScript("{$railWidth} > 200"); + + // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#hiding-rail [data-md-navigation-rail-menu]'); + + $page->assertScript("{$railWidth} === 0") ->assertScript("! {$rail}.hasAttribute('data-md-open')"); - // The only way back: an application's own menu button calling $store.rail.show(). - $page->click('#open-hiding-rail') - ->assertScript("{$rail}.hasAttribute('data-md-open')") + // The only way back: an application's own menu button calling $store.rail.show(), which opens + // the rail back over the page: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-hiding-rail'); + + $page->assertScript("{$rail}.hasAttribute('data-md-open')") ->assertScript("getComputedStyle(document.querySelector('#hiding-rail [data-md-navigation-rail-panel]')).display === 'flex'") ->assertNoJavaScriptErrors(); }); @@ -471,8 +496,10 @@ it('docks a rail that hid itself back into the layout from $store.rail.toggle(), $rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')"; $railWidth = "Math.round({$rail}.getBoundingClientRect().width)"; - $page->click('#hiding-rail [data-md-navigation-rail-menu]') - ->assertScript("{$railWidth} === 0"); + // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#hiding-rail [data-md-navigation-rail-menu]'); + + $page->assertScript("{$railWidth} === 0"); // The first interactive rail on the page is this one: the narrow rail above it is fixed. $page->script(TOGGLE_RAIL); @@ -620,9 +647,12 @@ it('moves an adaptive rail\'s value with the window and the menu button, in step // `expanded`: collapsed until the menu button widens it in place. $page->resize(840, 900) - ->assertScript(RAIL_VALUE." === 'collapsed'") - ->click('[data-md-navigation-rail-menu]') - ->assertScript(railWidth(256)) + ->assertScript(RAIL_VALUE." === 'collapsed'"); + + // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-navigation-rail-menu]'); + + $page->assertScript(railWidth(256)) ->assertScript(RAIL_VALUE." === 'expanded'") ->assertScript(railShows('expanded-only')) ->assertNoJavaScriptErrors(); @@ -633,8 +663,10 @@ it('reads expanded while a modal rail is open over the page, and collapsed once ->assertScript(RAIL_VALUE." === 'collapsed'") ->assertScript('! '.railShows('expanded-only')); - $page->click('#open-rail') - ->assertScript(RAIL.".hasAttribute('data-md-open')") + // Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-rail'); + + $page->assertScript(RAIL.".hasAttribute('data-md-open')") ->assertScript(RAIL_VALUE." === 'expanded'") ->assertScript(railShows('expanded-only')) ->assertScript('! '.railShows('collapsed-only')); @@ -665,9 +697,12 @@ function slidingOut(string $panel): string } it('slides the compact rail out on close, rather than making it vanish', function () { - $page = shellPage(599, 860) - ->click('@shell-menu') - ->assertScript(RAIL.".hasAttribute('data-md-open')") + $page = shellPage(599, 860); + + // Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('@shell-menu'); + + $page->assertScript(RAIL.".hasAttribute('data-md-open')") ->assertScript(settled(RAIL_PANEL)) ->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0'); @@ -685,10 +720,15 @@ it('slides a rail that hides when collapsed out on close, rather than making it $rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')"; $panel = "{$rail}.querySelector(':scope > [data-md-navigation-rail-panel]')"; - $page->click('#hiding-rail [data-md-navigation-rail-menu]') - ->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0") - ->click('#open-hiding-rail') - ->assertScript("{$rail}.hasAttribute('data-md-open')") + // The menu button toggles the rail, then the open button brings it back over the page: + // pressOnce(), tests/Pest.php. + pressOnce($page)->click('#hiding-rail [data-md-navigation-rail-menu]'); + + $page->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0"); + + pressOnce($page)->click('#open-hiding-rail'); + + $page->assertScript("{$rail}.hasAttribute('data-md-open')") ->assertScript(settled($panel)) ->assertScript("Math.round({$panel}.getBoundingClientRect().left) === 0 && Math.round({$panel}.getBoundingClientRect().width) === 256"); @@ -704,9 +744,12 @@ it('slides a rail that hides when collapsed out on close, rather than making it it('fades a modal rail\'s scrim out on close while the rail stands collapsed in the layout', function () { $scrim = RAIL.".querySelector(':scope > [data-md-navigation-rail-scrim]')"; - $page = railValueProbe('modal', 1000) - ->click('#open-rail') - ->assertScript(RAIL.".hasAttribute('data-md-open')") + $page = railValueProbe('modal', 1000); + + // Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#open-rail'); + + $page->assertScript(RAIL.".hasAttribute('data-md-open')") ->assertScript(settled($scrim)) ->assertScript("getComputedStyle({$scrim}).opacity === '1'"); diff --git a/tests/Browser/PickingTest.php b/tests/Browser/PickingTest.php index 2aa4d83b..be023ff4 100644 --- a/tests/Browser/PickingTest.php +++ b/tests/Browser/PickingTest.php @@ -80,12 +80,17 @@ function pickProbe() } it('keeps the values of filter chips as the property\'s own type', function () { - pickProbe() - ->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true') - ->click('button:has-text("Mon")') - ->assertSeeIn('#days', '[2,1]') - ->click('button:has-text("Tue")') - ->assertSeeIn('#days', '[1]') + $page = pickProbe() + ->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true'); + + // A filter chip toggles on every press: pressOnce(), tests/Pest.php. + pressOnce($page)->click('button:has-text("Mon")'); + + $page->assertSeeIn('#days', '[2,1]'); + + pressOnce($page)->click('button:has-text("Tue")'); + + $page->assertSeeIn('#days', '[1]') ->assertScript("[...document.querySelectorAll('[aria-pressed=\"true\"]')].map((chip) => chip.textContent.trim()).join() === 'Mon'"); }); @@ -93,9 +98,12 @@ it('filters a searchable choice as it is typed and chooses from the keyboard', f $list = "document.querySelector('#zone-field-list')"; $page = pickProbe() - ->assertValue('#zone-field', 'Zurich') - ->click('#zone-field') - ->assertScript("{$list}.matches(':popover-open')") + ->assertValue('#zone-field', 'Zurich'); + + // Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#zone-field'); + + $page->assertScript("{$list}.matches(':popover-open')") ->assertAttribute('#zone-field', 'aria-expanded', 'true'); $page->type('#zone-field', 'ber') @@ -108,20 +116,30 @@ it('filters a searchable choice as it is typed and chooses from the keyboard', f }); it('puts a searchable choice back on Escape and never chooses a disabled option', function () { - $page = pickProbe()->click('#zone-field')->type('#zone-field', 'ber'); + $page = pickProbe(); + + // Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#zone-field'); + + $page->type('#zone-field', 'ber'); $page->keys('#zone-field', 'Escape') ->assertValue('#zone-field', 'Zurich') ->assertSeeIn('#zone', 'Europe/Zurich'); - $page->click('#zone-field')->type('#zone-field', 'UTC')->keys('#zone-field', 'Enter') + pressOnce($page)->click('#zone-field'); + + $page->type('#zone-field', 'UTC')->keys('#zone-field', 'Enter') ->assertSeeIn('#zone', 'Europe/Zurich'); }); it('opens a searchable choice\'s list above a container that clips', function () { - pickProbe() - ->click('#zone-field') - ->assertScript(<<<'JS' + $page = pickProbe(); + + // Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#zone-field'); + + $page->assertScript(<<<'JS' (() => { const list = document.querySelector('#zone-field-list'); const clip = document.querySelector('#clip').getBoundingClientRect(); @@ -164,12 +182,18 @@ it('hangs a searchable choice\'s list under its field and as wide, bounded at 40 $page = ready(visit('/wide-choices-probe')->resize(1280, 800), livewire: false); - $page->click('#bounded-zone')->assertScript("document.getElementById('bounded-zone-list').matches(':popover-open')"); + // Opens the searchable choice's list over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#bounded-zone'); + + $page->assertScript("document.getElementById('bounded-zone-list').matches(':popover-open')"); expect($page->script($placed('bounded-zone')))->toBe([640, 640, 0]); $page->keys('#bounded-zone', 'Escape')->assertScript("! document.getElementById('bounded-zone-list').matches(':popover-open')"); - $page->click('#full-zone')->assertScript("document.getElementById('full-zone-list').matches(':popover-open')"); + + pressOnce($page)->click('#full-zone'); + + $page->assertScript("document.getElementById('full-zone-list').matches(':popover-open')"); expect($page->script($placed('full-zone')))->toBe([1168, 1168, 0]); }); @@ -178,9 +202,12 @@ it('opens the search view with the results Livewire renders for the query', func $view = "document.querySelector('#find-view')"; // The combobox is the wrapper around the input, which carries `aria-expanded`. - $page = pickProbe() - ->click('#find') - ->assertScript("getComputedStyle({$view}).display !== 'none'") + $page = pickProbe(); + + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#find'); + + $page->assertScript("getComputedStyle({$view}).display !== 'none'") ->assertAttribute('[role="combobox"]:has(> #find)', 'aria-expanded', 'true'); $page->type('#find', 'con') @@ -199,21 +226,29 @@ it('opens the search view with the results Livewire renders for the query', func it('says so when nothing matches, and closes on a press outside or a chosen result', function () { $view = "document.querySelector('#find-view')"; - $page = pickProbe() - ->click('#find') - ->type('#find', 'zzz') + $page = pickProbe(); + + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#find'); + + $page->type('#find', 'zzz') ->assertSeeIn('#find-view', 'No files match.'); - // The docked view opens over a scrim that covers the rest of the page, so a - // press outside the view lands on the scrim. - $page->click('[data-md-search]:has(#find) [data-md-search-scrim]') - ->assertScript("getComputedStyle({$view}).display === 'none'"); + // The docked view opens over a scrim that covers the rest of the page, so a press outside the + // view lands on the scrim, which closes the view: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-search]:has(#find) [data-md-search-scrim]'); - $page->click('#find') - ->clear('#find') - ->assertScript("{$view}.querySelectorAll('button').length === 3") - ->click('#find-view button:has-text("review.mp4")') - ->assertScript("getComputedStyle({$view}).display === 'none'"); + $page->assertScript("getComputedStyle({$view}).display === 'none'"); + + pressOnce($page)->click('#find'); + + $page->clear('#find') + ->assertScript("{$view}.querySelectorAll('button').length === 3"); + + // Selecting a result closes the view: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#find-view button:has-text("review.mp4")'); + + $page->assertScript("getComputedStyle({$view}).display === 'none'"); }); /** @@ -242,8 +277,12 @@ function searchCloseSample(string $element, string $expression): string it('fades the docked search\'s scrim out on close, rather than making it vanish', function () { $root = "document.querySelector('[data-md-search]:has(#find)')"; - $page = pickProbe() - ->click('#find') + $page = pickProbe(); + + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#find'); + + $page // Displayed as well as opaque: until the first frame of its entry the scrim is still // `display: none` at the open state's full opacity. And settled, not still fading in: an // engine reads a transition's end value until its next refresh tick, so an opacity of 1 @@ -262,9 +301,12 @@ it('fades the docked search\'s scrim out on close, rather than making it vanish' it('keeps the docked search above the page while its view closes', function () { $view = "document.querySelector('#find-view')"; - $page = pickProbe() - ->click('#find') - ->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'") + $page = pickProbe(); + + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#find'); + + $page->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'") // Settled, not still fading in: see the scrim's fade above. ->assertScript(settled($view)); @@ -279,20 +321,28 @@ it('keeps the docked search above the page while its view closes', function () { it('takes the whole screen on a compact window, with a back arrow', function () { $page = pickProbe()->resize(400, 800); - $page->click('#find') - ->assertScript("document.querySelector('[data-md-search]').hasAttribute('data-md-full-screen')") - ->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()") - ->click('[data-md-search]:has(#find) [data-md-search-back]') - ->assertScript("! document.querySelector('[data-md-search]').hasAttribute('data-md-open')"); + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#find'); + + $page->assertScript("document.querySelector('[data-md-search]').hasAttribute('data-md-full-screen')") + ->assertScript("(() => { const box = document.querySelector('#find-view').getBoundingClientRect(); return box.top === 0 && box.width === 400; })()"); + + // The back arrow closes the search: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-search]:has(#find) [data-md-search-back]'); + + $page->assertScript("! document.querySelector('[data-md-search]').hasAttribute('data-md-open')"); }); it('says how many results there are, politely, as they change', function () { $status = "document.querySelector('[data-md-search]:has(#find) [data-md-search-status]')"; $page = pickProbe() - ->assertScript("{$status}.getAttribute('aria-live') === 'polite' && {$status}.getAttribute('aria-atomic') === 'true'") - ->click('#find') - ->assertScript("{$status}.textContent === '3 results'"); + ->assertScript("{$status}.getAttribute('aria-live') === 'polite' && {$status}.getAttribute('aria-atomic') === 'true'"); + + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#find'); + + $page->assertScript("{$status}.textContent === '3 results'"); $page->type('#find', 'con') ->assertSeeIn('#query', 'con') @@ -323,8 +373,10 @@ it('expands the search icon into the full-screen view, and hands focus back to t ->assertScript("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'") ->assertScript("{$root}.getBoundingClientRect().width === 48 && {$trigger}.getAttribute('aria-expanded') === 'false'"); - $page->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]') - ->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.hasAttribute('data-md-full-screen')") + // Opens the search view over the trigger that expands it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]'); + + $page->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.hasAttribute('data-md-full-screen')") ->assertScript("document.activeElement.id === 'find-icon'") ->assertScript("(() => { const view = {$root}.querySelector('[data-md-search-view]').getBoundingClientRect(); return view.top === 0 && view.left === 0 && view.width === innerWidth; })()") // The toolbar does not shift under the expanded search. @@ -367,9 +419,12 @@ it('closes the full-screen view and its bar together, back into the search icon' $root = "document.querySelector('[data-md-search]:has(#find-icon)')"; $trigger = "{$root}.querySelector('[data-md-search-trigger]')"; - $page = pickProbe() - ->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]') - ->assertScript("{$root}.hasAttribute('data-md-full-screen')") + $page = pickProbe(); + + // Opens the search view over the trigger that expands it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]'); + + $page->assertScript("{$root}.hasAttribute('data-md-full-screen')") // Its entry run: Firefox reads a transition's end value until its next refresh tick, so an // opacity of 1 alone does not say the view has finished fading in. ->assertScript(settled($root, subtree: true)); @@ -391,8 +446,10 @@ it('closes a compact window\'s full-screen view and its bar together, back into $page = pickProbe()->resize(400, 800); - $page->click('#find') - ->assertScript("{$root}.hasAttribute('data-md-full-screen')") + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#find'); + + $page->assertScript("{$root}.hasAttribute('data-md-full-screen')") ->assertScript(settled($root, subtree: true)); slowMotion($page); @@ -410,9 +467,12 @@ it('shows the suggestions until the first key, then the results, and counts whic $root = "document.querySelector('[data-md-search]:has(#find-icon)')"; $shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()"; - $page = pickProbe() - ->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]') - ->assertScript("({$shown('suggestions')}) && ! ({$shown('results')})") + $page = pickProbe(); + + // Opens the search view over the trigger that expands it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]'); + + $page->assertScript("({$shown('suggestions')}) && ! ({$shown('results')})") ->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '3 suggestions'"); $page->type('#find-icon', 'h') diff --git a/tests/Browser/ShowcaseTest.php b/tests/Browser/ShowcaseTest.php index 6f5ee680..b03293a7 100644 --- a/tests/Browser/ShowcaseTest.php +++ b/tests/Browser/ShowcaseTest.php @@ -22,8 +22,10 @@ it('moves between sections through the rail and the next-section link, keeping t it('finds a component from anywhere and opens its section', function () { $page = ready(visit('/material/buttons')); - $page->keys('#content', '/') - ->assertScript("document.activeElement.id === 'showcase-search'"); + // "/" opens the search view: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#content', '/'); + + $page->assertScript("document.activeElement.id === 'showcase-search'"); $page->type('#showcase-search', 'datepicker') ->assertSeeIn('[data-md-search-view]', ''); @@ -36,16 +38,21 @@ it('finds a component from anywhere and opens its section', function () { it('opens an example on another page at the example', function () { $page = ready(visit('/material')); - $page->click('#showcase-search') - ->type('#showcase-search', 'split button') + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#showcase-search'); + + $page->type('#showcase-search', 'split button') ->click('[data-showcase-result]:has-text("Example")') ->assertScript("location.pathname.endsWith('/material/buttons') && location.hash.startsWith('#example-')") ->assertScript('(() => { const box = document.getElementById(decodeURIComponent(location.hash.slice(1))).getBoundingClientRect(); return box.top >= 0 && box.top < innerHeight; })()'); }); it('says so when nothing matches', function () { - ready(visit('/material')) - ->click('#showcase-search') - ->type('#showcase-search', 'zzzz') + $page = ready(visit('/material')); + + // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#showcase-search'); + + $page->type('#showcase-search', 'zzzz') ->assertSeeIn('[data-md-search-view]', 'Nothing matches'); }); diff --git a/tests/Browser/TimepickerTest.php b/tests/Browser/TimepickerTest.php index 3e737231..dfcc6e9c 100644 --- a/tests/Browser/TimepickerTest.php +++ b/tests/Browser/TimepickerTest.php @@ -73,10 +73,13 @@ function supportText(string $id): string } it('opens the dial on the hour, as the bound time says', function () { - timeProbe() - ->assertValue('#meeting', '9:30 AM') - ->click('#meeting') - ->assertScript("document.querySelector('#meeting-dialog').open") + $page = timeProbe() + ->assertValue('#meeting', '9:30 AM'); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); + + $page->assertScript("document.querySelector('#meeting-dialog').open") ->assertAttribute('#meeting', 'aria-expanded', 'true') ->assertSeeIn('#meeting-title', 'Select time') ->assertAttribute('#meeting-dialog [data-md-timepicker-box="hour"]', 'aria-pressed', 'true') @@ -90,9 +93,12 @@ it('opens the dial on the hour, as the bound time says', function () { it('writes the hour and the minute pressed on the dial to Livewire on OK', function () { $dial = inPicker('meeting', '[data-md-timepicker-dial]'); - $page = timeProbe() - ->click('#meeting') - ->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="3"]') + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); + + $page->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="3"]') ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '03') ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute') ->assertAttribute('#meeting-dialog [data-md-timepicker-box="minute"]', 'aria-pressed', 'true') @@ -108,34 +114,49 @@ it('writes the hour and the minute pressed on the dial to Livewire on OK', funct $page->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="45"]') ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="minute"]', '45') - ->assertSeeIn('#meeting-value', '09:30') - ->click('#meeting-dialog [data-md-timepicker-confirm]') - ->assertSeeIn('#meeting-value', '03:45') + ->assertSeeIn('#meeting-value', '09:30'); + + // The confirm button closes the dialog: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting-dialog [data-md-timepicker-confirm]'); + + $page->assertSeeIn('#meeting-value', '03:45') ->assertValue('#meeting', '3:45 AM') ->assertScript("! document.querySelector('#meeting-dialog').open") ->assertAttribute('#meeting', 'aria-expanded', 'false'); }); it('leaves the time alone when the picker is cancelled', function () { - timeProbe() - ->click('#meeting') - ->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="7"]') - ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '07') - ->click('#meeting-dialog [data-md-timepicker-cancel]') - ->assertScript("! document.querySelector('#meeting-dialog').open") + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); + + $page->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="7"]') + ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '07'); + + // The cancel button closes the dialog: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting-dialog [data-md-timepicker-cancel]'); + + $page->assertScript("! document.querySelector('#meeting-dialog').open") ->assertSeeIn('#meeting-value', '09:30') ->assertValue('#meeting', '9:30 AM'); }); it('turns three o\'clock into 15 with PM', function () { - timeProbe() - ->click('#meeting') - ->click('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]') + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); + + $page->click('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]') ->assertAttribute('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]', 'aria-checked', 'true') ->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="3"]') - ->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="15"]') - ->click('#meeting-dialog [data-md-timepicker-confirm]') - ->assertSeeIn('#meeting-value', '15:15') + ->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="15"]'); + + // The confirm button closes the dialog: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting-dialog [data-md-timepicker-confirm]'); + + $page->assertSeeIn('#meeting-value', '15:15') ->assertValue('#meeting', '3:15 PM'); }); @@ -150,9 +171,12 @@ it('draws a German dial with 24 hours, 12 to 23 on the inner ring', function () })() JS; - $page = timeProbe() - ->click('#alarm') - ->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-cycle', '24') + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#alarm'); + + $page->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-cycle', '24') ->assertScript("getComputedStyle(document.querySelector('#alarm-dialog [data-md-timepicker-period]')).display === 'none'") ->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"0\"]').textContent.trim() === '00'") ->assertScript($radius('0').' === 101') @@ -164,24 +188,33 @@ it('draws a German dial with 24 hours, 12 to 23 on the inner ring', function () $page->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="15"]') ->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '15') ->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute') - ->click('#alarm-dialog [data-md-timepicker-label="minute"][data-md-value="0"]') - ->click('#alarm-dialog [data-md-timepicker-confirm]') - ->assertSeeIn('#alarm-value', '15:00') + ->click('#alarm-dialog [data-md-timepicker-label="minute"][data-md-value="0"]'); + + // The confirm button closes the dialog: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#alarm-dialog [data-md-timepicker-confirm]'); + + $page->assertSeeIn('#alarm-value', '15:00') ->assertValue('#alarm', '15:00'); // The outer ring is the morning. - $page->click('#alarm') - ->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="3"]') + pressOnce($page)->click('#alarm'); + + $page->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="3"]') ->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '03') - ->click('#alarm-dialog [data-md-timepicker-label="minute"][data-md-value="0"]') - ->click('#alarm-dialog [data-md-timepicker-confirm]') - ->assertSeeIn('#alarm-value', '03:00'); + ->click('#alarm-dialog [data-md-timepicker-label="minute"][data-md-value="0"]'); + + pressOnce($page)->click('#alarm-dialog [data-md-timepicker-confirm]'); + + $page->assertSeeIn('#alarm-value', '03:00'); }); it('follows a drag round the dial and settles on the nearest hour', function () { $dial = inPicker('meeting', '[data-md-timepicker-dial]'); - $page = timeProbe()->click('#meeting'); + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); // Pointer events built in the page's own realm, from twelve o'clock round to just past four. $send = fn (string $type, int $degrees): string => <<script("document.querySelector('#meeting').focus()"); - $page->keys('#meeting', 'Enter') - ->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]')) + // Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#meeting', 'Enter'); + + $page->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]')) ->keys(':focus', 'ArrowUp') ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '10') ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '10') @@ -233,16 +268,22 @@ it('changes the focused hour and minute with the arrow keys and confirms with En $page->keys(':focus', ['ArrowDown', 'ArrowLeft']) ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '28') - ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuetext', '28 minutes') - ->keys(':focus', 'Enter') - ->assertScript("! document.querySelector('#meeting-dialog').open") + ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuetext', '28 minutes'); + + // Enter on the dial confirms and closes the dialog: pressOnce(), tests/Pest.php. + pressOnce($page)->keys(':focus', 'Enter'); + + $page->assertScript("! document.querySelector('#meeting-dialog').open") ->assertSeeIn('#meeting-value', '10:28'); }); it('takes a typed time in the input variant and says what is wrong with it', function () { - $page = timeProbe() - ->click('#meeting') - ->click('#meeting-dialog [data-md-timepicker-mode="input"]') + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); + + $page->click('#meeting-dialog [data-md-timepicker-mode="input"]') ->assertSeeIn('#meeting-title', 'Enter time') ->assertScript("document.activeElement.id === 'meeting-hour'") ->assertValue('#meeting-hour', '09') @@ -251,9 +292,13 @@ it('takes a typed time in the input variant and says what is wrong with it', fun $page->type('#meeting-hour', '13') ->assertAttribute('#meeting-hour', 'aria-invalid', 'true') // Word joiners keep the range on one line in the narrow column. - ->assertScript(supportText('meeting-hour')." === 'Hour must be 1–12'") - ->click('#meeting-dialog [data-md-timepicker-confirm]') - ->assertScript("document.querySelector('#meeting-dialog').open") + ->assertScript(supportText('meeting-hour')." === 'Hour must be 1–12'"); + + // The confirm button closes the dialog when the time is valid; here it stays open (invalid), + // but pressed once anyway: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting-dialog [data-md-timepicker-confirm]'); + + $page->assertScript("document.querySelector('#meeting-dialog').open") ->assertSeeIn('#meeting-value', '09:30'); $page->clear('#meeting-hour') @@ -275,9 +320,12 @@ it('takes a typed time in the input variant and says what is wrong with it', fun it('keeps to the step and the limits', function () { $dial = inPicker('slot', '[data-md-timepicker-dial]'); - $page = timeProbe() - ->click('#slot') - ->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"8\"]').hasAttribute('data-md-disabled')") + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#slot'); + + $page->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"8\"]').hasAttribute('data-md-disabled')") ->assertScript("! {$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"9\"]').hasAttribute('data-md-disabled')") ->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"18\"]').hasAttribute('data-md-disabled')") // An hour outside the limits is not taken. @@ -299,12 +347,18 @@ it('keeps to the step and the limits', function () { ->assertSeeIn('#slot-dialog [data-md-timepicker-range-error]', 'Choose a time from 09:00 to 17:00') ->type('#slot-hour', '16') ->type('#slot-minute', '20') - ->assertSeeIn('#slot-minute-support', 'Minute must be a multiple of 15') - ->click('#slot-dialog [data-md-timepicker-confirm]') - ->assertScript("document.activeElement.id === 'slot-minute'") - ->type('#slot-minute', '45') - ->click('#slot-dialog [data-md-timepicker-confirm]') - ->assertSeeIn('#slot-value', '16:45'); + ->assertSeeIn('#slot-minute-support', 'Minute must be a multiple of 15'); + + // The confirm button closes the dialog when the time is valid; here it stays open (invalid), + // but pressed once anyway: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#slot-dialog [data-md-timepicker-confirm]'); + + $page->assertScript("document.activeElement.id === 'slot-minute'") + ->type('#slot-minute', '45'); + + pressOnce($page)->click('#slot-dialog [data-md-timepicker-confirm]'); + + $page->assertSeeIn('#slot-value', '16:45'); }); it('gives focus back to the field on Escape', function () { @@ -312,8 +366,10 @@ it('gives focus back to the field on Escape', function () { $page->script("document.querySelector('#meeting').focus()"); - $page->keys('#meeting', 'Enter') - ->assertScript("document.querySelector('#meeting-dialog').open") + // Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php. + pressOnce($page)->keys('#meeting', 'Enter'); + + $page->assertScript("document.querySelector('#meeting-dialog').open") ->keys(':focus', 'ArrowUp') ->keys(':focus', 'Escape') ->assertScript("! document.querySelector('#meeting-dialog').open") @@ -322,9 +378,12 @@ it('gives focus back to the field on Escape', function () { }); it('stays open with its draft through a Livewire render', function () { - $page = timeProbe() - ->click('#meeting') - ->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="5"]') + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); + + $page->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="5"]') ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute'); $page->script('window.eval("Livewire.first().touch()")'); @@ -333,18 +392,23 @@ it('stays open with its draft through a Livewire render', function () { ->assertScript("document.querySelector('#meeting-dialog').open") ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '05') ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute') - ->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="10"]') - ->click('#meeting-dialog [data-md-timepicker-confirm]') - ->assertSeeIn('#meeting-value', '05:10'); + ->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="10"]'); + + pressOnce($page)->click('#meeting-dialog [data-md-timepicker-confirm]'); + + $page->assertSeeIn('#meeting-value', '05:10'); }); it('moves the period selector between AM and PM with arrow keys, as a radio group', function () { $am = '#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="am"]'; $pm = '#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]'; - $page = timeProbe() - ->click('#meeting') - ->assertAttribute($am, 'role', 'radio') + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); + + $page->assertAttribute($am, 'role', 'radio') ->assertAttribute($am, 'aria-checked', 'true') ->assertAttribute($pm, 'aria-checked', 'false') // show() moves focus to the dial from inside Alpine's $nextTick, which Livewire's bundled @@ -356,25 +420,32 @@ it('moves the period selector between AM and PM with arrow keys, as a radio grou $page->script("document.querySelector('{$am}').focus()"); - $page->keys(':focus', 'ArrowRight') - ->assertAttribute($pm, 'aria-checked', 'true') + // A two-option radio group: a second press of the same arrow cycles back: pressOnce(), + // tests/Pest.php. + pressOnce($page)->keys(':focus', 'ArrowRight'); + + $page->assertAttribute($pm, 'aria-checked', 'true') ->assertAttribute($am, 'aria-checked', 'false') ->assertScript("document.activeElement.dataset.mdTimepickerPeriodOption === 'pm'") // M3's radio-in-a-list: an arrow key moves to the other option and selects it, so PM // reaches the dial straight away — the hour turns 9 into 21 without a confirm. ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '09'); - $page->keys(':focus', 'ArrowLeft') - ->assertAttribute($am, 'aria-checked', 'true') + pressOnce($page)->keys(':focus', 'ArrowLeft'); + + $page->assertAttribute($am, 'aria-checked', 'true') ->assertAttribute($pm, 'aria-checked', 'false') ->assertScript("document.activeElement.dataset.mdTimepickerPeriodOption === 'am'"); }); it('lies the dial on its side in a short landscape window', function () { $page = timeProbe() - ->resize(700, 400) - ->click('#meeting') - ->assertScript("document.querySelector('#meeting-dialog').open"); + ->resize(700, 400); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); + + $page->assertScript("document.querySelector('#meeting-dialog').open"); // Side by side rather than stacked: the display (216px wide) sits centred in the dial's row, // inside its height, with the dial 36px after it, and the picker's own layout (flex column) @@ -407,7 +478,10 @@ it('keeps the dial and the boxes their fixed pixel size at a 20px root font size $dialWidth = "Math.round({$dial}.getBoundingClientRect().width)"; $font = "getComputedStyle({$hourBox}).fontSize"; - $page = timeProbe()->click('#meeting'); + $page = timeProbe(); + + // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php. + pressOnce($page)->click('#meeting'); // After the dialog's entry, not during it: mid-scale the dial measures 0.95 of its own size, // which would make the resting width this test compares against a number the CSS never sets. diff --git a/tests/Pest.php b/tests/Pest.php index db5aa831..f3206dec 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -67,12 +67,23 @@ function slowMotion(mixed $page, string $duration = '3s'): mixed $css = collect($tokens)->map(fn (string $token): string => "{$token}: {$duration} !important;")->implode(' '); $page->script(<<:root { {$css} }') + document.head.insertAdjacentHTML('beforeend', '') JS); return $page; } +/** + * Takes slowMotion() back off, for a test that samples one exit and then goes on to time the next + * against the real durations. + */ +function fullSpeed(mixed $page): mixed +{ + $page->script("document.querySelectorAll('style[data-slow-motion]').forEach((style) => style.remove())"); + + return $page; +} + /** * A JS expression answering whether `$element` has finished whatever it animates — chained as an * assertScript() after an open, before a test stretches motion and samples the exit, or before it @@ -132,6 +143,10 @@ function onceInPage(string $expression): string * a menu, or the full-screen range picker, which re-renders every day of every month it holds on * each press. Assert on `$page` again. The plugin retrying every action is the cause, and every * press in the suite is exposed to it; these are the ones slow and undoable enough to have shown it. + * + * The rule is every press that opens a covering layer (a dialog, a sheet, a full-screen view, a + * modal picker, a menu) or changes state a second press would alter (a toggle, a day cell, a + * paging key, a chip or switch, a grip, a Save or close), not only the ones a failure has caught. */ function pressOnce(mixed $page): Webpage {