Skip to content
LarawellUi

<x-widget.time-picker>

Time picker

A time of day, four ways: a list of times step minutes apart, hour and minute columns, segments typed straight into the field, or a grid of slots for bookings. Within min and max, on the locale's 12- or 24-hour clock, and always submits HH:MM. Works with Livewire wire:model, name optional.

php artisan larawell:add time-picker
Also adds
Icon
Needs PHP extension
intl

Usage

Livewire

In a Livewire component, bind with wire:model (deferred) or wire:model.live to a string property holding HH:MM, or null: public ?string $start = null. No name is needed. Every variant shows the property after every render, so a time set or cleared in PHP shows too, and its errors are read under the property. Validate it as a time: 'start' => ['required', 'date_format:H:i'].

Blade
<form wire:submit="book" class="space-y-5">
    <x-widget.time-picker label="Start" wire:model.live="start" min="08:00" max="18:00" :step="30" />

    <x-widget.time-picker label="Reminder" variant="segmented" wire:model="reminder" />

    <x-widget.time-picker label="Slot" variant="slots" :slots="$freeSlots" wire:model="slot" />

    <button type="submit">Book</button>
</form>

Examples

List

The default: a list of times, step minutes apart (15 by default) between min and max. Arrow keys move through it, and typing digits jumps: 9 to 9:00, 93 to 9:30. It submits HH:MM on a 24-hour clock. Like every input it fills the space it is given; a time needs about 12rem (class="max-w-48").

Show code
Blade
<x-widget.time-picker name="start" label="Start time" min="08:00" max="18:00" :step="30" value="09:30" class="max-w-48" />

Columns

variant="columns": hour, minute and, on a 12-hour clock, AM/PM columns, for any exact minute. The arrow keys turn each column, Left and Right move between them, and hours or minutes that would leave min and max are greyed out.

Show code
Blade
<x-widget.time-picker name="alarm" label="Alarm" variant="columns" value="07:45" :hour12="true" class="max-w-48" />

Segmented

variant="segmented": typed straight into the field, with no popover. Digits fill the hour and then the minute, the arrow keys change the part with focus, Left and Right move between parts, and A or P sets the period. Empty parts read hh, mm and AM/PM, and a hint shows an example to type (info="" hides it). Kept within min and max.

Opens at
hh mm AM/PM

Type it as 09:30 PM

Show code
Blade
<x-widget.time-picker name="opens_at" label="Opens at" variant="segmented" min="06:00" max="23:00" class="max-w-48" />

Slots

variant="slots": a grid of times to choose one of, for bookings. Pass the times with :slots, marking taken ones disabled, or leave it out for every time from min to max. They're real radio buttons, so the arrow keys and required work with no script.

Appointment
Show code
Blade
<x-widget.time-picker name="appointment" label="Appointment" variant="slots" :slots="[
    '09:00', '09:30', ['time' => '10:00', 'disabled' => true], '10:30',
    '11:00', ['time' => '11:30', 'disabled' => true], '14:00', '14:30',
]" value="10:30" />

Clocks

The clock follows the locale: 9:30 AM in en-US, 09:30 in de. hour12 sets it either way. Whatever it shows, it submits 09:30.

Show code
Blade
<div class="flex flex-wrap gap-6">
    <x-widget.time-picker name="t_us" label="en-US" locale="en-US" value="09:30" class="max-w-48" />
    <x-widget.time-picker name="t_de" label="de" locale="de" value="09:30" class="max-w-48" />
    <x-widget.time-picker name="t_24" label="hour12 false" :hour12="false" value="21:15" class="max-w-48" />
</div>

States

The error clears the moment a new time is picked. Required stops an empty submit with "Choose a time." in the same place.

Pick a time inside opening hours.

Show code
Blade
<div class="flex flex-wrap gap-6">
    <x-widget.time-picker name="t_error" label="With error" error="Pick a time inside opening hours." class="max-w-48" />
    <x-widget.time-picker name="t_required" label="Required" required class="max-w-48" />
    <x-widget.time-picker name="t_disabled" label="Disabled" value="12:00" disabled class="max-w-48" />
</div>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.time-picker>

Prop Default Description
name null What the time submits as (HH:MM, 24-hour). Optional with wire:model, which then names it.
id null Defaults to one made from the name (or the wire:model property).
label null Shown above the field, and its name for screen readers.
value null The time: "09:30", "9:30 PM" or a date object. Old input wins after a failed submit; with wire:model and no value, the bound property.
variant 'list' list: a menu of times, step minutes apart. columns: hour, minute (and AM/PM) columns, for any exact minute. segmented: typed straight into the field, hour and minute, with the arrow keys. slots: a grid of times to choose one of, for bookings.
placeholder null Shown while no time is picked; "Choose a time" by default (list and columns).
error null An error message of your own; otherwise the validation error for the name, from the session or Livewire.
info null A hint under the field.
bag 'default' Which error bag to read the error from.
disabled false Greyed out: it can't be changed.
min null The earliest time that can be picked, within one day: "09:00". Defaults to 00:00.
max null The latest time that can be picked, within one day: "17:30". Defaults to 23:59.
step null Minutes between the times offered: 15 by default for list and slots, 1 for columns and segmented.
hour12 null A 12-hour clock with AM and PM (true) or a 24-hour one (false); by default the locale's own.
locale null The language times are written in (en-US, de, ja…); defaults to the app's.
slots null slots: the times to offer, as "HH:MM" strings or ['time' => '10:30', 'disabled' => true] for a taken one. Without them, every time from min to max, step minutes apart.
required-message 'Choose a time.' With required: the message when nothing is picked.

Accessibility

All 6 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

Source

What larawell:add time-picker writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds and Field, and the theme and base CSS.

resources/views/components/widget/time-picker/index.blade.php Show
index.blade.php
@props([
    // What the time submits as (HH:MM, 24-hour). Optional with wire:model, which then names it.
    'name' => null,
    // Defaults to one made from the name (or the wire:model property).
    'id' => null,
    // Shown above the field, and its name for screen readers.
    'label' => null,
    // The time: "09:30", "9:30 PM" or a date object. Old input wins after a failed submit; with wire:model and no value,
    // the bound property.
    'value' => null,
    // list: a menu of times, step minutes apart. columns: hour, minute (and AM/PM) columns, for any exact minute.
    // segmented: typed straight into the field, hour and minute, with the arrow keys. slots: a grid of times to choose
    // one of, for bookings.
    'variant' => 'list',
    // Shown while no time is picked; "Choose a time" by default (list and columns).
    'placeholder' => null,
    // An error message of your own; otherwise the validation error for the name, from the session or Livewire.
    'error' => null,
    // A hint under the field.
    'info' => null,
    // Which error bag to read the error from.
    'bag' => 'default',
    // Greyed out: it can't be changed.
    'disabled' => false,
    // The earliest time that can be picked, within one day: "09:00". Defaults to 00:00.
    'min' => null,
    // The latest time that can be picked, within one day: "17:30". Defaults to 23:59.
    'max' => null,
    // Minutes between the times offered: 15 by default for list and slots, 1 for columns and segmented.
    'step' => null,
    // A 12-hour clock with AM and PM (true) or a 24-hour one (false); by default the locale's own.
    'hour12' => null,
    // The language times are written in (en-US, de, ja…); defaults to the app's.
    'locale' => null,
    // slots: the times to offer, as "HH:MM" strings or ['time' => '10:30', 'disabled' => true] for a taken one.
    // Without them, every time from min to max, step minutes apart.
    'slots' => null,
    // With required: the message when nothing is picked.
    'requiredMessage' => 'Choose a time.',
])

