*/ 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-md-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-md-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-md-datepicker-full') ->toContain('x-show="presentation === \'full\'"') ->toContain('data-md-datepicker-app-bar') ->toContain('data-md-datepicker-close') ->toContain('data-md-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-md-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-md-datepicker-nav data-md-docked') ->toContain('x-show="presentation === \'docked\'"') ->toContain('data-md-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-md-datepicker-menu-button="months"') ->toContain('data-md-datepicker-menu-button="years"'); }); it('draws the year, the menu buttons and the list options from the interaction classes', function () { $html = (string) $this->blade(''); $css = ComponentStylesheet::read('datepicker'); expect(preg_match_all('/class="md-state-layer md-focus-ring"\s+data-md-datepicker-menu-button=/', $html))->toBe(3) ->and(preg_match_all('/class="md-state-layer md-focus-ring"\s+data-md-datepicker-year\b/', $html))->toBe(1) ->and(preg_match_all('/class="md-state-layer md-focus-ring"\s+data-md-datepicker-option\b/', $html))->toBe(2) ->and($html)->toContain('') // Nothing in the stylesheet takes back what the classes draw… ->and($css->declarations('[data-md-datepicker-menu-button]'))->not->toHaveKeys(['position', 'isolation', 'outline']) ->and($css->declarations('[data-md-datepicker-year]'))->not->toHaveKeys(['position', 'isolation', 'outline']) ->and($css->declarations('[data-md-datepicker-option]'))->not->toHaveKeys(['position', 'isolation', 'outline']) // …but the option's ring, which sits inside its full-width row. ->and($css->declarations('[data-md-datepicker-option]:focus-visible'))->toBe(['outline-offset' => '-3px']); }); it('draws the day\'s state layer on its indicator at M3\'s state opacities, and none on a disabled day', function () { $css = ComponentStylesheet::read('datepicker'); expect($css->declarations('[data-md-datepicker-day] > span::before'))->toMatchArray(['background-color' => 'var(--datepicker-layer)', 'opacity' => '0', 'z-index' => '-1']) ->and($css->declarations('[data-md-datepicker-day] > span'))->toMatchArray(['width' => '40px', 'height' => '40px', 'isolation' => 'isolate']) ->and($css->declarations('[data-md-datepicker-day]:hover > span::before', ['@media (hover: hover)']))->toBe(['opacity' => 'var(--md-sys-state-hover-state-layer-opacity)']) ->and($css->declarations('[data-md-datepicker-day]:focus-visible > span::before'))->toBe(['opacity' => 'var(--md-sys-state-focus-state-layer-opacity)']) ->and($css->declarations('[data-md-datepicker-day]:active > span::before'))->toBe(['opacity' => 'var(--md-sys-state-pressed-state-layer-opacity)']) ->and($css->declarations("[data-md-datepicker-day]:is([aria-disabled='true'], [data-md-blank]) > span::before"))->toBe(['display' => 'none']) ->and($css->declarations('[data-md-datepicker-day]:focus-visible > span'))->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px']) ->and($css->declarations('[data-md-datepicker-day]'))->toMatchArray(['height' => '48px']); }); 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-md-variant="filled"') ->toContain('data-md-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-md-datepicker-error') ->and(substr_count($html, 'data-md-invalid=""'))->toBe(2); }); it('offers a clear button on request', function () { expect((string) $this->blade('')) ->toContain('data-md-field-clear') ->toContain('x-on:click="clear()"') ->toContain('aria-label="Clear"') ->and((string) $this->blade('')) ->not->toContain('data-md-field-clear'); });