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,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 (12–23, at 69dp;
|
||||
00–11 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 1–12'),
|
||||
'hourError24' => __('Hour must be 0–23'),
|
||||
'minuteError' => __('Minute must be 0–59'),
|
||||
'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>
|
||||
Reference in New Issue
Block a user