@php
    // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
    if (! in_array($variant, ['list', 'columns', 'segmented', 'slots'], true)) {
        throw new \InvalidArgumentException("Unknown variant [{$variant}] for <x-widget.time-picker>. Use one of: list, columns, segmented, slots.");
    }
    $field = \App\View\Widget\FormField::make($name, $id, $errors ?? null, $error, $bag, 'time', attributes: $attributes);
    $id = $field->id;
    $value = \App\View\Widget\TimeOfDay::normalize($field->old($value));
    $locale = str_replace('_', '-', (string) ($locale ?? app()->getLocale()));
    $hour12 = $hour12 === null ? \App\View\Widget\TimeOfDay::usesHour12($locale) : (bool) $hour12;
    // The browser writes times with this same pattern and these words (resources/js/widget/time-picker), so it agrees
    // with this first paint whatever its own ICU data says.
    $pattern = \App\View\Widget\TimeOfDay::pattern($locale, $hour12);
    $periods = \App\View\Widget\TimeOfDay::periods($locale);
    $format = static fn (string $time): string => \App\View\Widget\TimeOfDay::format($time, $pattern, $periods);
    $min = \App\View\Widget\TimeOfDay::normalize($min) ?? '00:00';
    $max = \App\View\Widget\TimeOfDay::normalize($max) ?? '23:59';
    $step = max(1, (int) ($step ?? (in_array($variant, ['columns', 'segmented'], true) ? 1 : 15)));
    $within = static fn (string $time): bool => $time >= $min && $time <= $max;
    $display = $value ? $format($value) : null;
    $placeholder ??= 'Choose a time';
    // segmented: an example of what to type, since the parts alone (hh : mm AM/PM) can read like hours, minutes and
    // seconds. info="" hides it; your own info replaces it.
    if ($variant === 'segmented') {
        $info ??= 'Type it as '.($hour12 ? "09:30 {$periods[1]}" : '21:30');
    }

    // list and slots: the times on offer, each with whether it can be picked.
    $times = collect($slots ?? \App\View\Widget\TimeOfDay::range($min, $max, $step))
        ->map(static function (mixed $slot) use ($within): ?array {
            $time = \App\View\Widget\TimeOfDay::normalize(is_array($slot) ? ($slot['time'] ?? null) : $slot);

            return $time === null ? null : ['time' => $time, 'disabled' => ! empty($slot['disabled']) || ! $within($time)];
        })
        ->filter()
        ->values();
    // The list's one Tab stop (a roving tabindex): the chosen time, or else the first that can be picked. The script
    // moves it to whichever option has focus, so Tab always lands back where you were.
    $stop = $times->search(fn (array $slot): bool => $slot['time'] === $value);
    $stop = $stop !== false ? $stop : $times->search(fn (array $slot): bool => ! $slot['disabled']);

    // columns and segmented: the parts of the value.
    [$hour, $minute] = $value ? array_map('intval', explode(':', $value)) : [null, null];
    $isPm = $hour !== null && $hour >= 12;
    $hourShown = $hour === null ? null : ($hour12 ? ($hour % 12 ?: 12) : $hour);

    $required = $attributes->has('required');
    $popover = in_array($variant, ['list', 'columns'], true);
@endphp

{{--
    Built on the shared input frame (widget/field): same label, box, error and hint as every other input. The time
    submits as HH:MM on a 24-hour clock, from the hidden input (or, for slots, the radio buttons), whatever it shows.
    The caller's attributes go on what's submitted; `class` goes on the wrapper.
--}}
<x-widget.field
    :required="$required"
    :announce-required="$variant !== 'slots'"
    :data-required-message="$required && ! $disabled && $variant !== 'slots' ? $requiredMessage : false"
    :data-time-picker="$variant"
    :data-pattern="$pattern"
    :data-periods="json_encode($periods)"
    :data-min="$min"
    :data-max="$max"
    :data-step="$step"
    :data-hour12="$hour12 ? 'true' : 'false'"
    :id="$id"
    :label="$label"
    :error="$field->errors"
    :info="$info"
    :disabled="$disabled"
    :bare="$variant === 'slots'"
    :labels-control="! in_array($variant, ['segmented', 'slots'], true)"
    box="h-12 items-center"
    :class="$attributes->get('class')"
