Add the M3 time picker
The dial and input time picker in a modal dialog, with the hour cycle from the locale, steps, a min and max that may span midnight, and a landscape layout, ported from Compose. 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
83ed671c4e
commit
b8239dd215
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
class TimeProbe extends Component
|
||||
{
|
||||
public ?string $meeting = '09:30';
|
||||
|
||||
public ?string $alarm = null;
|
||||
|
||||
public ?string $appointment = '10:00';
|
||||
|
||||
public int $renders = 0;
|
||||
|
||||
public function touch(): void
|
||||
{
|
||||
$this->renders++;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div class="grid max-w-md gap-6 p-4">
|
||||
<p>meeting: <span id="meeting-value">{{ $meeting ?? 'null' }}</span></p>
|
||||
<p>alarm: <span id="alarm-value">{{ $alarm ?? 'null' }}</span></p>
|
||||
<p>appointment: <span id="slot-value">{{ $appointment ?? 'null' }}</span></p>
|
||||
<p>renders: <span id="renders">{{ $renders }}</span></p>
|
||||
|
||||
<x-timepicker id="meeting" label="Meeting" wire:model.live="meeting" format="12" />
|
||||
<x-timepicker id="alarm" label="Alarm" wire:model.live="alarm" locale="de" />
|
||||
<x-timepicker id="slot" label="Slot" wire:model.live="appointment" format="24" step="15" min="09:00" max="17:00" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function timeProbe()
|
||||
{
|
||||
Livewire::component('time-probe', TimeProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/time-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:time-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
BLADE));
|
||||
|
||||
return visit('/time-probe')->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
/** A JavaScript expression for an element inside the named picker's dialog. */
|
||||
function inPicker(string $id, string $selector): string
|
||||
{
|
||||
return "document.querySelector('#{$id}-dialog {$selector}')";
|
||||
}
|
||||
|
||||
/** A JavaScript expression for the text under a time field, without its word joiners. */
|
||||
function supportText(string $id): string
|
||||
{
|
||||
return "document.querySelector('#{$id}-support').textContent.replaceAll('\\u2060', '')";
|
||||
}
|
||||
|
||||
it('opens the dial on the hour, as the bound time says', function () {
|
||||
timeProbe()
|
||||
->assertValue('#meeting', '9:30 AM')
|
||||
->click('#meeting')
|
||||
->assertScript("document.querySelector('#meeting-dialog').open")
|
||||
->assertAttribute('#meeting', 'aria-expanded', 'true')
|
||||
->assertSeeIn('#meeting-title', 'Select time')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-box="hour"]', 'aria-pressed', 'true')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '09')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="minute"]', '30')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="am"]', 'aria-pressed', 'true')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '9')
|
||||
->assertScript('document.activeElement === '.inPicker('meeting', '[data-timepicker-dial]'));
|
||||
});
|
||||
|
||||
it('writes the hour and the minute pressed on the dial to Livewire on OK', function () {
|
||||
$dial = inPicker('meeting', '[data-timepicker-dial]');
|
||||
|
||||
$page = timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-label="hour12"][data-value="3"]')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '03')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-box="minute"]', 'aria-pressed', 'true')
|
||||
// The selector turns to the value it chose: the handle ends over the number.
|
||||
->assertScript(<<<JS
|
||||
(() => {
|
||||
const handle = {$dial}.querySelector('[data-timepicker-handle]').getBoundingClientRect();
|
||||
const label = {$dial}.querySelector('[data-timepicker-label="minute"][data-value="30"]').getBoundingClientRect();
|
||||
|
||||
return Math.abs(handle.x - label.x) < 1 && Math.abs(handle.y - label.y) < 1;
|
||||
})()
|
||||
JS);
|
||||
|
||||
$page->click('#meeting-dialog [data-timepicker-label="minute"][data-value="45"]')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="minute"]', '45')
|
||||
->assertSeeIn('#meeting-value', '09:30')
|
||||
->click('#meeting-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#meeting-value', '03:45')
|
||||
->assertValue('#meeting', '3:45 AM')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertAttribute('#meeting', 'aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('leaves the time alone when the picker is cancelled', function () {
|
||||
timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-label="hour12"][data-value="7"]')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '07')
|
||||
->click('#meeting-dialog [data-timepicker-cancel]')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-value', '09:30')
|
||||
->assertValue('#meeting', '9:30 AM');
|
||||
});
|
||||
|
||||
it('turns three o\'clock into 15 with PM', function () {
|
||||
timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="pm"]')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-display] [data-timepicker-period-option="pm"]', 'aria-pressed', 'true')
|
||||
->click('#meeting-dialog [data-timepicker-label="hour12"][data-value="3"]')
|
||||
->click('#meeting-dialog [data-timepicker-label="minute"][data-value="15"]')
|
||||
->click('#meeting-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#meeting-value', '15:15')
|
||||
->assertValue('#meeting', '3:15 PM');
|
||||
});
|
||||
|
||||
it('draws a German dial with 24 hours, 12 to 23 on the inner ring', function () {
|
||||
$dial = inPicker('alarm', '[data-timepicker-dial]');
|
||||
$radius = fn (string $value): string => <<<JS
|
||||
(() => {
|
||||
const dial = {$dial}.getBoundingClientRect();
|
||||
const label = {$dial}.querySelector('[data-timepicker-label="hour24"][data-value="{$value}"]').getBoundingClientRect();
|
||||
|
||||
return Math.round(Math.hypot(label.x + label.width / 2 - dial.x - dial.width / 2, label.y + label.height / 2 - dial.y - dial.height / 2));
|
||||
})()
|
||||
JS;
|
||||
|
||||
$page = timeProbe()
|
||||
->click('#alarm')
|
||||
->assertAttribute('#alarm-dialog [data-timepicker-dial]', 'data-cycle', '24')
|
||||
->assertScript("getComputedStyle(document.querySelector('#alarm-dialog [data-timepicker-period]')).display === 'none'")
|
||||
->assertScript("{$dial}.querySelector('[data-timepicker-label=\"hour24\"][data-value=\"0\"]').textContent.trim() === '00'")
|
||||
->assertScript($radius('0').' === 101')
|
||||
->assertScript($radius('11').' === 101')
|
||||
->assertScript($radius('12').' === 69')
|
||||
->assertScript($radius('13').' === 69')
|
||||
->assertScript($radius('23').' === 69');
|
||||
|
||||
$page->click('#alarm-dialog [data-timepicker-label="hour24"][data-value="15"]')
|
||||
->assertSeeIn('#alarm-dialog [data-timepicker-box="hour"]', '15')
|
||||
->assertAttribute('#alarm-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->click('#alarm-dialog [data-timepicker-label="minute"][data-value="0"]')
|
||||
->click('#alarm-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#alarm-value', '15:00')
|
||||
->assertValue('#alarm', '15:00');
|
||||
|
||||
// The outer ring is the morning.
|
||||
$page->click('#alarm')
|
||||
->click('#alarm-dialog [data-timepicker-label="hour24"][data-value="3"]')
|
||||
->assertSeeIn('#alarm-dialog [data-timepicker-box="hour"]', '03')
|
||||
->click('#alarm-dialog [data-timepicker-label="minute"][data-value="0"]')
|
||||
->click('#alarm-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#alarm-value', '03:00');
|
||||
});
|
||||
|
||||
it('follows a drag round the dial and settles on the nearest hour', function () {
|
||||
$dial = inPicker('meeting', '[data-timepicker-dial]');
|
||||
|
||||
$page = timeProbe()->click('#meeting');
|
||||
|
||||
// Pointer events built in the page's own realm, from twelve o'clock round to just past four.
|
||||
$send = fn (string $type, int $degrees): string => <<<JS
|
||||
window.eval(`(() => {
|
||||
const dial = document.querySelector('#meeting-dialog [data-timepicker-dial]');
|
||||
const box = dial.getBoundingClientRect();
|
||||
const radians = {$degrees} * Math.PI / 180;
|
||||
|
||||
dial.dispatchEvent(new PointerEvent('{$type}', {
|
||||
bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1,
|
||||
clientX: box.x + box.width / 2 + Math.sin(radians) * 90,
|
||||
clientY: box.y + box.height / 2 - Math.cos(radians) * 90,
|
||||
}));
|
||||
})()`)
|
||||
JS;
|
||||
|
||||
$page->script($send('pointerdown', 0).';'.$send('pointermove', 20).';'.$send('pointermove', 60).';'.$send('pointermove', 125));
|
||||
|
||||
// While dragging, the handle stays under the pointer rather than on a number.
|
||||
$page->assertScript("{$dial}.hasAttribute('data-dragging') && parseFloat(getComputedStyle({$dial}).getPropertyValue('--timepicker-angle')) % 360 === 125")
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '04');
|
||||
|
||||
$page->script($send('pointerup', 125));
|
||||
|
||||
$page->assertScript("! {$dial}.hasAttribute('data-dragging')")
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '04');
|
||||
});
|
||||
|
||||
it('changes the focused hour and minute with the arrow keys and confirms with Enter', function () {
|
||||
$page = timeProbe();
|
||||
|
||||
$page->script("document.querySelector('#meeting').focus()");
|
||||
|
||||
$page->keys('#meeting', 'Enter')
|
||||
->assertScript('document.activeElement === '.inPicker('meeting', '[data-timepicker-dial]'))
|
||||
->keys(':focus', 'ArrowUp')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '10')
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '10')
|
||||
// The keyboard never moves on to the minutes by itself.
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'hour');
|
||||
|
||||
$page->script(inPicker('meeting', '[data-timepicker-box="minute"]').'.focus()');
|
||||
|
||||
$page->keys(':focus', 'Enter')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute');
|
||||
|
||||
$page->script(inPicker('meeting', '[data-timepicker-dial]').'.focus()');
|
||||
|
||||
$page->keys(':focus', ['ArrowDown', 'ArrowLeft'])
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuenow', '28')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'aria-valuetext', '28 minutes')
|
||||
->keys(':focus', 'Enter')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-value', '10:28');
|
||||
});
|
||||
|
||||
it('takes a typed time in the input variant and says what is wrong with it', function () {
|
||||
$page = timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-mode="input"]')
|
||||
->assertSeeIn('#meeting-title', 'Enter time')
|
||||
->assertScript("document.activeElement.id === 'meeting-hour'")
|
||||
->assertValue('#meeting-hour', '09')
|
||||
->assertValue('#meeting-minute', '30');
|
||||
|
||||
$page->type('#meeting-hour', '13')
|
||||
->assertAttribute('#meeting-hour', 'aria-invalid', 'true')
|
||||
// Word joiners keep the range on one line in the narrow column.
|
||||
->assertScript(supportText('meeting-hour')." === 'Hour must be 1–12'")
|
||||
->click('#meeting-dialog [data-timepicker-confirm]')
|
||||
->assertScript("document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-value', '09:30');
|
||||
|
||||
$page->clear('#meeting-hour')
|
||||
->typeSlowly('#meeting-hour', '11', 20)
|
||||
->assertScript("document.activeElement.id === 'meeting-minute'")
|
||||
->assertScript(supportText('meeting-hour')." === 'Hour'")
|
||||
->assertScript("! document.querySelector('#meeting-hour').hasAttribute('aria-invalid')");
|
||||
|
||||
$page->clear('#meeting-minute')
|
||||
->typeSlowly('#meeting-minute', '75', 20)
|
||||
->assertScript(supportText('meeting-minute')." === 'Minute must be 0–59'")
|
||||
->clear('#meeting-minute')
|
||||
->typeSlowly('#meeting-minute', '05', 20)
|
||||
->keys('#meeting-minute', 'Enter')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-value', '11:05');
|
||||
});
|
||||
|
||||
it('keeps to the step and the limits', function () {
|
||||
$dial = inPicker('slot', '[data-timepicker-dial]');
|
||||
|
||||
$page = timeProbe()
|
||||
->click('#slot')
|
||||
->assertScript("{$dial}.querySelector('[data-timepicker-label=\"hour24\"][data-value=\"8\"]').hasAttribute('data-disabled')")
|
||||
->assertScript("! {$dial}.querySelector('[data-timepicker-label=\"hour24\"][data-value=\"9\"]').hasAttribute('data-disabled')")
|
||||
->assertScript("{$dial}.querySelector('[data-timepicker-label=\"hour24\"][data-value=\"18\"]').hasAttribute('data-disabled')")
|
||||
// An hour outside the limits is not taken.
|
||||
->click('#slot-dialog [data-timepicker-label="hour24"][data-value="20"]')
|
||||
->assertSeeIn('#slot-dialog [data-timepicker-box="hour"]', '10')
|
||||
->click('#slot-dialog [data-timepicker-label="hour24"][data-value="16"]')
|
||||
->assertAttribute('#slot-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->assertScript("{$dial}.querySelector('[data-timepicker-label=\"minute\"][data-value=\"20\"]').hasAttribute('data-disabled')")
|
||||
// A press between the steps lands on the nearest one.
|
||||
->click('#slot-dialog [data-timepicker-label="minute"][data-value="20"]')
|
||||
->assertSeeIn('#slot-dialog [data-timepicker-box="minute"]', '15');
|
||||
|
||||
$page->script("{$dial}.focus()");
|
||||
|
||||
$page->keys(':focus', 'ArrowUp')
|
||||
->assertAttribute('#slot-dialog [data-timepicker-dial]', 'aria-valuenow', '30')
|
||||
->click('#slot-dialog [data-timepicker-mode="input"]')
|
||||
->type('#slot-hour', '18')
|
||||
->assertSeeIn('#slot-dialog [data-timepicker-range-error]', 'Choose a time from 09:00 to 17:00')
|
||||
->type('#slot-hour', '16')
|
||||
->type('#slot-minute', '20')
|
||||
->assertSeeIn('#slot-minute-support', 'Minute must be a multiple of 15')
|
||||
->click('#slot-dialog [data-timepicker-confirm]')
|
||||
->assertScript("document.activeElement.id === 'slot-minute'")
|
||||
->type('#slot-minute', '45')
|
||||
->click('#slot-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#slot-value', '16:45');
|
||||
});
|
||||
|
||||
it('gives focus back to the field on Escape', function () {
|
||||
$page = timeProbe();
|
||||
|
||||
$page->script("document.querySelector('#meeting').focus()");
|
||||
|
||||
$page->keys('#meeting', 'Enter')
|
||||
->assertScript("document.querySelector('#meeting-dialog').open")
|
||||
->keys(':focus', 'ArrowUp')
|
||||
->keys(':focus', 'Escape')
|
||||
->assertScript("! document.querySelector('#meeting-dialog').open")
|
||||
->assertScript("document.activeElement.id === 'meeting'")
|
||||
->assertSeeIn('#meeting-value', '09:30');
|
||||
});
|
||||
|
||||
it('stays open with its draft through a Livewire render', function () {
|
||||
$page = timeProbe()
|
||||
->click('#meeting')
|
||||
->click('#meeting-dialog [data-timepicker-label="hour12"][data-value="5"]')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute');
|
||||
|
||||
$page->script('window.eval("Livewire.first().touch()")');
|
||||
|
||||
$page->assertSeeIn('#renders', '1')
|
||||
->assertScript("document.querySelector('#meeting-dialog').open")
|
||||
->assertSeeIn('#meeting-dialog [data-timepicker-box="hour"]', '05')
|
||||
->assertAttribute('#meeting-dialog [data-timepicker-dial]', 'data-view', 'minute')
|
||||
->click('#meeting-dialog [data-timepicker-label="minute"][data-value="10"]')
|
||||
->click('#meeting-dialog [data-timepicker-confirm]')
|
||||
->assertSeeIn('#meeting-value', '05:10');
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
<?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" class="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"')
|
||||
->toContain('<p id="starts-support" class="field-support">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-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"')
|
||||
->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-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-variant="filled"')
|
||||
->toContain('data-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-field-clear')
|
||||
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
|
||||
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user