Files
livewire-material/tests/Browser/DatepickerTest.php
T
surtic86andClaude Opus 5 ad230e0f48
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
Keep the full-screen range picker's months where they were as it grows
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

781 lines
37 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 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()
->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");
});