8 Commits
Author SHA1 Message Date
surtic86andClaude Opus 5 a7aeb3c4ec Ask for PHP 8.5 and test one runtime
tests / feature (push) Successful in 1m55s
tests / browser (chrome, chromium) (push) Successful in 7m48s
tests / browser (firefox, firefox) (push) Successful in 11m57s
tests / browser (safari, webkit) (push) Successful in 12m44s
Nothing in the package needs a feature of either version: the floor sat
at 8.4 because that is where the scaffold put it, and Laravel 13 itself
asks only for 8.3. Two entries in the feature matrix bought a second run
of the same suite on a runtime the code cannot tell apart from the one
beside it.

2.3.0 asks for 8.5. The feature job loses its matrix and runs on 8.5
alone, so pint no longer needs the `if` that kept it from running twice;
the browser jobs were already pinned there. 2.2.0 stays available for an
application still on 8.4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 21:41:03 +02:00
surtic86andClaude Opus 5 ebdc2ef2e1 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>
2026-09-18 16:15:25 +02:00
surtic86andClaude Opus 5 ad230e0f48 Keep the full-screen range picker's months where they were as it grows
tests / feature (8.4) (push) Successful in 1m46s
tests / feature (8.5) (push) Successful in 1m53s
tests / browser (chrome, chromium) (push) Successful in 7m46s
tests / browser (firefox, firefox) (push) Successful in 11m56s
tests / browser (safari, webkit) (push) Failing after 13m58s
When the full-screen range picker adds months above the ones on screen,
extendMonths() puts the scroll back by however much the list grew. An
engine with scroll anchoring moved it back as well, so a reader paging up
landed half a year on, and a press meant for one day chose another. The
calendar now opts out of scroll anchoring (`overflow-anchor: none`) and
the picker's own correction is the only one, in every engine alike. A
test holds what sits under a fixed point of the list while it grows.

The browser plugin retries an action the way it retries an assertion:
when the page takes longer than a second to handle a press, it presses
again. On a loaded runner the full-screen picker, which re-renders every
day of every month it holds, took that long — so its toggle was pressed
a second time under the picker it had just opened, and never landed, and
a day pressed twice became the range's end as well as its start.
`pressOnce()` sends a press that must not be repeated exactly once, with
Playwright's own wait for the element; the picker's toggle, its paging
keys, its days and its Save go through it, and so do the menu triggers
that toggle.

Browser 300 passed on Chrome, Firefox and WebKit on macOS, and the date
picker and menu tests 61 passed on Linux Firefox and WebKitGTK held to
two busy cores. Feature 1159 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:45:51 +02:00
Andreas Reinhold / reiniandClaude Opus 5 e7885ce1eb Keep the search view closed when its focus comes back late
tests / feature (8.4) (push) Successful in 1m49s
tests / feature (8.5) (push) Successful in 1m51s
tests / browser (chrome, chromium) (push) Successful in 7m34s
tests / browser (firefox, firefox) (push) Failing after 14m19s
tests / browser (safari, webkit) (push) Failing after 13m16s
On a slow machine Escape could close the full-screen search and have it
open again for good. A close hands focus back to the input — Escape does,
and so does the view's focus trap as it lets go — and `focused()` told
that return from someone coming to search by a 250ms wall-clock window.
Both returns run on frames, and a runner painting a few frames a second
took longer than that, so the returning focus opened the view again. A
`returning` flag now covers the close until the hand-back has actually
run, on the same frame, and the constant is gone.

`hold()` times the full-screen layout from the exit's own duration token,
the one Alpine's x-transition holds `display` for, instead of waiting a
frame or two for the exit's transitions to appear: `getAnimations()` is
empty both before an engine creates them and after they end, and a loaded
engine can leave a second between frames.

The browser tests were racing the same slow machine, reproduced in a
Linux container like the runner, with its Playwright Firefox and
WebKitGTK and two cores kept busy:

- The browser plugin retries every script and action with a one-second
  attempt until the budget runs out. An in-page sleep longer than that
  only ever passed on the last attempt, which is where the 47-50s tests
  came from, and a script that clicks was run again against a page that
  had moved on. `onceInPage()` runs such a script once however often it
  is retried; the long sleeps are plain retried conditions now.
- Under load WebKitGTK paints no frame while a tight setTimeout loop
  runs, so a sample loop saw the start value and then nothing.
  `caughtMidExit()` samples on animation frames, for 2.5s.
- "No animations running" is also true before an opening transition
  exists, so a close could be sampled from a scrim at 4% opacity.
  `settled()` waits two frames before it asks.

The workflow no longer uploads failure screenshots: Gitea's artifact
service timed out on every attempt, two minutes per red run, and the job
logs are readable without it.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit on
macOS, and on Firefox and WebKitGTK in the Linux container under load,
twice each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:49:17 +02:00
surtic86andClaude Opus 5 8da8b1fa20 Say what stood in the way when a click never lands
tests / feature (8.4) (push) Successful in 1m51s
tests / feature (8.5) (push) Successful in 1m53s
tests / browser (chrome, chromium) (push) Successful in 9m11s
tests / browser (firefox, firefox) (push) Failing after 19m44s
tests / browser (safari, webkit) (push) Failing after 17m36s
The two full-screen range picker tests give up after the full timeout on
the runner's Firefox, and nowhere else. Playwright waits for a click
target to be visible, still, enabled and on top, and reports none of
those when it gives up — so the failure says only that 45 seconds
passed.

Both tests now name the element the browser would hit at the toggle's own
centre before they press it (`hitTarget()`), so a covered or undrawn
control fails with what covered it rather than with a timeout. The run
also keeps the screenshots Playwright writes for a failure, as an
artifact: a page that only misbehaves there is otherwise invisible from a
workstation where all three engines pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 06:14:31 +02:00
surtic86andClaude Opus 5 1b38b39a47 Stop every test page asking for a favicon nothing serves
tests / feature (8.4) (push) Successful in 1m51s
tests / feature (8.5) (push) Successful in 1m57s
tests / browser (chrome, chromium) (push) Successful in 9m7s
tests / browser (firefox, firefox) (push) Failing after 15m45s
tests / browser (safari, webkit) (push) Failing after 16m52s
`ready()` waits for the network to go idle, and each probe page asked for
/favicon.ico, which its route does not answer: the server threw
NotFoundHttpException on the way, and on the runner the two full-screen
date picker tests spent their whole budget inside that wait rather than
in the click Pest named. Every probe page now carries `<link rel="icon"
href="data:,">`, so the browser asks for nothing.

The showcase's own head carries it too. It ships no icon, and the 404 was
the application's to answer.

The runner takes two and a half to three times as long as a workstation,
so BROWSER_TIMEOUT there goes from 15 to 45 seconds: a page that loads
the application's Vite entries, Alpine and Livewire had no headroom left.

Feature 1159 passed; Browser 299 passed on Chrome, Firefox and WebKit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 05:30:29 +02:00
Andreas Reinhold / reiniandClaude Opus 5 94d6e31432 Catch an exit half-way on a slow machine, not only on a fast one
tests / feature (8.4) (push) Successful in 1m45s
tests / feature (8.5) (push) Successful in 2m0s
tests / browser (chrome, chromium) (push) Successful in 8m6s
tests / browser (firefox, firefox) (push) Failing after 12m52s
tests / browser (safari, webkit) (push) Failing after 13m1s
The browser suite passes on every engine here and fails on the runner,
which takes three times as long: each failing test triggers a close and
then samples for an in-between state — a rail part-way out, a scrim
part-way faded, a menu's exit copy part-way sunk — and a starved runner
takes its one sample after the 150-650ms exit has already finished.

