Plan step 36 (inputs group, last of the "not started" pair): <x-timepicker> renders data-md-timepicker-* throughout (data-md-mode, data-md-cycle, data-md-view, data-md-inner, data-md-dragging, data-md-disabled, data-md-error and data-md-value replace their unprefixed hooks), and timepicker.css moves into material.components, importing field.css, icon.css and button.css. The dialog's Tailwind utility class (position, the scrim, the spatial-fast pop and its @starting-style) becomes plain declarations under data-md-timepicker-dialog, matching the datepicker's modal dialog. The landscape rule stays keyed on orientation and viewport height, in px (560/364/346), never a breakpoint, as M3 asks. The hour and minute boxes and the period buttons swap their Tailwind `state-layer focus-ring` utilities for `md-state-layer md-focus-ring`: same declarations, so nothing else in the stylesheet changes for them. The dial swaps in `md-focus-ring` for its own hand-rolled, byte-identical outline-on-:focus-visible rule, which timepicker.css no longer carries. The period selector was already the radio-in-a-list M3's labelling table asks for (role="radio" in a role="radiogroup", moved by the arrow keys), and Shift+M/Y already reached the date picker's dropdowns — both predate this rewrite. field.css's data-md-field-button note is tidied now that every component that selects it (input, password, datepicker, timepicker) is rewritten. Browser tests written but not run (the inputs group's Chromium run follows this stream): the period radios moving with the arrow keys, and the dial lying on its side below the landscape height threshold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
394 lines
21 KiB
PHP
394 lines
21 KiB
PHP
{{-- 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; 114dp wide on a 24-hour clock, where there is no
|
||
period selector beside them, per TimeSelector24HVerticalContainerWidth), 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 keeps
|
||
Compose's current default shape morph (`isUpdatedTimepickerToggleEnabled`) but M3's own
|
||
tertiary-container, which is what holds hour/minute (primary-container) and AM/PM apart; it is a
|
||
radio group, as M3's labelling table says, so the arrows move between AM and PM. 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;
|
||
// A plain form's field is named, not bound: its errors are under its name (`files[]` → `files`, `a[b]` → `a.b`).
|
||
$errorKey = $model ?? (filled($name) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $name) : null);
|
||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||
$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);
|
||
$fieldIconSize = ['sm' => 20, 'xs' => 16][$size] ?? 24;
|
||
@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-livewire-material::field :$id :$label :$hint :$messages :$icon :$size :$variant data-md-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
|
||
data-md-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()"
|
||
data-md-field-trailing
|
||
data-md-field-button
|
||
data-md-field-clear
|
||
aria-label="{{ __('Clear') }}"
|
||
@disabled($disabled)
|
||
>
|
||
<x-livewire-material::icon name="close" :size="$fieldIconSize" />
|
||
</button>
|
||
@endif
|
||
|
||
<button
|
||
type="button"
|
||
tabindex="-1"
|
||
x-on:click="show()"
|
||
data-md-field-trailing
|
||
data-md-field-button
|
||
aria-label="{{ __('Choose time') }}"
|
||
data-md-timepicker-open
|
||
@disabled($disabled)
|
||
>
|
||
<x-livewire-material::icon name="schedule" :size="$fieldIconSize" />
|
||
</button>
|
||
</x-slot:trailing>
|
||
</x-livewire-material::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-md-timepicker-dialog
|
||
x-on:close="closed()"
|
||
x-on:pointerdown="scrimPressed = $event.target === $el"
|
||
x-on:click.self="if (scrimPressed) cancel()"
|
||
>
|
||
<div data-md-timepicker-surface x-bind:data-md-mode="mode" data-md-mode="dial">
|
||
<h2 id="{{ $id }}-title" data-md-timepicker-title x-text="mode === 'dial' ? @js(__('Select time')) : @js(__('Enter time'))">{{ __('Select time') }}</h2>
|
||
|
||
<div data-md-timepicker-picker>
|
||
<div data-md-timepicker-display x-bind:data-md-cycle="is24 ? '24' : '12'">
|
||
<div data-md-timepicker-numbers>
|
||
<button
|
||
type="button"
|
||
data-md-timepicker-box="hour"
|
||
class="md-state-layer md-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-md-timepicker-separator aria-hidden="true">:</span>
|
||
<button
|
||
type="button"
|
||
data-md-timepicker-box="minute"
|
||
class="md-state-layer md-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-md-timepicker-period
|
||
role="radiogroup"
|
||
aria-label="{{ __('Select AM or PM') }}"
|
||
x-show="! is24"
|
||
x-on:keydown.arrow-right.prevent="movePeriod($event)"
|
||
x-on:keydown.arrow-left.prevent="movePeriod($event)"
|
||
x-on:keydown.arrow-down.prevent="movePeriod($event)"
|
||
x-on:keydown.arrow-up.prevent="movePeriod($event)"
|
||
>
|
||
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
|
||
<button
|
||
type="button"
|
||
data-md-timepicker-period-option="{{ $period }}"
|
||
class="md-state-layer md-focus-ring"
|
||
role="radio"
|
||
aria-checked="false"
|
||
x-bind:aria-checked="({{ $pm ? '' : '! ' }}isPm).toString()"
|
||
x-bind:tabindex="({{ $pm ? '' : '! ' }}isPm) ? 0 : -1"
|
||
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"
|
||
class="md-focus-ring"
|
||
data-md-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-md-view="view"
|
||
x-bind:data-md-cycle="is24 ? '24' : '12'"
|
||
x-bind:data-md-inner="inner ? '' : null"
|
||
x-bind:data-md-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-md-timepicker-selector aria-hidden="true">
|
||
<span data-md-timepicker-track></span>
|
||
<span data-md-timepicker-centre></span>
|
||
<span data-md-timepicker-handle></span>
|
||
</div>
|
||
@endif
|
||
|
||
<div data-md-timepicker-{{ $layer }} aria-hidden="true">
|
||
@foreach ($sets as $set => $labels)
|
||
<div data-md-timepicker-set="{{ $set }}">
|
||
@foreach ($labels as $spotLabel)
|
||
<span
|
||
@if ($layer === 'labels') data-md-timepicker-label="{{ $set }}" data-md-value="{{ $spotLabel['value'] }}" @endif
|
||
@if ($spotLabel['inner']) data-md-inner @endif
|
||
@if ($constrained && $layer === 'labels') x-bind:data-md-disabled="{{ $spotLabel['allowed'] }} ? null : ''" @endif
|
||
style="{{ $spot($spotLabel['index']) }}"
|
||
>{{ $spotLabel['text'] }}</span>
|
||
@endforeach
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
</div>
|
||
|
||
<div data-md-timepicker-typing>
|
||
<div data-md-timepicker-inputs>
|
||
@foreach (['hour' => __('Hour'), 'minute' => __('Minute')] as $part => $partLabel)
|
||
@if ($part === 'minute')
|
||
<span data-md-timepicker-separator aria-hidden="true">:</span>
|
||
@endif
|
||
|
||
<div data-md-timepicker-column>
|
||
<input
|
||
id="{{ $id }}-{{ $part }}"
|
||
type="text"
|
||
inputmode="numeric"
|
||
maxlength="2"
|
||
autocomplete="off"
|
||
data-md-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-md-timepicker-support
|
||
aria-live="polite"
|
||
x-bind:data-md-error="{{ $part }}Error ? '' : null"
|
||
x-text="{{ $part }}Error ?? @js($partLabel)"
|
||
>{{ $partLabel }}</p>
|
||
</div>
|
||
@endforeach
|
||
|
||
<div
|
||
data-md-timepicker-period
|
||
role="radiogroup"
|
||
aria-label="{{ __('Select AM or PM') }}"
|
||
x-show="! is24"
|
||
x-on:keydown.arrow-right.prevent="movePeriod($event)"
|
||
x-on:keydown.arrow-left.prevent="movePeriod($event)"
|
||
x-on:keydown.arrow-down.prevent="movePeriod($event)"
|
||
x-on:keydown.arrow-up.prevent="movePeriod($event)"
|
||
>
|
||
@foreach ([false => 'am', true => 'pm'] as $pm => $period)
|
||
<button
|
||
type="button"
|
||
data-md-timepicker-period-option="{{ $period }}"
|
||
class="md-state-layer md-focus-ring"
|
||
role="radio"
|
||
aria-checked="false"
|
||
x-bind:aria-checked="({{ $pm ? '' : '! ' }}isPm).toString()"
|
||
x-bind:tabindex="({{ $pm ? '' : '! ' }}isPm) ? 0 : -1"
|
||
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-md-timepicker-range-error role="alert" x-show="rangeError" x-text="rangeError"></p>
|
||
</div>
|
||
|
||
<div data-md-timepicker-actions>
|
||
<span data-md-timepicker-when="dial">
|
||
<x-livewire-material::button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-md-timepicker-mode="input" />
|
||
</span>
|
||
<span data-md-timepicker-when="input">
|
||
<x-livewire-material::button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-md-timepicker-mode="dial" />
|
||
</span>
|
||
<span data-md-timepicker-spacer></span>
|
||
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-md-timepicker-cancel />
|
||
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-md-timepicker-confirm />
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
</div>
|