renders++;
}
public function render(): string
{
return <<<'BLADE'
meeting: {{ $meeting ?? 'null' }}
alarm: {{ $alarm ?? 'null' }}
appointment: {{ $appointment ?? 'null' }}
renders: {{ $renders }}
BLADE;
}
}
function timeProbe()
{
Livewire::component('time-probe', TimeProbe::class);
Route::middleware('web')->get('/time-probe', fn () => Blade::render(<<<'BLADE'
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
@livewireScripts
BLADE));
return ready(visit('/time-probe'));
}
/** 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 () {
$page = timeProbe()
->assertValue('#meeting', '9:30 AM');
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting');
$page->assertScript("document.querySelector('#meeting-dialog').open")
->assertAttribute('#meeting', 'aria-expanded', 'true')
->assertSeeIn('#meeting-title', 'Select time')
->assertAttribute('#meeting-dialog [data-md-timepicker-box="hour"]', 'aria-pressed', 'true')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '09')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="minute"]', '30')
->assertAttribute('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="am"]', 'aria-checked', 'true')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '9')
->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]'));
});
it('writes the hour and the minute pressed on the dial to Livewire on OK', function () {
$dial = inPicker('meeting', '[data-md-timepicker-dial]');
$page = timeProbe();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting');
$page->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="3"]')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '03')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->assertAttribute('#meeting-dialog [data-md-timepicker-box="minute"]', 'aria-pressed', 'true')
// The selector turns to the value it chose: the handle ends over the number.
->assertScript(<< {
const handle = {$dial}.querySelector('[data-md-timepicker-handle]').getBoundingClientRect();
const label = {$dial}.querySelector('[data-md-timepicker-label="minute"][data-md-value="30"]').getBoundingClientRect();
return Math.abs(handle.x - label.x) < 1 && Math.abs(handle.y - label.y) < 1;
})()
JS);
$page->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="45"]')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="minute"]', '45')
->assertSeeIn('#meeting-value', '09:30');
// The confirm button closes the dialog: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting-dialog [data-md-timepicker-confirm]');
$page->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 () {
$page = timeProbe();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting');
$page->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="7"]')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '07');
// The cancel button closes the dialog: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting-dialog [data-md-timepicker-cancel]');
$page->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 () {
$page = timeProbe();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting');
$page->click('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]')
->assertAttribute('#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]', 'aria-checked', 'true')
->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="3"]')
->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="15"]');
// The confirm button closes the dialog: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting-dialog [data-md-timepicker-confirm]');
$page->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-md-timepicker-dial]');
$radius = fn (string $value): string => << {
const dial = {$dial}.getBoundingClientRect();
const label = {$dial}.querySelector('[data-md-timepicker-label="hour24"][data-md-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();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#alarm');
$page->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-cycle', '24')
->assertScript("getComputedStyle(document.querySelector('#alarm-dialog [data-md-timepicker-period]')).display === 'none'")
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-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-md-timepicker-label="hour24"][data-md-value="15"]')
->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '15')
->assertAttribute('#alarm-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->click('#alarm-dialog [data-md-timepicker-label="minute"][data-md-value="0"]');
// The confirm button closes the dialog: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#alarm-dialog [data-md-timepicker-confirm]');
$page->assertSeeIn('#alarm-value', '15:00')
->assertValue('#alarm', '15:00');
// The outer ring is the morning.
pressOnce($page)->click('#alarm');
$page->click('#alarm-dialog [data-md-timepicker-label="hour24"][data-md-value="3"]')
->assertSeeIn('#alarm-dialog [data-md-timepicker-box="hour"]', '03')
->click('#alarm-dialog [data-md-timepicker-label="minute"][data-md-value="0"]');
pressOnce($page)->click('#alarm-dialog [data-md-timepicker-confirm]');
$page->assertSeeIn('#alarm-value', '03:00');
});
it('follows a drag round the dial and settles on the nearest hour', function () {
$dial = inPicker('meeting', '[data-md-timepicker-dial]');
$page = timeProbe();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->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 => << {
const dial = document.querySelector('#meeting-dialog [data-md-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-md-dragging') && parseFloat(getComputedStyle({$dial}).getPropertyValue('--timepicker-angle')) % 360 === 125")
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '04');
$page->script($send('pointerup', 125));
$page->assertScript("! {$dial}.hasAttribute('data-md-dragging')")
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->assertSeeIn('#meeting-dialog [data-md-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()");
// Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php.
pressOnce($page)->keys('#meeting', 'Enter');
$page->assertScript('document.activeElement === '.inPicker('meeting', '[data-md-timepicker-dial]'))
->keys(':focus', 'ArrowUp')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '10')
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '10')
// The keyboard never moves on to the minutes by itself.
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'hour');
$page->script(inPicker('meeting', '[data-md-timepicker-box="minute"]').'.focus()');
$page->keys(':focus', 'Enter')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute');
$page->script(inPicker('meeting', '[data-md-timepicker-dial]').'.focus()');
$page->keys(':focus', ['ArrowDown', 'ArrowLeft'])
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuenow', '28')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'aria-valuetext', '28 minutes');
// Enter on the dial confirms and closes the dialog: pressOnce(), tests/Pest.php.
pressOnce($page)->keys(':focus', 'Enter');
$page->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();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting');
$page->click('#meeting-dialog [data-md-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'");
// The confirm button closes the dialog when the time is valid; here it stays open (invalid),
// but pressed once anyway: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting-dialog [data-md-timepicker-confirm]');
$page->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-md-timepicker-dial]');
$page = timeProbe();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#slot');
$page->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"8\"]').hasAttribute('data-md-disabled')")
->assertScript("! {$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"9\"]').hasAttribute('data-md-disabled')")
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"hour24\"][data-md-value=\"18\"]').hasAttribute('data-md-disabled')")
// An hour outside the limits is not taken.
->click('#slot-dialog [data-md-timepicker-label="hour24"][data-md-value="20"]')
->assertSeeIn('#slot-dialog [data-md-timepicker-box="hour"]', '10')
->click('#slot-dialog [data-md-timepicker-label="hour24"][data-md-value="16"]')
->assertAttribute('#slot-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->assertScript("{$dial}.querySelector('[data-md-timepicker-label=\"minute\"][data-md-value=\"20\"]').hasAttribute('data-md-disabled')")
// A press between the steps lands on the nearest one.
->click('#slot-dialog [data-md-timepicker-label="minute"][data-md-value="20"]')
->assertSeeIn('#slot-dialog [data-md-timepicker-box="minute"]', '15');
$page->script("{$dial}.focus()");
$page->keys(':focus', 'ArrowUp')
->assertAttribute('#slot-dialog [data-md-timepicker-dial]', 'aria-valuenow', '30')
->click('#slot-dialog [data-md-timepicker-mode="input"]')
->type('#slot-hour', '18')
->assertSeeIn('#slot-dialog [data-md-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');
// The confirm button closes the dialog when the time is valid; here it stays open (invalid),
// but pressed once anyway: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#slot-dialog [data-md-timepicker-confirm]');
$page->assertScript("document.activeElement.id === 'slot-minute'")
->type('#slot-minute', '45');
pressOnce($page)->click('#slot-dialog [data-md-timepicker-confirm]');
$page->assertSeeIn('#slot-value', '16:45');
});
it('gives focus back to the field on Escape', function () {
$page = timeProbe();
$page->script("document.querySelector('#meeting').focus()");
// Enter on the field opens the dialog, same as clicking it: pressOnce(), tests/Pest.php.
pressOnce($page)->keys('#meeting', 'Enter');
$page->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();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting');
$page->click('#meeting-dialog [data-md-timepicker-label="hour12"][data-md-value="5"]')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute');
$page->script('window.eval("Livewire.first().touch()")');
$page->assertSeeIn('#renders', '1')
->assertScript("document.querySelector('#meeting-dialog').open")
->assertSeeIn('#meeting-dialog [data-md-timepicker-box="hour"]', '05')
->assertAttribute('#meeting-dialog [data-md-timepicker-dial]', 'data-md-view', 'minute')
->click('#meeting-dialog [data-md-timepicker-label="minute"][data-md-value="10"]');
pressOnce($page)->click('#meeting-dialog [data-md-timepicker-confirm]');
$page->assertSeeIn('#meeting-value', '05:10');
});
it('moves the period selector between AM and PM with arrow keys, as a radio group', function () {
$am = '#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="am"]';
$pm = '#meeting-dialog [data-md-timepicker-display] [data-md-timepicker-period-option="pm"]';
$page = timeProbe();
// Opens the modal dialog over the field that triggers it: pressOnce(), tests/Pest.php.
pressOnce($page)->click('#meeting');
$page->assertAttribute($am, 'role', 'radio')
->assertAttribute($am, 'aria-checked', 'true')
->assertAttribute($pm, 'aria-checked', 'false')
// show() moves focus to the dial from inside Alpine's $nextTick, which Livewire's bundled
// Alpine defers with a real setTimeout(0) rather than a microtask: wait for the dialog to
// actually open before stealing focus onto the period selector, or .focus() below is a no-op
// on a still-closed