<x-field> renders data-md-field with its props and parts as data-md-* attributes (box, control, label, outline, support, counter, trailing buttons), and field.css moves into material.components on tokens: px geometry, spacing and state tokens, the 600px breakpoint (plan step 36). Its icons take a size prop, 24/20/16 by the field's size. Every control the field wraps now marks itself data-md-field-control, so the inputs, the pickers, choices, field.js, menu.css's select and listbox rules and timepicker.css follow the renamed hooks. Browser tests cover the error icon, the disabled field's hover, the counter and the width bound from 600px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
178 lines
8.3 KiB
PHP
178 lines
8.3 KiB
PHP
<?php
|
|
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* The JSON the component hands its Alpine data as its second argument.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
function timepickerConfig(string $html): array
|
|
{
|
|
preg_match('/materialTimepicker\((.*?), JSON\.parse\(\'(.*?)\'\)\)"/s', $html, $matches);
|
|
|
|
return json_decode((string) json_decode('"'.($matches[2] ?? '{}').'"'), true) ?? [];
|
|
}
|
|
|
|
it('draws a read-only field that opens the picker in a labelled dialog', function () {
|
|
$html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />');
|
|
|
|
expect($html)
|
|
->toContain('<label for="starts" data-md-field-label>Starts at</label>')
|
|
->toMatch('/<input[^>]*id="starts"[^>]*readonly/s')
|
|
->toContain('value="14:30"')
|
|
->toContain('aria-haspopup="dialog"')
|
|
->toContain('aria-controls="starts-dialog"')
|
|
->toContain('aria-expanded="false"')
|
|
->toContain('aria-describedby="starts-support"')
|
|
->toMatch('/<p id="starts-support" data-md-field-support\s*>In your time zone<\/p>/')
|
|
->toContain('x-on:keydown.enter.prevent="show()"')
|
|
->toContain('data-timepicker-open')
|
|
->toContain('aria-label="Choose time"')
|
|
->toContain('<dialog')
|
|
->toContain('id="starts-dialog"')
|
|
->toContain('wire:ignore')
|
|
->toContain('aria-labelledby="starts-title"')
|
|
->toContain('<h2 id="starts-title" data-timepicker-title')
|
|
->toContain('x-modelable="value"')
|
|
->toContain("materialTimepicker( '14:30' ,")
|
|
->not->toContain('aria-invalid="true"')
|
|
->not->toContain('data-md-invalid');
|
|
});
|
|
|
|
it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () {
|
|
$html = (string) $this->blade('<x-timepicker id="alarm" label="Alarm" />');
|
|
|
|
expect($html)
|
|
->toContain('role="slider"')
|
|
->toContain('x-bind:aria-valuetext="valueText"')
|
|
->and(substr_count($html, 'data-timepicker-label="hour12"'))->toBe(12)
|
|
->and(substr_count($html, 'data-timepicker-label="hour24"'))->toBe(24)
|
|
->and(substr_count($html, 'data-timepicker-label="minute"'))->toBe(12)
|
|
->and($html)
|
|
->toMatch('/data-timepicker-label="hour24" data-value="0"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>00</')
|
|
->toMatch('/data-timepicker-label="hour24" data-value="12"\s+data-inner\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12</')
|
|
->toMatch('/data-timepicker-label="hour24" data-value="15"\s+data-inner\s+style="--x: 1\.0000; --y: -0\.0000"\s*>15</')
|
|
->toMatch('/data-timepicker-label="minute" data-value="0"\s+style="[^"]*"\s*>00</')
|
|
->toMatch('/data-timepicker-label="hour12" data-value="12"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12</')
|
|
->toContain('data-timepicker-handle')
|
|
->toContain('data-timepicker-ink')
|
|
->not->toContain('x-bind:data-disabled');
|
|
});
|
|
|
|
it('offers the input variant, the period selector and the actions', function () {
|
|
$html = (string) $this->blade('<x-timepicker id="pickup" label="Pick-up" />');
|
|
|
|
expect($html)
|
|
->toContain('id="pickup-hour"')
|
|
->toContain('id="pickup-minute"')
|
|
->toContain('inputmode="numeric"')
|
|
->toContain('aria-describedby="pickup-hour-support"')
|
|
->toContain('aria-describedby="pickup-minute-support"')
|
|
->toContain('data-timepicker-period-option="am"')
|
|
->toContain('data-timepicker-period-option="pm"')
|
|
->toContain('aria-label="Select AM or PM"')
|
|
// M3's labelling table: AM and PM are radios in a list, not toggle buttons.
|
|
->toContain('role="radiogroup"')
|
|
->toContain('role="radio"')
|
|
->toContain('x-on:keydown.arrow-right.prevent="movePeriod($event)"')
|
|
// The 24-hour selector widens into the room the period selector leaves.
|
|
->toContain('data-timepicker-display x-bind:data-cycle="is24 ? \'24\' : \'12\'"')
|
|
->toContain('aria-label="Switch to text input mode"')
|
|
->toContain('aria-label="Switch to clock mode"')
|
|
->toContain('data-timepicker-cancel')
|
|
->toContain('data-timepicker-confirm')
|
|
->toContain('>Cancel</span>')
|
|
->toContain('>OK</span>');
|
|
});
|
|
|
|
it('passes the format, locale, limits and step to the picker, and drops what it cannot use', function () {
|
|
app()->setLocale('de_CH');
|
|
|
|
$config = timepickerConfig((string) $this->blade('<x-timepicker label="Slot" format="12" step="15" min="8:00" max="17:30:00" />'));
|
|
|
|
expect($config)->toMatchArray(['locale' => 'de-CH', 'format' => 12, 'min' => '08:00', 'max' => '17:30', 'step' => 15])
|
|
->and($config['strings'])->toHaveKeys(['hourError12', 'hourError24', 'minuteError', 'stepError', 'between']);
|
|
|
|
$fallback = timepickerConfig((string) $this->blade('<x-timepicker label="Slot" format="13" step="0" min="25:00" max="noon" locale="en_GB" />'));
|
|
|
|
expect($fallback)->toMatchArray(['locale' => 'en-GB', 'format' => null, 'min' => null, 'max' => null, 'step' => 1]);
|
|
});
|
|
|
|
it('marks what lies outside the limits only when there are limits', function () {
|
|
$html = (string) $this->blade('<x-timepicker label="Slot" step="15" />');
|
|
|
|
expect($html)
|
|
->toContain('x-bind:data-disabled="hourAllowed(3 + (isPm ? 12 : 0)) ? null : \'\'"')
|
|
->toContain('x-bind:data-disabled="minuteAllowed(45) ? null : \'\'"');
|
|
});
|
|
|
|
it('writes the first frame as the locale writes the time', function () {
|
|
expect((string) $this->blade('<x-timepicker label="A" value="14:05" format="12" />'))->toMatch('/value="2:05[\s\x{202F}]PM"/u')
|
|
->and((string) $this->blade('<x-timepicker label="B" value="09:05:00" format="24" />'))->toContain('value="09:05"')
|
|
->and((string) $this->blade('<x-timepicker label="C" value="21:40" locale="de" />'))->toContain('value="21:40"')
|
|
->and((string) $this->blade('<x-timepicker label="D" value="21:40" locale="en" />'))->toMatch('/value="9:40[\s\x{202F}]PM"/u')
|
|
->and((string) $this->blade('<x-timepicker label="E" value="not a time" />'))->toMatch('/<input[^>]*value=""[^>]*x-bind:value="display"/s');
|
|
});
|
|
|
|
it('entangles the time with its Livewire property and shows it before Alpine starts', function () {
|
|
Livewire::component('timepicker-probe', new class extends Component
|
|
{
|
|
public ?string $startsAt = '18:45:00';
|
|
|
|
public function render(): string
|
|
{
|
|
return '<div><x-timepicker id="starts" label="Starts" wire:model.live="startsAt" format="24" /></div>';
|
|
}
|
|
});
|
|
|
|
$html = Livewire::test('timepicker-probe')->html();
|
|
|
|
expect($html)
|
|
->toContain(".entangle('startsAt').live")
|
|
->toContain('value="18:45"')
|
|
->not->toContain('x-modelable')
|
|
->not->toContain('wire:model.live="startsAt"');
|
|
});
|
|
|
|
it('shows the errors for its property in place of the hint', function () {
|
|
Livewire::component('timepicker-errors', new class extends Component
|
|
{
|
|
public ?string $startsAt = null;
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->addError('startsAt', 'Choose a time during opening hours.');
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return '<div><x-timepicker id="starts" label="Starts" hint="Opening hours only" wire:model="startsAt" /></div>';
|
|
}
|
|
});
|
|
|
|
expect(Livewire::test('timepicker-errors')->html())
|
|
->toContain('data-md-invalid')
|
|
->toContain('aria-invalid="true"')
|
|
->toContain('aria-describedby="starts-support"')
|
|
->toContain('Choose a time during opening hours.')
|
|
->not->toContain('Opening hours only');
|
|
});
|
|
|
|
it('puts the field props on the field, its attributes on the input, and posts a name', function () {
|
|
$html = (string) $this->blade('<x-timepicker label="Alarm" icon="alarm" variant="filled" size="sm" class="w-60" name="alarm" value="06:30" format="24" required disabled clearable placeholder="hh:mm" />');
|
|
|
|
expect($html)
|
|
->toMatch('/^<div\s+class="w-60"/')
|
|
->toContain('data-md-variant="filled"')
|
|
->toContain('data-md-size="sm"')
|
|
->toContain('data-timepicker-field')
|
|
->toContain('required')
|
|
->toContain('placeholder="hh:mm"')
|
|
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
|
|
->toContain('data-md-field-clear')
|
|
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
|
|
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
|
|
});
|