tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m9s
tests / feature (8.5) (push) Successful in 1m9s
tests / browser (chrome, chromium) (push) Successful in 3m19s
tests / browser (safari, webkit) (push) Successful in 5m0s
tests / browser (firefox, firefox) (push) Successful in 4m34s
Fields read their errors only under the wire:model name, so a Fortify login form (name="email", no wire:model) never showed "These credentials do not match". Without wire:model they now read the name, turning brackets into dots. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
365 lines
20 KiB
PHP
365 lines
20 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), 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;
|
||
// 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);
|
||
@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-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-livewire-material::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-livewire-material::icon name="schedule" class="size-(--field-icon)" />
|
||
</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-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-livewire-material::button icon="keyboard" :tooltip="__('Switch to text input mode')" x-on:click="toggleMode()" data-timepicker-mode="input" />
|
||
</span>
|
||
<span data-timepicker-when="input">
|
||
<x-livewire-material::button icon="schedule" :tooltip="__('Switch to clock mode')" x-on:click="toggleMode()" data-timepicker-mode="dial" />
|
||
</span>
|
||
<span data-timepicker-spacer></span>
|
||
<x-livewire-material::button :label="__('Cancel')" x-on:click="cancel()" data-timepicker-cancel />
|
||
<x-livewire-material::button :label="__('OK')" x-on:click="confirm()" data-timepicker-confirm />
|
||
</div>
|
||
</div>
|
||
</dialog>
|
||
</div>
|