Plan step 36 review of d46b07c1. The landscape queries moved from rem
to px (560/364/346, equal at the default 16px) while every size they
make room for stayed in rem, so with a larger browser text size the
dial grew past a threshold that no longer moved with it. The sizes are
px now, as M3 gives them in dp, spacing the measurement tokens, the
selected period corner corner-md, and disabled the 38% and 12% state
tokens; the supporting text's two-line room stays in rem, being text.
The header's arithmetic is in px, and its note on targets says why the
period halves render no md-touch-target instead of claiming nothing
here reaches 48px.
The landscape browser test expected the display and the dial to share a
top edge, but the display is centred in the dial's 256px row, 61px
lower; it now checks the display sits inside that row with the dial
36px after it. Feature tests pin the classes and the three queries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
207 lines
10 KiB
PHP
207 lines
10 KiB
PHP
<?php
|
|
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
|
|
|
/**
|
|
* 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-md-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-md-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-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*>00</')
|
|
->toMatch('/data-md-timepicker-label="hour24" data-md-value="12"\s+data-md-inner\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12</')
|
|
->toMatch('/data-md-timepicker-label="hour24" data-md-value="15"\s+data-md-inner\s+style="--x: 1\.0000; --y: -0\.0000"\s*>15</')
|
|
->toMatch('/data-md-timepicker-label="minute" data-md-value="0"\s+style="[^"]*"\s*>00</')
|
|
->toMatch('/data-md-timepicker-label="hour12" data-md-value="12"\s+style="--x: 0\.0000; --y: -1\.0000"\s*>12</')
|
|
->toContain('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('<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-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</span>')
|
|
->toContain('>OK</span>');
|
|
});
|
|
|
|
it('draws the boxes, the period and the dial from the interaction classes', function () {
|
|
$html = (string) $this->blade('<x-timepicker id="pickup" label="Pickup" />');
|
|
$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('<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-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('<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-md-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-md-timepicker-open[^>]*disabled/s')
|
|
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
|
|
});
|