*/
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(' ');
expect($html)
->toContain('Starts at ')
->toMatch('/ ]*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('/
In your time zone<\/p>/')
->toContain('x-on:keydown.enter.prevent="show()"')
->toContain('data-timepicker-open')
->toContain('aria-label="Choose time"')
->toContain('toContain('id="starts-dialog"')
->toContain('wire:ignore')
->toContain('aria-labelledby="starts-title"')
->toContain('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(' ');
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(' ');
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')
->toContain('>OK');
});
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(' '));
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(' '));
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(' ');
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(' '))->toMatch('/value="2:05[\s\x{202F}]PM"/u')
->and((string) $this->blade(' '))->toContain('value="09:05"')
->and((string) $this->blade(' '))->toContain('value="21:40"')
->and((string) $this->blade(' '))->toMatch('/value="9:40[\s\x{202F}]PM"/u')
->and((string) $this->blade(' '))->toMatch('/ ]*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 '
';
}
});
$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 '
';
}
});
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(' ');
expect($html)
->toMatch('/^toContain('data-md-variant="filled"')
->toContain('data-md-size="sm"')
->toContain('data-timepicker-field')
->toContain('required')
->toContain('placeholder="hh:mm"')
->toContain(' ')
->toContain('data-md-field-clear')
->toMatch('/]*data-timepicker-open[^>]*disabled/s')
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
});