*/ 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(''); expect($html) ->toContain('data-datepicker') ->toContain('') ->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('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('/]*\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('', ['mode' => $mode]); expect($html) ->toMatch('/]*\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('gives a range picker M3\'s full-screen presentation, with an app bar and Save', function () { $html = (string) $this->blade(''); expect($html) ->toContain('data-datepicker-full') ->toContain('x-show="presentation === \'full\'"') ->toContain('data-datepicker-app-bar') ->toContain('data-datepicker-close') ->toContain('data-datepicker-save') ->toContain('Save') ->toContain('aria-label="Select dates"') // The months come from one list of rows, each headed by its label. ->toContain('x-for="row in rows"') ->toContain('data-datepicker-month-label') // The action row is the modal's; the full-screen picker confirms from its app bar. ->toContain('x-show="presentation !== \'full\' && (view === \'days\' || typing || presentation === \'modal\')"') ->toContain('x-on:scroll="extendMonths($event)"'); expect(datepickerConfig($html))->toMatchArray(['range' => true, 'mode' => 'modal']); }); it('leaves the docked and single-date pickers as they were', function () { // Only the presentation is new: a single date never reaches it, whatever the window. expect(file_get_contents(__DIR__.'/../../../resources/js/datepicker.js')) ->toContain("this.presentation = config.range && this.compact ? 'full' : config.mode === 'docked' && !this.compact ? 'docked' : 'modal'"); $html = (string) $this->blade(''); expect($html) ->toContain('data-datepicker-nav data-docked') ->toContain('x-show="presentation === \'docked\'"') ->toContain('data-datepicker-menu-button="months"') ->toContain('x-show="presentation === \'modal\'"'); }); it('names each dropdown so Shift+M and Shift+Y can reach it', function () { $html = (string) $this->blade(''); 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(''))['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( '', ['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(''))) ->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(''); expect(datepickerConfig($html))->toMatchArray(['weekStart' => 0, 'format' => 'yyyy-MM-dd', 'locale' => 'de']) ->and($html)->toContain('value="2026-09-13"') ->and(datepickerConfig((string) $this->blade(''))) ->toMatchArray(['weekStart' => 6, 'format' => 'MM/dd/yyyy']) ->and((string) $this->blade('')) ->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(""))) ->toMatchArray(['weekStart' => null, 'format' => null]); } expect(datepickerConfig((string) $this->blade('')))->toMatchArray(['weekStart' => null, 'format' => null]) ->and((string) $this->blade(''))->toContain('value="13.09.2026"'); }); it('shows the value in the locale\'s numeric format before Alpine starts', function () { expect((string) $this->blade('')) ->toContain('value="09/13/2026"') ->toContain('setLocale('de'); expect((string) $this->blade('')) ->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(''); expect($html) ->toContain('class="w-60"') ->toContain('data-variant="filled"') ->toContain('data-size="sm"') ->toMatch('/]*required[^>]*id="due"/s') ->toMatch('/]*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'
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'
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('')) ->toContain('data-field-clear') ->toContain('x-on:click="clear()"') ->toContain('aria-label="Clear"') ->and((string) $this->blade('')) ->not->toContain('data-field-clear'); });