Shift+M and Shift+Y reach the month and year dropdowns, which is M3's date picker keyboard table; Home and End go to the ends of an open combobox list, and stay the field's own caret keys when it is closed. The password reveal drops aria-pressed and keeps only its flipping label, so a screen reader hears the state once rather than twice and inverted. The radio's `inline` now says that M3 cautions against a row and wants an option chosen on load. Plan step 20, findings IN-22, IN-23, IN-28 and IN-29. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
204 lines
8.8 KiB
PHP
204 lines
8.8 KiB
PHP
<?php
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Livewire\Component;
|
||
use Livewire\Livewire;
|
||
|
||
/**
|
||
* The JSON config handed to `materialDatepicker(…)`, decoded.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
function datepickerConfig(string $html): array
|
||
{
|
||
preg_match("/\.\.\.JSON\.parse\('(.+?)'\)/", $html, $match);
|
||
|
||
return json_decode(json_decode('"'.$match[1].'"'), true, flags: JSON_THROW_ON_ERROR);
|
||
}
|
||
|
||
it('draws a docked date field whose combobox controls a popover picker', function () {
|
||
$html = (string) $this->blade('<x-datepicker id="expires" label="Expires on" hint="Type a date or pick one" />');
|
||
|
||
expect($html)
|
||
->toContain('data-datepicker')
|
||
->toContain('<label for="expires" class="field-label">Expires on</label>')
|
||
->toContain('role="combobox"')
|
||
->toContain('aria-haspopup="dialog"')
|
||
->toContain('aria-controls="expires-picker"')
|
||
->toContain('aria-expanded="false"')
|
||
->toContain('aria-describedby="expires-support"')
|
||
->toContain('Type a date or pick one')
|
||
->toContain('<dialog')
|
||
->toContain('id="expires-picker"')
|
||
->toContain('popover="manual"')
|
||
->toContain('wire:ignore')
|
||
->toContain('aria-label="Select date"')
|
||
->toContain('role="grid"')
|
||
->toContain('data-datepicker-toggle')
|
||
->toContain('x-modelable="value"')
|
||
->toContain('x-on:input="typeInField()"')
|
||
->not->toMatch('/<input[^>]*\sreadonly[\s>]/')
|
||
->not->toContain('aria-invalid="true"')
|
||
->and(datepickerConfig($html))->toMatchArray(['mode' => 'docked', 'range' => false, 'min' => null, 'max' => null, 'locale' => 'en'])
|
||
->and($html)->toContain('style="anchor-name: --material-datepicker-expires"')
|
||
->toContain('style="position-anchor: --material-datepicker-expires"');
|
||
});
|
||
|
||
it('makes the field a read-only trigger for the modal and modal input pickers', function (string $mode) {
|
||
$html = (string) $this->blade('<x-datepicker label="Birthday" :mode="$mode" />', ['mode' => $mode]);
|
||
|
||
expect($html)
|
||
->toMatch('/<input[^>]*\sreadonly[\s>]/')
|
||
->toContain('x-on:keydown.space.prevent="show()"')
|
||
->not->toContain('x-on:input="typeInField()"')
|
||
->and(datepickerConfig($html)['mode'])->toBe($mode);
|
||
})->with(['modal', 'input']);
|
||
|
||
it('names each dropdown so Shift+M and Shift+Y can reach it', function () {
|
||
$html = (string) $this->blade('<x-datepicker label="Birthday" />');
|
||
|
||
expect($html)
|
||
->toContain('data-datepicker-menu-button="months"')
|
||
->toContain('data-datepicker-menu-button="years"');
|
||
});
|
||
|
||
it('falls back to the docked picker for an unknown mode', function () {
|
||
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" mode="wheel" />'))['mode'])->toBe('docked');
|
||
});
|
||
|
||
it('hands min, max and the application locale to the picker as Y-m-d', function () {
|
||
app()->setLocale('de_CH');
|
||
|
||
$config = datepickerConfig((string) $this->blade(
|
||
'<x-datepicker label="Date" :min="$min" max="2026-12-31 23:59:00" />',
|
||
['min' => CarbonImmutable::parse('2026-09-13 08:30')],
|
||
));
|
||
|
||
expect($config)->toMatchArray(['min' => '2026-09-13', 'max' => '2026-12-31', 'locale' => 'de-CH'])
|
||
->and($config['strings']['pattern'])->toBe('Date does not match expected pattern: :pattern')
|
||
->and(datepickerConfig((string) $this->blade('<x-datepicker label="Date" min="2026-02-30" max="soon" />')))
|
||
->toMatchArray(['min' => null, 'max' => null]);
|
||
});
|
||
|
||
it('hands a chosen first day of the week and format to the picker, and ignores values that are neither', function () {
|
||
app()->setLocale('de');
|
||
|
||
$html = (string) $this->blade('<x-datepicker label="Date" week-start="0" format="yyyy-MM-dd" value="2026-09-13" />');
|
||
|
||
expect(datepickerConfig($html))->toMatchArray(['weekStart' => 0, 'format' => 'yyyy-MM-dd', 'locale' => 'de'])
|
||
->and($html)->toContain('value="2026-09-13"')
|
||
->and(datepickerConfig((string) $this->blade('<x-datepicker label="Date" :week-start="6" format="MM/dd/yyyy" />')))
|
||
->toMatchArray(['weekStart' => 6, 'format' => 'MM/dd/yyyy'])
|
||
->and((string) $this->blade('<x-datepicker label="Trip" range format="dd/MM/yyyy" :value="[\'start\' => \'2026-09-13\', \'end\' => \'2026-09-20\']" />'))
|
||
->toContain('value="13/09/2026 – 20/09/2026"');
|
||
|
||
foreach (['week-start="7"', 'week-start="-1"', 'week-start="monday"', 'week-start', 'format="d.M.yy"', 'format="dd.MM/yyyy"', 'format="dd.dd.yyyy"', 'format="yyyy-mm-dd"'] as $attribute) {
|
||
expect(datepickerConfig((string) $this->blade("<x-datepicker label=\"Date\" {$attribute} />")))
|
||
->toMatchArray(['weekStart' => null, 'format' => null]);
|
||
}
|
||
|
||
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" />')))->toMatchArray(['weekStart' => null, 'format' => null])
|
||
->and((string) $this->blade('<x-datepicker label="Date" format="dd/MM/y" value="2026-09-13" />'))->toContain('value="13.09.2026"');
|
||
});
|
||
|
||
it('shows the value in the locale\'s numeric format before Alpine starts', function () {
|
||
expect((string) $this->blade('<x-datepicker label="Date" value="2026-09-13" name="expires" />'))
|
||
->toContain('value="09/13/2026"')
|
||
->toContain('<input type="hidden" name="expires" value="2026-09-13"');
|
||
|
||
app()->setLocale('de');
|
||
|
||
expect((string) $this->blade('<x-datepicker label="Trip" range :value="[\'start\' => \'2026-09-13\', \'end\' => \'2026-09-20\']" name="trip" />'))
|
||
->toContain('value="13.09.2026 – 20.09.2026"')
|
||
->toContain('name="trip[start]" value="2026-09-13"')
|
||
->toContain('name="trip[end]" value="2026-09-20"')
|
||
->toContain('aria-label="Select dates"');
|
||
});
|
||
|
||
it('puts class on the root and every other attribute on the text field', function () {
|
||
$html = (string) $this->blade('<x-datepicker id="due" label="Due" class="w-60" required disabled variant="filled" size="sm" />');
|
||
|
||
expect($html)
|
||
->toContain('class="w-60"')
|
||
->toContain('data-variant="filled"')
|
||
->toContain('data-size="sm"')
|
||
->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
|
||
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
|
||
->and(datepickerConfig($html)['disabled'])->toBeTrue();
|
||
});
|
||
|
||
it('entangles the value with its Livewire property and renders it as the property says', function () {
|
||
Livewire::component('datepicker-probe', new class extends Component
|
||
{
|
||
public ?string $expires = '2026-09-13';
|
||
|
||
/** @var array{start: ?string, end: ?string} */
|
||
public array $trip = ['start' => '2026-09-01', 'end' => null];
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div>
|
||
<x-datepicker label="Expires" wire:model.live="expires" />
|
||
<x-datepicker label="Trip" wire:model="trip" range mode="modal" />
|
||
</div>
|
||
BLADE;
|
||
}
|
||
});
|
||
|
||
$html = Livewire::test('datepicker-probe')->html();
|
||
|
||
expect($html)
|
||
->toContain(".entangle('expires').live")
|
||
->toContain(".entangle('trip')")
|
||
->not->toContain('x-modelable')
|
||
->not->toContain('wire:model')
|
||
->toContain('value="09/13/2026"')
|
||
->toContain('value="09/01/2026 – "');
|
||
});
|
||
|
||
it('replaces the hint with the errors for the property and its start and end', function () {
|
||
Livewire::component('datepicker-errors', new class extends Component
|
||
{
|
||
public ?string $expires = null;
|
||
|
||
/** @var array{start: ?string, end: ?string} */
|
||
public array $trip = ['start' => null, 'end' => null];
|
||
|
||
public function mount(): void
|
||
{
|
||
$this->addError('expires', 'Choose a date in the future.');
|
||
$this->addError('trip.end', 'The end must follow the start.');
|
||
}
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div>
|
||
<x-datepicker id="expires" label="Expires" hint="Pick a day" wire:model="expires" />
|
||
<x-datepicker id="trip" label="Trip" wire:model="trip" range />
|
||
</div>
|
||
BLADE;
|
||
}
|
||
});
|
||
|
||
$html = Livewire::test('datepicker-errors')->html();
|
||
|
||
expect($html)
|
||
->toContain('Choose a date in the future.')
|
||
->toContain('The end must follow the start.')
|
||
->not->toContain('Pick a day')
|
||
->toContain('aria-invalid="true"')
|
||
->toContain('data-datepicker-error')
|
||
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
|
||
});
|
||
|
||
it('offers a clear button on request', function () {
|
||
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
|
||
->toContain('data-field-clear')
|
||
->toContain('x-on:click="clear()"')
|
||
->toContain('aria-label="Clear"')
|
||
->and((string) $this->blade('<x-datepicker label="Expires" />'))
|
||
->not->toContain('data-field-clear');
|
||
});
|