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
{