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 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(<< { 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 => << { 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 => << { 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'); });