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>
862 lines
40 KiB
PHP
862 lines
40 KiB
PHP
<?php
|
||
|
||
use Illuminate\Support\Facades\Blade;
|
||
use Illuminate\Support\Facades\Route;
|
||
use Livewire\Component;
|
||
use Livewire\Livewire;
|
||
|
||
class DateProbe extends Component
|
||
{
|
||
public ?string $expires = '2026-09-13';
|
||
|
||
public ?string $birthday = '2000-05-17';
|
||
|
||
public ?string $delivery = null;
|
||
|
||
/** @var array{start: ?string, end: ?string} */
|
||
public array $trip = ['start' => '2026-09-13', 'end' => '2026-09-15'];
|
||
|
||
public int $renders = 0;
|
||
|
||
public function touch(): void
|
||
{
|
||
$this->renders++;
|
||
}
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div style="display: grid; max-width: 448px; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
|
||
<p>expires: <span id="expires">{{ $expires }}</span></p>
|
||
<p>birthday: <span id="birthday">{{ $birthday }}</span></p>
|
||
<p>delivery: <span id="delivery">{{ $delivery }}</span></p>
|
||
<p>trip: <span id="trip">{{ json_encode($trip) }}</span></p>
|
||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||
|
||
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" clearable />
|
||
<x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" />
|
||
<x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" />
|
||
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" clearable />
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
function dateProbe(string $locale = 'en')
|
||
{
|
||
Livewire::component('date-probe', DateProbe::class);
|
||
|
||
Route::middleware('web')->get('/date-probe/{locale}', function (string $locale) {
|
||
app()->setLocale($locale);
|
||
|
||
return Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<link rel="icon" href="data:,">
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:date-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE);
|
||
});
|
||
|
||
return ready(visit("/date-probe/{$locale}"));
|
||
}
|
||
|
||
class DateFormatProbe extends Component
|
||
{
|
||
public ?string $sunday = '2026-09-13';
|
||
|
||
public ?string $iso = '2026-09-13';
|
||
|
||
public ?string $dotted = '2026-09-13';
|
||
|
||
/** @var array{start: ?string, end: ?string} */
|
||
public array $span = ['start' => '2026-09-13', 'end' => '2026-09-15'];
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div style="display: grid; max-width: 448px; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);">
|
||
<p>sunday: <span id="sunday">{{ $sunday }}</span></p>
|
||
<p>iso: <span id="iso">{{ $iso }}</span></p>
|
||
<p>dotted: <span id="dotted">{{ $dotted }}</span></p>
|
||
<p>span: <span id="span">{{ json_encode($span) }}</span></p>
|
||
|
||
<x-datepicker id="sunday-field" label="Sunday first" wire:model.live="sunday" week-start="0" />
|
||
<x-datepicker id="iso-field" label="ISO" wire:model.live="iso" format="yyyy-MM-dd" />
|
||
<x-datepicker id="dotted-field" label="Dotted" mode="input" wire:model.live="dotted" format="dd.MM.yyyy" />
|
||
<x-datepicker id="span-field" label="Span" range mode="modal" wire:model.live="span" format="dd/MM/yyyy" week-start="6" />
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
function dateFormatProbe(string $locale = 'en')
|
||
{
|
||
Livewire::component('date-format-probe', DateFormatProbe::class);
|
||
|
||
Route::middleware('web')->get('/date-format-probe/{locale}', function (string $locale) {
|
||
app()->setLocale($locale);
|
||
|
||
return Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<link rel="icon" href="data:,">
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:date-format-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE);
|
||
});
|
||
|
||
return ready(visit("/date-format-probe/{$locale}"));
|
||
}
|
||
|
||
class ScopedDateProbe extends Component
|
||
{
|
||
public ?string $early = '2026-09-13';
|
||
|
||
public ?string $late = '2026-09-13';
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div style="display: grid; max-width: 448px; gap: var(--md-sys-measurement-space300); padding: var(--md-sys-measurement-space200);" x-data="{ step: 1 }">
|
||
<x-datepicker id="early-field" label="Early" wire:model.live="early" min="2026-09-10" week-start="0" />
|
||
<x-datepicker id="late-field" label="Late" wire:model.live="late" week-start="1" />
|
||
</div>
|
||
BLADE;
|
||
}
|
||
}
|
||
|
||
function scopedDateProbe()
|
||
{
|
||
Livewire::component('scoped-date-probe', ScopedDateProbe::class);
|
||
|
||
Route::middleware('web')->get('/scoped-date-probe', fn () => Blade::render(<<<'BLADE'
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<link rel="icon" href="data:,">
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body style="background-color: var(--md-sys-color-surface);">
|
||
<livewire:scoped-date-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
return ready(visit('/scoped-date-probe'));
|
||
}
|
||
|
||
/** A picker's day cell, by its ISO date. */
|
||
function day(string $picker, string $date): string
|
||
{
|
||
return "#{$picker}-picker [data-md-value=\"{$date}\"]";
|
||
}
|
||
|
||
function focusedDay(string $date): string
|
||
{
|
||
return "document.activeElement.dataset.mdValue === '{$date}'";
|
||
}
|
||
|
||
it('opens the docked picker under its field and walks the grid from the keyboard', function () {
|
||
$picker = "document.querySelector('#expires-field-picker')";
|
||
|
||
$page = dateProbe();
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.mdPresentation === 'docked'")
|
||
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
||
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('[data-md-field-box]').getBoundingClientRect(); const box = {$picker}.getBoundingClientRect(); return Math.abs(box.top - field.bottom - 4) < 2 && Math.abs(box.left - field.left) < 2; })()")
|
||
->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-09-14'));
|
||
$page->keys(':focus', 'ArrowDown')->assertScript(focusedDay('2026-09-21'));
|
||
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-26'));
|
||
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-20'));
|
||
$page->keys(':focus', 'ArrowUp')->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'PageDown')
|
||
->assertScript(focusedDay('2026-10-13'))
|
||
->assertScript("document.querySelector('#expires-field-month').textContent === 'October 2026'");
|
||
|
||
$page->keys(':focus', 'Enter')
|
||
->assertSeeIn('#expires', '2026-10-13')
|
||
->assertScript("! {$picker}.matches(':popover-open')")
|
||
->assertScript("document.activeElement.id === 'expires-field'")
|
||
->assertValue('#expires-field', '10/13/2026');
|
||
});
|
||
|
||
it('keeps focus and choice inside min and max', function () {
|
||
$page = dateProbe();
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript(focusedDay('2026-09-13'));
|
||
|
||
// Playwright will not press an aria-disabled cell, so the press is dispatched.
|
||
$page->assertAttribute(day('expires-field', '2026-09-09'), 'aria-disabled', 'true')
|
||
->script("document.querySelector('".day('expires-field', '2026-09-09')."').click()");
|
||
|
||
$page->assertScript("! document.querySelector('".day('expires-field', '2026-09-09')."').hasAttribute('data-md-selected')")
|
||
->assertScript("document.querySelector('".day('expires-field', '2026-09-13')."').hasAttribute('data-md-selected')");
|
||
|
||
$page->keys(':focus', 'PageUp')->assertScript(focusedDay('2026-09-10'));
|
||
$page->keys(':focus', 'ArrowLeft')->assertScript(focusedDay('2026-09-10'));
|
||
$page->keys(':focus', 'Shift+PageDown')->assertScript(focusedDay('2026-10-20'));
|
||
|
||
$page->keys(':focus', 'Enter')->assertSeeIn('#expires', '2026-10-20');
|
||
});
|
||
|
||
it('takes a date typed into the docked field once it is whole, and says when it cannot', function () {
|
||
$page = dateProbe()
|
||
->type('#expires-field', '09/15/2026')
|
||
->assertSeeIn('#expires', '2026-09-15');
|
||
|
||
$page->type('#expires-field', '13/45/2026')
|
||
->keys('#expires-field', 'Enter')
|
||
->assertSee('Date does not match expected pattern: MM/DD/YYYY')
|
||
->assertAttribute('#expires-field', 'aria-invalid', 'true')
|
||
->assertSeeIn('#expires', '2026-09-15');
|
||
|
||
$page->type('#expires-field', '09/01/2026')
|
||
->keys('#expires-field', 'Enter')
|
||
->assertSee('Date not allowed: Sep 1, 2026')
|
||
->assertSeeIn('#expires', '2026-09-15');
|
||
});
|
||
|
||
it('opens the modal picker, jumps through the year grid and confirms with OK', function () {
|
||
$dialog = "document.querySelector('#birthday-field-picker')";
|
||
|
||
$page = dateProbe();
|
||
|
||
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#birthday-field');
|
||
|
||
$page->assertScript("{$dialog}.matches(':modal')")
|
||
->assertSeeIn('#birthday-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'May 17, 2000')
|
||
->click('#birthday-field-picker [data-md-datepicker-nav]:not([data-md-docked]) [data-md-datepicker-menu-button]')
|
||
->assertScript("getComputedStyle(document.querySelector('#birthday-field-picker [data-md-datepicker-years]')).display !== 'none'")
|
||
->assertScript("document.activeElement.textContent.trim() === '2000'");
|
||
|
||
$page->click('#birthday-field-picker [data-md-datepicker-year]:has-text("1990")')
|
||
->assertSeeIn('#birthday-field-picker [data-md-datepicker-nav]', 'May 1990')
|
||
->click(day('birthday-field', '1990-05-03'))
|
||
->assertSeeIn('#birthday-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'May 3, 1990')
|
||
->assertSeeIn('#birthday', '2000-05-17');
|
||
|
||
// The confirm button closes the dialog: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#birthday-field-picker [data-md-datepicker-confirm]');
|
||
|
||
$page->assertSeeIn('#birthday', '1990-05-03')
|
||
->assertScript("! {$dialog}.open");
|
||
});
|
||
|
||
it('closes on Escape without keeping the draft and hands focus back to the field', function () {
|
||
$dialog = "document.querySelector('#birthday-field-picker')";
|
||
|
||
$page = dateProbe();
|
||
|
||
$page->script("document.querySelector('#birthday-field').focus()");
|
||
|
||
// Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->keys('#birthday-field', 'Enter');
|
||
|
||
$page->assertScript("{$dialog}.open")
|
||
->assertScript(focusedDay('2000-05-17'));
|
||
|
||
$page->keys(':focus', 'ArrowRight')
|
||
->keys(':focus', 'Space')
|
||
->assertSeeIn('#birthday-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'May 18, 2000');
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("! {$dialog}.open")
|
||
->assertScript("document.activeElement.id === 'birthday-field'")
|
||
->assertSeeIn('#birthday', '2000-05-17');
|
||
|
||
// The docked picker too.
|
||
$page->script("document.querySelector('[aria-controls=\"expires-field-picker\"][data-md-datepicker-toggle]').focus()");
|
||
|
||
// Enter on the focused toggle button is a native click on it, and it toggles: pressOnce(),
|
||
// tests/Pest.php. A retried Enter would land on the day it opened onto instead, closing it.
|
||
pressOnce($page)->keys(':focus', 'Enter');
|
||
|
||
$page->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')")
|
||
->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')")
|
||
->assertScript("document.activeElement.id === 'expires-field'");
|
||
});
|
||
|
||
it('reads the modal input in the locale\'s format and explains a date it cannot take', function () {
|
||
$dialog = "document.querySelector('#delivery-field-picker')";
|
||
|
||
$page = dateProbe();
|
||
|
||
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#delivery-field');
|
||
|
||
$page->assertScript("{$dialog}.matches(':modal')")
|
||
->assertScript("document.activeElement.id === 'delivery-field-entry'")
|
||
->assertSeeIn('#delivery-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'Entered date');
|
||
|
||
$page->type('#delivery-field-entry', '2026-03-07');
|
||
|
||
// The confirm button closes the dialog when the date is valid; here it stays open (invalid),
|
||
// but pressed once anyway: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#delivery-field-picker [data-md-datepicker-confirm]');
|
||
|
||
$page->assertSeeIn('#delivery-field-entry-support', 'Date does not match expected pattern: MM/DD/YYYY')
|
||
->assertAttribute('#delivery-field-entry', 'aria-invalid', 'true')
|
||
->assertScript("{$dialog}.open");
|
||
|
||
$page->type('#delivery-field-entry', '12/24/2025')
|
||
->keys('#delivery-field-entry', 'Enter')
|
||
->assertSeeIn('#delivery-field-entry-support', 'Date not allowed: Dec 24, 2025')
|
||
->assertScript("{$dialog}.open");
|
||
|
||
$page->type('#delivery-field-entry', '3/7/2026')
|
||
->assertSeeIn('#delivery-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'Mar 7, 2026')
|
||
->keys('#delivery-field-entry', 'Enter')
|
||
->assertSeeIn('#delivery', '2026-03-07')
|
||
->assertScript("! {$dialog}.open")
|
||
->assertValue('#delivery-field', '03/07/2026');
|
||
});
|
||
|
||
it('picks a range: a start, an end, and the days between', function () {
|
||
$page = dateProbe();
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertAttribute(day('trip-field', '2026-09-14'), 'aria-selected', 'true');
|
||
|
||
// A day pressed twice ends the range it began: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click(day('trip-field', '2026-09-20'));
|
||
|
||
$page->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-md-selected')")
|
||
->assertScript("! document.querySelector('".day('trip-field', '2026-09-14')."').hasAttribute('data-md-between')");
|
||
|
||
pressOnce($page)->click(day('trip-field', '2026-09-24'));
|
||
|
||
$page->assertScript("[...document.querySelectorAll('#trip-field-picker [data-md-between]')].map((cell) => cell.dataset.mdValue).join() === '2026-09-21,2026-09-22,2026-09-23'")
|
||
->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-md-start') && document.querySelector('".day('trip-field', '2026-09-24')."').hasAttribute('data-md-end')")
|
||
->assertSeeIn('#trip', '2026-09-15');
|
||
|
||
// The confirm button closes the dialog: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#trip-field-picker [data-md-datepicker-confirm]');
|
||
|
||
$page->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}')
|
||
->assertValue('#trip-field', '09/20/2026 – 09/24/2026');
|
||
});
|
||
|
||
it('follows the locale: German weeks start on Monday, American ones on Sunday', function () {
|
||
$firstWeekday = "document.querySelector('#expires-field-picker thead th').getAttribute('abbr')";
|
||
|
||
$page = dateProbe('de')
|
||
->assertValue('#expires-field', '13.09.2026');
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript("{$firstWeekday} === 'Montag'")
|
||
->assertScript("document.querySelector('#expires-field-month').textContent === 'September 2026'")
|
||
->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-10'));
|
||
$page->keys(':focus', 'Escape');
|
||
|
||
$page->type('#expires-field', '20.9.2026')
|
||
->assertSeeIn('#expires', '2026-09-20')
|
||
->assertAttribute('#expires-field', 'placeholder', 'DD.MM.YYYY');
|
||
|
||
$page = dateProbe('en_US');
|
||
|
||
pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript("{$firstWeekday} === 'Sunday'");
|
||
});
|
||
|
||
it('stays open, where it was, through a Livewire render', function () {
|
||
$page = dateProbe();
|
||
|
||
// 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->script('window.eval("Livewire.first().touch()")');
|
||
|
||
$page->assertSeeIn('#renders', '1')
|
||
->assertScript("document.querySelector('#expires-field-picker').matches(':popover-open')")
|
||
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
||
->assertScript("document.querySelector('".day('expires-field', '2026-09-14')."').tabIndex === 0");
|
||
|
||
$page->keys(day('expires-field', '2026-09-14'), 'Enter')->assertSeeIn('#expires', '2026-09-14');
|
||
|
||
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#birthday-field');
|
||
|
||
$page->script('window.eval("Livewire.first().touch()")');
|
||
|
||
$page->assertSeeIn('#renders', '2')
|
||
->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')");
|
||
});
|
||
|
||
it('opens a docked picker as a modal one on a compact window', function () {
|
||
$page = dateProbe()
|
||
->resize(400, 800);
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
|
||
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-md-datepicker-header]')).display !== 'none'");
|
||
});
|
||
|
||
it('empties a date, or both ends of a range, with its clear button', function () {
|
||
$page = dateProbe()
|
||
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('[data-md-field]').querySelector('[data-md-field-clear]')).display !== 'none'");
|
||
|
||
// The clear button vanishes once its field is empty: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]');
|
||
|
||
$page->assertScript("document.querySelector('#expires').textContent === ''")
|
||
->assertValue('#expires-field', '')
|
||
->assertScript("document.activeElement.id === 'expires-field'")
|
||
->assertScript("getComputedStyle(document.querySelector('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'");
|
||
|
||
pressOnce($page)->click('[data-md-datepicker]:has(#trip-field) [data-md-field-clear]');
|
||
|
||
$page->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||
->assertValue('#trip-field', '');
|
||
});
|
||
|
||
it('starts the week on the day week-start names, whatever the locale says', function () {
|
||
$page = dateFormatProbe('de')
|
||
->assertValue('#sunday-field', '13.09.2026');
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="sunday-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript("document.querySelector('#sunday-field-picker thead th').getAttribute('abbr') === 'Sonntag'")
|
||
->assertScript("document.querySelector('#sunday-field-picker tbody td').dataset.mdValue === '2026-08-30'")
|
||
->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-19'));
|
||
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'Enter')
|
||
->assertSeeIn('#sunday', '2026-09-13')
|
||
->assertValue('#sunday-field', '13.09.2026');
|
||
});
|
||
|
||
it('shows and reads a year-first format and still binds Y-m-d', function () {
|
||
$page = dateFormatProbe()
|
||
->assertValue('#iso-field', '2026-09-13')
|
||
->assertAttribute('#iso-field', 'placeholder', 'YYYY-MM-DD')
|
||
->type('#iso-field', '2026-10-01')
|
||
->assertSeeIn('#iso', '2026-10-01');
|
||
|
||
$page->type('#iso-field', '10/02/2026')
|
||
->keys('#iso-field', 'Enter')
|
||
->assertSee('Date does not match expected pattern: YYYY-MM-DD')
|
||
->assertSeeIn('#iso', '2026-10-01');
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="iso-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript(focusedDay('2026-10-01'));
|
||
|
||
$page->keys(':focus', 'ArrowRight')->assertScript(focusedDay('2026-10-02'));
|
||
|
||
$page->keys(':focus', 'Enter')
|
||
->assertSeeIn('#iso', '2026-10-02')
|
||
->assertValue('#iso-field', '2026-10-02');
|
||
});
|
||
|
||
it('reads the dialog\'s text field in the given format, not the locale\'s', function () {
|
||
$dialog = "document.querySelector('#dotted-field-picker')";
|
||
|
||
$page = dateFormatProbe()
|
||
->assertValue('#dotted-field', '13.09.2026');
|
||
|
||
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#dotted-field');
|
||
|
||
$page->assertScript("{$dialog}.matches(':modal')")
|
||
->assertScript("document.activeElement.id === 'dotted-field-entry'")
|
||
->assertValue('#dotted-field-entry', '13.09.2026')
|
||
->assertAttribute('#dotted-field-entry', 'placeholder', 'DD.MM.YYYY');
|
||
|
||
$page->type('#dotted-field-entry', '2026-10-01')
|
||
->keys('#dotted-field-entry', 'Enter')
|
||
->assertSeeIn('#dotted-field-entry-support', 'Date does not match expected pattern: DD.MM.YYYY')
|
||
->assertScript("{$dialog}.open");
|
||
|
||
$page->type('#dotted-field-entry', '1.10.2026')
|
||
->assertSeeIn('#dotted-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'Oct 1, 2026')
|
||
->keys('#dotted-field-entry', 'Enter')
|
||
->assertSeeIn('#dotted', '2026-10-01')
|
||
->assertScript("! {$dialog}.open")
|
||
->assertValue('#dotted-field', '01.10.2026');
|
||
});
|
||
|
||
it('lays out and shows a range in the given first day and format', function () {
|
||
$dialog = "document.querySelector('#span-field-picker')";
|
||
|
||
$page = dateFormatProbe()->assertValue('#span-field', '13/09/2026 – 15/09/2026');
|
||
|
||
$page->script("document.querySelector('#span-field').focus()");
|
||
|
||
// Enter on the readonly field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->keys('#span-field', 'Enter');
|
||
|
||
$page->assertScript("{$dialog}.matches(':modal')")
|
||
->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'")
|
||
->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12'));
|
||
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18'));
|
||
|
||
// A day pressed twice ends the range it began: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click(day('span-field', '2026-09-20'))
|
||
->click(day('span-field', '2026-09-24'));
|
||
|
||
// The confirm button closes the dialog: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#span-field-picker [data-md-datepicker-confirm]');
|
||
|
||
$page->assertSeeIn('#span', '{"start":"2026-09-20","end":"2026-09-24"}')
|
||
->assertValue('#span-field', '20/09/2026 – 24/09/2026');
|
||
});
|
||
|
||
it('keeps each picker\'s own settings inside a page\'s outer x-data scope', function () {
|
||
// Settings assigned in init() without being declared would land on the outermost scope,
|
||
// where the last picker's null min and Monday start would overwrite the first's.
|
||
$page = scopedDateProbe();
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="early-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->assertAttribute(day('early-field', '2026-09-09'), 'aria-disabled', 'true')
|
||
->assertScript('! (\'min\' in Alpine.$data(document.querySelector(\'[x-data="{ step: 1 }"]\')))');
|
||
});
|
||
|
||
/**
|
||
* 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 () {
|
||
$picker = "document.querySelector('#trip-field-picker')";
|
||
$scope = "Alpine.\$data(document.querySelector('[data-md-datepicker]:has(#trip-field)'))";
|
||
|
||
$page = dateProbe()
|
||
->resize(390, 800)
|
||
// Explicit, not implicit: the resize's own reflow can still be under way when the press
|
||
// 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("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.
|
||
->assertScript("document.querySelector('#trip-field-picker [data-md-datepicker-close]').getClientRects().length > 0")
|
||
->assertScript("document.querySelector('#trip-field-picker [data-md-datepicker-save]').getClientRects().length > 0")
|
||
->assertScript("getComputedStyle(document.querySelector('#trip-field-picker [data-md-datepicker-actions]')).display === 'none'")
|
||
// The window opens six months either side of the trip's start month.
|
||
->assertScript("{$scope}.monthsFrom === '2026-03-01' && {$scope}.monthsTo === '2027-03-01'")
|
||
->assertScript(focusedDay('2026-09-13'));
|
||
|
||
// Paging seven months forward crosses the window's forward edge, growing it to cover the month walked to.
|
||
foreach (range(1, 7) as $ignored) {
|
||
pressOnce($page)->keys(':focus', 'PageDown');
|
||
}
|
||
|
||
$page->assertScript("{$scope}.monthsTo > '2027-03-01'")
|
||
->assertScript(focusedDay('2027-04-13'))
|
||
->assertScript("[...document.querySelectorAll('#trip-field-picker [data-md-datepicker-month-label]')].some((label) => label.textContent.trim() === 'April 2027')");
|
||
|
||
// Paging fourteen months back does the same the other way, past the original start.
|
||
foreach (range(1, 14) as $ignored) {
|
||
pressOnce($page)->keys(':focus', 'PageUp');
|
||
}
|
||
|
||
$page->assertScript("{$scope}.monthsFrom < '2026-03-01'")
|
||
->assertScript(focusedDay('2026-02-13'))
|
||
->assertScript("[...document.querySelectorAll('#trip-field-picker [data-md-datepicker-month-label]')].some((label) => label.textContent.trim() === 'February 2026')");
|
||
|
||
// Picking a range and pressing Save commits it and closes the dialog.
|
||
// script() returns the script's result, not the page, so the chain starts again after it.
|
||
$page->script("{$scope}.moveTo('2026-09-13', false)");
|
||
|
||
pressOnce($page)->click(day('trip-field', '2026-09-20'))
|
||
->click(day('trip-field', '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");
|
||
});
|
||
|
||
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 () {
|
||
$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));
|
||
|
||
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('#trip-field-picker [data-md-datepicker-close]');
|
||
|
||
$page->assertScript("! {$picker}.open")
|
||
->assertSeeIn('#trip', '{"start":"2026-09-13","end":"2026-09-15"}');
|
||
});
|
||
|
||
it('reaches the month and year dropdowns with Shift+M and Shift+Y', function () {
|
||
$toggle = '[aria-controls="expires-field-picker"][data-md-datepicker-toggle]';
|
||
|
||
$page = dateProbe();
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click($toggle);
|
||
|
||
$page->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'Shift+M')
|
||
->assertScript("document.activeElement.dataset.mdDatepickerMenuButton === 'months'");
|
||
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')");
|
||
|
||
pressOnce($page)->click($toggle);
|
||
|
||
$page->assertScript(focusedDay('2026-09-13'));
|
||
|
||
$page->keys(':focus', 'Shift+Y')
|
||
->assertScript("document.activeElement.dataset.mdDatepickerMenuButton === 'years'");
|
||
});
|
||
|
||
it('draws the shared state layer and focus ring on the menu buttons, the year and the list options, the option\'s ring drawn inset', function () {
|
||
$monthButton = '#expires-field-picker [data-md-datepicker-menu-button="months"]';
|
||
$yearButton = '#expires-field-picker [data-md-datepicker-menu-button="years"]';
|
||
$modalYearButton = '#birthday-field-picker [data-md-datepicker-nav]:not([data-md-docked]) [data-md-datepicker-menu-button]';
|
||
$selectedOption = "document.querySelector('#expires-field-picker [data-md-datepicker-option][aria-selected=\"true\"]')";
|
||
$selectedYear = "document.querySelector('#birthday-field-picker [data-md-datepicker-year][aria-selected=\"true\"]')";
|
||
|
||
$hasBoth = fn (string $expr): string => "({$expr}).classList.contains('md-state-layer') && ({$expr}).classList.contains('md-focus-ring')";
|
||
|
||
$page = dateProbe();
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript($hasBoth("document.querySelector('{$monthButton}')"))
|
||
->assertScript($hasBoth("document.querySelector('{$yearButton}')"))
|
||
->click($monthButton)
|
||
// toggleView() scrolls the selected option into view and focuses it once settled.
|
||
->assertScript("document.activeElement === {$selectedOption}")
|
||
->assertScript($hasBoth($selectedOption))
|
||
// A real keydown (rather than the script focus above) puts the browser in keyboard
|
||
// modality, so :focus-visible — and the ring the foundation draws inside the row rather
|
||
// than round the control — actually applies.
|
||
->keys(':focus', 'ArrowDown')
|
||
->assertScript("document.activeElement.matches('[data-md-datepicker-option]')")
|
||
->assertScript("getComputedStyle(document.activeElement).outlineOffset === '-3px'");
|
||
|
||
// Close the docked popover, or it sits over the next field and swallows the click below.
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')");
|
||
|
||
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#birthday-field');
|
||
|
||
$page->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')")
|
||
->click($modalYearButton)
|
||
->assertScript($hasBoth("document.querySelector('{$modalYearButton}')"))
|
||
->assertScript("document.activeElement === {$selectedYear}")
|
||
->assertScript($hasBoth($selectedYear));
|
||
});
|
||
|
||
it('shows a state layer over a selected day\'s primary fill on hover, and none on a disabled or blank day', function () {
|
||
$selectedDay = day('expires-field', '2026-09-13');
|
||
$selectedSpan = "document.querySelector('{$selectedDay} > span')";
|
||
$disabledDay = day('expires-field', '2026-09-09');
|
||
$disabledSpan = "document.querySelector('{$disabledDay} > span')";
|
||
|
||
$page = dateProbe();
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$page->assertScript("document.querySelector('{$selectedDay}').hasAttribute('data-md-selected')")
|
||
->assertAttribute($disabledDay, 'aria-disabled', 'true')
|
||
// The day's own fill transitions in when the popover opens: let it settle before reading
|
||
// a resting value, or the "unchanged by hover" comparison below race against that instead.
|
||
->wait(0.3);
|
||
|
||
$fill = $page->script("getComputedStyle({$selectedSpan}).backgroundColor");
|
||
|
||
$page->hover($selectedDay)
|
||
// The layer draws over the same primary fill: hovering never changes the day's own background.
|
||
->assertScript("getComputedStyle({$selectedSpan}).backgroundColor === '{$fill}'")
|
||
->assertScript("getComputedStyle({$selectedSpan}, '::before').opacity !== '0'");
|
||
|
||
$page->hover($disabledDay)
|
||
->assertScript("getComputedStyle({$disabledSpan}, '::before').display === 'none'");
|
||
|
||
$page->script("document.querySelector('{$disabledDay}').focus()");
|
||
|
||
$page->assertScript("document.activeElement === document.querySelector('{$disabledDay}')")
|
||
->assertScript("getComputedStyle({$disabledSpan}, '::before').display === 'none'");
|
||
|
||
// Close the docked popover, or it sits over the next field and swallows the click below.
|
||
$page->keys(':focus', 'Escape')
|
||
->assertScript("! document.querySelector('#expires-field-picker').matches(':popover-open')");
|
||
|
||
// 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.
|
||
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('#birthday-field');
|
||
|
||
$page->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')")
|
||
->hover('#birthday-field-picker [data-md-blank] > span >> nth=0')
|
||
->assertScript("getComputedStyle([...document.querySelectorAll('#birthday-field-picker [data-md-blank] > span')][0], '::before').display === 'none'");
|
||
});
|
||
|
||
it('keeps the day indicator its fixed 40px size at a 20px root font size, growing only its text', function () {
|
||
$daySpan = "document.querySelector('".day('expires-field', '2026-09-13')." > span')";
|
||
$width = "Math.round({$daySpan}.getBoundingClientRect().width)";
|
||
$font = "getComputedStyle({$daySpan}).fontSize";
|
||
$picker = "document.querySelector('#expires-field-picker')";
|
||
|
||
$page = dateProbe();
|
||
|
||
// The toggle button opens or closes the picker on every press: pressOnce(), tests/Pest.php.
|
||
pressOnce($page)->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]');
|
||
|
||
$restingWidth = (int) $page->script($width);
|
||
$restingFont = $page->script($font);
|
||
|
||
$page->script("document.documentElement.style.fontSize = '20px'");
|
||
|
||
$page->assertScript("({$width}) === {$restingWidth}")
|
||
->assertScript("({$font}) !== '{$restingFont}'")
|
||
// The px-sized grid sets the popover's size, not the (rem-sized) text now growing inside it.
|
||
->assertScript("{$picker}.scrollWidth <= {$picker}.clientWidth + 1 && {$picker}.scrollHeight <= {$picker}.clientHeight + 1");
|
||
});
|