Files
livewire-material/tests/Feature/Components/DatepickerTest.php
T
Andreas Reinhold / reiniandClaude Fable 5.1 0074f9bd5c Finish the keyboards, and say a state once
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
2026-09-14 06:13:00 +02:00

204 lines
8.8 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 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');
});