An application that lets each person choose when their week starts and how dates are written needs the picker to follow that choice rather than the locale. <x-datepicker> takes week-start, 0 (Sunday) to 6 (Saturday), and format, dd, MM and yyyy each once around one delimiter (dd.MM.yyyy, dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd). They replace what Intl derives for firstDayOfWeek() and inputFormat(), so the field, the server-rendered value, the typed-date reader (year first too), the calendar's columns and weekday header, Home and End, the dialog's text fields and ranges all follow them. Month and weekday names stay the locale's, and wire:model still stores Y-m-d. Values that are neither are ignored, and without the props nothing changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
196 lines
8.5 KiB
PHP
196 lines
8.5 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('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');
|
||
});
|