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:
Andreas Reinhold / reini
2026-09-13 08:47:42 +02:00
co-authored by Claude Opus 5
parent 83ed671c4e
commit b8239dd215
12 changed files with 2092 additions and 1 deletions
+339
View File
@@ -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 112'")
->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 059'")
->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');
});