*/ 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('') ->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-md-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-md-timepicker-label="hour12"'))->toBe(12) ->and(substr_count($html, 'data-md-timepicker-label="hour24"'))->toBe(24) ->and(substr_count($html, 'data-md-timepicker-label="minute"'))->toBe(12) ->and($html) ->toMatch('/data-md-timepicker-label="hour24" data-md-value="0"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>00toMatch('/data-md-timepicker-label="hour24" data-md-value="12"\s+data-md-inner\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12toMatch('/data-md-timepicker-label="hour24" data-md-value="15"\s+data-md-inner\s+style="--x: 1\.0000; --y: -0\.0000"\s*>15toMatch('/data-md-timepicker-label="minute" data-md-value="0"\s+style="[^"]*"\s*>00toMatch('/data-md-timepicker-label="hour12" data-md-value="12"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12toContain('data-md-timepicker-handle') ->toContain('data-md-timepicker-ink') ->not->toContain('x-bind:data-md-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-md-timepicker-period-option="am"') ->toContain('data-md-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-md-timepicker-display x-bind:data-md-cycle="is24 ? \'24\' : \'12\'"') ->toContain('aria-label="Switch to text input mode"') ->toContain('aria-label="Switch to clock mode"') ->toContain('data-md-timepicker-cancel') ->toContain('data-md-timepicker-confirm') ->toContain('>Cancel') ->toContain('>OK'); }); it('draws the boxes, the period and the dial from the interaction classes', function () { $html = (string) $this->blade(''); $css = ComponentStylesheet::read('timepicker'); // Two boxes and two period selectors of two radios each; the dial takes the ring alone. expect(substr_count($html, 'class="md-state-layer md-focus-ring"'))->toBe(6) ->and($html)->toMatch('/class="md-focus-ring"\s+data-md-timepicker-dial/') ->and($css->declarations('[data-md-timepicker-dial]'))->not->toHaveKey('outline') ->and($css->declarations('[data-md-timepicker-box]'))->not->toHaveKeys(['outline', 'position', 'isolation']) ->and($css->declarations('[data-md-timepicker-period] > button:disabled'))->toMatchArray([ 'color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)', 'background-color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent)', ]); }); it('lies the dial down by orientation and viewport height alone, at the px the upright dial needs', function () { $css = ComponentStylesheet::read('timepicker'); expect($css->mediaQueries())->toBe([ '(orientation: landscape) and (height < 560px)', '(orientation: landscape) and (height <= 364px)', '(orientation: landscape) and (height <= 346px)', ]) ->and($css->declarations('[data-md-timepicker-dial]'))->toMatchArray(['--dial' => '256px']) ->and($css->declarations("[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial]", ['@media (orientation: landscape) and (height <= 364px)']))->toBe(['--dial' => '238px']) ->and($css->declarations("[data-md-timepicker-surface][data-md-mode='dial'] [data-md-timepicker-dial]", ['@media (orientation: landscape) and (height <= 346px)']))->toBe(['--dial' => '200px']); }); 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-md-disabled="hourAllowed(3 + (isPm ? 12 : 0)) ? null : \'\'"') ->toContain('x-bind:data-md-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-md-timepicker-field') ->toContain('required') ->toContain('placeholder="hh:mm"') ->toContain('') ->toContain('data-md-field-clear') ->toMatch('/]*data-md-timepicker-open[^>]*disabled/s') ->and(substr_count($html, 'class="field app-alarm-field"'))->toBe(0); });