Browser-test coverage the reviewer asked for on the step 36 batch: the year, menu-button and list-option state layer/focus ring (with the option's ring drawn inset in its row), a selected day's hover layer over its primary fill versus none on a disabled or blank day, and the 40px day indicator holding its size at a 20px root font size while only its (rem-sized) text grows. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
680 lines
32 KiB
PHP
680 lines
32 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 class="grid max-w-md gap-6 p-4">
|
||
<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>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body class="bg-surface">
|
||
<livewire:date-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE);
|
||
});
|
||
|
||
return visit("/date-probe/{$locale}")->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||
}
|
||
|
||
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 class="grid max-w-md gap-6 p-4">
|
||
<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>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body class="bg-surface">
|
||
<livewire:date-format-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE);
|
||
});
|
||
|
||
return visit("/date-format-probe/{$locale}")->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||
}
|
||
|
||
class ScopedDateProbe extends Component
|
||
{
|
||
public ?string $early = '2026-09-13';
|
||
|
||
public ?string $late = '2026-09-13';
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div class="grid max-w-md gap-6 p-4" 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>
|
||
<x-theme-script />
|
||
@vite(config('livewire-material.showcase.vite'))
|
||
@livewireStyles
|
||
</head>
|
||
<body class="bg-surface">
|
||
<livewire:scoped-date-probe />
|
||
@livewireScripts
|
||
</body>
|
||
</html>
|
||
BLADE));
|
||
|
||
return visit('/scoped-date-probe')->waitForEvent('networkidle')
|
||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||
}
|
||
|
||
/** 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 }"]\')))');
|
||
});
|
||
|
||
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)
|
||
->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'))
|
||
->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')";
|
||
|
||
dateProbe()
|
||
->resize(390, 800)
|
||
->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");
|
||
});
|