`clearable` empties a date, or both ends of a range, closes an open picker and hands focus back to the field, as the time picker's does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
314 lines
14 KiB
PHP
314 lines
14 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'");
|
||
}
|
||
|
||
/** A picker's day cell, by its ISO date. */
|
||
function day(string $picker, string $date): string
|
||
{
|
||
return "#{$picker}-picker [data-value=\"{$date}\"]";
|
||
}
|
||
|
||
function focusedDay(string $date): string
|
||
{
|
||
return "document.activeElement.dataset.value === '{$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-datepicker-toggle]')
|
||
->assertScript("{$picker}.matches(':popover-open') && {$picker}.dataset.presentation === 'docked'")
|
||
->assertAttribute('#expires-field', 'aria-expanded', 'true')
|
||
->assertScript("(() => { const field = document.querySelector('#expires-field').closest('.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-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-selected')")
|
||
->assertScript("document.querySelector('".day('expires-field', '2026-09-13')."').hasAttribute('data-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-datepicker-headline]', 'May 17, 2000')
|
||
->click('#birthday-field-picker [data-datepicker-nav]:not([data-docked]) [data-datepicker-menu-button]')
|
||
->assertScript("getComputedStyle(document.querySelector('#birthday-field-picker [data-datepicker-years]')).display !== 'none'")
|
||
->assertScript("document.activeElement.textContent.trim() === '2000'");
|
||
|
||
$page->click('#birthday-field-picker [data-datepicker-year]:has-text("1990")')
|
||
->assertSeeIn('#birthday-field-picker [data-datepicker-nav]', 'May 1990')
|
||
->click(day('birthday-field', '1990-05-03'))
|
||
->assertSeeIn('#birthday-field-picker [data-datepicker-headline]', 'May 3, 1990')
|
||
->assertSeeIn('#birthday', '2000-05-17');
|
||
|
||
$page->click('#birthday-field-picker [data-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-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-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-datepicker-headline]', 'Entered date');
|
||
|
||
$page->type('#delivery-field-entry', '2026-03-07')
|
||
->click('#delivery-field-picker [data-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-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-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-selected')")
|
||
->assertScript("! document.querySelector('".day('trip-field', '2026-09-14')."').hasAttribute('data-between')");
|
||
|
||
$page->click(day('trip-field', '2026-09-24'))
|
||
->assertScript("[...document.querySelectorAll('#trip-field-picker [data-between]')].map((cell) => cell.dataset.value).join() === '2026-09-21,2026-09-22,2026-09-23'")
|
||
->assertScript("document.querySelector('".day('trip-field', '2026-09-20')."').hasAttribute('data-start') && document.querySelector('".day('trip-field', '2026-09-24')."').hasAttribute('data-end')")
|
||
->assertSeeIn('#trip', '2026-09-15');
|
||
|
||
$page->click('#trip-field-picker [data-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-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-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-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-datepicker-toggle]')
|
||
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
|
||
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-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('.field').querySelector('[data-field-clear]')).display !== 'none'");
|
||
|
||
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
|
||
->assertScript("document.querySelector('#expires').textContent === ''")
|
||
->assertValue('#expires-field', '')
|
||
->assertScript("document.activeElement.id === 'expires-field'")
|
||
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
|
||
|
||
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
|
||
->assertSeeIn('#trip', '{"start":null,"end":null}')
|
||
->assertValue('#trip-field', '');
|
||
});
|