Add the M3 date pickers
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m10s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (firefox, firefox) (push) Failing after 8m28s
tests / browser (chrome, chromium) (push) Failing after 5m58s
tests / browser (safari, webkit) (push) Failing after 7m24s
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m10s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (firefox, firefox) (push) Failing after 8m28s
tests / browser (chrome, chromium) (push) Failing after 5m58s
tests / browser (safari, webkit) (push) Failing after 7m24s
Docked, modal and modal input date pickers for single dates and ranges, with locale month names, week start and typed format from Intl, min and max, and the APG grid keyboard. Completes Phase 7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
b8239dd215
commit
f4be9848e2
@@ -0,0 +1,165 @@
|
||||
<?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('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);
|
||||
});
|
||||
Reference in New Issue
Block a user