Files
livewire-material/tests/Feature/Components/DatepickerTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 22c141d380 Draw the datepicker's controls from the shared classes, in px
Plan step 36 review of 72d9dbfc. The year, the three menu buttons and
the docked lists' options hand-rolled md-state-layer and md-focus-ring
on themselves, in their own ink, on the claim that each was a bigger box
than an indicator inside it; only the day is. They now render the
classes, with the option's inset ring as the one refinement. The day
keeps its own layer on the 40px indicator, now at M3's state-opacity
tokens, gone from a disabled or blank day (a focused disabled day still
showed it), and in its own stacking context so it lies over a chosen
day's primary fill instead of under it.

The month announcement is md-visually-hidden again, not a hook with a
copy of its clip. The stylesheet kept every length in rem although the
commit said px: sizes are now px, spacing the measurement tokens, and
disabled ink the 38% state token, as search and slider were drawn. The
view header's "below sm" is 600px, medium.

Six browser assertions still read the old unprefixed hooks
(dataset.presentation, data-selected, data-between, data-start,
data-end); one of them would have passed vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 19:34:51 +02:00

269 lines
13 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;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* 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-md-datepicker')
->toContain('<label for="expires" data-md-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-md-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('gives a range picker M3\'s full-screen presentation, with an app bar and Save', function () {
$html = (string) $this->blade('<x-datepicker id="trip" label="Trip" range mode="modal" />');
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('<x-datepicker id="expires" label="Expires on" />');
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('<x-datepicker label="Birthday" />');
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('<x-datepicker id="expires" label="Expires on" />');
$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('<span id="expires-month" class="md-visually-hidden" aria-live="polite" x-text="monthYear"></span>')
// 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('<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-md-variant="filled"')
->toContain('data-md-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-md-datepicker-error')
->and(substr_count($html, 'data-md-invalid=""'))->toBe(2);
});
it('offers a clear button on request', function () {
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
->toContain('data-md-field-clear')
->toContain('x-on:click="clear()"')
->toContain('aria-label="Clear"')
->and((string) $this->blade('<x-datepicker label="Expires" />'))
->not->toContain('data-md-field-clear');
});