So the tests that assert *that* something animates now stretch every
motion duration token to three seconds first (`slowMotion()`, beside
`ready()` in tests/Pest.php; ActionsTest's own copy of it goes). The two
polling helpers grew their budget to match: the rail panel and the sheet
slide on emphasized-accelerate, which is under 1% of its travel at a
quarter of the way through, so a 300ms window no longer reached the
threshold once the exit itself was three seconds long.

The two full-screen date picker tests waited for a resize through
click()'s own retry, which ate the whole 15s budget on Firefox; they now
wait for the new width and a settled document first. The bottom sheet's
preset test waits for its entry to finish before pressing the grip.

`hold()` in search.js reads the view's animations a frame after the
closed state, as the rail's settle() does, but one frame is not always
enough: an engine that starts them on its next tick shows none, and the
full-screen layout would end at once, mid-exit. An empty list is now
asked again on the following frame.

Feature 1159 passed. Browser 299 passed on Chrome, Firefox and WebKit,
and again on WebKit under ten spinning cores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:57:43 +02:00
Andreas Reinhold / reiniandClaude Opus 5 145f60aa11 Measure the time picker's dial after its dialog has finished opening
tests / feature (8.4) (push) Successful in 1m51s
tests / feature (8.5) (push) Successful in 1m57s
tests / browser (chrome, chromium) (push) Successful in 7m45s
tests / browser (firefox, firefox) (push) Failing after 12m42s
tests / browser (safari, webkit) (push) Failing after 13m15s
The test that holds the dial to its pixel size at a 20px root font size
read the dial's width straight after the click, so on a loaded machine it
caught the dialog mid-entry: scale 0.95 of 256px rounds to 243, a number
the stylesheet never sets. The assertion then compared the settled dial
against that, and failed for the whole retry window.

It now waits for the dialog's own animations to finish before taking the
resting width, the way the sheet tests already wait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:42:54 +02:00
29 changed files with 1710 additions and 777 deletions
+3 -8
View File
@@ -14,11 +14,6 @@ jobs:
feature: feature:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.4', '8.5']
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -28,7 +23,7 @@ jobs:
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with: with:
php-version: ${{ matrix.php }} php-version: '8.5'
tools: composer:v2 tools: composer:v2
coverage: none coverage: none
@@ -45,7 +40,6 @@ jobs:
npm ci npm ci
- name: Check formatting - name: Check formatting
if: matrix.php == '8.5'
run: vendor/bin/pint --test run: vendor/bin/pint --test
- name: Run feature tests - name: Run feature tests
@@ -101,4 +95,5 @@ jobs:
- name: Run browser tests - name: Run browser tests
run: vendor/bin/pest --testsuite=Browser --browser ${{ matrix.browser }} run: vendor/bin/pest --testsuite=Browser --browser ${{ matrix.browser }}
env: env:
BROWSER_TIMEOUT: 15000 BROWSER_TIMEOUT: 45000
+1 -1
View File
@@ -12,7 +12,7 @@ No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Ch
## Requirements ## Requirements
PHP 8.4+, Laravel 13, Livewire 4, Vite, and Node (for `material:scheme`). PHP 8.5+, Laravel 13, Livewire 4, Vite, and Node (for `material:scheme`).
## Installation ## Installation
+6
View File
@@ -1,5 +1,11 @@
# Upgrading # Upgrading
## From 2.2.0 to 2.3.0
- **PHP 8.5 is required.** Composer refuses to install the package on 8.4. Nothing in the package's
code needs 8.5 — 2.2.0 runs on 8.4 and stays available for an application still on it — but the
package is now built and tested against one runtime rather than two.
## From 2.1.0 to 2.2.0 ## From 2.1.0 to 2.2.0
- **Run `php artisan view:clear` after upgrading.** The components are no longer registered under a - **Run `php artisan view:clear` after upgrading.** The components are no longer registered under a
+1 -1
View File
@@ -20,7 +20,7 @@
} }
], ],
"require": { "require": {
"php": "^8.4", "php": "^8.5",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"livewire/livewire": "^4.0" "livewire/livewire": "^4.0"
}, },
+6 -1
View File
@@ -189,11 +189,16 @@
padding: 0 var(--md-sys-measurement-space300) var(--md-sys-measurement-space200); padding: 0 var(--md-sys-measurement-space300) var(--md-sys-measurement-space200);
} }
/* The calendar is the only part that scrolls, with the weekday header held at its top. */ /* The calendar is the only part that scrolls, with the weekday header held at its top. It
opts out of the browser's scroll anchoring: when months are added above, extendMonths()
(resources/js/datepicker.js) puts the scroll back itself, in every engine alike, and an
engine that anchors as well moved it back a second time — six months on from where the
reader was. */
[data-md-datepicker-picker][data-md-presentation='full'] [data-md-datepicker-calendar] { [data-md-datepicker-picker][data-md-presentation='full'] [data-md-datepicker-calendar] {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
+1
View File
@@ -8060,6 +8060,7 @@
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
+4 -1
View File
@@ -625,7 +625,10 @@ document.addEventListener('alpine:init', () => {
/** /**
* Grows the window when the scroll comes near either end. Adding months above moves * Grows the window when the scroll comes near either end. Adding months above moves
* everything down, so the scroll is put back by however much the list grew. * everything down, so the scroll is put back by however much the list grew — here, and
* only here: the list opts out of the browser's scroll anchoring (datepicker.css), which
* Chrome, Firefox and WebKit would otherwise apply on top of this, and a Safari without
* `overflow-anchor` would not apply at all.
*/ */
extendMonths(event) { extendMonths(event) {
if (this.presentation !== 'full' || this.monthsFrom === null) { if (this.presentation !== 'full' || this.monthsFrom === null) {
+40 -30
View File
@@ -24,19 +24,15 @@
* transition has run, while the header bar fades out with it (search.css). Without it the bar went * transition has run, while the header bar fades out with it (search.css). Without it the bar went
* back to its resting form, or to nothing behind the icon, on the first frame of the exit, and the * back to its resting form, or to nothing behind the icon, on the first frame of the exit, and the
* fading view dropped to the docked layout. The focus trap lets go at the close itself, not at the * fading view dropped to the docked layout. The focus trap lets go at the close itself, not at the
* end of the exit, so its return of focus still lands inside RETURN_GUARD_MS. * end of the exit, so `returning` (close()) covers its return of focus like the field's own.
*/ */
import { upTo } from './breakpoints.js' import { upTo } from './breakpoints.js'
import { ms } from './util.js'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const CHOOSES = 'a[href], button:not([disabled]), [data-md-list-open]' const CHOOSES = 'a[href], button:not([disabled]), [data-md-list-open]'
// A close hands focus back to the input: Escape does, and so does the full-screen view's focus trap
// when it lets go, a moment later. Focus arriving this soon after a close is that, not someone
// coming to search.
const RETURN_GUARD_MS = 250
// A Livewire morph replaces the results in several mutations; wait for the batch to end before // A Livewire morph replaces the results in several mutations; wait for the batch to end before
// counting, so the live region speaks once. // counting, so the live region speaks once.
const SETTLE_MS = 120 const SETTLE_MS = 120
@@ -48,7 +44,8 @@ document.addEventListener('alpine:init', () => {
leaving: false, leaving: false,
leavings: 0, leavings: 0,
compact: false, compact: false,
closedAt: -Infinity, // A close is handing focus back; see close().
returning: false,
announcement: '', announcement: '',
// What is in the field, which is what tells suggestions from results. // What is in the field, which is what tells suggestions from results.
query: '', query: '',
@@ -126,51 +123,64 @@ document.addEventListener('alpine:init', () => {
}, },
focused() { focused() {
if (performance.now() - this.closedAt > RETURN_GUARD_MS) { if (!this.returning) {
this.show() this.show()
} }
}, },
/**
* `refocus` puts focus back where the view came from: the icon button, or the field itself.
*
* Either way a close hands focus back — the full-screen view's focus trap returns it as it
* lets go, whether this asked for it or not — and that focus must not read as someone
* coming to search, or the view would open again on its way out. `returning` covers it
* until the hand-back has run, rather than for a fixed stretch of wall-clock time after
* the close: both returns are scheduled on frames, and a runner painting a handful of
* frames a second takes longer over one than any such guard would allow, which left the
* view open for good.
*/
close(refocus = false) { close(refocus = false) {
const fromFullScreen = this.open && this.fullScreen const fromFullScreen = this.open && this.fullScreen
const back = refocus ? (this.$refs.trigger ?? this.$refs.input) : null
this.open = false this.open = false
this.closedAt = performance.now() this.returning = true
if (fromFullScreen) { if (fromFullScreen) {
this.hold() this.hold()
} }
if (refocus) { // A frame after the tick, as `expand()` waits: the icon button is `x-show`n, and Alpine
// Back to whatever opened the view: the icon button, or the field itself. A frame // only shows it on that frame, so a focus in the tick reaches a hidden button, which
// after the tick, as `expand()` waits: the icon button is `x-show`n, and Alpine only // Firefox and WebKit refuse (in Chrome the trap's own return had covered for it).
// shows it on that frame, so a focus in the tick reaches a hidden button, which this.$nextTick(() => requestAnimationFrame(() => {
// Firefox and WebKit refuse (in Chrome the trap's own return had covered for it). back?.focus()
const back = this.$refs.trigger ?? this.$refs.input this.returning = false
}))
this.$nextTick(() => requestAnimationFrame(() => back.focus()))
}
}, },
/** /**
* Keeps the full-screen layout for the length of the view's exit: until the animations the * Keeps the full-screen layout for the length of the view's exit — the duration search.css
* closed state starts, a frame on, have finished (at once when none run); reopening, which * gives it, which is also the one Alpine's `x-transition` holds its `display` for, so the
* counts `leavings` up, lets a pending end go by. * two end together. Reopening, which counts `leavings` up, lets a pending end go by, and
* reduced motion zeroes the token, which ends the hold on the next task, as it should.
*
* The duration rather than the exit's own animations: `getAnimations()` is empty both
* before an engine has created the transitions and after they have finished, and nothing
* on the element tells the two apart. Waiting a frame or two for them to appear only moves
* the guess — a loaded engine can leave a second between two frames, and holding for what
* it had not started yet ended the full-screen layout at once, mid-exit.
*/ */
hold() { hold() {
const leaving = ++this.leavings const leaving = ++this.leavings
this.leaving = true this.leaving = true
requestAnimationFrame(() => { setTimeout(() => {
const animations = this.$refs.view?.getAnimations() ?? [] if (leaving === this.leavings) {
this.leaving = false
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => { }
if (leaving === this.leavings) { }, ms(this.$refs.view, '--md-sys-motion-spatial-fast-duration') ?? 0)
this.leaving = false
}
})
})
}, },
clear() { clear() {
+4
View File
@@ -7,6 +7,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
{{-- The showcase is the package's own page and ships no icon: an empty data URI keeps the
browser from asking the application for /favicon.ico, which its routes need not answer. --}}
<link rel="icon" href="data:," />
<title>{{ $pageTitle }}</title> <title>{{ $pageTitle }}</title>
<x-livewire-material::theme-script /> <x-livewire-material::theme-script />
+217 -126
View File
@@ -111,6 +111,7 @@ function menuMorphProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -136,10 +137,12 @@ function focused(string $expression): string
} }
it('opens a menu on the first item and walks it with the keyboard', function () { it('opens a menu on the first item and walks it with the keyboard', function () {
$page = showcase('menus') $page = showcase('menus')->assertNoJavaScriptErrors();
->assertNoJavaScriptErrors()
->click(MORE) // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
->assertAttribute(MORE, 'aria-expanded', 'true') pressOnce($page)->click(MORE);
$page->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('Copy link')")); ->assertScript(focused("textContent.trim().startsWith('Copy link')"));
$page->keys(':focus', ['ArrowDown', 'ArrowDown']) $page->keys(':focus', ['ArrowDown', 'ArrowDown'])
@@ -164,8 +167,10 @@ it('opens a menu from the keyboard, on the last item with ArrowUp', function ()
$page->script("document.querySelector('".MORE."').focus()"); $page->script("document.querySelector('".MORE."').focus()");
$page->keys(':focus', 'ArrowUp') // ArrowUp on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php.
->assertAttribute(MORE, 'aria-expanded', 'true') pressOnce($page)->keys(':focus', 'ArrowUp');
$page->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('Delete')")); ->assertScript(focused("textContent.trim().startsWith('Delete')"));
}); });
@@ -173,7 +178,10 @@ it('shows no state layer on a disabled menu item even when keyboard focus reache
$trigger = '#menus button:has-text("Sort")'; $trigger = '#menus button:has-text("Sort")';
$reset = '#menus [role="menuitem"]:has-text("Reset")'; $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 // 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). // .md-state-layer withholds the layer itself from [aria-disabled="true"] (interaction.css).
@@ -186,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 () { 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 // 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. // time origin, and swallowed every click in the first quarter second.
visit('/material/menus') $page = visit('/material/menus');
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true'); // 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 () { it('closes a menu when an item is chosen, but not one that keeps it open', function () {
$page = showcase('menus'); $page = showcase('menus');
$page->click(MORE) // Opens the menu, then an item closes it: pressOnce(), tests/Pest.php.
->click('#menus [role="menuitem"]:has-text("Download")') pressOnce($page)->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'false'); ->click('#menus [role="menuitem"]:has-text("Download")');
$page->click('#menus button:has-text("Sort")') $page->assertAttribute(MORE, 'aria-expanded', 'false');
->click('#menus [role="menuitemcheckbox"]:has-text("Largest")')
->assertScript("document.querySelector('#menus [role=\"menu\"][aria-label=\"Sort\"]').matches(':popover-open')"); // 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 () { it('shows a tooltip on keyboard focus and hides it on Escape', function () {
@@ -236,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 () { it('rounds a split button\'s trailing half while its menu is open', function () {
$trailing = "document.querySelector('[data-md-split=\"trailing\"]')"; $trailing = "document.querySelector('[data-md-split=\"trailing\"]')";
showcase() $page = showcase();
->click('[data-md-split="trailing"] >> nth=0')
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'") // 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'"); ->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'");
}); });
@@ -254,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 () { it('turns the FAB into a close button while its menu is open', function () {
$fab = "document.querySelector('button[aria-label=\"New\"]')"; $fab = "document.querySelector('button[aria-label=\"New\"]')";
$page = showcase() $page = showcase();
->click('button[aria-label="New"]')
->assertScript("{$fab}.getAttribute('aria-expanded') === 'true'") // 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'")); ->assertScript(focused("textContent.trim() === 'Upload files'"));
$page->keys(':focus', 'Escape') $page->keys(':focus', 'Escape')
@@ -292,6 +314,7 @@ it('hangs a menu on its menu button, even when the button is fixed to a corner o
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -308,8 +331,10 @@ it('hangs a menu on its menu button, even when the button is fixed to a corner o
$page = ready(visit('/menu-anchor-probe')->resize(400, 800)); $page = ready(visit('/menu-anchor-probe')->resize(400, 800));
$page->click('@fab') // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
->assertAttribute('@fab', 'aria-expanded', 'true') pressOnce($page)->click('@fab');
$page->assertAttribute('@fab', 'aria-expanded', 'true')
->assertScript($above); ->assertScript($above);
$page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false'); $page->keys(':focus', 'Escape')->assertAttribute('@fab', 'aria-expanded', 'false');
@@ -321,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') $page->assertSeeIn('#renders', '1')
->script("document.querySelector('[data-test=\"fab\"]').focus()"); ->script("document.querySelector('[data-test=\"fab\"]').focus()");
$page->keys(':focus', 'ArrowDown') // ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php.
->assertAttribute('@fab', 'aria-expanded', 'true') pressOnce($page)->keys(':focus', 'ArrowDown');
$page->assertAttribute('@fab', 'aria-expanded', 'true')
->assertScript($above); ->assertScript($above);
// A button that also anchors its own tooltip keeps it, and the menu hangs under the button. // A button that also anchors its own tooltip keeps it, and the menu hangs under the button.
$page->resize(1024, 800) $page->resize(1024, 800);
->click('@more')
->assertAttribute('@more', 'aria-expanded', 'true') // 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("(() => { 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').')'); ->assertScript('(({ control, menu }) => menu.top >= control.bottom && menu.top - control.bottom <= 16 && Math.abs(menu.left - control.left) <= 16)('.menuAgainst('more', 'Share actions').')');
}); });
@@ -338,6 +368,7 @@ it('paints a group\'s hint and a menu item\'s icon in the colour their classes n
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
</head> </head>
@@ -366,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 () { it('keeps a menu open while the component around it renders, and closes it cleanly after', function () {
$page = menuMorphProbe()->assertNoJavaScriptErrors(); $page = menuMorphProbe()->assertNoJavaScriptErrors();
$page->click('@sort') // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
->assertAttribute('@sort', 'aria-expanded', 'true'); pressOnce($page)->click('@sort');
$page->assertAttribute('@sort', 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")'); $page->script('window.eval("Livewire.first().touch()")');
@@ -383,9 +416,11 @@ it('keeps a menu open while the component around it renders, and closes it clean
->assertScript(focused("dataset.test === 'sort'")); ->assertScript(focused("dataset.test === 'sort'"));
// Opened from the keyboard: a press this soon after the menu closed is taken for the // Opened from the keyboard: a press this soon after the menu closed is taken for the
// light-dismiss press and ignored. // light-dismiss press and ignored. ArrowDown on the trigger opens the menu, same as clicking
$page->keys('@sort', 'ArrowDown') // it: pressOnce(), tests/Pest.php.
->assertAttribute('@sort', 'aria-expanded', 'true'); pressOnce($page)->keys('@sort', 'ArrowDown');
$page->assertAttribute('@sort', 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")'); $page->script('window.eval("Livewire.first().touch()")');
@@ -399,15 +434,20 @@ it('closes a menu on a second press of its menu button, and after the component
$page = menuMorphProbe(); $page = menuMorphProbe();
// The press closes the menu before its click reaches the button: the guard against that click // 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. // opening it again once waited for the queued toggle event, which comes after the click. Each
$page->click('@sort') // press toggles it, so each is pressed once: pressOnce(), tests/Pest.php.
->assertAttribute('@sort', 'aria-expanded', 'true') pressOnce($page)->click('@sort');
->click('@sort')
->assertAttribute('@sort', 'aria-expanded', 'false') $page->assertAttribute('@sort', 'aria-expanded', 'true');
pressOnce($page)->click('@sort');
$page->assertAttribute('@sort', 'aria-expanded', 'false')
->assertScript('! '.SORT_MENU.".matches(':popover-open')"); ->assertScript('! '.SORT_MENU.".matches(':popover-open')");
$page->keys('@sort', 'ArrowDown') pressOnce($page)->keys('@sort', 'ArrowDown');
->assertAttribute('@sort', 'aria-expanded', 'true');
$page->assertAttribute('@sort', 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")'); $page->script('window.eval("Livewire.first().touch()")');
@@ -415,30 +455,36 @@ it('closes a menu on a second press of its menu button, and after the component
$page->assertSeeIn('#renders', '1') $page->assertSeeIn('#renders', '1')
->wait(0.3); ->wait(0.3);
$page->click('@sort') pressOnce($page)->click('@sort');
->assertAttribute('@sort', 'aria-expanded', 'false')
$page->assertAttribute('@sort', 'aria-expanded', 'false')
->assertScript('! '.SORT_MENU.".matches(':popover-open')"); ->assertScript('! '.SORT_MENU.".matches(':popover-open')");
}); });
it('keeps a menu open while a keep-open item\'s action runs', function () { it('keeps a menu open while a keep-open item\'s action runs', function () {
$page = menuMorphProbe(); $page = menuMorphProbe();
$page->click('@sort') // Opens the menu, then a checkbox item toggles: pressOnce(), tests/Pest.php.
->click('[role="menuitemcheckbox"]:has-text("Largest")') pressOnce($page)->click('@sort')
->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true') ->click('[role="menuitemcheckbox"]:has-text("Largest")');
$page->assertAttribute('[role="menuitemcheckbox"]:has-text("Largest")', 'aria-checked', 'true')
->assertScript(SORT_MENU.".matches(':popover-open')") ->assertScript(SORT_MENU.".matches(':popover-open')")
->assertAttribute('@sort', 'aria-expanded', 'true'); ->assertAttribute('@sort', 'aria-expanded', 'true');
$page->click('[role="menuitemcheckbox"]:has-text("Newest")') pressOnce($page)->click('[role="menuitemcheckbox"]:has-text("Newest")');
->assertAttribute('[role="menuitemcheckbox"]:has-text("Newest")', 'aria-checked', 'true')
$page->assertAttribute('[role="menuitemcheckbox"]:has-text("Newest")', 'aria-checked', 'true')
->assertScript(SORT_MENU.".matches(':popover-open')"); ->assertScript(SORT_MENU.".matches(':popover-open')");
}); });
it('keeps a FAB menu open while the component around it renders, and closes it cleanly after', function () { it('keeps a FAB menu open while the component around it renders, and closes it cleanly after', function () {
$page = menuMorphProbe(); $page = menuMorphProbe();
$page->click(NEW_FAB) // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
->assertAttribute(NEW_FAB, 'aria-expanded', 'true'); pressOnce($page)->click(NEW_FAB);
$page->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")'); $page->script('window.eval("Livewire.first().touch()")');
@@ -451,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') ->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
->assertScript(focused("getAttribute('aria-label') === 'New'")); ->assertScript(focused("getAttribute('aria-label') === 'New'"));
$page->keys(NEW_FAB, 'ArrowDown') // ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php.
->assertAttribute(NEW_FAB, 'aria-expanded', 'true'); pressOnce($page)->keys(NEW_FAB, 'ArrowDown');
$page->assertAttribute(NEW_FAB, 'aria-expanded', 'true');
$page->script('window.eval("Livewire.first().touch()")'); $page->script('window.eval("Livewire.first().touch()")');
@@ -460,23 +508,28 @@ it('keeps a FAB menu open while the component around it renders, and closes it c
$page->assertSeeIn('#renders', '2') $page->assertSeeIn('#renders', '2')
->wait(0.3); ->wait(0.3);
$page->click(NEW_FAB) pressOnce($page)->click(NEW_FAB);
->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
$page->assertAttribute(NEW_FAB, 'aria-expanded', 'false')
->assertScript('! '.FAB_MENU.".matches(':popover-open')"); ->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 () { it('closes a FAB menu when an item\'s action runs, and opens and closes it cleanly after', function () {
$page = menuMorphProbe(); $page = menuMorphProbe();
$page->click(NEW_FAB) // Opens the FAB menu, then an item closes it: pressOnce(), tests/Pest.php.
->click('[role="menuitem"]:has-text("Upload files")') pressOnce($page)->click(NEW_FAB)
->assertSeeIn('#created', 'files') ->click('[role="menuitem"]:has-text("Upload files")');
$page->assertSeeIn('#created', 'files')
->assertAttribute(NEW_FAB, 'aria-expanded', 'false'); ->assertAttribute(NEW_FAB, 'aria-expanded', 'false');
$page->script("document.querySelector('".NEW_FAB."').focus()"); $page->script("document.querySelector('".NEW_FAB."').focus()");
$page->keys(NEW_FAB, 'ArrowDown') // ArrowDown on the trigger opens the menu, same as clicking it: pressOnce(), tests/Pest.php.
->assertAttribute(NEW_FAB, 'aria-expanded', 'true') pressOnce($page)->keys(NEW_FAB, 'ArrowDown');
$page->assertAttribute(NEW_FAB, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim() === 'Upload files'")); ->assertScript(focused("textContent.trim() === 'Upload files'"));
$page->keys(':focus', 'Escape') $page->keys(':focus', 'Escape')
@@ -537,6 +590,7 @@ it('hides a disabled fab button below 600px, as M3 removes a FAB whose action is
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -563,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]); ->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 // A spinner disables its button while the action runs; that FAB is busy, not unavailable, and
// stays on screen with its loading indicator. // stays on screen with its loading indicator. A counter a retry would double: pressOnce(),
$page->click('#enabled-fab') // tests/Pest.php.
->assertScript("(() => { const button = document.getElementById('enabled-fab'); return button.hasAttribute('data-loading') && button.disabled && button.checkVisibility() && button.querySelector('[data-md-button-spinner]').checkVisibility(); })()") 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'); ->assertSeeIn('#created', '1');
$page->resize(600, 800)->assertScript("getComputedStyle(document.getElementById('enabled-fab')).position !== 'fixed'"); $page->resize(600, 800)->assertScript("getComputedStyle(document.getElementById('enabled-fab')).position !== 'fixed'");
@@ -612,6 +668,7 @@ function groupSizeProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -674,14 +731,19 @@ it('toggles each choice of a multi selection group, down to none', function () {
$group = '#buttons [data-md-selection="multi"]'; $group = '#buttons [data-md-selection="multi"]';
$marks = "document.querySelector('{$group}').parentElement.querySelectorAll('code')[1].textContent"; $marks = "document.querySelector('{$group}').parentElement.querySelectorAll('code')[1].textContent";
showcase() $page = showcase()
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true') ->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true');
->click("{$group} [aria-label=\"Italic\"]")
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true') // Each press toggles the choice: pressOnce(), tests/Pest.php.
->assertScript("{$marks} === 'bold, italic'") pressOnce($page)->click("{$group} [aria-label=\"Italic\"]");
->click("{$group} [aria-label=\"Bold\"]")
->click("{$group} [aria-label=\"Italic\"]") $page->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true')
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'false') ->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') ->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false')
->assertScript("{$marks} === 'none'"); ->assertScript("{$marks} === 'none'");
}); });
@@ -749,6 +811,7 @@ function longMenuProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -771,6 +834,7 @@ function sheetMenuProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -789,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")'; $trigger = '#menus button:has-text("Share")';
$sendTo = '#menus [role="menuitem"]:has-text("Send to")'; $sendTo = '#menus [role="menuitem"]:has-text("Send to")';
$page = showcase('menus') $page = showcase('menus');
->click($trigger)
->assertAttribute($trigger, 'aria-expanded', 'true') // 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')")); ->assertScript(focused("textContent.trim().startsWith('Copy link')"));
$page->keys(':focus', 'ArrowDown') $page->keys(':focus', 'ArrowDown')
@@ -824,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" is an item in five menus on the page; only the open one's is visible.
$delete = '#menus [role="menuitem"]:has-text("Delete"):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) $page->hover($sendTo)
->wait(0.3) ->wait(0.3)
@@ -847,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"; $ada = "{$list}.find((item) => item.textContent.trim().startsWith('Ada Lovelace')).id";
$grace = "{$list}.find((item) => item.textContent.trim().startsWith('Grace Hopper')).id"; $grace = "{$list}.find((item) => item.textContent.trim().startsWith('Grace Hopper')).id";
$page = showcase('menus') $page = showcase('menus');
->click($trigger)
->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($trigger);
$page->assertScript(focused("getAttribute('aria-label') === 'Find a person'"));
// Ada Lovelace and Grace Hopper are both "Engineering"; the rest are Research or Networks. // Ada Lovelace and Grace Hopper are both "Engineering"; the rest are Research or Networks.
$page->type($field, 'engineering') $page->type($field, 'engineering')
@@ -863,8 +936,9 @@ it('filters a menu\'s items as its field is typed into, moves the highlight with
->assertAttribute($trigger, 'aria-expanded', 'false'); ->assertAttribute($trigger, 'aria-expanded', 'false');
// Closing clears the query, so a reopen shows the whole list again. // Closing clears the query, so a reopen shows the whole list again.
$page->click($trigger) pressOnce($page)->click($trigger);
->assertScript("document.querySelector('{$field}').value === ''")
$page->assertScript("document.querySelector('{$field}').value === ''")
->assertScript("{$list}.every((item) => !item.hidden)"); ->assertScript("{$list}.every((item) => !item.hidden)");
}); });
@@ -879,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 = showcase('menus')->resize(400, 800);
$page->click($trigger) // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
->assertAttribute($trigger, 'aria-haspopup', 'dialog') pressOnce($page)->click($trigger);
$page->assertAttribute($trigger, 'aria-haspopup', 'dialog')
->assertScript(focused("textContent.trim().startsWith('Set as wallpaper')")) ->assertScript(focused("textContent.trim().startsWith('Set as wallpaper')"))
->assertVisible($dialog); ->assertVisible($dialog);
@@ -899,11 +975,14 @@ it('opens a sheet-at-compact menu below 600px, focused on its first item, and th
->assertScript(focused("textContent.trim() === 'Photo'")); ->assertScript(focused("textContent.trim() === 'Photo'"));
// From 600px the trigger opens the popover instead, and a resize across it while one is open // From 600px the trigger opens the popover instead, and a resize across it while one is open
// closes whichever was shown. // closes whichever was shown. The trigger says which it opens now before it is pressed — and
// pressed once, as it toggles: pressOnce() in tests/Pest.php.
$page->resize(900, 800) $page->resize(900, 800)
->click($trigger) ->assertAttribute($trigger, 'aria-haspopup', 'menu');
->assertAttribute($trigger, 'aria-haspopup', 'menu')
->assertAttribute($trigger, 'aria-expanded', 'true'); pressOnce($page)->click($trigger);
$page->assertAttribute($trigger, 'aria-expanded', 'true');
$page->resize(400, 800) $page->resize(400, 800)
->assertAttribute($trigger, 'aria-expanded', 'false'); ->assertAttribute($trigger, 'aria-expanded', 'false');
@@ -912,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 () { it('filters in the sheet too, and a Livewire render keeps it open with the field\'s focus', function () {
$page = sheetMenuProbe(); $page = sheetMenuProbe();
$page->click('button:has-text("Assign")') // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
->assertScript(focused("getAttribute('aria-label') === 'Find a person'")); pressOnce($page)->click('button:has-text("Assign")');
$page->assertScript(focused("getAttribute('aria-label') === 'Find a person'"));
$page->type(':focus', 'grace') $page->type(':focus', 'grace')
->assertScript("[...document.querySelectorAll('[role=\"dialog\"] [role=\"menuitem\"]')].filter((item) => !item.hidden).length === 1"); ->assertScript("[...document.querySelectorAll('[role=\"dialog\"] [role=\"menuitem\"]')].filter((item) => !item.hidden).length === 1");
@@ -931,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 () { 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\"]')"; $popover = "document.querySelector('[role=\"menu\"][aria-label=\"Long list\"]')";
$page = longMenuProbe() $page = longMenuProbe();
->click('button:has-text("Open")')
->assertScript("{$popover}.scrollHeight > {$popover}.clientHeight"); // 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') $page->keys(':focus', 'End')
->assertScript(focused("textContent.trim() === 'Item 20'")) ->assertScript(focused("textContent.trim() === 'Item 20'"))
@@ -964,6 +1048,7 @@ function longFabMenuProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -982,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\"]')"; $trigger = "document.querySelector('button[aria-label=\"New\"]')";
$list = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')"; $list = "document.querySelector('[role=\"menu\"][aria-label=\"New\"]')";
$page = longFabMenuProbe() $page = longFabMenuProbe();
->click('button[aria-label="New"]')
->assertAttribute('button[aria-label="New"]', 'aria-expanded', 'true') // 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"); ->assertScript("{$list}.scrollHeight > {$list}.clientHeight");
$page->script("window.__fabTop = {$trigger}.getBoundingClientRect().top; {$list}.scrollTop = 40"); $page->script("window.__fabTop = {$trigger}.getBoundingClientRect().top; {$list}.scrollTop = 40");
@@ -993,20 +1081,6 @@ it('scrolls the FAB menu\'s items on a short window, behind the close button, wh
$page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop"); $page->assertScript("{$trigger}.getBoundingClientRect().top === window.__fabTop");
}); });
/**
* Slows the page's motion tokens, so a popover's exit copy (resources/js/popover-exit.js) is
* still on screen after a separate round trip to the browser: a key press or a click already
* costs about as long as a 150–350ms exit, and the copy moves on the tokens as the popover would.
*/
function slowMotion(mixed $page, string $duration = '1500ms'): mixed
{
$page->script(<<<JS
document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-effects-fast-duration: {$duration}; --md-sys-motion-effects-default-duration: {$duration}; --md-sys-motion-spatial-fast-duration: {$duration}; --md-sys-motion-spatial-default-duration: {$duration}; }</style>')
JS);
return $page;
}
/** The exit copy on screen: shown, inert, hidden from assistive technology, with no ids of its own. */ /** The exit copy on screen: shown, inert, hidden from assistive technology, with no ids of its own. */
const EXIT_COPY = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); return copy !== null && copy.matches(':popover-open') && copy.inert && copy.getAttribute('aria-hidden') === 'true' && ! copy.hasAttribute('id') && copy.querySelector('[id], [popover]:not([data-md-popover-ghost])') === null; })()"; const EXIT_COPY = "(() => { const copy = document.querySelector('[data-md-popover-ghost]'); return copy !== null && copy.matches(':popover-open') && copy.inert && copy.getAttribute('aria-hidden') === 'true' && ! copy.hasAttribute('id') && copy.querySelector('[id], [popover]:not([data-md-popover-ghost])') === null; })()";
@@ -1016,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 () { 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\'))'; $menu = 'document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\'))';
$page = slowMotion(showcase('menus')) $page = slowMotion(showcase('menus'));
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true'); // 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. // Escape: the browser's own light dismiss, which no script can hold open.
$page->keys(':focus', 'Escape') $page->keys(':focus', 'Escape')
@@ -1029,13 +1106,17 @@ it('fades a menu out after the browser has closed it, on Escape or a press outsi
->assertScript(EXIT_COPY_FADING) ->assertScript(EXIT_COPY_FADING)
// The copy is decoration: Alpine starts nothing inside it. // The copy is decoration: Alpine starts nothing inside it.
->assertScript("window.eval(\"[...document.querySelectorAll('[data-md-popover-ghost], [data-md-popover-ghost] *')].every((element) => element._x_dataStack === undefined)\")") ->assertScript("window.eval(\"[...document.querySelectorAll('[data-md-popover-ghost], [data-md-popover-ghost] *')].every((element) => element._x_dataStack === undefined)\")")
// Gone once the slowest of its transitions has run. // Gone once the slowest of its transitions has run: assertScript's own retry waits for it,
->assertScript('new Promise((resolve) => setTimeout(() => resolve(document.querySelector("[data-md-popover-ghost]") === null), 1800))'); // where sleeping for the exit's length inside the page outlived the plugin's 1000ms budget
// for one script attempt and had the whole expectation run again until it timed out.
->assertScript('document.querySelector("[data-md-popover-ghost]") === null');
// A press outside it. // A press outside it.
$page->click(MORE) pressOnce($page)->click(MORE);
->assertAttribute(MORE, 'aria-expanded', 'true')
->click('#menus') $page->assertAttribute(MORE, 'aria-expanded', 'true');
$page->click('#menus')
->assertAttribute(MORE, 'aria-expanded', 'false') ->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript(EXIT_COPY) ->assertScript(EXIT_COPY)
->assertScript(EXIT_COPY_FADING) ->assertScript(EXIT_COPY_FADING)
@@ -1043,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 () { it('takes a menu\'s exit copy away when the menu opens again part-way through it', function () {
$page = slowMotion(showcase('menus')) $page = slowMotion(showcase('menus'));
->click(MORE);
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
pressOnce($page)->click(MORE);
$page->keys(':focus', 'Escape') $page->keys(':focus', 'Escape')
->assertScript(EXIT_COPY) ->assertScript(EXIT_COPY)
@@ -1053,15 +1136,18 @@ it('takes a menu\'s exit copy away when the menu opens again part-way through it
->wait(0.3) ->wait(0.3)
->assertScript(EXIT_COPY); ->assertScript(EXIT_COPY);
$page->click(MORE) pressOnce($page)->click(MORE);
->assertAttribute(MORE, 'aria-expanded', 'true')
$page->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript("document.querySelector('[data-md-popover-ghost]') === null") ->assertScript("document.querySelector('[data-md-popover-ghost]') === null")
->assertScript('document.getElementById(document.querySelector(\''.MORE.'\').getAttribute(\'aria-controls\')).matches(\':popover-open\')'); ->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 () { it('leaves no exit copy under reduced motion, where every duration token is zero', function () {
$page = slowMotion(showcase('menus'), '0ms') $page = slowMotion(showcase('menus'), '0ms');
->click(MORE);
// A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
pressOnce($page)->click(MORE);
$page->keys(':focus', 'Escape') $page->keys(':focus', 'Escape')
->assertAttribute(MORE, 'aria-expanded', 'false') ->assertAttribute(MORE, 'aria-expanded', 'false')
@@ -1072,8 +1158,10 @@ it('fades a submenu out on its own, while its menu stays open', function () {
$trigger = '#menus button:has-text("Share")'; $trigger = '#menus button:has-text("Share")';
$sendTo = '#menus [role="menuitem"]:has-text("Send to")'; $sendTo = '#menus [role="menuitem"]:has-text("Send to")';
$page = slowMotion(showcase('menus')) $page = slowMotion(showcase('menus'));
->click($trigger);
// 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', 'ArrowDown');
$page->keys(':focus', 'ArrowRight') $page->keys(':focus', 'ArrowRight')
@@ -1102,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 () { it('sinks a FAB menu\'s items back after the menu has closed', function () {
$page = slowMotion(showcase()) $page = slowMotion(showcase());
->click('button[aria-label="New"]')
->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'true'"); // 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') $page->keys(':focus', 'Escape')
->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'false'") ->assertScript("document.querySelector('button[aria-label=\"New\"]').getAttribute('aria-expanded') === 'false'")
+51 -24
View File
@@ -71,6 +71,7 @@ function barsProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -93,8 +94,11 @@ it('moves between tabs with the arrow keys, skipping disabled ones, and tells Li
->assertAttribute('#share-people', 'tabindex', '0') ->assertAttribute('#share-people', 'tabindex', '0')
->assertAttribute('#share-files', 'tabindex', '-1'); ->assertAttribute('#share-files', 'tabindex', '-1');
$page->keys('#share-people', 'ArrowRight') // Each arrow key sends its own wire:model.live request and moves one tab further: pressOnce(),
->assertSeeIn('#tab', 'activity') // tests/Pest.php.
pressOnce($page)->keys('#share-people', 'ArrowRight');
$page->assertSeeIn('#tab', 'activity')
->assertScript("document.activeElement.id === 'share-activity'") ->assertScript("document.activeElement.id === 'share-activity'")
->assertScript("getComputedStyle(document.querySelector('#share-activity [data-md-tab-indicator]')).opacity === '1'"); ->assertScript("getComputedStyle(document.querySelector('#share-activity [data-md-tab-indicator]')).opacity === '1'");
@@ -102,8 +106,9 @@ it('moves between tabs with the arrow keys, skipping disabled ones, and tells Li
->assertSeeIn('#tab', 'files') ->assertSeeIn('#tab', 'files')
->assertScript("document.activeElement.id === 'share-files'"); ->assertScript("document.activeElement.id === 'share-files'");
$page->keys('#share-files', 'ArrowLeft') pressOnce($page)->keys('#share-files', 'ArrowLeft');
->assertSeeIn('#tab', 'activity');
$page->assertSeeIn('#tab', 'activity');
}); });
it('sends a request per click with wire:model.live, but nothing until then with plain wire:model', function () { it('sends a request per click with wire:model.live, but nothing until then with plain wire:model', function () {
@@ -165,23 +170,32 @@ it('switches the theme from a toggle, a cycle and a picker', function () {
->assertScript("document.documentElement.dataset.theme === 'light'") ->assertScript("document.documentElement.dataset.theme === 'light'")
->assertAttribute('#toggle', 'aria-pressed', 'false'); ->assertAttribute('#toggle', 'aria-pressed', 'false');
$page->click('#toggle') // A theme toggle flips on every press: pressOnce(), tests/Pest.php.
->assertScript("document.documentElement.dataset.theme === 'dark'") pressOnce($page)->click('#toggle');
$page->assertScript("document.documentElement.dataset.theme === 'dark'")
->assertAttribute('#toggle', 'aria-pressed', 'true') ->assertAttribute('#toggle', 'aria-pressed', 'true')
->assertScript('document.querySelector(\'input[name="material-theme"][value="dark"]\').checked'); ->assertScript('document.querySelector(\'input[name="material-theme"][value="dark"]\').checked');
$page->click('#cycle') // A cycling toggle a retry would cycle twice: pressOnce(), tests/Pest.php.
->assertScript("document.documentElement.dataset.themeChoice === 'system'") pressOnce($page)->click('#cycle');
->click('#cycle')
->assertScript("document.documentElement.dataset.themeChoice === 'light'"); $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 () { it('turns section tabs into a picker on a phone', function () {
barsProbe() $page = barsProbe()
->resize(400, 800) ->resize(400, 800)
->assertScript("getComputedStyle(document.querySelector('[data-md-section-nav] nav')).display === 'none'") ->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')") // 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. // 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') ->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')"); ->assertScript("! document.querySelector('[data-md-section-nav-picker] [role=\"menuitem\"][href=\"#security\"]').hasAttribute('aria-current')");
@@ -273,6 +287,7 @@ function appBarOverflowProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -306,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 () { 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 = appBarOverflowProbe()->resize(599, 800);
$page->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button') // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')") 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') ->keys(':focus', 'ArrowDown')
->assertScript("document.activeElement.textContent.includes('Star')") ->assertScript("document.activeElement.textContent.includes('Star')")
->keys(':focus', 'ArrowDown') ->keys(':focus', 'ArrowDown')
@@ -320,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 // 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. // 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') // A menu's trigger toggles, so it is pressed once: pressOnce() in tests/Pest.php.
->assertScript("document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')") 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) ->resize(900, 800)
->assertScript("! document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')") ->assertScript("! document.querySelector('#bar [data-md-app-bar-overflow=\"compact\"] [data-md-menu-popover]').matches(':popover-open')")
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
@@ -330,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 () { it('runs a wire:click action from either the icon button or its overflow menu item', function () {
$page = appBarOverflowProbe()->resize(900, 800); $page = appBarOverflowProbe()->resize(900, 800);
$page->click('#bar [data-md-app-bar-action] [aria-label="Star"]') // A counter a retry would double: pressOnce(), tests/Pest.php.
->assertSeeIn('#stars', '1'); pressOnce($page)->click('#bar [data-md-app-bar-action] [aria-label="Star"]');
$page->resize(599, 800) $page->assertSeeIn('#stars', '1');
->click('#bar [data-md-app-bar-overflow="compact"] [data-md-menu-trigger] button')
->click('[role="menuitem"]:has-text("Star")') $page->resize(599, 800);
->assertSeeIn('#stars', '2');
// 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() function toolbarScaffoldProbe()
@@ -345,6 +369,7 @@ function toolbarScaffoldProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -397,6 +422,7 @@ it('lifts a snackbar above a floating toolbar placed at the bottom, without a sc
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -458,6 +484,7 @@ function tabsFirstPaintProbe(): void
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
+50 -29
View File
@@ -107,29 +107,37 @@ it('moves one item with the next and previous buttons', function () {
$page = carouselShowcase() $page = carouselShowcase()
->assertScript(onCarousel(0, 'return at(0) && root.querySelector(\'[aria-label="Previous"]\').disabled')); ->assertScript(onCarousel(0, 'return at(0) && root.querySelector(\'[aria-label="Previous"]\').disabled'));
$page->click('#carousel button[aria-label="Next"] >> nth=0') // A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php.
->assertScript(onCarousel(0, 'return at(1) && inset(1) < 0.5 && !root.querySelector(\'[aria-label="Previous"]\').disabled')); pressOnce($page)->click('#carousel button[aria-label="Next"] >> nth=0');
$page->click('#carousel button[aria-label="Next"] >> nth=0') $page->assertScript(onCarousel(0, 'return at(1) && inset(1) < 0.5 && !root.querySelector(\'[aria-label="Previous"]\').disabled'));
->assertScript(onCarousel(0, 'return at(2)'));
$page->click('#carousel button[aria-label="Previous"] >> nth=0') pressOnce($page)->click('#carousel button[aria-label="Next"] >> nth=0');
->assertScript(onCarousel(0, 'return at(1)'));
$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 () { it('moves one item with the arrow keys, and to the ends with Home and End', function () {
$page = carouselShowcase(); $page = carouselShowcase();
// M3 puts the tab stop on the item, so the keys are the focused item's and each one moves // 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. // focus to the item it scrolls to. A retried press drifts the carousel one further item each
$page->keys(FIRST_ITEM, 'ArrowRight') // time: pressOnce(), tests/Pest.php.
->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]')); pressOnce($page)->keys(FIRST_ITEM, 'ArrowRight');
$page->keys(':focus', 'ArrowRight') $page->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]'));
->assertScript(onCarousel(0, 'return at(2)'));
$page->keys(':focus', 'ArrowLeft') pressOnce($page)->keys(':focus', 'ArrowRight');
->assertScript(onCarousel(0, 'return at(1)'));
$page->assertScript(onCarousel(0, 'return at(2)'));
pressOnce($page)->keys(':focus', 'ArrowLeft');
$page->assertScript(onCarousel(0, 'return at(1)'));
$page->keys(':focus', 'End') $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')); ->assertScript(onCarousel(0, 'return Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5 && open(items.length - 1) && root.querySelector(\'[aria-label="Next"]\').disabled'));
@@ -212,6 +220,7 @@ it('mirrors in a right-to-left page', function () {
<!DOCTYPE html> <!DOCTYPE html>
<html dir="rtl"> <html dir="rtl">
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -233,17 +242,21 @@ it('mirrors in a right-to-left page', function () {
->assertNoJavaScriptErrors() ->assertNoJavaScriptErrors()
->assertScript(onCarousel(0, 'return size > 0 && at(0) && open(0) && ! open(items.length - 1)', 'body')); ->assertScript(onCarousel(0, 'return size > 0 && at(0) && open(0) && ! open(items.length - 1)', 'body'));
$page->keys('[data-md-carousel-item] >> nth=0', 'ArrowLeft') // A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php.
->assertScript(onCarousel(0, <<<'JS' 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) return scroller.scrollLeft < -1 && at(1) && ! open(0) && open(1)
&& parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0 && parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0
JS, 'body')); JS, 'body'));
$page->click('button[aria-label="Next"]') pressOnce($page)->click('button[aria-label="Next"]');
->assertScript(onCarousel(0, 'return at(2)', 'body'));
$page->keys('[data-md-carousel-item] >> nth=2', 'ArrowRight') $page->assertScript(onCarousel(0, 'return at(2)', 'body'));
->assertScript(onCarousel(0, 'return at(1)', '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 () { it('measures itself again after a Livewire morph adds an item', function () {
@@ -253,6 +266,7 @@ it('measures itself again after a Livewire morph adds an item', function () {
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -271,8 +285,10 @@ it('measures itself again after a Livewire morph adds an item', function () {
->assertScript($masked, 6) ->assertScript($masked, 6)
->assertAttribute('[data-md-carousel-item] >> nth=5', 'aria-label', '6 of 6'); ->assertAttribute('[data-md-carousel-item] >> nth=5', 'aria-label', '6 of 6');
$page->click('Add') // A counter a retry would double: pressOnce(), tests/Pest.php.
->assertScript($masked, 7) pressOnce($page)->click('Add');
$page->assertScript($masked, 7)
->assertAttribute('[data-md-carousel-item] >> nth=6', 'aria-label', '7 of 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 // Scrolled once the morph has settled, so only the row's own scroll listener can re-mask the
@@ -314,26 +330,31 @@ it('moves the multi-aspect carousel one item at a time with the buttons, arrows,
->assertNoJavaScriptErrors() ->assertNoJavaScriptErrors()
->assertScript(onMultiAspectCarousel('return scroller.scrollLeft === 0 && items.length > 2')); ->assertScript(onMultiAspectCarousel('return scroller.scrollLeft === 0 && items.length > 2'));
$page->click(MULTI_ASPECT_SCOPE.' button[aria-label="Next"]') // A retried press drifts the carousel one further item each time: pressOnce(), tests/Pest.php.
->assertScript(onMultiAspectCarousel(<<<'JS' pressOnce($page)->click(MULTI_ASPECT_SCOPE.' button[aria-label="Next"]');
$page->assertScript(onMultiAspectCarousel(<<<'JS'
await pause(400) await pause(400)
return scroller.scrollLeft > 0 return scroller.scrollLeft > 0
JS)); JS));
$page->click(MULTI_ASPECT_SCOPE.' button[aria-label="Previous"]') pressOnce($page)->click(MULTI_ASPECT_SCOPE.' button[aria-label="Previous"]');
->assertScript(onMultiAspectCarousel(<<<'JS'
$page->assertScript(onMultiAspectCarousel(<<<'JS'
await pause(400) await pause(400)
return scroller.scrollLeft === 0 return scroller.scrollLeft === 0
JS)); JS));
$page->keys(MULTI_ASPECT_SCOPE.' [data-md-carousel-item] >> nth=0', 'ArrowRight') pressOnce($page)->keys(MULTI_ASPECT_SCOPE.' [data-md-carousel-item] >> nth=0', 'ArrowRight');
->assertScript(onMultiAspectCarousel(<<<'JS'
$page->assertScript(onMultiAspectCarousel(<<<'JS'
await pause(400) await pause(400)
return scroller.scrollLeft > 0 && document.activeElement === items[1] return scroller.scrollLeft > 0 && document.activeElement === items[1]
JS)); JS));
$page->keys(':focus', 'ArrowLeft') pressOnce($page)->keys(':focus', 'ArrowLeft');
->assertScript(onMultiAspectCarousel(<<<'JS'
$page->assertScript(onMultiAspectCarousel(<<<'JS'
await pause(400) await pause(400)
return scroller.scrollLeft === 0 && document.activeElement === items[0] return scroller.scrollLeft === 0 && document.activeElement === items[0]
JS)); JS));
+12 -4
View File
@@ -28,6 +28,7 @@ it('lets an application\'s unlayered rule beat a component default', function ()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -52,6 +53,7 @@ it('lets [hidden] beat a component\'s display', function () {
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -88,6 +90,7 @@ it('keeps the layer order even when a component file is imported before the foun
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/cascade-layer-order-probe.css"> <link rel="stylesheet" href="/cascade-layer-order-probe.css">
</head> </head>
<body> <body>
@@ -131,6 +134,7 @@ it('keeps a data-md-* attribute through a Livewire $refresh', function () {
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -146,12 +150,15 @@ it('keeps a data-md-* attribute through a Livewire $refresh', function () {
$page = cascadeReady(visit('/cascade-variant-probe')) $page = cascadeReady(visit('/cascade-variant-probe'))
->assertScript("{$button}.getAttribute('data-md-variant') === 'filled'"); ->assertScript("{$button}.getAttribute('data-md-variant') === 'filled'");
$page->click('[data-test="button"]') // A second press flips the variant back: pressOnce(), tests/Pest.php.
->assertScript("{$button}.getAttribute('data-md-variant') === 'outlined'"); 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. // Twice, so the morph is proven both ways round, not just once off the initial paint.
$page->click('[data-test="button"]') pressOnce($page)->click('[data-test="button"]');
->assertScript("{$button}.getAttribute('data-md-variant') === 'filled'");
$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) { it('mirrors an icon in a right-to-left document, not in left-to-right', function (string $dir, string $transform) {
@@ -159,6 +166,7 @@ it('mirrors an icon in a right-to-left document, not in left-to-right', function
<!DOCTYPE html> <!DOCTYPE html>
<html dir="{$dir}"> <html dir="{$dir}">
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
+34 -16
View File
@@ -57,6 +57,7 @@ function chipProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -89,8 +90,10 @@ it('toggles a filter chip with a click and with Space, and grows its check in',
$page = chipShowcase()->assertNoJavaScriptErrors(); $page = chipShowcase()->assertNoJavaScriptErrors();
$page->click('#chips label:has-text("Documents")') // A filter chip toggles on every press: pressOnce(), tests/Pest.php.
->assertScript(filterInput('documents').'.checked') pressOnce($page)->click('#chips label:has-text("Documents")');
$page->assertScript(filterInput('documents').'.checked')
->assertScript("Math.round({$check('documents')}) === 18") ->assertScript("Math.round({$check('documents')}) === 18")
->assertScript('getComputedStyle('.filterInput('documents').".parentElement).backgroundColor !== 'rgba(0, 0, 0, 0)'") ->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'"); ->assertScript("window.eval(\"Alpine.\$data(document.querySelector('#chips input[value=documents]')).kinds.join(',')\") === 'photos,documents'");
@@ -99,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. // because WebKit leaves form controls out of the Tab order unless full keyboard access is on.
$page->keys('#content', 'Tab'); $page->keys('#content', 'Tab');
$page->script(filterInput('archives').'.focus()'); $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"); ->assertScript("Math.round({$check('archives')}) === 18");
$page->keys(':focus', 'Space') pressOnce($page)->keys(':focus', 'Space');
->assertScript('! '.filterInput('archives').'.checked')
$page->assertScript('! '.filterInput('archives').'.checked')
->assertScript("Math.round({$check('archives')}) === 0"); ->assertScript("Math.round({$check('archives')}) === 0");
}); });
@@ -115,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->assertScript("{$input('photos')}.checked && ! {$input('documents')}.checked");
$page->click('label:has-text("Documents")') // A filter chip toggles on every press: pressOnce(), tests/Pest.php.
->assertSeeIn('#kinds', 'photos,documents'); pressOnce($page)->click('label:has-text("Documents")');
$page->assertSeeIn('#kinds', 'photos,documents');
$page->click('button:has-text("Only video")') $page->click('button:has-text("Only video")')
->assertSeeIn('#kinds', 'video') ->assertSeeIn('#kinds', 'video')
@@ -128,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 () { it('removes a Livewire input chip with its button and with Delete, focusing the chip after it', function () {
$page = chipProbe(); $page = chipProbe();
$page->click('button[aria-label="Remove js"]') // The remove button vanishes once its chip is gone: pressOnce(), tests/Pest.php.
->assertSeeIn('#tags', 'php,css') pressOnce($page)->click('button[aria-label="Remove js"]');
$page->assertSeeIn('#tags', 'php,css')
->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null"); ->assertScript("document.querySelector('button[aria-label=\"Remove js\"]') === null");
$page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()"); $page->script("document.querySelector('button[aria-label=\"Remove php\"]').focus()");
$page->keys(':focus', 'Delete') // Removes the focused chip, whose button then vanishes: pressOnce(), tests/Pest.php.
->assertScript("document.querySelector('#tags').textContent === 'css'") pressOnce($page)->keys(':focus', 'Delete');
$page->assertScript("document.querySelector('#tags').textContent === 'css'")
->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null") ->assertScript("document.querySelector('button[aria-label=\"Remove php\"]') === null")
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'"); ->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove css'");
}); });
@@ -149,8 +162,10 @@ it('removes an Alpine input chip with Backspace, focusing the chip before it', f
$page->assertScript("{$remove('ben')} !== null"); $page->assertScript("{$remove('ben')} !== null");
$page->script("{$remove('ben')}.focus()"); $page->script("{$remove('ben')}.focus()");
$page->keys(':focus', 'Backspace') // Removes the focused chip, whose button then vanishes: pressOnce(), tests/Pest.php.
->assertScript("{$remove('ben')} === null") pressOnce($page)->keys(':focus', 'Backspace');
$page->assertScript("{$remove('ben')} === null")
->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null") ->assertScript("{$remove('anna')} !== null && {$remove('chiara')} !== null")
->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'"); ->assertScript("document.activeElement.getAttribute('aria-label') === 'Remove anna@example.com'");
}); });
@@ -215,6 +230,7 @@ function chipScrollProbe(string $dir)
<!DOCTYPE html> <!DOCTYPE html>
<html dir="{{ request('dir') }}"> <html dir="{{ request('dir') }}">
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -243,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("{$row}.scrollWidth > {$row}.clientWidth")
->assertScript("! ({$shown('start')}) && ({$shown('end')})"); ->assertScript("! ({$shown('start')}) && ({$shown('end')})");
$page->click('[data-md-chip-scroll="end"]') // A paging key a retry would page twice: pressOnce(), tests/Pest.php.
->wait(0.8) pressOnce($page)->click('[data-md-chip-scroll="end"]');
$page->wait(0.8)
->assertScript("Math.abs({$row}.scrollLeft) > 0") ->assertScript("Math.abs({$row}.scrollLeft) > 0")
->assertScript("({$shown('start')})"); ->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(pagePrimary()." === '{$this->profiles['rose']['light']['primary']}'")
->assertScript("window.eval(\"Alpine.store('theme').scheme\") === 'rose'"); ->assertScript("window.eval(\"Alpine.store('theme').scheme\") === 'rose'");
$page->click('[data-test="showcase-profiles"] [aria-haspopup="menu"]') // Opens the menu, then an item closes it: pressOnce(), tests/Pest.php.
->click('[data-scheme-preview="teal"]') pressOnce($page)->click('[data-test="showcase-profiles"] [aria-haspopup="menu"]')
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'"); ->click('[data-scheme-preview="teal"]');
$page->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
$page->click('[data-md-navigation-rail-panel] a[href$="/material/buttons"]') $page->click('[data-md-navigation-rail-panel] a[href$="/material/buttons"]')
->assertScript("location.pathname.endsWith('/material/buttons')") ->assertScript("location.pathname.endsWith('/material/buttons')")
+49 -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->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
$page->click('[x-data="materialSnackbar"] button:has-text("Undo")') // The Undo button vanishes once the toast is dismissed: pressOnce(), tests/Pest.php.
->assertScript('window.__undone === true') pressOnce($page)->click('[x-data="materialSnackbar"] button:has-text("Undo")');
$page->assertScript('window.__undone === true')
->assertScript(TOAST.' === null') ->assertScript(TOAST.' === null')
->assertScript(SNACKBAR.' !== 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->assertScript(TOAST."?.textContent.includes('Version 2 is ready')");
$page->click('[data-md-toast-dismiss]') // The dismiss button vanishes with its toast: pressOnce(), tests/Pest.php.
->assertScript(TOAST.' === null'); 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, // Dismissed rather than left to time out: the pointer still rests where the snackbar appears,
// and hovering pauses it. // and hovering pauses it.
$page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")"); $page->script("window.eval(\"materialToast('Link copied', { timeout: 0 })\")");
$page->assertScript(TOAST."?.textContent.includes('Link copied')") $page->assertScript(TOAST."?.textContent.includes('Link copied')");
->click('[data-md-toast-dismiss]')
->assertScript(TOAST.' === null'); 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 () { 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->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]') // The action closes the toast: pressOnce(), tests/Pest.php.
->assertScript(TOAST.' === null') pressOnce($page)->click('[data-md-toast-action]');
$page->assertScript(TOAST.' === null')
->assertScript("window.eval('window.reloads') === 1") ->assertScript("window.eval('window.reloads') === 1")
->assertScript("window.eval('window.handled') === 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')") $page->assertScript(TOAST."?.textContent.includes('Share deleted')")
->wait(4.5); ->wait(4.5);
$page->assertScript(TOAST."?.textContent.includes('Share deleted')") $page->assertScript(TOAST."?.textContent.includes('Share deleted')");
->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');
}); });
it('dismisses a focused snackbar with Escape', function () { 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 () { it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')"; $bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
ready(visit('/material/communication')) $page = ready(visit('/material/communication'));
->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')"); // 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 () { it('closes a transient rich tooltip 1.5s after focus leaves, not at once', function () {
@@ -245,6 +259,7 @@ it('keeps a rich tooltip open while its action renders the component, and opens
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -262,10 +277,14 @@ it('keeps a rich tooltip open while its action renders the component, and opens
$page = ready(visit('/rich-tooltip-morph-probe')) $page = ready(visit('/rich-tooltip-morph-probe'))
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
$page->click('@details') // Opens the tooltip, then a counter a retry would double: pressOnce(), tests/Pest.php.
->assertScript("{$persistent}.matches(':popover-open')") pressOnce($page)->click('@details');
->click('@refresh')
->assertSeeIn('#renders', '1') $page->assertScript("{$persistent}.matches(':popover-open')");
pressOnce($page)->click('@refresh');
$page->assertSeeIn('#renders', '1')
->assertScript("{$persistent}.matches(':popover-open')"); ->assertScript("{$persistent}.matches(':popover-open')");
// Past the reopen guard (rich-tooltip.js's REOPEN_GUARD_MS), which takes a press this soon after // Past the reopen guard (rich-tooltip.js's REOPEN_GUARD_MS), which takes a press this soon after
@@ -274,8 +293,9 @@ it('keeps a rich tooltip open while its action renders the component, and opens
->assertScript("! {$persistent}.matches(':popover-open')") ->assertScript("! {$persistent}.matches(':popover-open')")
->wait(0.3); ->wait(0.3);
$page->click('@details') pressOnce($page)->click('@details');
->assertScript("{$persistent}.matches(':popover-open')");
$page->assertScript("{$persistent}.matches(':popover-open')");
$page->click('#outside') $page->click('#outside')
->hover('@hint') ->hover('@hint')
@@ -291,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. // 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->script("document.head.insertAdjacentHTML('beforeend', '<style>:root { --md-sys-motion-effects-fast-duration: 1500ms; }</style>')");
$page->click('#communication button:has-text("Press for details")') // The trigger toggles the tooltip, so each press is pressed once: pressOnce(), tests/Pest.php.
->assertScript("{$bubble}.matches(':popover-open')") pressOnce($page)->click('#communication button:has-text("Press for details")');
->click('#communication button:has-text("Press for details")')
->assertScript("! {$bubble}.matches(':popover-open')") $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; })()"); ->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; })()");
}); });
+242 -132
View File
@@ -93,6 +93,7 @@ function collapseProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -115,6 +116,7 @@ function overlayProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -137,19 +139,25 @@ function containment()
it('writes back what closed means: false for a flag, null for an id', function () { it('writes back what closed means: false for a flag, null for an id', function () {
$page = overlayProbe(); $page = overlayProbe();
$page->click('button:has-text("Confirm")') // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php.
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))"); pressOnce($page)->click('button:has-text("Confirm")');
$page->click('dialog[open] button:has-text("Re-render")') $page->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
->assertSeeIn('#renders', '1')
// 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?'))"); ->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
$page->keys('dialog[open] button:has-text("Re-render")', 'Escape') $page->keys('dialog[open] button:has-text("Re-render")', 'Escape')
->assertScript("! [...document.querySelectorAll('dialog')].some((d) => d.open)") ->assertScript("! [...document.querySelectorAll('dialog')].some((d) => d.open)")
->assertSeeIn('#confirming', 'false'); ->assertSeeIn('#confirming', 'false');
$page->click('button:has-text("Delete 7")') // Opens the dialog over the button that triggers it: pressOnce(), tests/Pest.php.
->assertSeeIn('#deleting', '7') 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?'))"); ->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 }))"); $page->script("document.querySelector('dialog[open]').dispatchEvent(new Event('cancel', { cancelable: true }))");
@@ -164,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->script("[...document.querySelectorAll('#containment button')].find((b) => b.textContent.trim() === 'Basic dialog').focus()");
$page->keys(':focus', 'Enter') // Enter on the focused button is a native click on it, opening the dialog over it: pressOnce(),
->assertScript("document.querySelector('#containment dialog').open"); // 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') $page->keys('#containment dialog[open] button:has-text("Cancel")', 'Escape')
->assertScript("! document.querySelector('#containment dialog').open") ->assertScript("! document.querySelector('#containment dialog').open")
@@ -175,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 () { it('slides a side sheet in, traps the page, and closes on the scrim', function () {
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')"; $sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
$page = containment() $page = containment();
->click('#containment button:has-text("Side sheet")')
->assertScript("getComputedStyle({$sheet}).display !== 'none'") // 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"); ->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. // This sheet's own scrim: the standard side sheet above it on the page is a `[data-md-drawer]` too.
@@ -190,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 () { it('dismisses a bottom sheet dragged down past a quarter of its height', function () {
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')"; $sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
$page = containment() $page = containment();
->click('#containment button:has(> span:text-is("Bottom sheet"))')
->assertScript("getComputedStyle({$sheet}).display !== 'none'") // 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); ->wait(0.5);
$page->script(<<<JS $page->script(<<<JS
@@ -251,6 +268,7 @@ function nestedDialogProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -269,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. // Short and narrow so the probes' paragraphs genuinely overflow their body.
$page = nestedDialogProbe()->resize(320, 300); $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 // 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, // outer dialog's textContent also contains the nested inner dialog's text, "Inner" included,
@@ -281,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. // Scrolled to the top: nothing hidden above, so the head's rule stays off.
$page->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'"); $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')"; $inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Inner')";
$innerHead = "{$inner}.querySelector('[data-md-modal-head]')"; $innerHead = "{$inner}.querySelector('[data-md-modal-head]')";
@@ -308,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. // CSS on the fullscreen flag, so which comes first makes no difference to what is asserted.
$page = containment(); $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); $page->resize(400, 800);
@@ -325,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 () { it('draws separator dividers whatever the scroll, on a dialog that fits without scrolling', function () {
$page = containment(); $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]')"; $dialog = "document.querySelector('#containment dialog[open]')";
$head = "{$dialog}.querySelector('[data-md-modal-head]')"; $head = "{$dialog}.querySelector('[data-md-modal-head]')";
@@ -345,7 +367,8 @@ it('opens a dialog with no fade under reduced motion, but a real one without it'
$normal = containment() $normal = containment()
->assertScript("! window.matchMedia('(prefers-reduced-motion: reduce)').matches"); ->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"); $normal->assertScript("parseFloat(getComputedStyle({$dialog}).transitionDuration) > 0");
@@ -355,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'])) $reduced = ready(visit('/material/containment', ['reducedMotion' => 'reduce']))
->assertScript("window.matchMedia('(prefers-reduced-motion: reduce)').matches"); ->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") $reduced->assertScript("parseFloat(getComputedStyle({$dialog}).transitionDuration) === 0")
->assertScript("getComputedStyle({$dialog}).opacity === '1'"); ->assertScript("getComputedStyle({$dialog}).opacity === '1'");
@@ -387,6 +411,7 @@ function textOnlyDialogProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -408,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->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]')"; $body = "document.querySelector('dialog[open] [data-md-modal-body]')";
@@ -428,24 +455,34 @@ 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 () { it('cycles a bottom sheet\'s preset heights from its handle, announcing each, and closes from the last', function () {
$page = containment(); $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]')"; $sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')";
$handle = "{$sheet}.querySelector('[data-md-bottom-sheet-grip]')"; $handle = "{$sheet}.querySelector('[data-md-bottom-sheet-grip]')";
$announce = "{$sheet}.querySelector('[data-md-bottom-sheet-announce]')"; $announce = "{$sheet}.querySelector('[data-md-bottom-sheet-announce]')";
$page->assertScript("getComputedStyle({$sheet}).display !== 'none'") $page->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'"); ->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'")
// Settled, not still sliding in: a grip pressed while the entry is still under way would
// read as unstable on a loaded runner, the way opening it does elsewhere in this file.
->assertScript(settled($sheet, subtree: true));
$gripSelector = '#containment [data-md-bottom-sheet-panel][data-md-preset] [data-md-bottom-sheet-grip]'; $gripSelector = '#containment [data-md-bottom-sheet-panel][data-md-preset] [data-md-bottom-sheet-grip]';
$page->click($gripSelector); // Each press moves the sheet to its next stop, and from the last one closes it, so a press
// repeated by the plugin's retry would skip a stop: pressed once, pressOnce() in tests/Pest.php.
pressOnce($page)->click($gripSelector);
$page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '90dvh'") $page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '90dvh'")
->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'"); ->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'")
// The stop is written before the sheet has grown to it, so wait for the growth as well as
// for the entry above: the grip travels with the sheet's edge, and a press that lands
// while it is moving never reads as stable.
->assertScript(settled($sheet, subtree: true));
// From the last stop, activating the handle closes the sheet, as a handle with no stops does. // From the last stop, activating the handle closes the sheet, as a handle with no stops does.
$page->click($gripSelector); pressOnce($page)->click($gripSelector);
$page->assertScript("getComputedStyle({$sheet}).display === 'none'"); $page->assertScript("getComputedStyle({$sheet}).display === 'none'");
}); });
@@ -453,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 () { it('settles a dragged bottom sheet on the nearest preset height', function () {
$page = containment(); $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]')"; $sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')";
@@ -490,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("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null")
->assertScript("! {$sheet}.hasAttribute('inert')"); ->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'"); $page->assertScript("getComputedStyle({$sheet}).opacity === '0'");
// Below 840px the same sheet becomes the modal one: fixed, with a scrim, and the page inert. // Below 840px the same sheet becomes the modal one: fixed, with a scrim, and the page inert.
$page->resize(700, 800) $page->resize(700, 800);
->click($toggle);
pressOnce($page)->click($toggle);
$page->assertScript("getComputedStyle({$sheet}).position === 'fixed'") $page->assertScript("getComputedStyle({$sheet}).position === 'fixed'")
->assertScript("getComputedStyle({$scrim}).display !== 'none'") ->assertScript("getComputedStyle({$scrim}).display !== 'none'")
@@ -512,35 +552,35 @@ it('gives a closing standard side sheet\'s room back to the content beside it as
// The example starts open, and stands open from the first frame Alpine draws: full 400px, fully // The example starts open, and stands open from the first frame Alpine draws: full 400px, fully
// shown, nothing on it animating. // shown, nothing on it animating.
$page = containment()->resize(1000, 800) $page = containment()->resize(1000, 800)
->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1' && {$root}.getAnimations({ subtree: true }).length === 0"); ->assertScript("{$root}.hasAttribute('data-md-open') && Math.round({$root}.getBoundingClientRect().width) === 400 && getComputedStyle({$sheet}).opacity === '1'")
->assertScript(settled($root, subtree: true))
// Only once the view has settled, two frames after Alpine starts, which WebKit can still
// be waiting for here: until then the sheet has no transitions, on purpose, and closes at
// once.
->assertScript("{$root}.hasAttribute('data-md-drawer-settled')");
// Closed and sampled in one round trip: the sheet's root caught part-way between its width // 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 // 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. Only once the view has // the whole sheet and its gap handed back in one jump at the end. Stretched, as every other
// settled, two frames after Alpine starts, which WebKit can still be waiting for here: until // mid-exit sample is: a loaded engine can paint too few frames in the real exit to see it.
// then the sheet has no transitions, on purpose, and closes at once. slowMotion($page);
$midExit = $page->script(<<<JS
(async () => {
const root = {$root}
const column = {$column}
for (let i = 0; i < 100 && ! root.hasAttribute('data-md-drawer-settled'); i++) { $midExit = $page->script(caughtMidExit(
await new Promise((resolve) => setTimeout(resolve, 10)) <<<JS
} const root = {$root}
const column = {$column}
const open = { root: root.getBoundingClientRect().width, column: column.getBoundingClientRect().width, row: root.parentElement.getBoundingClientRect().width }
{$toggle}.click()
JS,
<<<'JS'
(() => {
const width = root.getBoundingClientRect().width
const columnWidth = column.getBoundingClientRect().width
const open = { root: root.getBoundingClientRect().width, column: column.getBoundingClientRect().width, row: root.parentElement.getBoundingClientRect().width } return ! root.hasAttribute('data-md-drawer-collapsed') && getComputedStyle(root).display !== 'none' && width > 1 && width < open.root - 1 && columnWidth > open.column + 1 && columnWidth < open.row - 1
{$toggle}.click()
for (let i = 0; i < 80; i++) {
const width = root.getBoundingClientRect().width
const columnWidth = column.getBoundingClientRect().width
if (! root.hasAttribute('data-md-drawer-collapsed') && getComputedStyle(root).display !== 'none' && width > 1 && width < open.root - 1 && columnWidth > open.column + 1 && columnWidth < open.row - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})() })()
JS); JS,
));
expect($midExit)->toBeTrue(); expect($midExit)->toBeTrue();
@@ -548,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'") $page->assertScript("{$root}.hasAttribute('data-md-drawer-collapsed') && getComputedStyle({$root}).display === 'none'")
->assertScript("Math.abs({$column}.getBoundingClientRect().width - {$root}.parentElement.getBoundingClientRect().width) < 1"); ->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 // 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. // the browser plugin's one-second call timeout, past which it runs the script a second time.
$page->script("{$toggle}.click()"); $page->script("{$toggle}.click()");
@@ -577,6 +620,7 @@ function layersProbe(): mixed
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -644,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 () { it('closes only a dialog opened over a modal sheet on Escape, readable and tabbable while it is open', function () {
$page = layersProbe(); $page = layersProbe();
$page->click('#open-sheet')->assertScript(sheetOpen('sheet')); // Opens the sheet, then the dialog over it: pressOnce(), tests/Pest.php.
$page->click('#open-dialog')->assertScript("document.getElementById('dialog').open"); 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 // 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. // focus trap does not take the Tab back to the sheet.
@@ -670,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 () { it('closes only the menu, select list or searchable choice open inside a modal sheet on Escape', function () {
$page = layersProbe(); $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') $page->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')") ->assertScript("! document.querySelector('#sheet [data-md-menu-popover]').matches(':popover-open')")
->assertScript(sheetOpen('sheet')); ->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') $page->keys('#sheet-choices', 'Escape')
->assertScript("! document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')") ->assertScript("! document.querySelector('#sheet [data-md-field-menu]').matches(':popover-open')")
->assertScript(sheetOpen('sheet')); ->assertScript(sheetOpen('sheet'));
@@ -685,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 // A customizable select's list, where the browser has one: elsewhere there is no list of the
// page's own open over the sheet. // page's own open over the sheet.
if ($page->script("CSS.supports('appearance', 'base-select')") === true) { 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') $page->keys(':focus', 'Escape')
->assertScript("! document.getElementById('sheet-select').matches(':open')") ->assertScript("! document.getElementById('sheet-select').matches(':open')")
->assertScript(sheetOpen('sheet')); ->assertScript(sheetOpen('sheet'));
@@ -697,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 () { 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 = 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->click('#inner-field');
$page->keys('#inner-field', 'Escape') $page->keys('#inner-field', 'Escape')
->assertScript('! '.sheetOpen('inner-sheet')) ->assertScript('! '.sheetOpen('inner-sheet'))
->assertScript(sheetOpen('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')); ->assertScript('! '.hiddenFromAssistiveTech('beside-sheet'));
$page->click('#beside-field'); $page->click('#beside-field');
$page->keys('#beside-field', 'Escape') $page->keys('#beside-field', 'Escape')
@@ -721,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->script("window.dispatchEvent(new CustomEvent('open-probe-dialog'))");
$page->assertScript("document.getElementById('dialog').open"); $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->click('#dialog-sheet-field');
$page->keys('#dialog-sheet-field', 'Escape') $page->keys('#dialog-sheet-field', 'Escape')
->assertScript('! '.sheetOpen('dialog-sheet')) ->assertScript('! '.sheetOpen('dialog-sheet'))
->assertScript("document.getElementById('dialog').open"); ->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') $page->keys('#dialog-choices', 'Escape')
->assertScript("! document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')") ->assertScript("! document.querySelector('#dialog [data-md-field-menu]').matches(':popover-open')")
->assertScript("document.getElementById('dialog').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') $page->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')") ->assertScript("! document.querySelector('#dialog [data-md-menu-popover]').matches(':popover-open')")
->assertScript("document.getElementById('dialog').open"); ->assertScript("document.getElementById('dialog').open");
if ($page->script("CSS.supports('appearance', 'base-select')") === true) { 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') $page->keys(':focus', 'Escape')
->assertScript("! document.getElementById('dialog-select').matches(':open')") ->assertScript("! document.getElementById('dialog-select').matches(':open')")
->assertScript("document.getElementById('dialog').open"); ->assertScript("document.getElementById('dialog').open");
@@ -753,6 +820,7 @@ it('draws a standard side sheet that starts open standing open, without growing
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -788,6 +856,7 @@ it('sticks a standard side sheet under the top safe area and a scaffold\'s stick
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -866,6 +935,7 @@ it('draws standard side sheets as the server renders them from the first paint,
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -897,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->script("window.__opened = 0; document.querySelector('{$row} [data-md-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->click("{$row} p") // A counter a retry would double: pressOnce(), tests/Pest.php.
->assertScript('window.__opened === 1'); pressOnce($page)->click("{$row} p");
$page->assertScript('window.__opened === 1');
$page->click("{$row} button:has-text(\"Copy link\")") $page->click("{$row} button:has-text(\"Copy link\")")
->assertScript('window.__opened === 1'); ->assertScript('window.__opened === 1');
@@ -915,8 +987,10 @@ it('makes a directly actionable card the one tab stop, answering Enter', functio
$page->assertScript("document.activeElement.matches('{$card}')"); $page->assertScript("document.activeElement.matches('{$card}')");
$page->keys($card, 'Enter') // A counter a retry would double: pressOnce(), tests/Pest.php.
->assertScript('window.__opened === 1'); pressOnce($page)->keys($card, 'Enter');
$page->assertScript('window.__opened === 1');
}); });
it('binds a collapse to a Livewire property both ways', function () { it('binds a collapse to a Livewire property both ways', function () {
@@ -925,10 +999,15 @@ it('binds a collapse to a Livewire property both ways', function () {
$page = collapseProbe() $page = collapseProbe()
->assertScript("{$collapse}.open === false"); ->assertScript("{$collapse}.open === false");
$page->click('#fine-tuning-collapse summary') // A native <summary> click toggles the collapse: pressOnce(), tests/Pest.php.
->assertScript("{$collapse}.open === true") pressOnce($page)->click('#fine-tuning-collapse summary');
->click('button:has-text("Re-render")')
->assertSeeIn('#renders', '1') $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') ->assertSeeIn('#fine-tuning', 'true')
->assertScript("{$collapse}.open === true"); ->assertScript("{$collapse}.open === true");
@@ -947,8 +1026,10 @@ it('binds a collapse to an Alpine property both ways', function () {
$page = collapseProbe() $page = collapseProbe()
->assertScript("{$collapse}.open === false"); ->assertScript("{$collapse}.open === false");
$page->click('#advanced-collapse summary') // A native <summary> click toggles the collapse: pressOnce(), tests/Pest.php.
->assertScript("{$collapse}.open === true") pressOnce($page)->click('#advanced-collapse summary');
$page->assertScript("{$collapse}.open === true")
->assertSeeIn('#advanced', 'true'); ->assertSeeIn('#advanced', 'true');
$page->click('button:has-text("Close from Alpine")') $page->click('button:has-text("Close from Alpine")')
@@ -987,29 +1068,30 @@ dataset('containment sheets', [
it('fades the sheet\'s scrim out on close, rather than making it vanish', function (string $sheet, string $trigger, string $scrimAttr) { 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}]')"; $scrim = "{$sheet}.parentElement.querySelector(':scope > [{$scrimAttr}]')";
$page = containment() $page = containment();
->click($trigger)
->assertScript("getComputedStyle({$sheet}).display !== 'none'") // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php.
->assertScript("getComputedStyle({$scrim}).opacity === '1'"); 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
// sampled from there has nothing left to fade.
->assertScript(settled($scrim));
slowMotion($page);
// Triggering the close and sampling for a mid-fade opacity in the same round trip: a round // Triggering the close and sampling for a mid-fade opacity in the same round trip: a round
// trip apiece for a separate `script` and `assertScript` already costs real time, easily as // trip apiece for a separate `script` and `assertScript` already costs real time, easily as
// much as the 200ms fade itself, so a click, then a later separate read, can just as easily // much as the fade itself, so a click, then a later separate read, can just as easily land
// land before the fade starts or after it ends. This samples every few ms, in the page itself, // before the fade starts or after it ends. This samples once a frame, in the page itself, for
// for whether it was ever caught strictly between fully shown and fully hidden, rather than // whether it was ever caught strictly between fully shown and fully hidden, rather than simply
// simply gone at once. // gone at once (caughtMidExit() in tests/Pest.php).
$midFade = $page->script(<<<JS $midFade = $page->script(caughtMidExit(
(async () => { "{$scrim}.click()",
{$scrim}.click() "(() => { const opacity = parseFloat(getComputedStyle({$scrim}).opacity); return opacity > 0.02 && opacity < 0.98 })()",
));
for (let i = 0; i < 60; i++) {
const opacity = parseFloat(getComputedStyle({$scrim}).opacity)
if (opacity > 0.02 && opacity < 0.98) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS);
expect($midFade)->toBeTrue(); expect($midFade)->toBeTrue();
@@ -1021,29 +1103,38 @@ it('slides the sheet out on close, rather than making it vanish', function (stri
$offset = "(parseFloat(getComputedStyle({$sheet}).translate{$axis}) || 0)"; $offset = "(parseFloat(getComputedStyle({$sheet}).translate{$axis}) || 0)";
$sample = $absolute ? "Math.abs(parseFloat(style.translate{$axis}) || 0)" : "(parseFloat(style.translate{$axis}) || 0)"; $sample = $absolute ? "Math.abs(parseFloat(style.translate{$axis}) || 0)" : "(parseFloat(style.translate{$axis}) || 0)";
$page = containment() $page = containment();
->click($trigger)
->assertScript("getComputedStyle({$sheet}).display !== 'none'") // Opens the sheet over the button that triggers it: pressOnce(), tests/Pest.php.
->assertScript("Math.abs({$offset}) < 0.5"); 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));
slowMotion($page);
// Sampled in the page, in the same round trip as the close, as the scrim's fade is above: the // Sampled in the page, in the same round trip as the close, as the scrim's fade is above: the
// sheet must be caught still displayed and part of the way to its closed offset (one sheet's // sheet must be caught still displayed and part of the way to its closed offset (one sheet's
// own size past its edge), not gone at once — which is what Firefox showed while the exit // own size past its edge), not gone at once — which is what Firefox showed while the exit
// leaned on `allow-discrete` holding `display`. // leaned on `allow-discrete` holding `display`. The sheet closes on the emphasized-accelerate
$midSlide = $page->script(<<<JS // easing, which stays close to its start for a good part of its run, so it takes a few of the
(async () => { // stretched exit's frames before the offset has moved past a pixel.
const size = {$sheet}.getBoundingClientRect().{$dimension} $midSlide = $page->script(caughtMidExit(
{$scrim}.click() <<<JS
const size = {$sheet}.getBoundingClientRect().{$dimension}
{$scrim}.click()
JS,
<<<JS
(() => {
const style = getComputedStyle({$sheet})
const offset = {$sample}
for (let i = 0; i < 60; i++) { return style.display !== 'none' && offset > 1 && offset < size - 1
const style = getComputedStyle({$sheet})
const offset = {$sample}
if (style.display !== 'none' && offset > 1 && offset < size - 1) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})() })()
JS); JS,
));
expect($midSlide)->toBeTrue(); expect($midSlide)->toBeTrue();
@@ -1055,6 +1146,7 @@ it('slides the side sheet in from its own edge in a right-to-left page', functio
<!DOCTYPE html> <!DOCTYPE html>
<html dir="rtl"> <html dir="rtl">
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -1073,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]')"; $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 // `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 // slides in from off the left, not the right (the CSS mirror reads `[dir='rtl']` rather than
@@ -1123,6 +1216,7 @@ it('keeps a basic dialog\'s own title and padding when it is nested inside a ful
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -1136,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 = 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]')"; $outerBar = "document.querySelector('dialog[data-md-fullscreen][open] [data-md-modal-bar]')";
@@ -1145,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. // must keep its own head regardless.
$page->assertScript("getComputedStyle({$outerBar}).display !== 'none'"); $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?')"; $inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.querySelector('[data-md-modal-title]')?.textContent === 'Delete this share?')";
$innerHead = "{$inner}.querySelector('[data-md-modal-head]')"; $innerHead = "{$inner}.querySelector('[data-md-modal-head]')";
@@ -1164,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->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')") $page->assertScript("{$card}.hasAttribute('data-md-dragged')")
->assertScript("getComputedStyle({$card}, '::before').opacity === '0.16'"); ->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')") $page->assertScript("! {$card}.hasAttribute('data-md-dragged')")
->assertScript("getComputedStyle({$card}, '::before').opacity === '0'"); ->assertScript("getComputedStyle({$card}, '::before').opacity === '0'");
@@ -1180,6 +1277,7 @@ it('keeps a selected segmented row\'s fill under the hover and focus tint', func
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -1237,6 +1335,7 @@ it('fills a selected row written by hand in secondary-container in both themes,
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -1306,6 +1405,7 @@ function collapseMotionProbe(string $durations = ''): mixed
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -1441,10 +1541,12 @@ it('eases a collapse open and shut in every engine, moving what is under it', fu
expect($clipped)->toBe('clip'); 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->script("document.querySelector('#plain summary').focus()");
$page->keys('#plain summary', 'Enter') pressOnce($page)->keys('#plain summary', 'Enter');
->assertScript("document.querySelector('#plain').open === false && document.querySelector('#plain').getAnimations().length === 0");
$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 () { it('eases a collapse bound to Alpine open and shut, keeping the binding in step', function () {
@@ -1466,16 +1568,19 @@ it('eases a collapse bound to Alpine open and shut, keeping the binding in step'
$page->assertSeeIn('#advanced', 'false'); $page->assertSeeIn('#advanced', 'false');
// A press on the summary tells the binding when the section has closed, and never loops. // A press on the summary tells the binding when the section has closed, and never loops; a
$page->click('#bound summary') // native <summary> click toggles the collapse: pressOnce(), tests/Pest.php.
->assertSeeIn('#advanced', 'true') pressOnce($page)->click('#bound summary');
$page->assertSeeIn('#advanced', 'true')
->assertScript("document.querySelector('#bound').open === true"); ->assertScript("document.querySelector('#bound').open === true");
}); });
it('turns a collapse round from the height it has reached, ending as the last press asked', function () { it('turns a collapse round from the height it has reached, ending as the last press asked', function () {
$page = collapseMotionProbe('1500ms'); $page = collapseMotionProbe('1500ms');
$result = $page->script(<<<'JS' // Two seconds in the page, so it is run once: see onceInPage() in tests/Pest.php.
$result = $page->script(onceInPage(<<<'JS'
(async () => { (async () => {
const details = document.querySelector('#plain') const details = document.querySelector('#plain')
const summary = details.querySelector('summary') const summary = details.querySelector('summary')
@@ -1493,7 +1598,7 @@ it('turns a collapse round from the height it has reached, ending as the last pr
return { closed, midway, turned, end: details.getBoundingClientRect().height, open: details.open, style: details.getAttribute('style') } return { closed, midway, turned, end: details.getBoundingClientRect().height, open: details.open, style: details.getAttribute('style') }
})() })()
JS); JS));
expect($result['midway'])->toBeGreaterThan($result['closed'] + 2) expect($result['midway'])->toBeGreaterThan($result['closed'] + 2)
// Turned round from where it was, not from either end. // Turned round from where it was, not from either end.
@@ -1508,7 +1613,7 @@ it('closes the open member of a name group on the same spring as the one opening
$first = "document.querySelector('#first')"; $first = "document.querySelector('#first')";
$result = $page->script(<<<'JS' $result = $page->script(onceInPage(<<<'JS'
(async () => { (async () => {
const first = document.querySelector('#first') const first = document.querySelector('#first')
const second = document.querySelector('#second') const second = document.querySelector('#second')
@@ -1527,7 +1632,7 @@ it('closes the open member of a name group on the same spring as the one opening
return { stillOpen, caught, first: first.open, second: second.open, name: first.getAttribute('name') } return { stillOpen, caught, first: first.open, second: second.open, name: first.getAttribute('name') }
})() })()
JS); JS));
expect($result)->toBe(['stillOpen' => true, 'caught' => true, 'first' => false, 'second' => true, 'name' => 'faq']); expect($result)->toBe(['stillOpen' => true, 'caught' => true, 'first' => false, 'second' => true, 'name' => 'faq']);
@@ -1601,6 +1706,7 @@ it('shows no tooltip on the control a sheet or a dialog focuses as it opens, but
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -1630,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(). // 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 = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false);
$page->keys('#open-setback', 'Enter') // Enter on the focused button opens the dialog over it: pressOnce(), tests/Pest.php.
->assertScript("document.querySelector('{$dialog}').open && document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')") pressOnce($page)->keys('#open-setback', 'Enter');
$page->assertScript("document.querySelector('{$dialog}').open && document.activeElement.matches('{$dialog} button[aria-label=\"Close\"]')")
->wait(0.3) ->wait(0.3)
->assertScript('! '.closeTooltipOpen($dialog)); ->assertScript('! '.closeTooltipOpen($dialog));
@@ -1642,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. // 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 = ready(visit('/opened-layers-probe', ['viewport' => ['width' => 393, 'height' => 800]]), alpine: false);
$page->keys('#open-about', 'Enter') // Enter on the focused button opens the dialog over it: pressOnce(), tests/Pest.php.
->assertScript("document.activeElement.id === 'about-help'") pressOnce($page)->keys('#open-about', 'Enter');
$page->assertScript("document.activeElement.id === 'about-help'")
->wait(0.3) ->wait(0.3)
->assertScript("! document.querySelector('[data-md-rich-tooltip-bubble]').matches(':popover-open')"); ->assertScript("! document.querySelector('[data-md-rich-tooltip-bubble]').matches(':popover-open')");
}); });
+12 -6
View File
@@ -65,6 +65,7 @@ function dataProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -86,12 +87,15 @@ it('sorts by a column and flips the direction on a second press', function () {
->assertAttribute('#by-name', 'aria-sort', 'ascending') ->assertAttribute('#by-name', 'aria-sort', 'ascending')
->assertScript("{$first} === 'file-01.zip'"); ->assertScript("{$first} === 'file-01.zip'");
$page->click('#by-name button') // A second press on the same header flips its direction again: pressOnce(), tests/Pest.php.
->assertAttribute('#by-name', 'aria-sort', 'descending') pressOnce($page)->click('#by-name button');
$page->assertAttribute('#by-name', 'aria-sort', 'descending')
->assertScript("{$first} === 'file-25.zip'"); ->assertScript("{$first} === 'file-25.zip'");
$page->click('#by-size button') pressOnce($page)->click('#by-size button');
->assertAttribute('#by-size', 'aria-sort', 'ascending')
$page->assertAttribute('#by-size', 'aria-sort', 'ascending')
->assertScript("! document.querySelector('#by-name').hasAttribute('aria-sort')"); ->assertScript("! document.querySelector('#by-name').hasAttribute('aria-sort')");
}); });
@@ -103,8 +107,10 @@ it('pages through Livewire results and marks the current page', function () {
->assertSee('21–25 of 25') ->assertSee('21–25 of 25')
->assertSeeIn('[data-md-pagination] [aria-current="page"]', '3'); ->assertSeeIn('[data-md-pagination] [aria-current="page"]', '3');
$page->click('[data-md-pagination] button[aria-label="Previous"]') // A relative paging key a retry would page twice: pressOnce(), tests/Pest.php.
->assertSee('11–20 of 25'); pressOnce($page)->click('[data-md-pagination] button[aria-label="Previous"]');
$page->assertSee('11–20 of 25');
$page->resize(400, 800) $page->resize(400, 800)
->assertSee('Page 2 of 3'); ->assertSee('Page 2 of 3');
+279 -94
View File
@@ -53,6 +53,7 @@ function dateProbe(string $locale = 'en')
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -108,6 +109,7 @@ function dateFormatProbe(string $locale = 'en')
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -148,6 +150,7 @@ function scopedDateProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -176,9 +179,12 @@ function focusedDay(string $date): string
it('opens the docked picker under its field and walks the grid from the keyboard', function () { it('opens the docked picker under its field and walks the grid from the keyboard', function () {
$picker = "document.querySelector('#expires-field-picker')"; $picker = "document.querySelector('#expires-field-picker')";
$page = dateProbe() $page = dateProbe();
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.mdPresentation === 'docked'") // 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') ->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("(() => { 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')); ->assertScript(focusedDay('2026-09-13'));
@@ -201,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 () { it('keeps focus and choice inside min and max', function () {
$page = dateProbe() $page = dateProbe();
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript(focusedDay('2026-09-13')); // 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. // Playwright will not press an aria-disabled cell, so the press is dispatched.
$page->assertAttribute(day('expires-field', '2026-09-09'), 'aria-disabled', 'true') $page->assertAttribute(day('expires-field', '2026-09-09'), 'aria-disabled', 'true')
@@ -239,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 () { it('opens the modal picker, jumps through the year grid and confirms with OK', function () {
$dialog = "document.querySelector('#birthday-field-picker')"; $dialog = "document.querySelector('#birthday-field-picker')";
$page = dateProbe() $page = dateProbe();
->click('#birthday-field')
->assertScript("{$dialog}.matches(':modal')") // 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') ->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]') ->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'") ->assertScript("getComputedStyle(document.querySelector('#birthday-field-picker [data-md-datepicker-years]')).display !== 'none'")
@@ -253,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-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'May 3, 1990')
->assertSeeIn('#birthday', '2000-05-17'); ->assertSeeIn('#birthday', '2000-05-17');
$page->click('#birthday-field-picker [data-md-datepicker-confirm]') // The confirm button closes the dialog: pressOnce(), tests/Pest.php.
->assertSeeIn('#birthday', '1990-05-03') pressOnce($page)->click('#birthday-field-picker [data-md-datepicker-confirm]');
$page->assertSeeIn('#birthday', '1990-05-03')
->assertScript("! {$dialog}.open"); ->assertScript("! {$dialog}.open");
}); });
@@ -265,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->script("document.querySelector('#birthday-field').focus()");
$page->keys('#birthday-field', 'Enter') // Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php.
->assertScript("{$dialog}.open") pressOnce($page)->keys('#birthday-field', 'Enter');
$page->assertScript("{$dialog}.open")
->assertScript(focusedDay('2000-05-17')); ->assertScript(focusedDay('2000-05-17'));
$page->keys(':focus', 'ArrowRight') $page->keys(':focus', 'ArrowRight')
@@ -281,8 +297,11 @@ it('closes on Escape without keeping the draft and hands focus back to the field
// The docked picker too. // The docked picker too.
$page->script("document.querySelector('[aria-controls=\"expires-field-picker\"][data-md-datepicker-toggle]').focus()"); $page->script("document.querySelector('[aria-controls=\"expires-field-picker\"][data-md-datepicker-toggle]').focus()");
$page->keys(':focus', 'Enter') // Enter on the focused toggle button is a native click on it, and it toggles: pressOnce(),
->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')") // 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')); ->assertScript(focusedDay('2026-09-13'));
$page->keys(':focus', 'Escape') $page->keys(':focus', 'Escape')
@@ -293,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 () { it('reads the modal input in the locale\'s format and explains a date it cannot take', function () {
$dialog = "document.querySelector('#delivery-field-picker')"; $dialog = "document.querySelector('#delivery-field-picker')";
$page = dateProbe() $page = dateProbe();
->click('#delivery-field')
->assertScript("{$dialog}.matches(':modal')") // 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'") ->assertScript("document.activeElement.id === 'delivery-field-entry'")
->assertSeeIn('#delivery-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'Entered date'); ->assertSeeIn('#delivery-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'Entered date');
$page->type('#delivery-field-entry', '2026-03-07') $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') // 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') ->assertAttribute('#delivery-field-entry', 'aria-invalid', 'true')
->assertScript("{$dialog}.open"); ->assertScript("{$dialog}.open");
@@ -319,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 () { it('picks a range: a start, an end, and the days between', function () {
$page = dateProbe() $page = dateProbe();
->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]')
->assertAttribute(day('trip-field', '2026-09-14'), 'aria-selected', 'true');
$page->click(day('trip-field', '2026-09-20')) // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-md-selected')") 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')"); ->assertScript("! document.querySelector('".day('trip-field', '2026-09-14')."').hasAttribute('data-md-between')");
$page->click(day('trip-field', '2026-09-24')) pressOnce($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'")
$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')") ->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'); ->assertSeeIn('#trip', '2026-09-15');
$page->click('#trip-field-picker [data-md-datepicker-confirm]') // The confirm button closes the dialog: pressOnce(), tests/Pest.php.
->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}') 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'); ->assertValue('#trip-field', '09/20/2026 – 09/24/2026');
}); });
@@ -341,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')"; $firstWeekday = "document.querySelector('#expires-field-picker thead th').getAttribute('abbr')";
$page = dateProbe('de') $page = dateProbe('de')
->assertValue('#expires-field', '13.09.2026') ->assertValue('#expires-field', '13.09.2026');
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript("{$firstWeekday} === 'Montag'") // 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("document.querySelector('#expires-field-month').textContent === 'September 2026'")
->assertScript(focusedDay('2026-09-13')); ->assertScript(focusedDay('2026-09-13'));
@@ -354,15 +391,20 @@ it('follows the locale: German weeks start on Monday, American ones on Sunday',
->assertSeeIn('#expires', '2026-09-20') ->assertSeeIn('#expires', '2026-09-20')
->assertAttribute('#expires-field', 'placeholder', 'DD.MM.YYYY'); ->assertAttribute('#expires-field', 'placeholder', 'DD.MM.YYYY');
dateProbe('en_US') $page = dateProbe('en_US');
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript("{$firstWeekday} === 'Sunday'"); 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 () { it('stays open, where it was, through a Livewire render', function () {
$page = dateProbe() $page = dateProbe();
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript(focusedDay('2026-09-13')); // 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')); $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
@@ -375,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->keys(day('expires-field', '2026-09-14'), 'Enter')->assertSeeIn('#expires', '2026-09-14');
$page->click('#birthday-field') // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
->script('window.eval("Livewire.first().touch()")'); pressOnce($page)->click('#birthday-field');
$page->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '2') $page->assertSeeIn('#renders', '2')
->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')"); ->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')");
}); });
it('opens a docked picker as a modal one on a compact window', function () { it('opens a docked picker as a modal one on a compact window', function () {
dateProbe() $page = dateProbe()
->resize(400, 800) ->resize(400, 800);
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')") // 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'"); ->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-md-datepicker-header]')).display !== 'none'");
}); });
@@ -394,22 +441,28 @@ it('empties a date, or both ends of a range, with its clear button', function ()
$page = dateProbe() $page = dateProbe()
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('[data-md-field]').querySelector('[data-md-field-clear]')).display !== 'none'"); ->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]') // The clear button vanishes once its field is empty: pressOnce(), tests/Pest.php.
->assertScript("document.querySelector('#expires').textContent === ''") pressOnce($page)->click('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]');
$page->assertScript("document.querySelector('#expires').textContent === ''")
->assertValue('#expires-field', '') ->assertValue('#expires-field', '')
->assertScript("document.activeElement.id === 'expires-field'") ->assertScript("document.activeElement.id === 'expires-field'")
->assertScript("getComputedStyle(document.querySelector('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'"); ->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]') pressOnce($page)->click('[data-md-datepicker]:has(#trip-field) [data-md-field-clear]');
->assertSeeIn('#trip', '{"start":null,"end":null}')
$page->assertSeeIn('#trip', '{"start":null,"end":null}')
->assertValue('#trip-field', ''); ->assertValue('#trip-field', '');
}); });
it('starts the week on the day week-start names, whatever the locale says', function () { it('starts the week on the day week-start names, whatever the locale says', function () {
$page = dateFormatProbe('de') $page = dateFormatProbe('de')
->assertValue('#sunday-field', '13.09.2026') ->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'") // 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("document.querySelector('#sunday-field-picker tbody td').dataset.mdValue === '2026-08-30'")
->assertScript(focusedDay('2026-09-13')); ->assertScript(focusedDay('2026-09-13'));
@@ -433,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') ->assertSee('Date does not match expected pattern: YYYY-MM-DD')
->assertSeeIn('#iso', '2026-10-01'); ->assertSeeIn('#iso', '2026-10-01');
$page->click('[aria-controls="iso-field-picker"][data-md-datepicker-toggle]') // The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
->assertScript(focusedDay('2026-10-01')); 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')); $page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-10-02'));
@@ -447,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')"; $dialog = "document.querySelector('#dotted-field-picker')";
$page = dateFormatProbe() $page = dateFormatProbe()
->assertValue('#dotted-field', '13.09.2026') ->assertValue('#dotted-field', '13.09.2026');
->click('#dotted-field')
->assertScript("{$dialog}.matches(':modal')") // 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'") ->assertScript("document.activeElement.id === 'dotted-field-entry'")
->assertValue('#dotted-field-entry', '13.09.2026') ->assertValue('#dotted-field-entry', '13.09.2026')
->assertAttribute('#dotted-field-entry', 'placeholder', 'DD.MM.YYYY'); ->assertAttribute('#dotted-field-entry', 'placeholder', 'DD.MM.YYYY');
@@ -474,40 +532,89 @@ it('lays out and shows a range in the given first day and format', function () {
$page->script("document.querySelector('#span-field').focus()"); $page->script("document.querySelector('#span-field').focus()");
$page->keys('#span-field', 'Enter') // Enter on the readonly field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php.
->assertScript("{$dialog}.matches(':modal')") pressOnce($page)->keys('#span-field', 'Enter');
$page->assertScript("{$dialog}.matches(':modal')")
->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'") ->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'")
->assertScript(focusedDay('2026-09-13')); ->assertScript(focusedDay('2026-09-13'));
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12')); $page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12'));
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18')); $page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18'));
$page->click(day('span-field', '2026-09-20')) // A day pressed twice ends the range it began: pressOnce(), tests/Pest.php.
->click(day('span-field', '2026-09-24')) pressOnce($page)->click(day('span-field', '2026-09-20'))
->click('#span-field-picker [data-md-datepicker-confirm]') ->click(day('span-field', '2026-09-24'));
->assertSeeIn('#span', '{"start":"2026-09-20","end":"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'); ->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 () { 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, // 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. // where the last picker's null min and Monday start would overwrite the first's.
$page = scopedDateProbe() $page = scopedDateProbe();
->click('[aria-controls="early-field-picker"][data-md-datepicker-toggle]')
->assertScript(focusedDay('2026-09-13')); // 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') $page->assertAttribute(day('early-field', '2026-09-09'), 'aria-disabled', 'true')
->assertScript('! (\'min\' in Alpine.$data(document.querySelector(\'[x-data="{ step: 1 }"]\')))'); ->assertScript('! (\'min\' in Alpine.$data(document.querySelector(\'[x-data="{ step: 1 }"]\')))');
}); });
/**
* What the browser would hit at the centre of `$selector`, as `tag#id[attributes]`, and whether the
* element itself is the answer. Playwright waits for a click target to be visible, still and on top,
* and gives up with nothing but a timeout when it never is; this says what stood in the way, on a
* runner where a layout that works elsewhere does not.
*/
function hitTarget(string $selector): string
{
return <<<JS
(() => {
const target = document.querySelector("{$selector}")
if (! target) return 'nothing matches {$selector}'
const box = target.getBoundingClientRect()
const top = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2)
if (top === null) return `outside the viewport: \${JSON.stringify(box)}`
if (target === top || target.contains(top)) return 'the target'
const name = (element) => element.tagName.toLowerCase() + (element.id ? '#' + element.id : '')
+ [...element.attributes].filter((a) => a.name.startsWith('data-md')).map((a) => `[\${a.name}]`).join('')
return `\${name(top)} covers \${name(target)}`
})()
JS;
}
it('opens the range picker full screen below 600px and grows its month list both ways by keyboard paging', function () { it('opens the range picker full screen below 600px and grows its month list both ways by keyboard paging', function () {
$picker = "document.querySelector('#trip-field-picker')"; $picker = "document.querySelector('#trip-field-picker')";
$scope = "Alpine.\$data(document.querySelector('[data-md-datepicker]:has(#trip-field)'))"; $scope = "Alpine.\$data(document.querySelector('[data-md-datepicker]:has(#trip-field)'))";
$page = dateProbe() $page = dateProbe()
->resize(390, 800) ->resize(390, 800)
->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]') // Explicit, not implicit: the resize's own reflow can still be under way when the press
->assertScript("{$picker}.dataset.mdPresentation === 'full'") // that follows lands.
->assertScript('window.innerWidth === 390')
->assertScript(settled('document.documentElement', subtree: true));
expect($page->script(hitTarget('[aria-controls=\"trip-field-picker\"][data-md-datepicker-toggle]')))->toBe('the target');
// Every press into the full-screen picker goes through pressOnce(): it re-renders every day of
// every month it holds on each one, which on a loaded runner takes longer than the plugin's
// second for an attempt — and a retried press is a second press. The first one here opens the
// picker over its own toggle, so a second could never land.
pressOnce($page)->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]');
$page->assertScript("{$picker}.dataset.mdPresentation === 'full'")
->assertScript("{$picker}.matches(':modal')") ->assertScript("{$picker}.matches(':modal')")
->assertScript("document.querySelector('#trip-field-picker [data-md-datepicker-app-bar]').offsetHeight > 0") ->assertScript("document.querySelector('#trip-field-picker [data-md-datepicker-app-bar]').offsetHeight > 0")
// M3's 14-element anatomy: a close (×) icon button and Save in the app bar, not Cancel/OK. // M3's 14-element anatomy: a close (×) icon button and Save in the app bar, not Cancel/OK.
@@ -520,7 +627,7 @@ it('opens the range picker full screen below 600px and grows its month list both
// Paging seven months forward crosses the window's forward edge, growing it to cover the month walked to. // Paging seven months forward crosses the window's forward edge, growing it to cover the month walked to.
foreach (range(1, 7) as $ignored) { foreach (range(1, 7) as $ignored) {
$page->keys(':focus', 'PageDown'); pressOnce($page)->keys(':focus', 'PageDown');
} }
$page->assertScript("{$scope}.monthsTo > '2027-03-01'") $page->assertScript("{$scope}.monthsTo > '2027-03-01'")
@@ -529,7 +636,7 @@ it('opens the range picker full screen below 600px and grows its month list both
// Paging fourteen months back does the same the other way, past the original start. // Paging fourteen months back does the same the other way, past the original start.
foreach (range(1, 14) as $ignored) { foreach (range(1, 14) as $ignored) {
$page->keys(':focus', 'PageUp'); pressOnce($page)->keys(':focus', 'PageUp');
} }
$page->assertScript("{$scope}.monthsFrom < '2026-03-01'") $page->assertScript("{$scope}.monthsFrom < '2026-03-01'")
@@ -540,31 +647,95 @@ it('opens the range picker full screen below 600px and grows its month list both
// script() returns the script's result, not the page, so the chain starts again after it. // script() returns the script's result, not the page, so the chain starts again after it.
$page->script("{$scope}.moveTo('2026-09-13', false)"); $page->script("{$scope}.moveTo('2026-09-13', false)");
$page->click(day('trip-field', '2026-09-20')) pressOnce($page)->click(day('trip-field', '2026-09-20'))
->click(day('trip-field', '2026-09-24')) ->click(day('trip-field', '2026-09-24'));
->click('#trip-field-picker [data-md-datepicker-save]')
->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}') // What Save is about to commit, read from the picker before it is pressed: a day pressed twice
// makes it the range's end as well as its start, and the next press then begins a new range —
// which otherwise shows up only as the value never reaching the server.
$page->assertScript("JSON.stringify({$scope}.draft) === '{\"start\":\"2026-09-20\",\"end\":\"2026-09-24\"}'");
pressOnce($page)->click('#trip-field-picker [data-md-datepicker-save]');
$page->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}')
->assertScript("! {$picker}.open"); ->assertScript("! {$picker}.open");
}); });
it('keeps the months in view where they were when the full-screen range picker adds months above them', function () {
$picker = "document.querySelector('#trip-field-picker')";
$page = dateProbe()
->resize(390, 800)
// Settled before the press, as the test above says at length.
->assertScript('window.innerWidth === 390')
->assertScript(settled('document.documentElement', subtree: true));
pressOnce($page)->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]');
$page->assertScript("{$picker}.dataset.mdPresentation === 'full'")
->assertScript(focusedDay('2026-09-13'));
// Scrolled close to the top, the list grows by six months above what is on screen. What sits
// under a fixed point of the list stays what sat there, rather than the list being put back
// twice — once by the picker and once more by the browser's own scroll anchoring — and
// landing half a year on.
$result = $page->script(onceInPage(<<<'JS'
(async () => {
const list = document.querySelector('#trip-field-picker [data-md-datepicker-calendar]')
const scope = Alpine.$data(document.querySelector('[data-md-datepicker]:has(#trip-field)'))
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
const under = () => {
const box = list.getBoundingClientRect()
const element = document.elementFromPoint(box.left + box.width / 2, box.top + 150)
return element?.closest('[data-md-value]')?.dataset.mdValue ?? element?.textContent.trim() ?? null
}
const from = scope.monthsFrom
list.scrollTop = 300
const before = under()
for (let i = 0; i < 4; i++) {
await frame()
}
return { grew: scope.monthsFrom < from, before, after: under() }
})()
JS));
expect($result['grew'])->toBeTrue()
->and($result['before'])->not->toBeNull()
->and($result['after'])->toBe($result['before']);
});
it('closes the full-screen range picker from its app bar close button without keeping the draft', function () { it('closes the full-screen range picker from its app bar close button without keeping the draft', function () {
$picker = "document.querySelector('#trip-field-picker')"; $picker = "document.querySelector('#trip-field-picker')";
dateProbe() $page = dateProbe()
->resize(390, 800) ->resize(390, 800)
->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]') // Settled before the press, as the test above says at length.
->assertScript('window.innerWidth === 390')
->assertScript(settled('document.documentElement', subtree: true));
expect($page->script(hitTarget('[aria-controls=\"trip-field-picker\"][data-md-datepicker-toggle]')))->toBe('the target');
pressOnce($page)->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]')
->click(day('trip-field', '2026-09-20')) ->click(day('trip-field', '2026-09-20'))
->click('#trip-field-picker [data-md-datepicker-close]') ->click('#trip-field-picker [data-md-datepicker-close]');
->assertScript("! {$picker}.open")
$page->assertScript("! {$picker}.open")
->assertSeeIn('#trip', '{"start":"2026-09-13","end":"2026-09-15"}'); ->assertSeeIn('#trip', '{"start":"2026-09-13","end":"2026-09-15"}');
}); });
it('reaches the month and year dropdowns with Shift+M and Shift+Y', function () { it('reaches the month and year dropdowns with Shift+M and Shift+Y', function () {
$toggle = '[aria-controls="expires-field-picker"][data-md-datepicker-toggle]'; $toggle = '[aria-controls="expires-field-picker"][data-md-datepicker-toggle]';
$page = dateProbe() $page = dateProbe();
->click($toggle)
->assertScript(focusedDay('2026-09-13')); // 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') $page->keys(':focus', 'Shift+M')
->assertScript("document.activeElement.dataset.mdDatepickerMenuButton === 'months'"); ->assertScript("document.activeElement.dataset.mdDatepickerMenuButton === 'months'");
@@ -572,8 +743,9 @@ it('reaches the month and year dropdowns with Shift+M and Shift+Y', function ()
$page->keys(':focus', 'Escape') $page->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')"); ->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')");
$page->click($toggle) pressOnce($page)->click($toggle);
->assertScript(focusedDay('2026-09-13'));
$page->assertScript(focusedDay('2026-09-13'));
$page->keys(':focus', 'Shift+Y') $page->keys(':focus', 'Shift+Y')
->assertScript("document.activeElement.dataset.mdDatepickerMenuButton === 'years'"); ->assertScript("document.activeElement.dataset.mdDatepickerMenuButton === 'years'");
@@ -588,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')"; $hasBoth = fn (string $expr): string => "({$expr}).classList.contains('md-state-layer') && ({$expr}).classList.contains('md-focus-ring')";
$page = dateProbe() $page = dateProbe();
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript($hasBoth("document.querySelector('{$monthButton}')")) // 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}')")) ->assertScript($hasBoth("document.querySelector('{$yearButton}')"))
->click($monthButton) ->click($monthButton)
// toggleView() scrolls the selected option into view and focuses it once settled. // toggleView() scrolls the selected option into view and focuses it once settled.
@@ -607,8 +782,10 @@ it('draws the shared state layer and focus ring on the menu buttons, the year an
$page->keys(':focus', 'Escape') $page->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')"); ->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')");
$page->click('#birthday-field') // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')") pressOnce($page)->click('#birthday-field');
$page->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')")
->click($modalYearButton) ->click($modalYearButton)
->assertScript($hasBoth("document.querySelector('{$modalYearButton}')")) ->assertScript($hasBoth("document.querySelector('{$modalYearButton}')"))
->assertScript("document.activeElement === {$selectedYear}") ->assertScript("document.activeElement === {$selectedYear}")
@@ -621,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'); $disabledDay = day('expires-field', '2026-09-09');
$disabledSpan = "document.querySelector('{$disabledDay} > span')"; $disabledSpan = "document.querySelector('{$disabledDay} > span')";
$page = dateProbe() $page = dateProbe();
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript("document.querySelector('{$selectedDay}').hasAttribute('data-md-selected')") // 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') ->assertAttribute($disabledDay, 'aria-disabled', 'true')
// The day's own fill transitions in when the popover opens: let it settle before reading // 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. // a resting value, or the "unchanged by hover" comparison below race against that instead.
@@ -650,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 // 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. // all — the same rule hides its layer regardless.
$page->click('#birthday-field') // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')") pressOnce($page)->click('#birthday-field');
$page->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')")
->hover('#birthday-field-picker [data-md-blank] > span >> nth=0') ->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'"); ->assertScript("getComputedStyle([...document.querySelectorAll('#birthday-field-picker [data-md-blank] > span')][0], '::before').display === 'none'");
}); });
@@ -662,7 +844,10 @@ it('keeps the day indicator its fixed 40px size at a 20px root font size, growin
$font = "getComputedStyle({$daySpan}).fontSize"; $font = "getComputedStyle({$daySpan}).fontSize";
$picker = "document.querySelector('#expires-field-picker')"; $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); $restingWidth = (int) $page->script($width);
$restingFont = $page->script($font); $restingFont = $page->script($font);
+70 -31
View File
@@ -73,6 +73,7 @@ function fieldProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -102,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 () { it('clears a field and tells Livewire', function () {
fieldProbe() $page = fieldProbe()
->type('#name-field', 'Holiday') ->type('#name-field', 'Holiday')
->assertSeeIn('#name', 'Holiday') ->assertSeeIn('#name', 'Holiday');
->click('[data-md-field-clear]')
->assertScript("document.querySelector('#name-field').value === ''") // 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.querySelector('#name').textContent === ''")
->assertScript("document.activeElement.id === 'name-field'"); ->assertScript("document.activeElement.id === 'name-field'");
}); });
@@ -122,13 +126,18 @@ it('copies a field\'s value and says so', function () {
}); });
it('shows and hides a password', function () { it('shows and hides a password', function () {
fieldProbe() $page = fieldProbe()
->type('#password-field', 'secret') ->type('#password-field', 'secret');
->click('[data-md-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'text'") // The reveal button toggles on every press: pressOnce(), tests/Pest.php.
->assertAttribute('[data-md-field-reveal]', 'aria-label', 'Hide password') pressOnce($page)->click('[data-md-field-reveal]');
->click('[data-md-field-reveal]')
->assertScript("document.querySelector('#password-field').type === 'password'"); $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 () { it('shows the server\'s error in place of the hint', function () {
@@ -161,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->assertScript("getComputedStyle({$select}).appearance === 'base-select'");
$page->click('#hours-field') // Opens the customizable select's list over the field that triggers it: pressOnce(), tests/Pest.php.
->assertScript("{$select}.matches(':open')") 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. // M3's menu row height (menu-item.css's 48px), not the browser's own tiny option row.
->assertScript("parseFloat(getComputedStyle({$option}).minBlockSize) === 48"); ->assertScript("parseFloat(getComputedStyle({$option}).minBlockSize) === 48");
}); });
@@ -219,6 +230,7 @@ it('ends a select\'s value before its arrow with an ellipsis, at every size and
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -248,30 +260,42 @@ it('keeps a partly ticked checkbox in step with the server', function () {
$page = fieldProbe()->assertScript("! {$all}.indeterminate"); $page = fieldProbe()->assertScript("! {$all}.indeterminate");
$page->click('label[for="file-a"]') // A checkbox toggles on every press: pressOnce(), tests/Pest.php.
->assertSeeIn('#files', 'a') pressOnce($page)->click('label[for="file-a"]');
$page->assertSeeIn('#files', 'a')
->assertScript("{$all}.hasAttribute('data-md-indeterminate') && {$all}.indeterminate"); ->assertScript("{$all}.hasAttribute('data-md-indeterminate') && {$all}.indeterminate");
$page->click('label[for="file-b"]') pressOnce($page)->click('label[for="file-b"]')
->click('label[for="file-c"]') ->click('label[for="file-c"]');
->assertSeeIn('#files', 'a,b,c')
$page->assertSeeIn('#files', 'a,b,c')
->assertScript("! {$all}.hasAttribute('data-md-indeterminate') && ! {$all}.indeterminate"); ->assertScript("! {$all}.hasAttribute('data-md-indeterminate') && ! {$all}.indeterminate");
}); });
it('moves between radio buttons with the arrow keys', function () { it('moves between radio buttons with the arrow keys', function () {
fieldProbe() $page = fieldProbe();
->keys('input[type="radio"][value="anyone"]', 'ArrowDown')
->assertSeeIn('#audience', 'team') // 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"); ->assertScript("document.querySelector('input[type=\"radio\"][value=\"team\"]').checked");
}); });
it('turns a switch on from the keyboard and from its label', function () { it('turns a switch on from the keyboard and from its label', function () {
$page = fieldProbe() $page = fieldProbe();
->keys('#public', 'Space')
->assertSeeIn('#public-state', 'true');
$page->click('label[for="public"]') // Space toggles a switch on every press: pressOnce(), tests/Pest.php.
->assertSeeIn('#public-state', 'false') 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'); ->assertAttribute('#public', 'role', 'switch');
}); });
@@ -336,6 +360,7 @@ function fieldStateProbe(): mixed
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -391,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 // 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. // 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) { 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')); expect($page->script(settledEdge('select-field')))->toBe('2px '.$role('primary'));
$page->keys('#select-field', 'Escape')->assertScript("! document.getElementById('select-field').matches(':open')"); $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')); expect($page->script(settledEdge('invalid-select-field')))->toBe('2px '.$role('error'));
} }
@@ -431,6 +462,7 @@ function fieldWidthProbe(int $width)
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
<style>.probe-narrow { max-width: 200px; }</style> <style>.probe-narrow { max-width: 200px; }</style>
@@ -509,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 = fieldProbe()->assertScript("{$handle} === '52,32,16,16,16'");
$page->keys('#public', 'Space') // Space toggles a switch on every press: pressOnce(), tests/Pest.php.
->assertSeeIn('#public-state', 'true') pressOnce($page)->keys('#public', 'Space');
$page->assertSeeIn('#public-state', 'true')
->wait(0.7) ->wait(0.7)
->assertScript("{$handle} === '52,32,24,36,16'"); ->assertScript("{$handle} === '52,32,24,36,16'");
}); });
@@ -520,6 +554,7 @@ it('keeps a switch\'s track and a checkbox\'s box whole beside long text in a ro
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
</head> </head>
@@ -555,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. // 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]) 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]) ->and($page->script($control('row-labelled-switch', '[data-md-switch]', '[data-md-toggle]')))->toBe([52, true, true])
+30 -14
View File
@@ -19,6 +19,7 @@ function layoutPage(string $body, int $width, int $height = 900, string $dir = '
<!DOCTYPE html> <!DOCTYPE html>
<html dir="{$dir}" lang="en"> <html dir="{$dir}" lang="en">
<head> <head>
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@@ -249,8 +250,11 @@ it('shows the list alone below expanded, and the detail with a back button once
$page = listDetailPage(599); $page = listDetailPage(599);
$page->script("document.querySelector('a[href=\"#message-2\"]').focus()"); $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') ->assertSeeIn('#detail-text', 'The message')
->assertScript('! '.listDetailVisible(LIST_PANE)) ->assertScript('! '.listDetailVisible(LIST_PANE))
->assertScript(listDetailVisible(DETAIL_PANE)) ->assertScript(listDetailVisible(DETAIL_PANE))
@@ -258,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.activeElement === document.querySelector('".DETAIL_PANE."')")
->assertScript("document.querySelector('".DETAIL_PANE." [data-md-list-detail-back] button').checkVisibility()"); ->assertScript("document.querySelector('".DETAIL_PANE." [data-md-list-detail-back] button').checkVisibility()");
$page->click(DETAIL_PANE.' [data-md-list-detail-back] button') // The back button closes the detail pane: pressOnce(), tests/Pest.php.
->assertSeeIn('#selected', 'NULL') pressOnce($page)->click(DETAIL_PANE.' [data-md-list-detail-back] button');
$page->assertSeeIn('#selected', 'NULL')
->assertScript(listDetailVisible(LIST_PANE)) ->assertScript(listDetailVisible(LIST_PANE))
->assertScript('! '.listDetailVisible(DETAIL_PANE)) ->assertScript('! '.listDetailVisible(DETAIL_PANE))
// And back to the item it came from. // And back to the item it came from.
@@ -302,8 +308,10 @@ it('mirrors the list-detail in a right-to-left document', function () {
$page = listDetailPage(599, 'rtl'); $page = listDetailPage(599, 'rtl');
$page->click('a[href="#message-1"]') // The selected item's link is hidden once the detail pane covers it: pressOnce(), tests/Pest.php.
->assertSeeIn('#selected', '1') 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'"); ->assertScript("getComputedStyle(document.querySelector('".DETAIL_PANE." [data-md-list-detail-back] svg')).transform !== 'none'");
}); });
@@ -320,14 +328,18 @@ it('selects from Alpine through x-model, with the layout\'s own back row', funct
$page = layoutPage($body, 599); $page = layoutPage($body, 599);
$page->click('#pick') // The list pane is hidden once the button that picks it is covered: pressOnce(), tests/Pest.php.
->assertSeeIn('#chosen', 'b') pressOnce($page)->click('#pick');
$page->assertSeeIn('#chosen', 'b')
->assertSeeIn('#shown', 'b') ->assertSeeIn('#shown', 'b')
->assertScript('! '.listDetailVisible(LIST_PANE)) ->assertScript('! '.listDetailVisible(LIST_PANE))
->assertScript("document.querySelector('[data-md-list-detail-back-row]').checkVisibility()"); ->assertScript("document.querySelector('[data-md-list-detail-back-row]').checkVisibility()");
$page->click('[data-md-list-detail-back-row] button') // The back button closes the detail pane: pressOnce(), tests/Pest.php.
->assertSeeIn('#chosen', 'null') pressOnce($page)->click('[data-md-list-detail-back-row] button');
$page->assertSeeIn('#chosen', 'null')
->assertScript(listDetailVisible(LIST_PANE)) ->assertScript(listDetailVisible(LIST_PANE))
->assertScript("document.activeElement === document.querySelector('#pick')"); ->assertScript("document.activeElement === document.querySelector('#pick')");
}); });
@@ -385,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(layoutRect(SUPPORTING_SIDE, 'bottom').' === window.innerHeight')
->assertScript(layoutStyle(SUPPORTING_SIDE, 'borderTopLeftRadius')." === '28px'"); ->assertScript(layoutStyle(SUPPORTING_SIDE, 'borderTopLeftRadius')." === '28px'");
$page->click($handle) // The handle toggles the sheet on every press: pressOnce(), tests/Pest.php.
->assertAttribute($handle, 'aria-expanded', 'true') pressOnce($page)->click($handle);
$page->assertAttribute($handle, 'aria-expanded', 'true')
->assertScript("document.querySelector('#comment').checkVisibility()") ->assertScript("document.querySelector('#comment').checkVisibility()")
->assertScript(layoutRect(SUPPORTING_SIDE, 'bottom').' === window.innerHeight'); ->assertScript(layoutRect(SUPPORTING_SIDE, 'bottom').' === window.innerHeight');
@@ -559,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 = layoutReady(visit('/material/layout/list-detail')->resize(599, 900));
$compactListDetail->click('#example-list-detail a:has-text("Chiara Rossi")') // The selected item's link is hidden once the detail pane covers it: pressOnce(), tests/Pest.php.
->assertSeeIn('#example-list-detail [data-md-list-detail-pane="detail"]', 'Train times for Friday') 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()") ->assertScript("! document.querySelector('#example-list-detail [data-md-list-detail-pane=\"list\"]').checkVisibility()")
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
+102 -66
View File
@@ -33,6 +33,7 @@ function firstPaintProbe(): void
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -101,8 +102,10 @@ it('starts the rail collapsed across the expanded class and expands it in the la
->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'") ->assertScript('getComputedStyle('.BOTTOM_BAR.").display === 'none'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false'); ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false');
$page->click('[data-md-navigation-rail-menu]') // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
->assertScript(railWidth(256)) pressOnce($page)->click('[data-md-navigation-rail-menu]');
$page->assertScript(railWidth(256))
->assertScript('! '.RAIL.".hasAttribute('data-md-open')") ->assertScript('! '.RAIL.".hasAttribute('data-md-open')")
->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-scrim]')).display === 'none'") ->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-scrim]')).display === 'none'")
->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null") ->assertScript("document.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
@@ -193,7 +196,10 @@ it('morphs the rail header FAB between collapsed (icon only) and extended (with
->assertScript("getComputedStyle({$label}).maxWidth === '0px'") ->assertScript("getComputedStyle({$label}).maxWidth === '0px'")
->assertScript("Math.round({$fab}.getBoundingClientRect().width) === Math.round({$fab}.getBoundingClientRect().height)"); ->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 // 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. // transition; wait past it for the settled, extended end state.
->wait(0.6) ->wait(0.6)
@@ -215,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'") ->assertScript("window.eval('window.firstPaint.width') === '256px'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true'); ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'true');
$page->click('[data-md-navigation-rail-menu]') // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'") pressOnce($page)->click('[data-md-navigation-rail-menu]');
$page->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'")
->assertScript("localStorage.getItem('material-rail') === 'collapsed'") ->assertScript("localStorage.getItem('material-rail') === 'collapsed'")
->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false') ->assertAttribute('[data-md-navigation-rail-menu]', 'aria-expanded', 'false')
->assertScript(railWidth(96)); ->assertScript(railWidth(96));
@@ -248,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. // 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->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('Math.round('.RAIL_PANEL.'.getBoundingClientRect().width) === 256')
->assertScript(railWidth(96)) ->assertScript(railWidth(96))
->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-heading]')).display !== 'none'") ->assertScript("getComputedStyle(document.querySelector('[data-md-navigation-rail-heading]')).display !== 'none'")
@@ -262,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.getElementById('content').closest('[aria-hidden=\"true\"]') === null")
->assertScript("document.activeElement === document.querySelector('[data-md-navigation-rail-menu]')"); ->assertScript("document.activeElement === document.querySelector('[data-md-navigation-rail-menu]')");
$page->keys('[data-md-navigation-rail-menu]', 'Enter') // Enter on the focused button is a native click on it, and it toggles the rail: pressOnce(),
->assertScript(RAIL.".hasAttribute('data-md-open')"); // tests/Pest.php.
pressOnce($page)->keys('[data-md-navigation-rail-menu]', 'Enter');
$page->click('[data-md-navigation-rail-scrim]') $page->assertScript(RAIL.".hasAttribute('data-md-open')");
->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"); ->assertScript("localStorage.getItem('material-rail') === null");
}); });
it('slides the modal rail in on a compact window from the app bar\'s menu button', function () { it('slides the modal rail in on a compact window from the app bar\'s menu button', function () {
$page = shellPage(599, 860); $page = shellPage(599, 860);
$page->click('@shell-menu') // Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php.
->assertScript(RAIL.".hasAttribute('data-md-open')") pressOnce($page)->click('@shell-menu');
$page->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'flex'") ->assertScript('getComputedStyle('.RAIL_PANEL.").display === 'flex'")
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0') ->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0')
->assertScript(RAIL_PANEL.'.contains(document.activeElement)'); ->assertScript(RAIL_PANEL.'.contains(document.activeElement)');
@@ -287,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 () { it('keeps the rail and the theme through wire:navigate and moves aria-current', function () {
$page = shellPage(1200); $page = shellPage(1200);
$page->click('[data-md-navigation-rail-menu]') // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
->assertScript("document.documentElement.getAttribute('data-rail') === 'collapsed'"); 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\")"); $page->script("window.eval(\"Alpine.store('theme').set('dark'); window.samePage = true\")");
@@ -358,6 +379,7 @@ function navigationExtrasProbe(): mixed
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -452,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]')"; $rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
$railWidth = "Math.round({$rail}.getBoundingClientRect().width)"; $railWidth = "Math.round({$rail}.getBoundingClientRect().width)";
$page->assertScript("{$railWidth} > 200") $page->assertScript("{$railWidth} > 200");
->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")
->assertScript("! {$rail}.hasAttribute('data-md-open')"); ->assertScript("! {$rail}.hasAttribute('data-md-open')");
// The only way back: an application's own menu button calling $store.rail.show(). // The only way back: an application's own menu button calling $store.rail.show(), which opens
$page->click('#open-hiding-rail') // the rail back over the page: pressOnce(), tests/Pest.php.
->assertScript("{$rail}.hasAttribute('data-md-open')") 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'") ->assertScript("getComputedStyle(document.querySelector('#hiding-rail [data-md-navigation-rail-panel]')).display === 'flex'")
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
}); });
@@ -469,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]')"; $rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
$railWidth = "Math.round({$rail}.getBoundingClientRect().width)"; $railWidth = "Math.round({$rail}.getBoundingClientRect().width)";
$page->click('#hiding-rail [data-md-navigation-rail-menu]') // The menu button toggles the rail on every press: pressOnce(), tests/Pest.php.
->assertScript("{$railWidth} === 0"); 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. // The first interactive rail on the page is this one: the narrow rail above it is fixed.
$page->script(TOGGLE_RAIL); $page->script(TOGGLE_RAIL);
@@ -531,6 +560,7 @@ function railValueProbe(string $mode, int $width = 1280): mixed
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -617,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. // `expanded`: collapsed until the menu button widens it in place.
$page->resize(840, 900) $page->resize(840, 900)
->assertScript(RAIL_VALUE." === 'collapsed'") ->assertScript(RAIL_VALUE." === 'collapsed'");
->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_VALUE." === 'expanded'") ->assertScript(RAIL_VALUE." === 'expanded'")
->assertScript(railShows('expanded-only')) ->assertScript(railShows('expanded-only'))
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
@@ -630,8 +663,10 @@ it('reads expanded while a modal rail is open over the page, and collapsed once
->assertScript(RAIL_VALUE." === 'collapsed'") ->assertScript(RAIL_VALUE." === 'collapsed'")
->assertScript('! '.railShows('expanded-only')); ->assertScript('! '.railShows('expanded-only'));
$page->click('#open-rail') // Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php.
->assertScript(RAIL.".hasAttribute('data-md-open')") pressOnce($page)->click('#open-rail');
$page->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(RAIL_VALUE." === 'expanded'") ->assertScript(RAIL_VALUE." === 'expanded'")
->assertScript(railShows('expanded-only')) ->assertScript(railShows('expanded-only'))
->assertScript('! '.railShows('collapsed-only')); ->assertScript('! '.railShows('collapsed-only'));
@@ -644,34 +679,15 @@ it('reads expanded while a modal rail is open over the page, and collapsed once
}); });
/** /**
* Closes a rail with the given script and reports whether, sampling every few ms in the page * Closes a rail with the given script and reports whether, sampling once a frame in the page
* itself, it was ever caught part-way: the whole close in one round trip, since a separate * itself, it was ever caught part-way (caughtMidExit() in tests/Pest.php). Called after
* `script` and `assertScript` apiece already take as long as the 200ms exit (see ContainmentTest's * slowMotion(), so the panel's own close — on the emphasized-accelerate easing, which stays close
* scrim fades). * to its start for a good part of its run — has moved the couple of pixels `$partWay` looks for
* within a handful of the stretched exit's frames.
*/ */
function caughtMidClose(mixed $page, string $close, string $partWay): bool function caughtMidClose(mixed $page, string $close, string $partWay): bool
{ {
return $page->script(<<<JS return $page->script(caughtMidExit($close, $partWay)) === true;
(async () => {
{$close}
for (let i = 0; i < 60; i++) {
if ({$partWay}) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})()
JS) === true;
}
/**
* Opened and done opening: no transition left on the element. Firefox creates a transition on its
* next refresh tick, so straight after the change its computed style already reads the end value
* while the entry has not begun, and a close then would start from where the entry did.
*/
function settled(string $element): string
{
return "{$element}.getAnimations().length === 0";
} }
/** Still drawn, and strictly between resting at the window's edge and gone past it. */ /** Still drawn, and strictly between resting at the window's edge and gone past it. */
@@ -681,10 +697,16 @@ function slidingOut(string $panel): string
} }
it('slides the compact rail out on close, rather than making it vanish', function () { it('slides the compact rail out on close, rather than making it vanish', function () {
$page = shellPage(599, 860) $page = shellPage(599, 860);
->click('@shell-menu')
->assertScript(RAIL.".hasAttribute('data-md-open')") // Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php.
->assertScript(settled(RAIL_PANEL).' && Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0'); pressOnce($page)->click('@shell-menu');
$page->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled(RAIL_PANEL))
->assertScript('Math.round('.RAIL_PANEL.'.getBoundingClientRect().left) === 0');
slowMotion($page);
expect(caughtMidClose($page, "document.querySelector('[data-md-navigation-rail-scrim]').click()", slidingOut(RAIL_PANEL)))->toBeTrue(); expect(caughtMidClose($page, "document.querySelector('[data-md-navigation-rail-scrim]').click()", slidingOut(RAIL_PANEL)))->toBeTrue();
@@ -698,11 +720,19 @@ it('slides a rail that hides when collapsed out on close, rather than making it
$rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')"; $rail = "document.querySelector('#hiding-rail [data-md-navigation-rail]')";
$panel = "{$rail}.querySelector(':scope > [data-md-navigation-rail-panel]')"; $panel = "{$rail}.querySelector(':scope > [data-md-navigation-rail-panel]')";
$page->click('#hiding-rail [data-md-navigation-rail-menu]') // The menu button toggles the rail, then the open button brings it back over the page:
->assertScript("Math.round({$rail}.getBoundingClientRect().width) === 0") // pressOnce(), tests/Pest.php.
->click('#open-hiding-rail') pressOnce($page)->click('#hiding-rail [data-md-navigation-rail-menu]');
->assertScript("{$rail}.hasAttribute('data-md-open')")
->assertScript(settled($panel)." && Math.round({$panel}.getBoundingClientRect().left) === 0 && Math.round({$panel}.getBoundingClientRect().width) === 256"); $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");
slowMotion($page);
expect(caughtMidClose($page, "{$rail}.querySelector(':scope > [data-md-navigation-rail-scrim]').click()", slidingOut($panel)))->toBeTrue(); expect(caughtMidClose($page, "{$rail}.querySelector(':scope > [data-md-navigation-rail-scrim]').click()", slidingOut($panel)))->toBeTrue();
@@ -714,13 +744,19 @@ 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 () { 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]')"; $scrim = RAIL.".querySelector(':scope > [data-md-navigation-rail-scrim]')";
$page = railValueProbe('modal', 1000) $page = railValueProbe('modal', 1000);
->click('#open-rail')
->assertScript(RAIL.".hasAttribute('data-md-open')") // Opens the modal rail over the button that triggers it: pressOnce(), tests/Pest.php.
->assertScript(settled($scrim)." && getComputedStyle({$scrim}).opacity === '1'"); pressOnce($page)->click('#open-rail');
$page->assertScript(RAIL.".hasAttribute('data-md-open')")
->assertScript(settled($scrim))
->assertScript("getComputedStyle({$scrim}).opacity === '1'");
$fading = "(getComputedStyle({$scrim}).display !== 'none' && parseFloat(getComputedStyle({$scrim}).opacity) > 0.02 && parseFloat(getComputedStyle({$scrim}).opacity) < 0.98)"; $fading = "(getComputedStyle({$scrim}).display !== 'none' && parseFloat(getComputedStyle({$scrim}).opacity) > 0.02 && parseFloat(getComputedStyle({$scrim}).opacity) < 0.98)";
slowMotion($page);
expect(caughtMidClose($page, "{$scrim}.click()", $fading))->toBeTrue(); expect(caughtMidClose($page, "{$scrim}.click()", $fading))->toBeTrue();
// The panel goes straight back into the layout: only the scrim was over the page. // The panel goes straight back into the layout: only the scrim was over the page.
+162 -85
View File
@@ -64,6 +64,7 @@ function pickProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -79,12 +80,17 @@ function pickProbe()
} }
it('keeps the values of filter chips as the property\'s own type', function () { it('keeps the values of filter chips as the property\'s own type', function () {
pickProbe() $page = pickProbe()
->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true') ->assertAttribute('button[aria-pressed="true"]', 'aria-pressed', 'true');
->click('button:has-text("Mon")')
->assertSeeIn('#days', '[2,1]') // A filter chip toggles on every press: pressOnce(), tests/Pest.php.
->click('button:has-text("Tue")') pressOnce($page)->click('button:has-text("Mon")');
->assertSeeIn('#days', '[1]')
$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'"); ->assertScript("[...document.querySelectorAll('[aria-pressed=\"true\"]')].map((chip) => chip.textContent.trim()).join() === 'Mon'");
}); });
@@ -92,9 +98,12 @@ it('filters a searchable choice as it is typed and chooses from the keyboard', f
$list = "document.querySelector('#zone-field-list')"; $list = "document.querySelector('#zone-field-list')";
$page = pickProbe() $page = pickProbe()
->assertValue('#zone-field', 'Zurich') ->assertValue('#zone-field', 'Zurich');
->click('#zone-field')
->assertScript("{$list}.matches(':popover-open')") // 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'); ->assertAttribute('#zone-field', 'aria-expanded', 'true');
$page->type('#zone-field', 'ber') $page->type('#zone-field', 'ber')
@@ -107,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 () { 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') $page->keys('#zone-field', 'Escape')
->assertValue('#zone-field', 'Zurich') ->assertValue('#zone-field', 'Zurich')
->assertSeeIn('#zone', 'Europe/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'); ->assertSeeIn('#zone', 'Europe/Zurich');
}); });
it('opens a searchable choice\'s list above a container that clips', function () { it('opens a searchable choice\'s list above a container that clips', function () {
pickProbe() $page = pickProbe();
->click('#zone-field')
->assertScript(<<<'JS' // 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 list = document.querySelector('#zone-field-list');
const clip = document.querySelector('#clip').getBoundingClientRect(); const clip = document.querySelector('#clip').getBoundingClientRect();
@@ -137,6 +156,7 @@ it('hangs a searchable choice\'s list under its field and as wide, bounded at 40
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -162,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 = 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]); 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->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]); expect($page->script($placed('full-zone')))->toBe([1168, 1168, 0]);
}); });
@@ -176,9 +202,12 @@ it('opens the search view with the results Livewire renders for the query', func
$view = "document.querySelector('#find-view')"; $view = "document.querySelector('#find-view')";
// The combobox is the wrapper around the input, which carries `aria-expanded`. // The combobox is the wrapper around the input, which carries `aria-expanded`.
$page = pickProbe() $page = pickProbe();
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none'") // 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'); ->assertAttribute('[role="combobox"]:has(> #find)', 'aria-expanded', 'true');
$page->type('#find', 'con') $page->type('#find', 'con')
@@ -197,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 () { it('says so when nothing matches, and closes on a press outside or a chosen result', function () {
$view = "document.querySelector('#find-view')"; $view = "document.querySelector('#find-view')";
$page = pickProbe() $page = pickProbe();
->click('#find')
->type('#find', 'zzz') // 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.'); ->assertSeeIn('#find-view', 'No files match.');
// The docked view opens over a scrim that covers the rest of the page, so a // The docked view opens over a scrim that covers the rest of the page, so a press outside the
// press outside the view lands on the scrim. // view lands on the scrim, which closes the view: pressOnce(), tests/Pest.php.
$page->click('[data-md-search]:has(#find) [data-md-search-scrim]') pressOnce($page)->click('[data-md-search]:has(#find) [data-md-search-scrim]');
->assertScript("getComputedStyle({$view}).display === 'none'");
$page->click('#find') $page->assertScript("getComputedStyle({$view}).display === 'none'");
->clear('#find')
->assertScript("{$view}.querySelectorAll('button').length === 3") pressOnce($page)->click('#find');
->click('#find-view button:has-text("review.mp4")')
->assertScript("getComputedStyle({$view}).display === 'none'"); $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'");
}); });
/** /**
@@ -221,30 +258,40 @@ it('says so when nothing matches, and closes on a press outside or a chosen resu
*/ */
function searchCloseSample(string $element, string $expression): string function searchCloseSample(string $element, string $expression): string
{ {
return <<<JS return caughtMidExit(
(async () => { <<<'JS'
const root = document.querySelector('[data-md-search]:has(#find)') const root = document.querySelector('[data-md-search]:has(#find)')
root.querySelector('[data-md-search-scrim]').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) root.querySelector('[data-md-search-scrim]').dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
JS,
<<<JS
(() => {
const element = root.querySelector('{$element}')
const value = {$expression}
for (let i = 0; i < 80; i++) { return getComputedStyle(element).display !== 'none' && value > 0.02 && value < 0.98
const element = root.querySelector('{$element}')
const value = {$expression}
if (getComputedStyle(element).display !== 'none' && value > 0.02 && value < 0.98) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})() })()
JS; JS,
);
} }
it('fades the docked search\'s scrim out on close, rather than making it vanish', function () { it('fades the docked search\'s scrim out on close, rather than making it vanish', function () {
$root = "document.querySelector('[data-md-search]:has(#find)')"; $root = "document.querySelector('[data-md-search]:has(#find)')";
$page = pickProbe() $page = pickProbe();
->click('#find')
// 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 // 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. // `display: none` at the open state's full opacity. And settled, not still fading in: an
->assertScript("(({ display, opacity }) => display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-scrim]')))"); // 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 sampled from there has nothing left
// to fade.
->assertScript("(({ display, opacity }) => display !== 'none' && opacity === '1')(getComputedStyle({$root}.querySelector('[data-md-search-scrim]')))")
->assertScript(settled("{$root}.querySelector('[data-md-search-scrim]')"));
slowMotion($page);
expect($page->script(searchCloseSample('[data-md-search-scrim]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue(); expect($page->script(searchCloseSample('[data-md-search-scrim]', 'parseFloat(getComputedStyle(element).opacity)')))->toBeTrue();
@@ -254,9 +301,16 @@ 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 () { it('keeps the docked search above the page while its view closes', function () {
$view = "document.querySelector('#find-view')"; $view = "document.querySelector('#find-view')";
$page = pickProbe() $page = pickProbe();
->click('#find')
->assertScript("getComputedStyle({$view}).display !== 'none' && getComputedStyle({$view}).opacity === '1'"); // 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));
slowMotion($page);
// The view caught part-way through its fade only counts while the root is still at z-index 50. // The view caught part-way through its fade only counts while the root is still at z-index 50.
expect($page->script(searchCloseSample('[data-md-search-view]', "getComputedStyle(root).zIndex === '50' ? parseFloat(getComputedStyle(element).opacity) : 0")))->toBeTrue(); expect($page->script(searchCloseSample('[data-md-search-view]', "getComputedStyle(root).zIndex === '50' ? parseFloat(getComputedStyle(element).opacity) : 0")))->toBeTrue();
@@ -267,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 () { it('takes the whole screen on a compact window, with a back arrow', function () {
$page = pickProbe()->resize(400, 800); $page = pickProbe()->resize(400, 800);
$page->click('#find') // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
->assertScript("document.querySelector('[data-md-search]').hasAttribute('data-md-full-screen')") pressOnce($page)->click('#find');
->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]') $page->assertScript("document.querySelector('[data-md-search]').hasAttribute('data-md-full-screen')")
->assertScript("! document.querySelector('[data-md-search]').hasAttribute('data-md-open')"); ->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 () { it('says how many results there are, politely, as they change', function () {
$status = "document.querySelector('[data-md-search]:has(#find) [data-md-search-status]')"; $status = "document.querySelector('[data-md-search]:has(#find) [data-md-search-status]')";
$page = pickProbe() $page = pickProbe()
->assertScript("{$status}.getAttribute('aria-live') === 'polite' && {$status}.getAttribute('aria-atomic') === 'true'") ->assertScript("{$status}.getAttribute('aria-live') === 'polite' && {$status}.getAttribute('aria-atomic') === 'true'");
->click('#find')
->assertScript("{$status}.textContent === '3 results'"); // 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') $page->type('#find', 'con')
->assertSeeIn('#query', 'con') ->assertSeeIn('#query', 'con')
@@ -311,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("getComputedStyle({$root}.querySelector('[data-md-search-bar]')).display === 'none'")
->assertScript("{$root}.getBoundingClientRect().width === 48 && {$trigger}.getAttribute('aria-expanded') === 'false'"); ->assertScript("{$root}.getBoundingClientRect().width === 48 && {$trigger}.getAttribute('aria-expanded') === 'false'");
$page->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]') // Opens the search view over the trigger that expands it: pressOnce(), tests/Pest.php.
->assertScript("{$root}.hasAttribute('data-md-open') && {$root}.hasAttribute('data-md-full-screen')") 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("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; })()") ->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. // The toolbar does not shift under the expanded search.
@@ -328,38 +392,44 @@ it('expands the search icon into the full-screen view, and hands focus back to t
* Closes a full-screen search from its back arrow and samples, in the page, for a moment in its * Closes a full-screen search from its back arrow and samples, in the page, for a moment in its
* exit where the layout is still full screen — the root marked, the header bar fixed at the top — * exit where the layout is still full screen — the root marked, the header bar fixed at the top —
* with both the bar and the view part-way through their fade, rather than the bar gone back to * with both the bar and the view part-way through their fade, rather than the bar gone back to
* its resting form (or to nothing, behind the icon) on the first frame. * its resting form (or to nothing, behind the icon) on the first frame. Call it after
* slowMotion(): a loaded runner paints only a handful of frames a second, and a real 350ms exit
* can pass between two of them with nothing in between to catch.
*/ */
function fullScreenCloseSample(string $input): string function fullScreenCloseSample(string $input): string
{ {
return <<<JS return caughtMidExit(
(async () => { <<<JS
const root = document.querySelector('[data-md-search]:has(#{$input})') const root = document.querySelector('[data-md-search]:has(#{$input})')
root.querySelector('[data-md-search-back]').click() root.querySelector('[data-md-search-back]').click()
JS,
<<<'JS'
(() => {
const bar = getComputedStyle(root.querySelector('[data-md-search-bar]'))
const view = getComputedStyle(root.querySelector('[data-md-search-view]'))
const fading = (style) => style.display !== 'none' && parseFloat(style.opacity) > 0.02 && parseFloat(style.opacity) < 0.98
for (let i = 0; i < 80; i++) { return ! root.hasAttribute('data-md-open') && root.hasAttribute('data-md-full-screen') && bar.position === 'fixed' && fading(bar) && fading(view)
const bar = getComputedStyle(root.querySelector('[data-md-search-bar]'))
const view = getComputedStyle(root.querySelector('[data-md-search-view]'))
const fading = (style) => style.display !== 'none' && parseFloat(style.opacity) > 0.02 && parseFloat(style.opacity) < 0.98
if (! root.hasAttribute('data-md-open') && root.hasAttribute('data-md-full-screen') && bar.position === 'fixed' && fading(bar) && fading(view)) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return false
})() })()
JS; JS,
);
} }
it('closes the full-screen view and its bar together, back into the search icon', function () { it('closes the full-screen view and its bar together, back into the search icon', function () {
$root = "document.querySelector('[data-md-search]:has(#find-icon)')"; $root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$trigger = "{$root}.querySelector('[data-md-search-trigger]')"; $trigger = "{$root}.querySelector('[data-md-search-trigger]')";
$page = pickProbe() $page = pickProbe();
->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("{$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-full-screen')")
// Its entry run: Firefox reads a transition's end value until its next refresh tick, so an // 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. // opacity of 1 alone does not say the view has finished fading in.
->assertScript("{$root}.getAnimations({ subtree: true }).length === 0"); ->assertScript(settled($root, subtree: true));
slowMotion($page);
expect($page->script(fullScreenCloseSample('find-icon')))->toBeTrue(); expect($page->script(fullScreenCloseSample('find-icon')))->toBeTrue();
@@ -376,9 +446,13 @@ it('closes a compact window\'s full-screen view and its bar together, back into
$page = pickProbe()->resize(400, 800); $page = pickProbe()->resize(400, 800);
$page->click('#find') // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
->assertScript("{$root}.hasAttribute('data-md-full-screen')") pressOnce($page)->click('#find');
->assertScript("{$root}.getAnimations({ subtree: true }).length === 0");
$page->assertScript("{$root}.hasAttribute('data-md-full-screen')")
->assertScript(settled($root, subtree: true));
slowMotion($page);
expect($page->script(fullScreenCloseSample('find')))->toBeTrue(); expect($page->script(fullScreenCloseSample('find')))->toBeTrue();
@@ -393,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)')"; $root = "document.querySelector('[data-md-search]:has(#find-icon)')";
$shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()"; $shown = fn (string $list): string => "{$root}.querySelector('[data-md-search-{$list}]').checkVisibility()";
$page = pickProbe() $page = pickProbe();
->click('[data-md-search]:has(#find-icon) [data-md-search-trigger]')
->assertScript("({$shown('suggestions')}) && ! ({$shown('results')})") // 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'"); ->assertScript("{$root}.querySelector('[data-md-search-status]').textContent === '3 suggestions'");
$page->type('#find-icon', 'h') $page->type('#find-icon', 'h')
+1
View File
@@ -127,6 +127,7 @@ it('follows a value the server changes through a Livewire morph', function () {
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
+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 () { it('finds a component from anywhere and opens its section', function () {
$page = ready(visit('/material/buttons')); $page = ready(visit('/material/buttons'));
$page->keys('#content', '/') // "/" opens the search view: pressOnce(), tests/Pest.php.
->assertScript("document.activeElement.id === 'showcase-search'"); pressOnce($page)->keys('#content', '/');
$page->assertScript("document.activeElement.id === 'showcase-search'");
$page->type('#showcase-search', 'datepicker') $page->type('#showcase-search', 'datepicker')
->assertSeeIn('[data-md-search-view]', '<x-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 () { it('opens an example on another page at the example', function () {
$page = ready(visit('/material')); $page = ready(visit('/material'));
$page->click('#showcase-search') // Opens the search view over the field that triggers it: pressOnce(), tests/Pest.php.
->type('#showcase-search', 'split button') pressOnce($page)->click('#showcase-search');
$page->type('#showcase-search', 'split button')
->click('[data-showcase-result]:has-text("Example")') ->click('[data-showcase-result]:has-text("Example")')
->assertScript("location.pathname.endsWith('/material/buttons') && location.hash.startsWith('#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; })()'); ->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 () { it('says so when nothing matches', function () {
ready(visit('/material')) $page = ready(visit('/material'));
->click('#showcase-search')
->type('#showcase-search', 'zzzz') // 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'); ->assertSeeIn('[data-md-search-view]', 'Nothing matches');
}); });
+1
View File
@@ -42,6 +42,7 @@ function sliderProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
+2
View File
@@ -127,6 +127,7 @@ it('repaints the contrast level at once, and it survives a wire:navigate', funct
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -165,6 +166,7 @@ it('repaints the page\'s own theme-color meta, and the next page\'s after wire:n
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta name="theme-color" media="print" content="#ffffff" /> <meta name="theme-color" media="print" content="#ffffff" />
<x-theme-script /> <x-theme-script />
+152 -73
View File
@@ -45,6 +45,7 @@ function timeProbe()
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="icon" href="data:,">
<x-theme-script /> <x-theme-script />
@vite(config('livewire-material.showcase.vite')) @vite(config('livewire-material.showcase.vite'))
@livewireStyles @livewireStyles
@@ -72,10 +73,13 @@ function supportText(string $id): string
} }
it('opens the dial on the hour, as the bound time says', function () { it('opens the dial on the hour, as the bound time says', function () {
timeProbe() $page = timeProbe()
->assertValue('#meeting', '9:30 AM') ->assertValue('#meeting', '9:30 AM');
->click('#meeting')
->assertScript("document.querySelector('#meeting-dialog').open") // 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') ->assertAttribute('#meeting', 'aria-expanded', 'true')
->assertSeeIn('#meeting-title', 'Select time') ->assertSeeIn('#meeting-title', 'Select time')
->assertAttribute('#meeting-dialog [data-md-timepicker-box="hour"]', 'aria-pressed', 'true') ->assertAttribute('#meeting-dialog [data-md-timepicker-box="hour"]', 'aria-pressed', 'true')
@@ -89,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 () { it('writes the hour and the minute pressed on the dial to Livewire on OK', function () {
$dial = inPicker('meeting', '[data-md-timepicker-dial]'); $dial = inPicker('meeting', '[data-md-timepicker-dial]');
$page = timeProbe() $page = timeProbe();
->click('#meeting')
->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="3"]') // 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') ->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-dial]', 'data-md-view', 'minute')
->assertAttribute('#meeting-dialog [data-md-timepicker-box="minute"]', 'aria-pressed', 'true') ->assertAttribute('#meeting-dialog [data-md-timepicker-box="minute"]', 'aria-pressed', 'true')
@@ -107,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"]') $page->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="45"]')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="minute"]', '45') ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="minute"]', '45')
->assertSeeIn('#meeting-value', '09:30') ->assertSeeIn('#meeting-value', '09:30');
->click('#meeting-dialog [data-md-timepicker-confirm]')
->assertSeeIn('#meeting-value', '03:45') // 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') ->assertValue('#meeting', '3:45 AM')
->assertScript("! document.querySelector('#meeting-dialog').open") ->assertScript("! document.querySelector('#meeting-dialog').open")
->assertAttribute('#meeting', 'aria-expanded', 'false'); ->assertAttribute('#meeting', 'aria-expanded', 'false');
}); });
it('leaves the time alone when the picker is cancelled', function () { it('leaves the time alone when the picker is cancelled', function () {
timeProbe() $page = timeProbe();
->click('#meeting')
->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="7"]') // Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '07') pressOnce($page)->click('#meeting');
->click('#meeting-dialog [data-md-timepicker-cancel]')
->assertScript("! document.querySelector('#meeting-dialog').open") $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') ->assertSeeIn('#meeting-value', '09:30')
->assertValue('#meeting', '9:30 AM'); ->assertValue('#meeting', '9:30 AM');
}); });
it('turns three o\'clock into 15 with PM', function () { it('turns three o\'clock into 15 with PM', function () {
timeProbe() $page = timeProbe();
->click('#meeting')
->click('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]') // 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') ->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="hour12"][data-md-value="3"]')
->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="15"]') ->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="15"]');
->click('#meeting-dialog [data-md-timepicker-confirm]')
->assertSeeIn('#meeting-value', '15: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'); ->assertValue('#meeting', '3:15 PM');
}); });
@@ -149,9 +171,12 @@ it('draws a German dial with 24 hours, 12 to 23 on the inner ring', function ()
})() })()
JS; JS;
$page = timeProbe() $page = timeProbe();
->click('#alarm')
->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-cycle', '24') // 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("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("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"0\"]').textContent.trim() === '00'")
->assertScript($radius('0').' === 101') ->assertScript($radius('0').' === 101')
@@ -163,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"]') $page->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="15"]')
->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '15') ->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '15')
->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute') ->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-label="minute"][data-md-value="0"]');
->click('#alarm-dialog [data-md-timepicker-confirm]')
->assertSeeIn('#alarm-value', '15:00') // 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'); ->assertValue('#alarm', '15:00');
// The outer ring is the morning. // The outer ring is the morning.
$page->click('#alarm') pressOnce($page)->click('#alarm');
->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="3"]')
$page->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="3"]')
->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '03') ->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-label="minute"][data-md-value="0"]');
->click('#alarm-dialog [data-md-timepicker-confirm]')
->assertSeeIn('#alarm-value', '03:00'); 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 () { it('follows a drag round the dial and settles on the nearest hour', function () {
$dial = inPicker('meeting', '[data-md-timepicker-dial]'); $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. // 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 $send = fn (string $type, int $degrees): string => <<<JS
@@ -215,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->script("document.querySelector('#meeting').focus()");
$page->keys('#meeting', 'Enter') // Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php.
->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]')) pressOnce($page)->keys('#meeting', 'Enter');
$page->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]'))
->keys(':focus', 'ArrowUp') ->keys(':focus', 'ArrowUp')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '10') ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '10')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '10') ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '10')
@@ -232,16 +268,22 @@ it('changes the focused hour and minute with the arrow keys and confirms with En
$page->keys(':focus', ['ArrowDown', 'ArrowLeft']) $page->keys(':focus', ['ArrowDown', 'ArrowLeft'])
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '28') ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '28')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuetext', '28 minutes') ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuetext', '28 minutes');
->keys(':focus', 'Enter')
->assertScript("! document.querySelector('#meeting-dialog').open") // 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'); ->assertSeeIn('#meeting-value', '10:28');
}); });
it('takes a typed time in the input variant and says what is wrong with it', function () { it('takes a typed time in the input variant and says what is wrong with it', function () {
$page = timeProbe() $page = timeProbe();
->click('#meeting')
->click('#meeting-dialog [data-md-timepicker-mode="input"]') // 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') ->assertSeeIn('#meeting-title', 'Enter time')
->assertScript("document.activeElement.id === 'meeting-hour'") ->assertScript("document.activeElement.id === 'meeting-hour'")
->assertValue('#meeting-hour', '09') ->assertValue('#meeting-hour', '09')
@@ -250,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') $page->type('#meeting-hour', '13')
->assertAttribute('#meeting-hour', 'aria-invalid', 'true') ->assertAttribute('#meeting-hour', 'aria-invalid', 'true')
// Word joiners keep the range on one line in the narrow column. // Word joiners keep the range on one line in the narrow column.
->assertScript(supportText('meeting-hour')." === 'Hour must be 1–12'") ->assertScript(supportText('meeting-hour')." === 'Hour must be 1–12'");
->click('#meeting-dialog [data-md-timepicker-confirm]')
->assertScript("document.querySelector('#meeting-dialog').open") // 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'); ->assertSeeIn('#meeting-value', '09:30');
$page->clear('#meeting-hour') $page->clear('#meeting-hour')
@@ -274,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 () { it('keeps to the step and the limits', function () {
$dial = inPicker('slot', '[data-md-timepicker-dial]'); $dial = inPicker('slot', '[data-md-timepicker-dial]');
$page = timeProbe() $page = timeProbe();
->click('#slot')
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"8\"]').hasAttribute('data-md-disabled')") // 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=\"9\"]').hasAttribute('data-md-disabled')")
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"18\"]').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. // An hour outside the limits is not taken.
@@ -298,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') ->assertSeeIn('#slot-dialog [data-md-timepicker-range-error]', 'Choose a time from 09:00 to 17:00')
->type('#slot-hour', '16') ->type('#slot-hour', '16')
->type('#slot-minute', '20') ->type('#slot-minute', '20')
->assertSeeIn('#slot-minute-support', 'Minute must be a multiple of 15') ->assertSeeIn('#slot-minute-support', 'Minute must be a multiple of 15');
->click('#slot-dialog [data-md-timepicker-confirm]')
->assertScript("document.activeElement.id === 'slot-minute'") // The confirm button closes the dialog when the time is valid; here it stays open (invalid),
->type('#slot-minute', '45') // but pressed once anyway: pressOnce(), tests/Pest.php.
->click('#slot-dialog [data-md-timepicker-confirm]') pressOnce($page)->click('#slot-dialog [data-md-timepicker-confirm]');
->assertSeeIn('#slot-value', '16:45');
$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 () { it('gives focus back to the field on Escape', function () {
@@ -311,8 +366,10 @@ it('gives focus back to the field on Escape', function () {
$page->script("document.querySelector('#meeting').focus()"); $page->script("document.querySelector('#meeting').focus()");
$page->keys('#meeting', 'Enter') // Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php.
->assertScript("document.querySelector('#meeting-dialog').open") pressOnce($page)->keys('#meeting', 'Enter');
$page->assertScript("document.querySelector('#meeting-dialog').open")
->keys(':focus', 'ArrowUp') ->keys(':focus', 'ArrowUp')
->keys(':focus', 'Escape') ->keys(':focus', 'Escape')
->assertScript("! document.querySelector('#meeting-dialog').open") ->assertScript("! document.querySelector('#meeting-dialog').open")
@@ -321,9 +378,12 @@ it('gives focus back to the field on Escape', function () {
}); });
it('stays open with its draft through a Livewire render', function () { it('stays open with its draft through a Livewire render', function () {
$page = timeProbe() $page = timeProbe();
->click('#meeting')
->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="5"]') // 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'); ->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute');
$page->script('window.eval("Livewire.first().touch()")'); $page->script('window.eval("Livewire.first().touch()")');
@@ -332,18 +392,23 @@ it('stays open with its draft through a Livewire render', function () {
->assertScript("document.querySelector('#meeting-dialog').open") ->assertScript("document.querySelector('#meeting-dialog').open")
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '05') ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '05')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute') ->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-label="minute"][data-md-value="10"]');
->click('#meeting-dialog [data-md-timepicker-confirm]')
->assertSeeIn('#meeting-value', '05: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 () { 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"]'; $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"]'; $pm = '#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]';
$page = timeProbe() $page = timeProbe();
->click('#meeting')
->assertAttribute($am, 'role', 'radio') // 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($am, 'aria-checked', 'true')
->assertAttribute($pm, 'aria-checked', 'false') ->assertAttribute($pm, 'aria-checked', 'false')
// show() moves focus to the dial from inside Alpine's $nextTick, which Livewire's bundled // show() moves focus to the dial from inside Alpine's $nextTick, which Livewire's bundled
@@ -355,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->script("document.querySelector('{$am}').focus()");
$page->keys(':focus', 'ArrowRight') // A two-option radio group: a second press of the same arrow cycles back: pressOnce(),
->assertAttribute($pm, 'aria-checked', 'true') // tests/Pest.php.
pressOnce($page)->keys(':focus', 'ArrowRight');
$page->assertAttribute($pm, 'aria-checked', 'true')
->assertAttribute($am, 'aria-checked', 'false') ->assertAttribute($am, 'aria-checked', 'false')
->assertScript("document.activeElement.dataset.mdTimepickerPeriodOption === 'pm'") ->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 // 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. // reaches the dial straight away — the hour turns 9 into 21 without a confirm.
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '09'); ->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '09');
$page->keys(':focus', 'ArrowLeft') pressOnce($page)->keys(':focus', 'ArrowLeft');
->assertAttribute($am, 'aria-checked', 'true')
$page->assertAttribute($am, 'aria-checked', 'true')
->assertAttribute($pm, 'aria-checked', 'false') ->assertAttribute($pm, 'aria-checked', 'false')
->assertScript("document.activeElement.dataset.mdTimepickerPeriodOption === 'am'"); ->assertScript("document.activeElement.dataset.mdTimepickerPeriodOption === 'am'");
}); });
it('lies the dial on its side in a short landscape window', function () { it('lies the dial on its side in a short landscape window', function () {
$page = timeProbe() $page = timeProbe()
->resize(700, 400) ->resize(700, 400);
->click('#meeting')
->assertScript("document.querySelector('#meeting-dialog').open"); // 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, // 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) // inside its height, with the dial 36px after it, and the picker's own layout (flex column)
@@ -406,7 +478,14 @@ it('keeps the dial and the boxes their fixed pixel size at a 20px root font size
$dialWidth = "Math.round({$dial}.getBoundingClientRect().width)"; $dialWidth = "Math.round({$dial}.getBoundingClientRect().width)";
$font = "getComputedStyle({$hourBox}).fontSize"; $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.
$page->assertScript(settled("document.querySelector('#meeting-dialog')", subtree: true));
$restingWidth = (int) $page->script($dialWidth); $restingWidth = (int) $page->script($dialWidth);
$restingFont = $page->script($font); $restingFont = $page->script($font);
+159
View File
@@ -2,6 +2,7 @@
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\TestCase; use NoNameWeb\LivewireMaterial\Tests\TestCase;
use Pest\Browser\Api\Webpage;
pest()->extend(TestCase::class)->in('Feature', 'Browser'); pest()->extend(TestCase::class)->in('Feature', 'Browser');
@@ -33,6 +34,164 @@ function layoutRoot(string $html, ?string $tag = null): array
return $root + collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all(); return $root + collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all();
} }
/**
* Stretches every motion duration token (resources/css/tokens/motion.css) on :root to $duration,
* so a test that has to catch an exit half-way is not racing the machine: a round trip for a
* separate script() and assertScript() already costs real time, easily as much as a real exit
* (150–650ms), so a single sample taken after triggering one can just as easily land before it
* starts or after it has already finished on a loaded runner. Chain it, after the open has
* settled, immediately before the action that triggers the exit being sampled.
*
* Three seconds, not one: a loaded runner paints only a handful of frames a second, and a one-second
* exit can begin and end between two of them with no frame in between showing it part-way. It costs
* nothing in runtime — what made those tests take 45 seconds apiece was a script sleeping through
* the exit in the page, past the plugin's budget for one attempt (onceInPage()), not the stretch.
*
* Use this only in a test asserting *that* something animates — a part-way opacity, offset or
* transform. Never in a test asserting a duration value, which this would make wrong.
*/
function slowMotion(mixed $page, string $duration = '3s'): mixed
{
$tokens = [
'--md-sys-motion-spatial-fast-duration',
'--md-sys-motion-spatial-default-duration',
'--md-sys-motion-spatial-slow-duration',
'--md-sys-motion-effects-fast-duration',
'--md-sys-motion-effects-default-duration',
'--md-sys-motion-effects-slow-duration',
'--md-sys-motion-duration-short',
'--md-sys-motion-duration-medium',
'--md-sys-motion-duration-long',
];
$css = collect($tokens)->map(fn (string $token): string => "{$token}: {$duration} !important;")->implode(' ');
$page->script(<<<JS
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
* presses something whose box is still moving. `$subtree` reads its descendants' animations too.
*
* Two animation frames first, because an empty `getAnimations()` means two different things: an
* entry that has run, and one the engine has not created yet. Until the first style update after
* `x-show` reveals an element its `@starting-style` entry does not exist, and the computed style
* already reads the open value, so a bare "no animations, opacity 1" passes on a page that has
* drawn nothing since the open — and the exit sampled from there turns out to be an entry barely
* begun, with nothing left to catch. A runner painting a handful of frames a second sits in that
* window for hundreds of milliseconds at a time.
*/
function settled(string $element, bool $subtree = false): string
{
$options = $subtree ? '{ subtree: true }' : '';
return <<<JS
(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve))
await new Promise((resolve) => requestAnimationFrame(resolve))
return {$element}.getAnimations({$options}).length === 0
})()
JS;
}
/**
* Runs `$expression` — an async IIFE — in the page once, however often the plugin asks for it, and
* answers with what that one run returned.
*
* The plugin gives each script() and assertScript() attempt 1000ms and then runs the whole
* expression again, for 45 seconds, before one last attempt with the full timeout (AwaitableWebpage,
* Execution::waitForExpectation). Anything that takes longer than a second in the page therefore
* runs some fifty times and only succeeds on that last attempt, which costs 45 seconds a test —
* and where the expression presses something, every run after the first presses it again, on a page
* the first run has already left somewhere else, so what comes back describes none of them.
*/
function onceInPage(string $expression): string
{
$memo = '__onceInPage'.crc32($expression);
return "(() => (window.{$memo} ??= {$expression}))()";
}
/**
* `$page` for an action that must happen exactly once: the plugin's own page object, without the
* retry every call on `$page` goes through (see onceInPage()). That retry does the whole call
* again after 1000ms, which costs an assertion nothing but repeats a press: a key or a click the
* page took longer than that to handle arrived twice — one PageDown paging two months, a day
* pressed twice making the range's start its end as well — or the second press found what the
* first one opened lying over its button, and never landed. Here the action runs once, with
* Playwright's own wait for the element and the whole timeout.
*
* For a press a second one would undo or overturn — a menu's trigger toggles it, a day pressed
* twice ends the range it began — into something that can take a loaded runner that long: opening
* 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
{
return new Webpage($page->page(), $page->url());
}
/**
* A JS expression that runs `$trigger` in the page and then samples `$condition` there once per
* animation frame, answering whether it ever held: the whole exit and its sample in one round trip,
* since a separate script() and assertScript() apiece already cost as much as a real exit.
*
* On animation frames rather than a timer, which is what the sampling loops used to do: a loaded
* engine — WebKitGTK on the runner — stops painting while a tight `setTimeout` loop holds the main
* thread, so the CSS transition never advances and every sample reads the opening value, then
* `display: none`. The exit was reported as a vanishing when nothing had gone wrong but the frames.
* Asking for a frame both paces the loop to the renderer and makes it run, so each sample is a
* frame someone would have seen.
*
* The budget covers most of a slowMotion() exit, because a loaded runner can leave a second between
* two frames of a heavy page and the one frame that shows the exit half-way may be the third.
*
* Sampled once through onceInPage(), because triggering the exit a second time on a page whose
* first one is long finished would report a miss whatever the page did the first time.
*/
function caughtMidExit(string $trigger, string $condition, int $budget = 2500): string
{
return onceInPage(<<<JS
(async () => {
{$trigger}
const deadline = performance.now() + {$budget}
while (performance.now() < deadline) {
if ({$condition}) {
return true
}
await new Promise((resolve) => requestAnimationFrame(resolve))
}
return false
})()
JS);
}
/** /**
* The page is done loading, and Alpine and/or Livewire (whichever the page renders) have booted — * The page is done loading, and Alpine and/or Livewire (whichever the page renders) have booted —
* chained after visit(), before a Browser test probe reads anything either one wires up. Most * chained after visit(), before a Browser test probe reads anything either one wires up. Most