Files
livewire-material/tests/Browser/DatepickerTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 e7885ce1eb
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
Keep the search view closed when its focus comes back late
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

730 lines
35 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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()
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->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()
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->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()
->click('#birthday-field')
->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');
$page->click('#birthday-field-picker [data-md-datepicker-confirm]')
->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()");
$page->keys('#birthday-field', 'Enter')
->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()");
$page->keys(':focus', 'Enter')
->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()
->click('#delivery-field')
->assertScript("{$dialog}.matches(':modal')")
->assertScript("document.activeElement.id === 'delivery-field-entry'")
->assertSeeIn('#delivery-field-picker [data-md-datepicker-header] [data-md-datepicker-headline]', 'Entered date');
$page->type('#delivery-field-entry', '2026-03-07')
->click('#delivery-field-picker [data-md-datepicker-confirm]')
->assertSeeIn('#delivery-field-entry-support', 'Date does not match expected pattern: MM/DD/YYYY')
->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()
->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'))
->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-md-selected')")
->assertScript("! document.querySelector('".day('trip-field', '2026-09-14')."').hasAttribute('data-md-between')");
$page->click(day('trip-field', '2026-09-24'))
->assertScript("[...document.querySelectorAll('#trip-field-picker [data-md-between]')].map((cell) => cell.dataset.mdValue).join() === '2026-09-21,2026-09-22,2026-09-23'")
->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-md-start') && document.querySelector('".day('trip-field', '2026-09-24')."').hasAttribute('data-md-end')")
->assertSeeIn('#trip', '2026-09-15');
$page->click('#trip-field-picker [data-md-datepicker-confirm]')
->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}')
->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')
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->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');
dateProbe('en_US')
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript("{$firstWeekday} === 'Sunday'");
});
it('stays open, where it was, through a Livewire render', function () {
$page = dateProbe()
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript(focusedDay('2026-09-13'));
$page->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');
$page->click('#birthday-field')
->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '2')
->assertScript("document.querySelector('#birthday-field-picker').matches(':modal')");
});
it('opens a docked picker as a modal one on a compact window', function () {
dateProbe()
->resize(400, 800)
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
->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'");
$page->click('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]')
->assertScript("document.querySelector('#expires').textContent === ''")
->assertValue('#expires-field', '')
->assertScript("document.activeElement.id === 'expires-field'")
->assertScript("getComputedStyle(document.querySelector('[data-md-datepicker]:has(#expires-field) [data-md-field-clear]')).display === 'none'");
$page->click('[data-md-datepicker]:has(#trip-field) [data-md-field-clear]')
->assertSeeIn('#trip', '{"start":null,"end":null}')
->assertValue('#trip-field', '');
});
it('starts the week on the day week-start names, whatever the locale says', function () {
$page = dateFormatProbe('de')
->assertValue('#sunday-field', '13.09.2026')
->click('[aria-controls="sunday-field-picker"][data-md-datepicker-toggle]')
->assertScript("document.querySelector('#sunday-field-picker thead th').getAttribute('abbr') === 'Sonntag'")
->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');
$page->click('[aria-controls="iso-field-picker"][data-md-datepicker-toggle]')
->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')
->click('#dotted-field')
->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()");
$page->keys('#span-field', 'Enter')
->assertScript("{$dialog}.matches(':modal')")
->assertScript("document.querySelector('#span-field-picker thead th').getAttribute('abbr') === 'Saturday'")
->assertScript(focusedDay('2026-09-13'));
$page->keys(':focus', 'Home')->assertScript(focusedDay('2026-09-12'));
$page->keys(':focus', 'End')->assertScript(focusedDay('2026-09-18'));
$page->click(day('span-field', '2026-09-20'))
->click(day('span-field', '2026-09-24'))
->click('#span-field-picker [data-md-datepicker-confirm]')
->assertSeeIn('#span', '{"start":"2026-09-20","end":"2026-09-24"}')
->assertValue('#span-field', '20/09/2026 24/09/2026');
});
it('keeps each picker\'s own settings inside a page\'s outer x-data scope', function () {
// Settings assigned in init() without being declared would land on the outermost scope,
// where the last picker's null min and Monday start would overwrite the first's.
$page = scopedDateProbe()
->click('[aria-controls="early-field-picker"][data-md-datepicker-toggle]')
->assertScript(focusedDay('2026-09-13'));
$page->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 click
// that follows fires, and the press it costs the runner is the one press this test cannot
// afford to lose. The browser plugin gives each attempt a second and then presses again,
// and once the full-screen picker is up it covers the toggle it came from — the month
// grid is what the browser finds there — so no later attempt can ever land. That is the
// 90 seconds (45s of attempts, then one last one with the whole budget) this timed out in.
->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');
$page->click('[aria-controls="trip-field-picker"][data-md-datepicker-toggle]')
->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) {
$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) {
$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)");
$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 press that
// landed on another day — the month list grows both ways as it scrolls, so a cell can move
// under one — otherwise shows up only as the value never reaching the server, which says
// nothing about which half went wrong.
->assertScript("JSON.stringify({$scope}.draft) === '{\"start\":\"2026-09-20\",\"end\":\"2026-09-24\"}'")
->click('#trip-field-picker [data-md-datepicker-save]')
->assertSeeIn('#trip', '{"start":"2026-09-20","end":"2026-09-24"}')
->assertScript("! {$picker}.open");
});
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');
$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]')
->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()
->click($toggle)
->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')");
$page->click($toggle)
->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()
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->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')");
$page->click('#birthday-field')
->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()
->click('[aria-controls="expires-field-picker"][data-md-datepicker-toggle]')
->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.
$page->click('#birthday-field')
->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()->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");
});