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
@@ -0,0 +1,362 @@
{{-- A time: M3's time picker, opened from a text field.
<x-timepicker label="Starts at" wire:model="startsAt" />
<x-timepicker label="Alarm" wire:model.live="alarm" format="24" step="5" min="06:00" max="22:00" clearable />
The field is read-only and opens the picker in a modal `<dialog>` on a press, Enter, Space or
ArrowDown (the clock icon at its end opens it too). The picker is M3's dial: the hour and minute
boxes on top, AM and PM beside them on a 12-hour clock, the clock face below. A press or a drag
on the dial picks the hour, then it moves on to the minutes; the arrow keys change the value on
the focused dial, Home and End go to the ends, Enter confirms, and Escape, Cancel or a press on
the scrim close it without a change, handing focus back to the field. The keyboard icon swaps
the dial for M3's input variant: two text fields, with the error under a field that holds
something impossible. In a landscape window the dial lies on its side, as Compose lays it out.
`wire:model` (or `x-model`) holds the time as `H:i`, and null until one is chosen; a value with
seconds (`09:30:00`, from a `time` column) is read, and written back as `H:i`. Nothing is written
until OK. The draft starts at the bound time, or now. Errors are read from the bag under the
`wire:model` name and replace the hint.
`format` is `12` or `24`; without it the hour cycle is the locale's (`locale`, the app locale by
default) as `Intl.DateTimeFormat` reports it, and the field shows the time as the locale writes
it. `step` is the minute step (a tap on the minute dial picks fives, or steps when five is not a
multiple of the step; a drag and the arrows move by the step). `min` and `max` (`H:i`, inclusive;
a `min` later than `max` spans midnight) grey out and skip what lies outside them and turn typed
values outside them into errors validate on the server as well. `clearable` adds a button that
empties the field; `name` posts the value from a hidden input. `label`, `hint`, `icon`, `variant`
and `size` are the field's; other attributes (`required`, `disabled`, `placeholder`) reach the
field's input.
From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 (Apache-2.0):
TimePickerTokens and TimeInputTokens a surface-container-high dialog with an extra-large
corner and elevation 3, a 256dp surface-container-highest dial with body-large numbers, a primary
selector with a 48dp handle, a 2dp line and an 8dp centre, 96×80 time selector boxes in
display-large (primary-container when selected), 96×72 time fields in display-medium and
TimePicker.kt and TimePickerDialog.kt for the layout, the 24-hour inner ring (1223, at 69dp;
0011 outside at 101dp, as Material Components for Android labels them too), the gestures, the
move on to minutes and the error texts. The period selector is Compose's current default
(`isUpdatedTimepickerToggleEnabled`): two separate shape-morphing toggle buttons in
primary-container, not the outlined pair its tokens still describe. The dial's numbers are
aria-hidden; the dial is a `slider` whose value text names the hour or minute. The dialog is
`wire:ignore`, so a Livewire render never closes an open picker or resets its draft. --}}
@props([
'label' => null,
'hint' => null,
'icon' => null,
'variant' => null,
'size' => 'md',
'format' => null,
'locale' => null,
'min' => null,
'max' => null,
'step' => 1,
'value' => null,
'name' => null,
'clearable' => false,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
$id = $attributes->get('id') ?? 'timepicker-'.substr(md5($model.'|'.$label.'|'.$name.'|timepicker'), 0, 12);
$cycle = in_array((string) $format, ['12', '24'], true) ? (int) $format : null;
$locale = str_replace('_', '-', filled($locale) ? $locale : app()->getLocale());
$interval = is_numeric($step) && (int) $step >= 1 && (int) $step <= 60 ? (int) $step : 1;
$clock = fn (mixed $time): ?string => is_string($time) && preg_match('/^([01]?\d|2[0-3]):([0-5]\d)/', $time, $parts) === 1
? sprintf('%02d:%02d', $parts[1], $parts[2])
: null;
$earliest = $clock($min);
$latest = $clock($max);
// Rendered as the bound property already says, so the field is not empty until Alpine starts.
$current = $value;
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$current = data_get($component, $model);
}
$current = $clock($current);
// The first frame writes the time as the browser will; ICU decides the hour cycle on both sides.
$display = '';
if ($current !== null) {
$time = \Carbon\CarbonImmutable::createFromFormat('!H:i', $current, 'UTC');
$shown = $cycle;
if (class_exists(\IntlDatePatternGenerator::class)) {
$generator = new \IntlDatePatternGenerator(str_replace('-', '_', $locale));
$shown ??= preg_match('/[hK]/', preg_replace("/'[^']*'/", '', (string) $generator->getBestPattern('j'))) === 1 ? 12 : 24;
$pattern = $generator->getBestPattern($shown === 12 ? 'hmm' : 'Hmm');
$display = (string) (new \IntlDateFormatter(str_replace('-', '_', $locale), \IntlDateFormatter::NONE, \IntlDateFormatter::NONE, 'UTC', null, $pattern))->format($time);
} else {
$display = $shown === 12 ? $time->format('g:i A') : $time->format('H:i');
}
}
$config = [
'locale' => $locale,
'format' => $cycle,
'min' => $earliest,
'max' => $latest,
'step' => $interval,
'strings' => [
'am' => __('AM'),
'pm' => __('PM'),
'oclock' => __(':hour o\'clock'),
'hours' => __(':hour hours'),
'minutes' => __(':minute minutes'),
'hourError12' => __('Hour must be 112'),
'hourError24' => __('Hour must be 023'),
'minuteError' => __('Minute must be 059'),
'stepError' => __('Minute must be a multiple of :step'),
'between' => __('Choose a time from :min to :max'),
'after' => __('Choose :min or later'),
'before' => __('Choose :max or earlier'),
],
];
$constrained = $earliest !== null || $latest !== null || $interval > 1;
// The dial's numbers, placed round the ring from twelve o'clock (TimePicker.kt's CircularLayout).
$spot = fn (int $index): string => sprintf('--x: %.4F; --y: %.4F', sin(deg2rad($index * 30)), -cos(deg2rad($index * 30)));
$sets = [
'hour12' => array_map(fn (int $index): array => ['value' => $index ?: 12, 'text' => $index ?: 12, 'index' => $index, 'inner' => false, 'allowed' => "hourAllowed({$index} + (isPm ? 12 : 0))"], range(0, 11)),
'hour24' => [
...array_map(fn (int $index): array => ['value' => $index, 'text' => $index === 0 ? '00' : $index, 'index' => $index, 'inner' => false, 'allowed' => "hourAllowed({$index})"], range(0, 11)),
...array_map(fn (int $index): array => ['value' => $index + 12, 'text' => $index + 12, 'index' => $index, 'inner' => true, 'allowed' => 'hourAllowed('.($index + 12).')'], range(0, 11)),
],
'minute' => array_map(fn (int $index): array => ['value' => $index * 5, 'text' => $index === 0 ? '00' : $index * 5, 'index' => $index, 'inner' => false, 'allowed' => 'minuteAllowed('.($index * 5).')'], range(0, 11)),
];
$inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'id', 'wire:key', 'placeholder']);
$disabled = (bool) $attributes->get('disabled');
$described = $messages !== [] || filled($hint);
@endphp
<div
{{ $attributes->only(['class', 'wire:key', 'x-model']) }}
x-data="materialTimepicker(@if ($model !== null) @entangle($attributes->wire('model')) @else @js($current) @endif, @js($config))"
@if ($model === null) x-modelable="value" @endif
>
<x-field :$id :$label :$hint :$messages :$icon :$size :$variant data-timepicker-field>
<input
{{ $inputAttributes }}
id="{{ $id }}"
type="text"
readonly
value="{{ $display }}"
x-bind:value="display"
x-ref="input"
placeholder="{{ filled($attributes->get('placeholder')) ? $attributes->get('placeholder') : ' ' }}"
autocomplete="off"
aria-haspopup="dialog"
aria-controls="{{ $id }}-dialog"
aria-expanded="false"
x-bind:aria-expanded="open.toString()"
@if ($messages !== []) aria-invalid="true" @endif
@if ($described) aria-describedby="{{ $id }}-support" @endif
class="field-control"
x-on:click="show()"
x-on:keydown.enter.prevent="show()"
x-on:keydown.space.prevent="show()"
x-on:keydown.arrow-down.prevent="show()"
/>
<x-slot:trailing>
@if ($clearable)
<button
type="button"
x-on:click="value = null; $refs.input.focus()"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}"
data-field-clear
@disabled($disabled)
>
<x-icon name="close" class="size-(--field-icon)" />
</button>
@endif
<button
type="button"
tabindex="-1"
x-on:click="show()"
class="field-trailing field-button"
aria-label="{{ __('Choose time') }}"
data-timepicker-open
@disabled($disabled)
>
<x-icon name="schedule" class="size-(--field-icon)" />
</button>
</x-slot:trailing>
</x-field>
@if (filled($name))
<input type="hidden" name="{{ $name }}" value="{{ $current }}" x-bind:value="value ?? ''" />
@endif
<dialog
id="{{ $id }}-dialog"
wire:ignore
x-ref="dialog"
aria-labelledby="{{ $id }}-title"
data-timepicker-dialog
x-on:close="closed()"
x-on:pointerdown="scrimPressed = $event.target === $el"
x-on:click.self="if (scrimPressed) cancel()"
class="m-auto overflow-visible bg-transparent p-0 text-on-surface backdrop:bg-scrim/32 opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast"
>
<div data-timepicker-surface x-bind:data-mode="mode" data-mode="dial">
<h2 id="{{ $id }}-title" data-timepicker-title x-text="mode === 'dial' ? @js(__('Select time')) : @js(__('Enter time'))">{{ __('Select time') }}</h2>
<div data-timepicker-picker>
<div data-timepicker-display>
<div data-timepicker-numbers>
<button
type="button"
data-timepicker-box="hour"
class="state-layer focus-ring"
aria-pressed="true"
x-bind:aria-pressed="(view === 'hour').toString()"
x-bind:aria-label="@js(__('Select hour')) + ', ' + hourLabel"
x-on:click="choose('hour')"
x-text="hourLabel"
></button>
<span data-timepicker-separator aria-hidden="true">:</span>
<button
type="button"
data-timepicker-box="minute"
class="state-layer focus-ring"
aria-pressed="false"
x-bind:aria-pressed="(view === 'minute').toString()"
x-bind:aria-label="@js(__('Select minutes')) + ', ' + minuteLabel"
x-on:click="choose('minute')"
x-text="minuteLabel"
></button>
</div>
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24">
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring"
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
>{{ $pm ? __('PM') : __('AM') }}</button>
@endforeach
</div>
</div>
<div
x-ref="dial"
data-timepicker-dial
role="slider"
tabindex="0"
aria-valuemin="0"
x-bind:aria-label="view === 'hour' ? @js(__('Select hour')) : @js(__('Select minutes'))"
x-bind:aria-valuemax="view === 'hour' ? 23 : 59"
x-bind:aria-valuenow="view === 'hour' ? hour : minute"
x-bind:aria-valuetext="valueText"
x-bind:data-view="view"
x-bind:data-cycle="is24 ? '24' : '12'"
x-bind:data-inner="inner ? '' : null"
x-bind:data-dragging="dragging ? '' : null"
x-bind:style="{ '--timepicker-angle': angle + 'deg' }"
x-on:pointerdown="press($event)"
x-on:keydown="key($event)"
>
@foreach (['labels', 'ink'] as $layer)
@if ($layer === 'ink')
<div data-timepicker-selector aria-hidden="true">
<span data-timepicker-track></span>
<span data-timepicker-centre></span>
<span data-timepicker-handle></span>
</div>
@endif
<div data-timepicker-{{ $layer }} aria-hidden="true">
@foreach ($sets as $set => $labels)
<div data-timepicker-set="{{ $set }}">
@foreach ($labels as $spotLabel)
<span
@if ($layer === 'labels') data-timepicker-label="{{ $set }}" data-value="{{ $spotLabel['value'] }}" @endif
@if ($spotLabel['inner']) data-inner @endif
@if ($constrained && $layer === 'labels') x-bind:data-disabled="{{ $spotLabel['allowed'] }} ? null : ''" @endif
style="{{ $spot($spotLabel['index']) }}"
>{{ $spotLabel['text'] }}</span>
@endforeach
</div>
@endforeach
</div>
@endforeach
</div>
</div>
<div data-timepicker-typing>
<div data-timepicker-inputs>
@foreach (['hour' => __('Hour'), 'minute' => __('Minute')] as $part => $partLabel)
@if ($part === 'minute')
<span data-timepicker-separator aria-hidden="true">:</span>
@endif
<div data-timepicker-column>
<input
id="{{ $id }}-{{ $part }}"
type="text"
inputmode="numeric"
maxlength="2"
autocomplete="off"
data-timepicker-input="{{ $part }}"
x-ref="{{ $part }}Input"
aria-label="{{ $partLabel }}"
aria-describedby="{{ $id }}-{{ $part }}-support"
x-bind:aria-invalid="{{ $part }}Error ? 'true' : null"
x-bind:value="{{ $part }}Text"
x-on:input="{{ $part === 'hour' ? 'typeHour' : 'typeMinute' }}($event)"
x-on:focus="view = '{{ $part }}'; $el.select()"
x-on:keydown.enter.prevent="confirm()"
/>
<p
id="{{ $id }}-{{ $part }}-support"
data-timepicker-support
aria-live="polite"
x-bind:data-error="{{ $part }}Error ? '' : null"
x-text="{{ $part }}Error ?? @js($partLabel)"
>{{ $partLabel }}</p>
</div>
@endforeach
<div data-timepicker-period role="group" aria-label="{{ __('Select AM or PM') }}" x-show="! is24">
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
<button
type="button"
data-timepicker-period-option="{{ $period }}"
class="state-layer focus-ring"
x-bind:aria-pressed="({{ $pm ? '' : '! ' }}isPm).toString()"
x-bind:disabled="! periodAllowed({{ $pm ? 'true' : 'false' }})"
x-on:click="setPeriod({{ $pm ? 'true' : 'false' }})"
x-text="periods[{{ (int) $pm }}]"
>{{ $pm ? __('PM') : __('AM') }}</button>
@endforeach
</div>
</div>
<p data-timepicker-range-error role="alert" x-show="rangeError" x-text="rangeError"></p>
</div>
<div data-timepicker-actions>
<span data-timepicker-when="dial">
<x-button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-timepicker-mode="input" />
</span>
<span data-timepicker-when="input">
<x-button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-timepicker-mode="dial" />
</span>
<span data-timepicker-spacer></span>
<x-button :label="__('Cancel')" x-on:click="cancel()" data-timepicker-cancel />
<x-button :label="__('OK')" x-on:click="confirm()" data-timepicker-confirm />
</div>
</div>
</dialog>
</div>