>
    @if ($popover)
        {{-- wire:ignore.self: the script keeps aria-expanded in step with the popover; a render would reset it. --}}
        <button
            type="button"
            id="{{ $id }}"
            wire:ignore.self
            popovertarget="{{ $id }}-popover"
            aria-haspopup="{{ $variant === 'list' ? 'listbox' : 'dialog' }}"
            aria-expanded="false"
            {{-- A <label> would otherwise be the button's whole name, and the chosen time inside it would never be read. --}}
            @if ($label) aria-labelledby="{{ $id }}-label {{ $id }}-display" @endif
            {{ $field->aria($attributes, (bool) $info) }}
            @disabled($disabled)
            data-time-picker-trigger
            class="flex h-full w-full min-w-0 items-center justify-between gap-2.5 px-5 text-start whitespace-nowrap outline-none select-none enabled:cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
        >
            <span
                id="{{ $id }}-display"
                data-time-picker-display
                data-placeholder="{{ $placeholder }}"
                @if (! $display) data-empty @endif
                class="text-style-2 data-empty:text-muted group-data-invalid/field:data-empty:text-error truncate tabular-nums"
            >{{ $display ?? $placeholder }}</span>
            <x-widget.icon name="clock" @class(['size-5', 'opacity-40' => $disabled]) />
        </button>
    @endif

    @if ($variant === 'segmented')
        {{-- One spinbutton per part, as the browser's own time field has: arrows change it, digits type it, Left and Right
             move between them. The first one carries the field's id, so the label, errors and focus all land on it. --}}
        <div
            role="group"
            @if ($label) aria-labelledby="{{ $id }}-label" @endif
            data-time-picker-segments
            @class(['flex h-full w-full min-w-0 items-center gap-0.5 px-5 tabular-nums', 'opacity-50' => $disabled])
        >
            @foreach (array_filter([
                'hour' => ['Hour', $hourShown, $hour12 ? 1 : 0, $hour12 ? 12 : 23, 'hh'],
                'minute' => ['Minute', $minute, 0, 59, 'mm'],
            ]) as $part => [$partLabel, $partValue, $low, $high, $empty])
                @if ($part === 'minute')
                    <span aria-hidden="true" class="text-muted">:</span>
                @endif
                <span
                    @if ($part === 'hour') id="{{ $id }}" data-time-picker-trigger {{ $field->aria($attributes, (bool) $info) }} @endif
                    role="spinbutton"
                    @unless ($disabled) tabindex="0" @endunless
                    aria-label="{{ $partLabel }}"
                    aria-valuemin="{{ $low }}"
                    aria-valuemax="{{ $high }}"
                    @if ($partValue !== null) aria-valuenow="{{ $partValue }}" aria-valuetext="{{ $partValue }}" @else aria-valuetext="Empty" @endif
                    @if ($disabled) aria-disabled="true" @endif
                    data-time-picker-segment="{{ $part }}"
                    {{-- What goes here while it's empty: hh, mm. The script puts it back when a part is cleared. --}}
                    data-placeholder="{{ $empty }}"
                    class="focus:bg-primary/15 data-empty:text-muted min-w-[2ch] rounded-md px-0.5 text-center outline-none"
                    @if ($partValue === null) data-empty @endif
                >{{ $partValue === null ? $empty : str_pad((string) $partValue, 2, '0', STR_PAD_LEFT) }}</span>
            @endforeach
            @if ($hour12)
                <span
                    role="spinbutton"
                    @unless ($disabled) tabindex="0" @endunless
                    aria-label="AM or PM"
                    aria-valuemin="0"
                    aria-valuemax="1"
                    @if ($hour !== null) aria-valuenow="{{ $isPm ? 1 : 0 }}" aria-valuetext="{{ $periods[$isPm ? 1 : 0] }}" @else aria-valuetext="Empty" @endif
                    @if ($disabled) aria-disabled="true" @endif
                    data-time-picker-segment="period"
                    data-placeholder="{{ implode('/', $periods) }}"
                    class="focus:bg-primary/15 data-empty:text-muted ms-1.5 rounded-md px-1 text-center outline-none"
                    @if ($hour === null) data-empty @endif
                >{{ $hour === null ? implode('/', $periods) : $periods[$isPm ? 1 : 0] }}</span>
            @endif
            <x-widget.icon name="clock" class="text-muted ms-auto size-5 shrink-0" />
        </div>
    @endif

    @if ($variant === 'slots')
        {{-- Real radio buttons: the arrow keys, required and wire:model work as the browser has them, with no script. --}}
        <div
            role="radiogroup"
            @if ($label) aria-labelledby="{{ $id }}-label" @endif
            {{ $field->aria($attributes, (bool) $info) }}
            class="grid grid-cols-[repeat(auto-fill,minmax(6.5rem,1fr))] gap-2"
        >
            @foreach ($times as $i => $slot)
                <label @class([
                    'border-line text-foreground relative grid h-11 place-items-center rounded-xl border text-sm font-medium tabular-nums transition-colors select-none',
                    'has-checked:bg-primary-fill has-checked:text-on-primary has-checked:border-primary-fill has-focus-visible:ring-primary has-focus-visible:ring-2 has-focus-visible:ring-offset-2 has-focus-visible:ring-offset-surface',
                    'hover:border-primary cursor-pointer' => ! $slot['disabled'] && ! $disabled,
                    'text-muted cursor-not-allowed line-through opacity-60' => $slot['disabled'] || $disabled,
                    'group-data-invalid/field:border-error',
                ])>
                    <input
                        type="radio"
                        @if ($i === 0) id="{{ $id }}" @endif
                        @if ($name) name="{{ $name }}" @endif
                        value="{{ $slot['time'] }}"
                        @checked($value === $slot['time'])
                        @disabled($slot['disabled'] || $disabled)
                        {{ $field->forwarded($attributes)->except(['aria-describedby']) }}
                        class="sr-only"
                    >
                    {{ $format($slot['time']) }}
                </label>
            @endforeach
        </div>
    @else
        <input type="hidden" @if ($name) name="{{ $name }}" @endif value="{{ $value }}" data-time-picker-input {{ $field->forwarded($attributes)->except(['required']) }}>
    @endif

    @if ($variant === 'list')
        {{-- wire:ignore.self: the script positions the open popover (inline top, left and width); a render would wipe that.
             The times inside still render from the server, the chosen one included. --}}
        <div
            id="{{ $id }}-popover"
            wire:ignore.self
            popover
            role="listbox"
            aria-label="{{ $label ?? 'Choose a time' }}"
            data-time-picker-popover
            class="border-line bg-surface text-foreground fixed inset-auto m-0 hidden max-h-72 min-w-40 flex-col overflow-y-auto overscroll-contain rounded-2xl border p-1.5 text-sm shadow-lg open:flex [scrollbar-width:thin]"
        >
            @foreach ($times as $i => $slot)
                <div
                    id="{{ $id }}-option-{{ $i }}"
                    role="option"
                    tabindex="{{ $i === $stop ? 0 : -1 }}"
                    data-value="{{ $slot['time'] }}"
                    aria-selected="{{ $value === $slot['time'] ? 'true' : 'false' }}"
                    @if ($slot['disabled']) aria-disabled="true" @endif
                    class="focus-visible:bg-field hover:bg-field aria-selected:bg-primary/10 aria-selected:text-primary flex shrink-0 cursor-pointer items-center justify-between gap-3 rounded-xl px-3 py-2 tabular-nums outline-none select-none aria-disabled:cursor-not-allowed aria-disabled:opacity-40"
                >{{ $format($slot['time']) }}<x-widget.icon name="check" class="in-aria-selected:block hidden size-4" /></div>
            @endforeach
        </div>
    @endif

    @if ($variant === 'columns')
        {{-- wire:ignore.self, as for the list. Which hours and minutes are out of range is worked out by the script as you
             pick, since it depends on the hour chosen. --}}
        <div
            id="{{ $id }}-popover"
            wire:ignore.self
            popover
            role="dialog"
            aria-label="{{ $label ?? 'Choose a time' }}"
            data-time-picker-popover
            class="border-line bg-surface text-foreground fixed inset-auto m-0 hidden flex-col rounded-2xl border p-2 text-sm shadow-lg open:flex"
        >
            <div class="flex gap-1">
                @foreach (array_filter([
                    'hour' => ['Hour', 'Hr', $hour12 ? [12, ...range(1, 11)] : range(0, 23), $hourShown],
                    'minute' => ['Minute', 'Min', range(0, 59, $step), $minute],
                    'period' => $hour12 ? ['AM or PM', '', [0, 1], $hour === null ? null : (int) $isPm] : null,
                ]) as $part => [$partLabel, $heading, $choices, $chosen])
                    {{-- A heading over each column, so the hours and minutes can't be mistaken for each other. Screen readers get
                         the listbox's own name instead. --}}
                    <div class="flex flex-col gap-1">
                        <span aria-hidden="true" class="text-muted h-4 text-center text-xs font-medium">{{ $heading }}</span>
                        <div role="listbox" aria-label="{{ $partLabel }}" data-time-picker-column="{{ $part }}" class="flex max-h-60 w-14 flex-col gap-0.5 overflow-y-auto overscroll-contain [scrollbar-width:none]">
                            {{-- One Tab stop per column, the chosen value or else the first, so Tab moves from column to column. --}}
                            @foreach ($choices as $choice)
                                <div
                                    role="option"
                                    tabindex="{{ $choice === (in_array($chosen, $choices, true) ? $chosen : reset($choices)) ? 0 : -1 }}"
                                    data-value="{{ $choice }}"
                                    aria-selected="{{ $chosen === $choice ? 'true' : 'false' }}"
                                    class="hover:bg-field focus-visible:ring-primary aria-selected:bg-primary-fill aria-selected:text-on-primary grid h-9 shrink-0 cursor-pointer place-items-center rounded-lg tabular-nums outline-none select-none focus-visible:ring-2 focus-visible:ring-inset aria-disabled:cursor-not-allowed aria-disabled:opacity-30"
                                >{{ $part === 'period' ? $periods[$choice] : str_pad((string) $choice, 2, '0', STR_PAD_LEFT) }}</div>
                            @endforeach
                        </div>
                    </div>
                @endforeach
            </div>
            <div class="border-line mt-2 flex justify-end border-t pt-2">
                <button type="button" data-time-picker-done class="text-primary hover:bg-primary/10 focus-visible:ring-primary rounded-lg px-3 py-1.5 font-medium outline-none focus-visible:ring-2">Done</button>
            </div>
        </div>
    @endif
</x-widget.field>
resources/js/widget/time-picker/index.js Show
index.js
// Drives <x-widget.time-picker>. The server paints the time and the choices; this opens the popover, moves through the
// times with the keyboard, writes the choice into the hidden input (HH:MM, 24-hour) and shows it the locale's way.
// Slots are plain radio buttons and need nothing from here. Delegated from `document`, so pickers added later (a
// Livewire render, fetched HTML) work without setting up.
import { closeIfOutOfView, onLivewireMorph, typingIn } from '../field';

const ROOT = '[data-time-picker]';
const POPOVER = '[data-time-picker-popover]';
const VIEWPORT_EDGE = 16;
const GAP = 4;

const pad = (n) => String(n).padStart(2, '0');
const toMinutes = (time) => {
    const [hour, minute] = time.split(':').map(Number);

    return hour * 60 + minute;
};
const fromMinutes = (minutes) => `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;

// The same pattern and words the server used (TimeOfDay::format), filled in the same way, so a time picked here reads
// exactly as the server writes it, whatever this browser's own Intl data would say.
export function formatTime(time, pattern, periods) {
    const [hour, minute] = time.split(':').map(Number);

    return pattern.replace(/'((?:[^']|'')*)'|h{1,2}|H{1,2}|K{1,2}|k{1,2}|m{1,2}|a+/g, (token, quoted) => {
        const width = token.length;
        switch (token[0]) {
            case "'":
                // Quoted text is literal; '' is a quote mark, inside quotes or on its own.
                return quoted === '' ? "'" : quoted.replaceAll("''", "'");
            case 'h':
                return String(hour % 12 || 12).padStart(width, '0');
            case 'H':
                return String(hour).padStart(width, '0');
            case 'K':
                return String(hour % 12).padStart(width, '0');
            case 'k':
                return String(hour || 24).padStart(width, '0');
            case 'm':
                return String(minute).padStart(width, '0');
            default:
                return periods[hour < 12 ? 0 : 1];
        }
    });
}

function parts(root) {
    return {
        input: root.querySelector('[data-time-picker-input]'),
        trigger: root.querySelector('[data-time-picker-trigger]'),
        display: root.querySelector('[data-time-picker-display]'),
        popover: root.querySelector(POPOVER),
        pattern: root.dataset.pattern,
        periods: JSON.parse(root.dataset.periods ?? '["AM","PM"]'),
        min: toMinutes(root.dataset.min ?? '00:00'),
        max: toMinutes(root.dataset.max ?? '23:59'),
        step: Number(root.dataset.step) || 1,
        hour12: root.dataset.hour12 === 'true',
    };
}

// Writes the time (or '' for none) where the form and wire:model read it, and shows it.
function setValue(root, time) {
    const { input, display, pattern, periods } = parts(root);
    if (display) {
        display.textContent = time ? formatTime(time, pattern, periods) : display.dataset.placeholder;
        display.toggleAttribute('data-empty', !time);
    }
    root.querySelectorAll('[role="listbox"]:not([data-time-picker-column]) [role="option"]').forEach((option) => {
        option.setAttribute('aria-selected', String(option.dataset.value === time));
    });
    if (input.value === time) {
        return;
    }
    input.value = time;
    // Both: Alpine x-model and Livewire wire:model listen for `input`, plain forms for `change`.
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.dispatchEvent(new Event('change', { bubbles: true }));
}

// --- The popover (list and columns) ---------------------------------------------------------------------

// Under the field, lined up with it and at least as wide; above it when there's no room below.
function position(root) {
    const { trigger, popover } = parts(root);
    if (closeIfOutOfView(trigger, popover)) {
        return;
    }
    const box = trigger.getBoundingClientRect();
    if (root.dataset.timePicker === 'list') {
        popover.style.minWidth = `${box.width}px`;
    }
    const width = popover.offsetWidth;
    const height = popover.offsetHeight;
    const rtl = getComputedStyle(root).direction === 'rtl';
    const left = rtl ? box.right - width : box.left;
    popover.style.left = `${Math.min(Math.max(left, VIEWPORT_EDGE), window.innerWidth - width - VIEWPORT_EDGE)}px`;
    const below = window.innerHeight - box.bottom - GAP - VIEWPORT_EDGE;
    const up = height > below && box.top - GAP - VIEWPORT_EDGE > below;
    popover.style.top = `${up ? box.top - GAP - height : box.bottom + GAP}px`;
}

let openRoot = null;
const reposition = () => openRoot && position(openRoot);

document.addEventListener('beforetoggle', (event) => {
    if (event.target.matches?.(POPOVER) && event.newState === 'open') {
        // Hidden until it's placed, or it flashes in the middle of the screen first.
        event.target.style.visibility = 'hidden';
    }
}, true);

document.addEventListener('toggle', (event) => {
    const popover = event.target;
    if (!popover.matches?.(POPOVER)) {
        return;
    }
    const root = popover.closest(ROOT);
    const { trigger } = parts(root);
    const open = event.newState === 'open';
    trigger.setAttribute('aria-expanded', String(open));
    if (open) {
        openRoot = root;
        if (root.dataset.timePicker === 'columns') {
            refreshColumns(root);
        }
        position(root);
        popover.style.visibility = '';
        focusChosen(root);
        window.addEventListener('scroll', reposition, true);
        window.addEventListener('resize', reposition);

        return;
    }
    if (openRoot === root) {
        openRoot = null;
        window.removeEventListener('scroll', reposition, true);
        window.removeEventListener('resize', reposition);
    }
    // Esc, a choice or Done leave focus nowhere; put it back on the field. A click elsewhere keeps its own focus.
    if (document.activeElement === document.body || popover.contains(document.activeElement)) {
        trigger.focus({ preventScroll: true });
    }
}, true);

const enabled = (options) => options.filter((option) => option.getAttribute('aria-disabled') !== 'true');

// Opens on the chosen time, or the first one that can be picked, scrolled into the middle of the list.
function focusChosen(root) {
    const lists = [...root.querySelectorAll(`${POPOVER} [role="listbox"], ${POPOVER}[role="listbox"]`)];
    for (const list of lists) {
        const options = [...list.querySelectorAll('[role="option"]')];
        const chosen = options.find((option) => option.getAttribute('aria-selected') === 'true') ?? enabled(options)[0];
        chosen?.scrollIntoView({ block: 'center' });
    }
    const first = lists[0];
    const target = first?.querySelector('[role="option"][aria-selected="true"]') ?? enabled([...(first?.querySelectorAll('[role="option"]') ?? [])])[0];
    target?.focus({ preventScroll: true });
}

function moveIn(list, from, by) {
    const options = enabled([...list.querySelectorAll('[role="option"]')]);
    if (options.length === 0) {
        return null;
    }
    const index = options.indexOf(from);
    const next = by === Infinity ? options.at(-1) : by === -Infinity ? options[0] : options[Math.min(options.length - 1, Math.max(0, (index === -1 ? -1 : index) + by))];
    next.focus();
    next.scrollIntoView({ block: 'nearest' });

    return next;
}

// --- List: a menu of times ------------------------------------------------------------------------------

let typed = '';
let typedTimer = null;

function chooseFromList(root, option) {
    if (option.getAttribute('aria-disabled') === 'true') {
        return;
    }
    setValue(root, option.dataset.value);
    parts(root).popover.hidePopover();
}

// Like a native select: typing "9" jumps to the first 9-o'clock time, "93" to 9:30. Digits only, matched against what
// the option shows and its 24-hour time, so on a 12-hour clock both "2" and "14" reach 2:00 PM.
function jump(list, key) {
    typed += key;
    clearTimeout(typedTimer);
    typedTimer = setTimeout(() => { typed = ''; }, 700);
    const forms = (option) => [option.textContent, option.dataset.value].map((text) => text.replace(/\D/g, ''));
    const match = enabled([...list.querySelectorAll('[role="option"]')]).find((option) => forms(option).some((digits) => digits.startsWith(typed) || digits.startsWith(`0${typed}`)));
    match?.focus();
    match?.scrollIntoView({ block: 'nearest' });
}

// --- Columns: hour, minute and AM/PM ------------------------------------------------------------------

// The time the columns show, filling in what's missing from the first time that can be picked.
function columnsTime(root) {
    const { input, min } = parts(root);

    return input.value || fromMinutes(min);
}

function columnValue(root, part, value) {
    const { hour12, min, max, step } = parts(root);
    let [hour, minute] = columnsTime(root).split(':').map(Number);
    if (part === 'hour') {
        hour = hour12 ? (Number(value) % 12) + (hour >= 12 ? 12 : 0) : Number(value);
    } else if (part === 'minute') {
        minute = Number(value);
    } else {
        hour = (hour % 12) + (Number(value) === 1 ? 12 : 0);
    }
    // A new hour keeps its minute where it can; otherwise the nearest one that's in range.
    let total = hour * 60 + minute;
    if (total < min || total > max) {
        const candidates = [];
        for (let m = 0; m < 60; m += step) {
            const t = hour * 60 + m;
            if (t >= min && t <= max) {
                candidates.push(t);
            }
        }
        total = candidates.sort((a, b) => Math.abs(a - total) - Math.abs(b - total))[0] ?? Math.min(max, Math.max(min, total));
    }

    return fromMinutes(total);
}

// Marks the chosen hour, minute and period, and greys out what would leave the range given the rest of the time.
function refreshColumns(root) {
    const { input, hour12, min, max, step } = parts(root);
    const time = input.value;
    const [hour, minute] = (time || columnsTime(root)).split(':').map(Number);
    const inRange = (h, m) => h * 60 + m >= min && h * 60 + m <= max;
    const anyMinute = (h) => {
        for (let m = 0; m < 60; m += step) {
            if (inRange(h, m)) {
                return true;
            }
        }

        return false;
    };
    root.querySelectorAll('[data-time-picker-column]').forEach((column) => {
        const part = column.dataset.timePickerColumn;
        column.querySelectorAll('[role="option"]').forEach((option) => {
            const value = Number(option.dataset.value);
            let chosen;
            let allowed;
            if (part === 'hour') {
                const h = hour12 ? (value % 12) + (hour >= 12 ? 12 : 0) : value;
                chosen = Boolean(time) && h === hour;
                allowed = anyMinute(h);
            } else if (part === 'minute') {
                chosen = Boolean(time) && value === minute;
                allowed = inRange(hour, value);
            } else {
                chosen = Boolean(time) && value === (hour >= 12 ? 1 : 0);
                allowed = Array.from({ length: 12 }, (_, i) => i + value * 12).some(anyMinute);
            }
            option.setAttribute('aria-selected', String(chosen));
            allowed ? option.removeAttribute('aria-disabled') : option.setAttribute('aria-disabled', 'true');
        });
    });
}

function chooseInColumn(root, option) {
    if (option.getAttribute('aria-disabled') === 'true') {
        return;
    }
    const column = option.closest('[data-time-picker-column]');
    setValue(root, columnValue(root, column.dataset.timePickerColumn, option.dataset.value));
    refreshColumns(root);
}

// --- Segmented: typed into the field ------------------------------------------------------------------

// Per picker, what's been typed: { hour, minute, pm }, each null until set. The hour as shown (1-12 on a 12-hour clock).
const segmentState = new WeakMap();
// Per segment, the first digit while a second may follow.
const pendingDigit = new WeakMap();

function readSegments(root) {
    const state = { hour: null, minute: null, pm: null };
    root.querySelectorAll('[data-time-picker-segment]').forEach((segment) => {
        const now = segment.getAttribute('aria-valuenow');
        const value = now === null ? null : Number(now);
        const part = segment.dataset.timePickerSegment;
        if (part === 'period') {
            state.pm = value === null ? null : value === 1;
        } else {
            state[part] = value;
        }
    });
    segmentState.set(root, state);

    return state;
}

const stateOf = (root) => segmentState.get(root) ?? readSegments(root);

function renderSegments(root) {
    const state = stateOf(root);
    const { periods } = parts(root);
    root.querySelectorAll('[data-time-picker-segment]').forEach((segment) => {
        const part = segment.dataset.timePickerSegment;
        const value = part === 'period' ? (state.pm === null ? null : Number(state.pm)) : state[part];
        const text = value === null ? segment.dataset.placeholder : part === 'period' ? periods[value] : pad(value);
        segment.textContent = text;
        segment.toggleAttribute('data-empty', value === null);
        if (value === null) {
            segment.removeAttribute('aria-valuenow');
            segment.setAttribute('aria-valuetext', 'Empty');
        } else {
            segment.setAttribute('aria-valuenow', String(value));
            segment.setAttribute('aria-valuetext', part === 'period' ? periods[value] : String(value));
        }
    });
}

// A whole time once every part is in, kept within min and max; '' while any part is missing.
function commitSegments(root) {
    const state = stateOf(root);
    const { hour12, min, max } = parts(root);
    if (state.hour === null || state.minute === null || (hour12 && state.pm === null)) {
        setValue(root, '');

        return;
    }
    const hour = hour12 ? (state.hour % 12) + (state.pm ? 12 : 0) : state.hour;
    const typed = hour * 60 + state.minute;
    const kept = Math.min(max, Math.max(min, typed));
    // Held within min and max: show what's submitted, not what was typed.
    if (kept !== typed) {
        const keptHour = Math.floor(kept / 60);
        Object.assign(state, { hour: hour12 ? keptHour % 12 || 12 : keptHour, minute: kept % 60, pm: keptHour >= 12 });
        renderSegments(root);
    }
    setValue(root, fromMinutes(kept));
}

function segmentKey(root, segment, event) {
    const state = stateOf(root);
    const { step, periods } = parts(root);
    const part = segment.dataset.timePickerSegment;
    const low = Number(segment.getAttribute('aria-valuemin'));
    const high = Number(segment.getAttribute('aria-valuemax'));
    const segments = [...root.querySelectorAll('[data-time-picker-segment]')];
    const index = segments.indexOf(segment);
    const rtl = getComputedStyle(root).direction === 'rtl';
    const wrap = (n) => ((n - low + (high - low + 1)) % (high - low + 1)) + low;
    const key = event.key;
    let changed = false;

    if (key === 'ArrowUp' || key === 'ArrowDown') {
        const by = key === 'ArrowUp' ? 1 : -1;
        if (part === 'period') {
            state.pm = state.pm === null ? by === 1 : !state.pm;
        } else if (part === 'minute') {
            state.minute = state.minute === null ? (by === 1 ? 0 : 60 - step) : ((state.minute + by * step) % 60 + 60) % 60;
        } else {
            state.hour = state.hour === null ? (by === 1 ? low : high) : wrap(state.hour + by);
        }
        changed = true;
    } else if (key === 'ArrowLeft' || key === 'ArrowRight') {
        const by = (key === 'ArrowRight' ? 1 : -1) * (rtl ? -1 : 1);
        segments[index + by]?.focus();
    } else if (key === 'Backspace' || key === 'Delete') {
        part === 'period' ? (state.pm = null) : (state[part] = null);
        pendingDigit.delete(segment);
        changed = true;
    } else if (/^\d$/.test(key) && part !== 'period') {
        const digit = Number(key);
        const first = pendingDigit.get(segment);
        let value = first === undefined ? digit : first * 10 + digit;
        // "1" then "5" on a 1-12 hour can't be 15: the 5 starts again.
        if (value > high) {
            value = digit;
        }
        pendingDigit.delete(segment);
        // Another digit may follow ("1" could become "12", "0" become "09"); wait for it, then move on.
        if (first === undefined && value * 10 <= high) {
            pendingDigit.set(segment, value);
        }
        // A lone 0 on a 1-12 hour isn't an hour yet.
        state[part] = value < low ? null : value;
        if (!pendingDigit.has(segment)) {
            segments[index + 1]?.focus();
        }
        changed = true;
    } else if (part === 'period' && key.length === 1) {
        // The first letter of either word: a or p, v or n (vorm., nachm.).
        const letter = key.toLowerCase();
        const pick = periods.findIndex((word) => word.toLowerCase().startsWith(letter));
        if (pick !== -1) {
            state.pm = pick === 1;
            changed = true;
        }
    } else {
        return;
    }
    if (key !== 'Tab') {
        event.preventDefault();
    }
    if (changed) {
        renderSegments(root);
        commitSegments(root);
    }
}

// --- Events ------------------------------------------------------------------------------------------

document.addEventListener('click', (event) => {
    const option = event.target.closest?.(`${ROOT} ${POPOVER} [role="option"]`);
    if (option) {
        const root = option.closest(ROOT);
        option.closest('[data-time-picker-column]') ? chooseInColumn(root, option) : chooseFromList(root, option);

        return;
    }
    const done = event.target.closest?.('[data-time-picker-done]');
    if (done) {
        done.closest(POPOVER).hidePopover();
    }
});

document.addEventListener('keydown', (event) => {
    const segment = event.target.closest?.('[data-time-picker-segment]');
    if (segment && segment.getAttribute('aria-disabled') !== 'true') {
        segmentKey(segment.closest(ROOT), segment, event);

        return;
    }
    const option = event.target.closest?.(`${ROOT} ${POPOVER} [role="option"]`);
    if (!option) {
        return;
    }
    const root = option.closest(ROOT);
    const column = option.closest('[data-time-picker-column]');
    const list = option.closest('[role="listbox"]');
    const rtl = getComputedStyle(root).direction === 'rtl';
    const moves = { ArrowDown: 1, ArrowUp: -1, Home: -Infinity, End: Infinity, PageDown: 5, PageUp: -5 };

    if (event.key in moves) {
        event.preventDefault();
        const next = moveIn(list, option, moves[event.key]);
        // In the columns, moving is choosing, like turning a wheel.
        if (column && next) {
            chooseInColumn(root, next);
            next.focus();
        }
    } else if (column && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
        event.preventDefault();
        const columns = [...root.querySelectorAll('[data-time-picker-column]')];
        const by = (event.key === 'ArrowRight' ? 1 : -1) * (rtl ? -1 : 1);
        const to = columns[columns.indexOf(column) + by];
        (to?.querySelector('[role="option"][aria-selected="true"]') ?? enabled([...(to?.querySelectorAll('[role="option"]') ?? [])])[0])?.focus();
    } else if (event.key === 'Enter' || event.key === ' ') {
        event.preventDefault();
        column ? parts(root).popover.hidePopover() : chooseFromList(root, option);
    } else if (event.key === 'Tab' && !column) {
        // Back to the field first, so Tab carries on from there.
        parts(root).popover.hidePopover();
    } else if (!column && /^\d$/.test(event.key)) {
        jump(list, event.key);
    }
});

// Roving tabindex: the option with focus is its list's one Tab stop, so Tab leaves the list and comes back to it.
document.addEventListener('focusin', (event) => {
    const option = event.target.closest?.(`${ROOT} [role="listbox"] > [role="option"]`);
    if (!option) {
        return;
    }
    option.parentElement.querySelectorAll(':scope > [role="option"][tabindex="0"]').forEach((other) => {
        other.tabIndex = -1;
    });
    option.tabIndex = 0;
});

// The first digit typed into a segment waits for a second only while the segment has focus.
document.addEventListener('focusout', (event) => {
    if (event.target.matches?.('[data-time-picker-segment]')) {
        pendingDigit.delete(event.target);
    }
});

// --- Livewire renders ---------------------------------------------------------------------------------

// A render puts back the server's markup. The list and the display come back right (they're drawn from the bound
// property), but the columns' greyed-out options and what's being typed into the segments are this script's: redo the
// one, and keep the other while it's being typed in (the render may answer an earlier keystroke).
onLivewireMorph((scope) => {
    const roots = [...(scope.matches?.(ROOT) ? [scope] : []), ...(scope.querySelectorAll?.(ROOT) ?? [])];
    for (const root of roots) {
        if (root.dataset.timePicker === 'columns') {
            refreshColumns(root);
        } else if (root.dataset.timePicker === 'segmented') {
            typingIn(root) ? renderSegments(root) : readSegments(root);
        }
    }
});
app/View/Widget/ElementIds.php Show
ElementIds.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Container\Attributes\Scoped;
use LogicException;

/**
 * Keeps element ids unique within one response, so labels, aria-describedby and #fragments
 * always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
 */
#[Scoped]
final class ElementIds
{
    /** @var array<string, true> */
    private array $used = [];

    /**
     * Reserves an id for this response.
     *
     * A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
     * id is one the caller chose and may reference from JS or CSS, so silently renaming it would
     * break that reference; a duplicate throws instead, which surfaces in development and tests.
     */
    public function claim(string $id, bool $explicit = false): string
    {
        if (!isset($this->used[$id])) {
            return $this->reserve($id);
        }

        if ($explicit) {
            throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
        }

        $suffix = 2;
        while (isset($this->used["{$id}-{$suffix}"])) {
            $suffix++;
        }

        return $this->reserve("{$id}-{$suffix}");
    }

    private function reserve(string $id): string
    {
        $this->used[$id] = true;

        return $id;
    }
}
app/View/Widget/FormField.php Show
FormField.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use Illuminate\Contracts\Support\MessageBag;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use Illuminate\View\ComponentAttributeBag;

/**
 * Server-side state of one form widget: its dot-notation key, a valid id, its validation
 * messages and its old input. Every <x-widget.input.*> and the date picker resolve through
 * here, so array names (items[0][date]) and named error bags behave the same everywhere.
 */
final class FormField
{
    /**
     * @param  list<string>  $errors
     */
    private function __construct(
        public readonly ?string $name,
        public readonly string $id,
        public readonly ?string $key,
        public readonly array $errors,
        // The property a wire:model or x-model attribute binds it to, if any.
        public readonly ?string $bound = null,
    ) {}

    /**
     * @param  mixed  $errorBag  the view's shared $errors (absent outside a web request)
     * @param  string|array<int, string>|null  $error  an explicit message from the caller; overrides the bag
     */
    public static function make(
        ?string $name,
        ?string $id,
        mixed $errorBag,
        string|array|null $error = null,
        string $bag = 'default',
        string $idPrefix = 'field',
        ?ComponentAttributeBag $attributes = null,
    ): self {
        // With no name, a Livewire or Alpine binding (wire:model="email") names the field. Its errors are filed under
        // that property, and its id stays the same on every render, which Livewire's morph needs to keep the element
        // (it matches elements by id: a random one makes it swap in a new field, dropping focus mid-typing).
        $bound = $attributes === null ? null : self::boundTo($attributes);
        $key = match (true) {
            $name !== null && $name !== '' => self::key($name),
            $bound !== null => self::key($bound),
            default => null,
        };

        $messages = match (true) {
            $error !== null => Arr::wrap($error),
            $key !== null && $errorBag instanceof ViewErrorBag => self::messagesFor($errorBag->getBag($bag), $key),
            default => [],
        };

        return new self(
            $name,
            app(ElementIds::class)->claim(
                $id ?? ($key !== null ? self::idFrom($key) : $idPrefix.'-'.Str::random(6)),
                explicit: $id !== null,
            ),
            $key,
            array_values(array_filter($messages, static fn (mixed $message): bool => is_string($message) && $message !== '')),
            $bound,
        );
    }

    /**
     * The field's own messages, plus those Laravel files per item for a list of values: a 'tags.*' rule
     * reports a bad second choice under tags.1, which a multiple select named tags must still show. Only
     * numbered children count, so a field named address doesn't take errors meant for address[city].
     *
     * @return list<string>
     */
    private static function messagesFor(MessageBag $bag, string $key): array
    {
        $items = array_filter(
            $bag->getMessages(),
            static fn (string $name): bool => preg_match('/^'.preg_quote($key, '/').'\.\d+$/', $name) === 1,
            ARRAY_FILTER_USE_KEY,
        );

        return array_values(array_unique([...$bag->get($key), ...array_merge(...array_values($items))]));
    }

    /**
     * items[0][date] → items.0.date and tags[] → tags: the key Laravel files errors and old input under.
     */
    public static function key(string $name): string
    {
        return trim((string) preg_replace('/\[([^\]]*)\]/', '.$1', $name), '.');
    }

    /**
     * The id a field named $name gets when it's the first of that name on the page, for links to it
     * (the error summary). A later duplicate gets a -2 suffix, which links can't know about.
     */
    public static function idFor(string $name): string
    {
        return self::idFrom(self::key($name));
    }

    /**
     * The property a wire:model or x-model attribute (any modifiers) binds the field to; null without one.
     */
    public static function boundTo(ComponentAttributeBag $attributes): ?string
    {
        return array_values(self::binding($attributes))[0] ?? null;
    }

    private static function idFrom(string $key): string
    {
        return trim((string) preg_replace('/[^A-Za-z0-9_-]+/', '-', $key), '-');
    }

    public function hasError(): bool
    {
        return $this->errors !== [];
    }

    public function errorId(): string
    {
        return $this->id.'-error';
    }

    public function infoId(): string
    {
        return $this->id.'-info';
    }

    /**
     * Old input after a failed validation, falling back to the widget's value prop, or with none, to the bound Livewire
     * property: a re-render then draws the field as it is, which Livewire morphs onto the page.
     */
    public function old(mixed $default = null): mixed
    {
        if ($default === null) {
            [$found, $live] = $this->fromLivewire();
            $default = $found ? $live : null;
        }

        return $this->key === null ? $default : old($this->key, $default);
    }

    /**
     * The bound property's value while Livewire renders the component that holds it: Livewire shares that component
     * with every view as $__livewire. Livewire isn't a dependency; it's only looked for. [false, null] otherwise.
     * $key reads inside it: a range bound to period reads period.start.
     *
     * @return array{0: bool, 1: mixed}
     */
    public function fromLivewire(?string $key = null): array
    {
        $component = $this->bound === null ? null : view()->shared('__livewire');

        return is_object($component) ? [true, data_get($component, $key === null ? $this->bound : "{$this->bound}.{$key}")] : [false, null];
    }

    /**
     * The binding attribute as written (wire:model.live => period), to put on the inputs that carry the value: a range
     * picker binds period.start and period.end with the same modifiers. Empty without one.
     *
     * @return array<string, string>
     */
    public static function binding(ComponentAttributeBag $attributes): array
    {
        foreach ($attributes->getAttributes() as $attribute => $value) {
            if (is_string($value) && $value !== '' && (str_starts_with($attribute, 'wire:model') || str_starts_with($attribute, 'x-model'))) {
                return [$attribute => $value];
            }
        }

        return [];
    }

    /**
     * Whether a checkbox or switch renders ticked. An unticked box isn't in the request at all, so after a
     * failed submit "no old value" means unticked, but only when that submit was this box's own form. A page
     * with a second form (or a disabled box, which is never sent) would otherwise lose every `checked`.
     *
     * @param  bool  $alwaysSent  it has an unchecked-value, so its form always sends something under its name
     * @param  mixed  $errorBag  the view's shared $errors
     */
    public function checked(mixed $value, bool $default, bool $disabled, bool $alwaysSent, mixed $errorBag, string $bag = 'default'): bool
    {
        // Bound to a Livewire property: that says, true/false, or for a list of boxes, whether it holds this value.
        [$found, $live] = $this->fromLivewire();
        if ($found) {
            return is_array($live) ? in_array(self::text($value), array_map(self::text(...), $live), true) : (bool) $live;
        }
        if ($this->key === null || $disabled || !session()->hasOldInput()) {
            return $default;
        }

        $old = old($this->key);
        if ($old !== null) {
            return in_array(self::text($value), array_map(self::text(...), Arr::wrap($old)), true);
        }
        // Nothing under this name, though this box always sends something: its form wasn't the one submitted.
        if ($alwaysSent) {
            return $default;
        }
        // A form with its own error bag: no errors in that bag means the failed submit was a different form.
        if ($bag !== 'default') {
            return $errorBag instanceof ViewErrorBag && $errorBag->getBag($bag)->isNotEmpty() ? false : $default;
        }

        // One form, or forms sharing the default bag: there's no telling them apart, so trust the old input.
        return false;
    }

    /** Values cast to backed enums (Plan::Pro) compare as their backing value. */
    private static function text(mixed $value): string
    {
        return (string) ($value instanceof \BackedEnum ? $value->value : $value);
    }

    /**
     * What the caller passed, minus `class` (that styles the wrapper) and the aria attributes
     * this widget manages itself. Goes on the element that is actually submitted.
     */
    public function forwarded(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->except(['class', 'aria-invalid', 'aria-describedby']);
    }

    /**
     * aria-invalid plus one aria-describedby that joins the error or the hint, and the caller's own
     * ids. Two separate aria-describedby attributes would make the browser silently drop one.
     */
    public function aria(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        // Not both: the frame hides the hint while there's an error, and aria-describedby reads hidden text, so a
        // screen reader would hear two messages that often say the same thing. resources/js/field brings the hint
        // back when the error clears.
        $describedBy = array_filter([
            $this->hasError() ? $this->errorId() : null,
            $hasInfo && !$this->hasError() ? $this->infoId() : null,
            $attributes->get('aria-describedby'),
        ]);

        return new ComponentAttributeBag([
            'aria-invalid' => $this->hasError() ? 'true' : null,
            'aria-describedby' => $describedBy === [] ? null : implode(' ', $describedBy),
        ]);
    }

    /**
     * For a widget whose visible control isn't the submitted input (grouped number, phone): what belongs on the
     * hidden input that carries the value. Which form it's in, and Livewire and Alpine bindings, go with the value.
     */
    public function bindings(ComponentAttributeBag $attributes): ComponentAttributeBag
    {
        return $attributes->filter(static fn (mixed $value, string $key): bool => self::isBinding($key));
    }

    /**
     * The other side of bindings(): everything else the caller passed (required, autofocus, aria-label,
     * placeholder…) goes on the visible control, where the browser validates it and screen readers hear it.
     */
    public function visibleAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->controlAttributes($attributes->filter(static fn (mixed $value, string $key): bool => !self::isBinding($key)), $hasInfo);
    }

    private static function isBinding(string $key): bool
    {
        return $key === 'form' || str_starts_with($key, 'wire:model') || str_starts_with($key, 'x-model');
    }

    /**
     * forwarded() and aria() together, for widgets whose visible control is also the submitted one.
     */
    public function controlAttributes(ComponentAttributeBag $attributes, bool $hasInfo = false): ComponentAttributeBag
    {
        return $this->forwarded($attributes)->merge($this->aria($attributes, $hasInfo)->getAttributes());
    }
}
app/View/Widget/TimeOfDay.php Show
TimeOfDay.php
<?php

declare(strict_types=1);

namespace App\View\Widget;

use DateTimeInterface;
use IntlDateFormatter;
use IntlDatePatternGenerator;

/**
 * Times of day for <x-widget.time-picker>: "HH:MM" on a 24-hour clock, the one form it submits, and how a locale
 * writes them. A time of day has no date or timezone, so 09:30 is 09:30 for everyone. Needs PHP's intl extension.
 */
final class TimeOfDay
{
    /**
     * "HH:MM" from "9:30", "09:30:15", "9:30 PM" or a date object; null for anything else.
     */
    public static function normalize(mixed $time): ?string
    {
        if ($time instanceof DateTimeInterface) {
            return $time->format('H:i');
        }
        if (!is_string($time) || preg_match('/^\s*(\d{1,2}):(\d{2})(?::\d{2})?\s*([AaPp][Mm])?\s*$/', $time, $parts) !== 1) {
            return null;
        }
        [$hour, $minute] = [(int) $parts[1], (int) $parts[2]];
        if (isset($parts[3])) {
            if ($hour < 1 || $hour > 12) {
                return null;
            }
            $hour = $hour % 12 + (strtolower($parts[3]) === 'pm' ? 12 : 0);
        }

        return $hour <= 23 && $minute <= 59 ? sprintf('%02d:%02d', $hour, $minute) : null;
    }

    /** Minutes since midnight: 09:30 → 570. */
    public static function minutes(string $time): int
    {
        [$hour, $minute] = array_map('intval', explode(':', $time));

        return $hour * 60 + $minute;
    }

    public static function fromMinutes(int $minutes): string
    {
        $minutes = (($minutes % 1440) + 1440) % 1440;

        return sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
    }

    /**
     * Every time from min to max, step minutes apart, counted from min: 09:00, 09:15 … 17:00. Within one day.
     *
     * @return list<string>
     */
    public static function range(string $min = '00:00', string $max = '23:59', int $step = 15): array
    {
        $step = max(1, $step);
        $times = [];
        for ($minute = self::minutes($min), $last = self::minutes($max); $minute <= $last; $minute += $step) {
            $times[] = self::fromMinutes($minute);
        }

        return $times;
    }

    /** Whether the locale writes times on a 12-hour clock (en-US, ar, hi) rather than a 24-hour one (en-GB, de, ja). */
    public static function usesHour12(string $locale): bool
    {
        $pattern = (new IntlDateFormatter(self::icu($locale), IntlDateFormatter::NONE, IntlDateFormatter::SHORT))->getPattern();

        return preg_match('/[hK]/', (string) preg_replace("/'[^']*'/", '', $pattern)) === 1;
    }

    /**
     * How the locale writes a time on the clock asked for, as a CLDR pattern: "h:mm a", "HH:mm", "aK:mm".
     */
    public static function pattern(string $locale, bool $hour12): string
    {
        $generated = IntlDatePatternGenerator::create(self::icu($locale))?->getBestPattern($hour12 ? 'hmm' : 'HHmm');

        return is_string($generated) && $generated !== '' ? $generated : ($hour12 ? 'h:mm a' : 'HH:mm');
    }

    /**
     * The locale's words for before and after noon: ['AM', 'PM'], ['vorm.', 'nachm.'], ['午前', '午後'].
     *
     * @return array{0: string, 1: string}
     */
    public static function periods(string $locale): array
    {
        $formatter = new IntlDateFormatter(self::icu($locale), IntlDateFormatter::NONE, IntlDateFormatter::NONE, 'UTC', IntlDateFormatter::GREGORIAN, 'a');

        return [(string) ($formatter->format(9 * 3600) ?: 'AM'), (string) ($formatter->format(15 * 3600) ?: 'PM')];
    }

    /**
     * The time written out with a pattern and period words from pattern() and periods(): "9:30 AM", "09:30". Filled in
     * here rather than by ICU, and the same way by resources/js/widget/time-picker, so the server's first paint and the
     * browser always agree: their ICU data can differ (PHP's is often older).
     *
     * @param  array{0: string, 1: string}  $periods
     */
    public static function format(string $time, string $pattern, array $periods): string
    {
        [$hour, $minute] = array_map('intval', explode(':', $time));

        return (string) preg_replace_callback(
            "/'((?:[^']|'')*)'|h{1,2}|H{1,2}|K{1,2}|k{1,2}|m{1,2}|a+/",
            static fn (array $token): string => match ($token[0][0]) {
                // Quoted text is literal; '' is a quote mark, inside quotes or on its own.
                "'" => $token[1] === '' ? "'" : str_replace("''", "'", $token[1]),
                'h' => self::pad($hour % 12 ?: 12, strlen($token[0])),
                'H' => self::pad($hour, strlen($token[0])),
                'K' => self::pad($hour % 12, strlen($token[0])),
                'k' => self::pad($hour ?: 24, strlen($token[0])),
                'm' => self::pad($minute, strlen($token[0])),
                default => $periods[$hour < 12 ? 0 : 1],
            },
            $pattern,
        );
    }

    private static function pad(int $number, int $width): string
    {
        return str_pad((string) $number, $width, '0', STR_PAD_LEFT);
    }

    private static function icu(string $locale): string
    {
        return str_replace('-', '_', $locale);
    }
}