Press once whatever a second press would open over or undo
tests / feature (8.4) (push) Successful in 1m50s
tests / feature (8.5) (push) Successful in 1m54s
tests / browser (chrome, chromium) (push) Successful in 7m52s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Successful in 12m35s

The browser plugin runs every call on a page again when its first
attempt takes over a second, and on the runner a press can. For most
presses that costs nothing. For two kinds it breaks the test: a press
that opens a dialog, a sheet, a full-screen view or a modal rail over
its own trigger, whose second press can never land, and a press that
changes state a second one would change again — a menu trigger, a
toggle, a chip, a range picker's day, a paging key, a Save. The bottom
sheet with preset heights timed out on WebKit that way after the date
picker had on Firefox.

Every such press in the browser suite now goes through pressOnce(), 253
of them across sixteen files, not only the ones the runner happened to
catch; focus moves inside an open view, Escape, links and plain "set"
actions keep the retry, which is harmless for them.

Two samples that were still racing the machine:

- The standard side sheet's exit is caught half-way with its motion
  stretched, as every other mid-exit sample is, and fullSpeed() takes
  the stretch off again before the test times a reopen against the real
  exit. Under load on Linux WebKit it had failed two runs in five; it
  passes ten in ten.
- The switch-and-checkbox row measures its widths once, so it now waits
  for the resize to land and the brand face to load before it does.

Browser 300 passed on Firefox and WebKitGTK in a Linux container held to
two busy cores, and on Chrome, Firefox and WebKit on macOS. Feature 1159
passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
surtic86
2026-09-18 16:15:25 +02:00
co-authored by Claude Opus 5
parent ad230e0f48
commit ebdc2ef2e1
17 changed files with 1170 additions and 578 deletions
+188 -100
View File
@@ -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'")
+46 -24
View File
@@ -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()
+48 -29
View File
@@ -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));
+7 -4
View File
@@ -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) {
+32 -16
View File
@@ -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')})");
+5 -3
View File
@@ -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')")
+48 -25
View File
@@ -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', '<style>:root { --md-sys-motion-effects-fast-duration: 1500ms; }</style>')");
$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; })()");
});
+156 -71
View File
@@ -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(<<<JS
@@ -272,7 +287,8 @@ it('marks a scrolling dialog\'s divider at the edge with more content, and a nes
// Short and narrow so the probes' paragraphs genuinely overflow their body.
$page = nestedDialogProbe()->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(
<<<JS
const root = {$root}
@@ -558,6 +588,9 @@ it('gives a closing standard side sheet\'s room back to the content beside it as
$page->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 <summary> 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 <summary> 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 <summary> 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')");
});
+11 -6
View File
@@ -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('2125 of 25')
->assertSeeIn('[data-md-pagination] [aria-current="page"]', '3');
$page->click('[data-md-pagination] button[aria-label="Previous"]')
->assertSee('1120 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('1120 of 25');
$page->resize(400, 800)
->assertSee('Page 2 of 3');
+163 -82
View File
@@ -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);
+65 -31
View File
@@ -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])
+29 -14
View File
@@ -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();
+81 -38
View File
@@ -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'");
+114 -54
View File
@@ -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')
+14 -7
View File
@@ -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]', '<x-datepicker>');
@@ -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');
});
+147 -73
View File
@@ -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 => <<<JS
@@ -216,8 +249,10 @@ it('changes the focused hour and minute with the arrow keys and confirms with En
$page->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 112'")
->click('#meeting-dialog [data-md-timepicker-confirm]')
->assertScript("document.querySelector('#meeting-dialog').open")
->assertScript(supportText('meeting-hour')." === 'Hour must be 112'");
// 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.
+16 -1
View File
@@ -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(<<<JS
document.head.insertAdjacentHTML('beforeend', '<style>:root { {$css} }</style>')
document.head.insertAdjacentHTML('beforeend', '<style data-slow-motion>:root { {$css} }</style>')
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
{