Files
livewire-material/resources/views/components/timepicker.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 648a4cfe0f
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
Show a plain form field's errors under its name
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
2026-09-13 10:41:37 +02:00

365 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{-- 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;
// 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 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-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>