Plan step 36 (inputs group): <x-datepicker> renders data-md-datepicker with every part data-md-datepicker-* (data-md-presentation replaces data-presentation; data-md-range, data-md-docked, data-md-concealed and data-md-value replace their unprefixed hooks), and datepicker.css moves into material.components on the same px, spacing and colour tokens, importing field.css, icon.css and button.css for what the view renders. The month's live-announced text drops its `sr-only` class for a data-md-datepicker-month-live hook with the same visually-hidden clip (a package view writes no class but the interaction classes), matching field's and search's counter/status live regions. The day, year, menu button and list option keep their own hand-rolled state layer and focus ring rather than the foundation's classes, as data-md-field-button already does: each is a bigger interactive box than the round indicator drawn inside it, which the foundation's fixed-geometry classes can't draw, and `:focus-visible` only ever matches the actually-focused element. Cancel, OK and the icon buttons are <x-button>, which already draws from the classes. datepicker.js follows the renamed hooks and dataset properties. Shift+M/Y (reaching the month/year dropdowns) and the full-screen range picker at compact were already implemented; this commit adds the browser tests the plan owed for them plus the month list's keyboard-driven growth in both directions and Save/close from the full-screen app bar (docs/plans/material-3-browser-tests.md), written but not run — the inputs group's Chromium run follows this stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
239 lines
11 KiB
PHP
239 lines
11 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-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('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');
|
||
});
|