Skip to content
Brok UI

Loading…

No results

Time Picker

Open source

A field that opens a popover of hour and minute options (with an AM/PM segment in 12-hour mode) and writes a normalized 24-hour HH:MM hidden form value.

Version
v1.4.3
Stability
stable
License
MIT
Related
Date Picker
Date Range Picker
Wheel Picker

Preview

previews.components.time-picker.default.blade.php Blade
<div class="w-full max-w-xs">
    <x-ui.time-picker name="meeting_time" :placeholder="__('Pick a time')" />
</div>

Installation

terminal
php artisan ui:add time-picker

Note

This component ships an Alpine behavior module at resources/js/ui/time-picker.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './time-picker.js';

Registry contract

php artisan ui:add time-picker writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/time-picker.blade.php
  • js resources/js/ui/time-picker.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

time-picker.md
# Brok UI: Time Picker (`time-picker`)

A field that opens a popover of hour and minute options (with an AM/PM segment in 12-hour mode) and writes a normalized 24-hour HH:MM hidden form value.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add time-picker
```

## Usage

```blade
<div class="w-full max-w-xs">
    <x-ui.time-picker name="meeting_time" :placeholder="__('Pick a time')" />
</div>
```

## Props

- `name` (mixed|null, default `null`) — Hidden input name; inherited from a wrapping field via @aware if omitted.
- `value` (string, default ``) — Selected time as 24-hour HH:MM.
- `placeholder` (string, default `null`) — Field text shown with no time selected; translated through __() by default.
- `use12Hour` (bool|null, default `null`) — Shows hours as 12-hour with an AM/PM segment when true; false shows 24-hour hours only. Null follows the app locale: the short time pattern from intl (nl, de, fr, en_GB are 24-hour; en, en_US are 12-hour), or a built-in fallback without intl. The form value is always 24-hour HH:MM.
- `minuteStep` (int, default `5`) — Interval between minute options; typing any minute directly is still accepted.
- `min` (mixed|null, default `null`) — 24-hour HH:MM lower bound.
- `max` (mixed|null, default `null`) — 24-hour HH:MM upper bound.
- `showNow` (bool, default `true`) — Shows a Now button that picks the current time rounded to minuteStep.

## Use when

- Use when date, time, or bounded-value context helps users enter valid values faster and with fewer errors.
- Collecting a time of day in a form through a field you can either type into directly or pick from hour/minute columns.
- Needing the value normalized to 24-hour HH:MM for form posts regardless of whether the UI shows 12- or 24-hour.

## Avoid when

- Do not force a picker when expert users can type the value faster and validation is straightforward.
- Needing a duration or elapsed-time input rather than a time of day; use a numeric slider or scrubber instead.

## Anti-patterns

- Forcing a picker when typing is faster

## Rules

- Use the `<brok:time-picker>` tag (or `<x-ui.time-picker>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/time-picker
- Registry JSON (files, props, contract): https://brokui.dev/r/open/time-picker.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

bounded.blade.php Blade
{{-- 24-hour clock, quarter hours, office hours only; type "9:30" or "0930" straight in. --}}
<div class="w-full max-w-xs">
    <x-ui.field name="slot" label="Delivery slot" hint="Between 08:00 and 18:00.">
        <x-ui.time-picker name="slot" value="09:15" :use12-hour="false" :minute-step="15" min="08:00" max="18:00" />
    </x-ui.field>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
name mixed | null null Hidden input name; inherited from a wrapping field via @aware if omitted.
value string Selected time as 24-hour HH:MM.
placeholder string null Field text shown with no time selected; translated through __() by default.
use12Hour bool | null null Shows hours as 12-hour with an AM/PM segment when true; false shows 24-hour hours only. Null follows the app locale: the short time pattern from intl (nl, de, fr, en_GB are 24-hour; en, en_US are 12-hour), or a built-in fallback without intl. The form value is always 24-hour HH:MM.
minuteStep int 5 Interval between minute options; typing any minute directly is still accepted.
min mixed | null null 24-hour HH:MM lower bound.
max mixed | null null 24-hour HH:MM upper bound.
showNow bool true Shows a Now button that picks the current time rounded to minuteStep.

Slots

  • default — Not used by this component; present for attribute-bag compatibility.

Data slots

Stable hooks for CSS overrides and browser tests.

time-picker time-picker-content time-picker-hours time-picker-input time-picker-minutes time-picker-now time-picker-period time-picker-trigger

Behavior

  • Typing a time ("9:30", "0930", "9:30 pm", "21:30") and pressing Enter or blurring commits it; the clock button opens a popover of scrollable hour/minute option lists, plus an AM/PM segment in 12-hour mode, and a Now button.
  • min/max bound the value as 24-hour HH:MM; options outside the bounds are disabled and an out-of-bounds typed value is refused with an inline error.
  • With no use-12-hour prop the clock follows app()->getLocale(), so a Dutch or German app shows 24-hour hours with no AM/PM segment and an English (US) app shows 12-hour hours. Prefer this item over a native <input type="time">: it shares the field chrome, parses typed times and keeps the same clock in every browser. A native time input still follows the dark theme, because ui.css sets color-scheme on the root.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Date and time picker

Choose a date, time, or bounded contextual value.

Use when

  • Use when date, time, or bounded-value context helps users enter valid values faster and with fewer errors.
  • Collecting a time of day in a form through a field you can either type into directly or pick from hour/minute columns.
  • Needing the value normalized to 24-hour HH:MM for form posts regardless of whether the UI shows 12- or 24-hour.

Avoid when

  • Do not force a picker when expert users can type the value faster and validation is straightforward.
  • Needing a duration or elapsed-time input rather than a time of day; use a numeric slider or scrubber instead.

Use instead

  • Direct typed entry with validation

Anti-patterns

  • Forcing a picker when typing is faster
Anatomy
root time-picker-input time-picker-trigger time-picker-content time-picker-hours time-picker-minutes time-picker-period time-picker-now
Theming hooks
field-trigger

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape ArrowUp ArrowDown Home End PageUp PageDown
Focus
managed
  • The trigger input exposes aria-haspopup="dialog" and aria-expanded; hour and minute lists are role="listbox"/role="option" with roving tabindex, and an invalid typed value is announced through a role="alert" message.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="time-picker-{{ $record->id }}">
    <div class="w-full max-w-xs">
        <x-ui.time-picker name="meeting_time" :placeholder="__('Pick a time')" />
    </div>
</div>

Validation

Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:time-picker
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/time-picker.blade.php Blade
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware so this
     composite self-wires old()/aria-invalid/aria-describedby off the shared
     $errors bag. Inert with no field ancestor and no own `name` —
     fully backward-compatible. --}}
@aware([
    'name' => null,
    'hint' => null,
    'required' => false,
])

{{--
    Props:
    - min / max: 24-hour `HH:MM` bounds; options outside them are disabled and a
      typed time outside them is refused.
    - minute-step: the minute options (typing any minute is still accepted).
    - show-now: a Now button that picks the current time (rounded to the step).
    - use-12-hour: true (h:mm AM/PM) or false (HH:mm). Null, the default,
      follows the app locale: the locale's short time pattern through intl
      when the extension is loaded, else English outside GB/IE and a short
      list of 12-hour languages. The form value is always 24-hour HH:MM.
--}}
@props([
    'name' => null,
    'value' => '',
    'placeholder' => null,
    'use12Hour' => null,
    'minuteStep' => 5,
    'min' => null,
    'max' => null,
    'showNow' => true,
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $trigger = $styles['field-trigger'];
    $placeholder ??= __('Pick a time');

    if ($use12Hour === null) {
        $locale = str_replace('-', '_', (string) app()->getLocale());
        $pattern = class_exists(\IntlDateFormatter::class)
            ? (string) (\IntlDateFormatter::create($locale, \IntlDateFormatter::NONE, \IntlDateFormatter::SHORT)?->getPattern() ?? '')
            : '';

        if ($pattern !== '') {
            // h and K are the 12-hour fields; quoted text is a literal.
            $use12Hour = (bool) preg_match('/[hK]/', (string) preg_replace("/'[^']*'/", '', $pattern));
        } else {
            [$language, $region] = array_pad(explode('_', $locale, 2), 2, '');
            $use12Hour = ($language === 'en' && ! in_array(strtoupper($region), ['GB', 'IE'], true))
                || in_array($language, ['hi', 'ko', 'ar', 'ur', 'bn', 'fil'], true);
        }
    }
    $awareName = $name ?? null;
    $fieldName = $awareName ?? $attributes->get('name');

    $bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
    $hasBagError = $bag && filled($fieldName) && $bag->has($fieldName);

    // old() repopulates the selected time after a failed redirect-back.
    $resolvedValue = filled($fieldName) ? old($fieldName, $value) : $value;

    $describedBy = collect(preg_split('/\s+/', trim((string) $attributes->get('aria-describedby', ''))) ?: [])
        ->when(filled($awareName) && filled($hint ?? null), fn ($ids) => $ids->push($fieldName.'-description'))
        ->when(filled($awareName) && $hasBagError, fn ($ids) => $ids->push($fieldName.'-error'))
        ->filter()
        ->unique()
        ->implode(' ');
    $isRequired = (bool) ($required ?? false) || $attributes->has('required');
@endphp

{{--
    Time Picker. A text field you can type into ("9:30", "0930", "9:30 pm",
    "21:30" all parse on Enter or blur) with a clock button that opens a popover
    of hour / minute options (an AM/PM segment in 12-hour mode), column labels
    and a Now button. It owns the popover (open/close, Escape, click-outside,
    focus return), the visible label, and a hidden form value that is always
    normalized to 24-hour `HH:MM`.
--}}
<div
    x-data="uiTimePicker({ value: @js($resolvedValue), use12Hour: @js((bool) $use12Hour), minuteStep: @js((int) $minuteStep), min: @js($min), max: @js($max) })"
    {{-- Modelable: `x-model` (Alpine) and `wire:model` (Livewire binds through
         the same x-model contract) attach to the root. Inert without either. --}}
    x-modelable="model"
    data-slot="time-picker"
    @if ($hasBagError) data-invalid="true" @endif
    @keydown.escape="if (open) { $event.stopPropagation(); closeAndFocus() }"
    @click.outside="close()"
    {{ $attributes->except(['name', 'required', 'aria-required', 'aria-describedby'])->merge(['class' => 'relative w-full']) }}
>
    @if (filled($fieldName))
        <input type="hidden" name="{{ $fieldName }}" :value="value" />
    @endif

    {{-- The field: type a time, or open the picker with the clock button. --}}
    <div class="{{ $trigger['within'] }} h-control-h-md {{ $trigger['states'][$hasBagError ? 'invalid' : 'default'] }}" :class="typedInvalid ? 'border-destructive' : ''">
        <input
            type="text"
            inputmode="numeric"
            autocomplete="off"
            data-slot="time-picker-input"
            x-ref="trigger"
            x-model="typed"
            x-bind:aria-controls="$id('time-picker-content')"
            :aria-expanded="open"
            aria-haspopup="dialog"
            @if ($isRequired) aria-required="true" @endif
            @if ($hasBagError) aria-invalid="true" @endif
            x-bind:aria-invalid="typedInvalid ? 'true' : null"
            @if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
            placeholder="{{ $placeholder }}"
            @keydown.enter.prevent="commitTyped(); close()"
            @keydown.down.prevent="show('hour')"
            @blur="commitTyped()"
            class="h-full min-w-0 flex-1 bg-transparent px-control-x-md text-sm text-foreground outline-none placeholder:text-muted-foreground"
        />
        <button
            type="button"
            data-slot="time-picker-trigger"
            tabindex="-1"
            aria-label="{{ __('Open time picker') }}"
            @click="toggle(); $refs.trigger.focus()"
            class="inline-flex h-full w-9 shrink-0 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground motion-reduce:transition-none"
        >
            <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
                <circle cx="12" cy="12" r="9" />
                <path d="M12 7v5l3 2" />
            </svg>
        </button>
    </div>
    <p x-show="typedInvalid" x-cloak role="alert" class="mt-1 text-xs text-destructive">{{ __('Enter a time such as 9:30 or 21:30.') }}</p>

    <div
        data-slot="time-picker-content"
        x-bind:id="$id('time-picker-content')"
        role="dialog"
        aria-modal="false"
        aria-label="{{ __('Choose time') }}"
        x-show="open"
        x-cloak
        x-transition:enter="motion-safe:transition motion-safe:duration-100"
        x-transition:enter-start="opacity-0 scale-95"
        x-transition:enter-end="opacity-100 scale-100"
        class="absolute z-dropdown mt-1 w-64 max-w-none rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md motion-reduce:transition-none"
    >
        <div class="mb-1 flex gap-1 text-2xs font-medium uppercase tracking-wide text-muted-foreground" aria-hidden="true">
            <span class="flex-1 text-center">{{ __('Hour') }}</span>
            <span class="flex-1 text-center">{{ __('Min') }}</span>
            <span x-show="use12Hour" class="w-12"></span>
        </div>
        <div class="flex gap-1">
            {{-- Hours --}}
            <div
                role="listbox"
                tabindex="-1"
                aria-label="{{ __('Hour') }}"
                data-slot="time-picker-hours"
                x-ref="hours"
                @keydown="onListKey($event, 'hour')"
                class="h-48 flex-1 overflow-y-auto rounded-sm border border-border/60 p-1 [scrollbar-width:thin]"
            >
                <template x-for="h in hours" :key="h">
                    <button
                        type="button"
                        role="option"
                        x-bind:data-option-value="h"
                        :aria-selected="hour === h"
                        :aria-disabled="hourDisabled(h) ? 'true' : null"
                        :tabindex="optionTabIndex('hour', h)"
                        @click="!hourDisabled(h) && selectHour(h)"
                        x-text="padHour(h)"
                        class="flex w-full items-center justify-center rounded-sm px-2 py-2 text-sm tabular-nums outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring aria-[disabled=true]:cursor-not-allowed aria-[disabled=true]:text-muted-foreground/50 aria-[disabled=true]:hover:bg-transparent motion-reduce:transition-none"
                        :class="hour === h ? 'bg-primary text-primary-foreground' : 'text-foreground'"
                    ></button>
                </template>
            </div>

            {{-- Minutes --}}
            <div
                role="listbox"
                tabindex="-1"
                aria-label="{{ __('Minute') }}"
                data-slot="time-picker-minutes"
                x-ref="minutes"
                @keydown="onListKey($event, 'minute')"
                class="h-48 flex-1 overflow-y-auto rounded-sm border border-border/60 p-1 [scrollbar-width:thin]"
            >
                <template x-for="m in minutes" :key="m">
                    <button
                        type="button"
                        role="option"
                        x-bind:data-option-value="m"
                        :aria-selected="minute === m"
                        :aria-disabled="minuteDisabled(m) ? 'true' : null"
                        :tabindex="optionTabIndex('minute', m)"
                        @click="!minuteDisabled(m) && selectMinute(m)"
                        x-text="pad(m)"
                        class="flex w-full items-center justify-center rounded-sm px-2 py-2 text-sm tabular-nums outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring aria-[disabled=true]:cursor-not-allowed aria-[disabled=true]:text-muted-foreground/50 aria-[disabled=true]:hover:bg-transparent motion-reduce:transition-none"
                        :class="minute === m ? 'bg-primary text-primary-foreground' : 'text-foreground'"
                    ></button>
                </template>
            </div>

            {{-- AM / PM --}}
            <div
                x-show="use12Hour"
                role="group"
                aria-label="{{ __('Period') }}"
                data-slot="time-picker-period"
                class="flex w-12 flex-col gap-1"
            >
                <template x-for="p in periods" :key="p">
                    <button
                        type="button"
                        @click="selectPeriod(p)"
                        x-text="p"
                        :aria-pressed="period === p"
                        class="rounded-sm border border-input px-2 py-2 text-xs font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
                        :class="period === p ? 'bg-primary text-primary-foreground' : 'text-foreground'"
                    ></button>
                </template>
            </div>
        </div>
        @if ($showNow)
            <div class="mt-2 flex items-center justify-between gap-2 border-t border-border pt-2">
                <span class="min-w-0 truncate text-xs text-muted-foreground" x-text="label || @js(__('No time chosen'))"></span>
                <button type="button" data-slot="time-picker-now" @click="selectNow()" class="rounded-sm text-xs font-medium text-link underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">{{ __('Now') }}</button>
            </div>
        @endif
    </div>
</div>
resources/js/ui/time-picker.js JS
/**
 * Time Picker behavior.
 *
 * Owns the popover (open/close, Escape, click-outside, focus return), the
 * displayed label, and a hidden form value normalized to 24-hour `HH:MM`.
 * Hour/minute/period selections recompose the value; the label respects the
 * 12-/24-hour mode. Self-registers on `alpine:init` so import order does not
 * matter. No global listeners are added, so there is nothing to tear down.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiTimePicker', (config = {}) => ({
        open: false,
        use12Hour: config.use12Hour !== false,
        minuteStep: Math.min(60, Math.max(1, config.minuteStep || 5)),
        value: config.value || '',
        label: '',
        hour: null,
        minute: null,
        period: 'AM',

        min: config.min || '',
        max: config.max || '',
        typed: '',
        typedInvalid: false,

        init() {
            this.period = 'AM';
            if (this.value && /^\d{1,2}:\d{2}$/.test(this.value)) {
                this.parse(this.value);
                this.label = this.format();
                this.typed = this.label;
            }
            // Keep the field's text in step with picks from the popover.
            this.$watch('label', (label) => { this.typed = label; });
        },

        /**
         * Modelable value (`x-modelable="model"` on the root): the 24h `HH:MM`
         * or ''. An inbound value (Livewire, `x-model`) re-parses hour, minute
         * and period and refreshes the label.
         */
        get model() {
            return this.value;
        },
        set model(v) {
            const next = v == null ? '' : String(v);
            if (next === this.value) return;
            if (next === '') {
                this.value = '';
                this.hour = null;
                this.minute = null;
                this.label = '';
                return;
            }
            if (!/^\d{1,2}:\d{2}$/.test(next)) return;
            this.value = next;
            this.parse(next);
            this.label = this.format();
        },

        /** Minutes since midnight for a 24h `HH:MM`. */
        toMinutes(hhmm) {
            const [h, m] = hhmm.split(':').map(Number);
            return h * 60 + m;
        },

        /** True when a 24h `HH:MM` falls outside min/max. */
        outOfRange(hhmm) {
            const t = this.toMinutes(hhmm);
            return (this.min && t < this.toMinutes(this.min)) || (this.max && t > this.toMinutes(this.max));
        },

        h24For(hour, period = this.period) {
            if (!this.use12Hour) return hour;
            return (hour % 12) + (period === 'PM' ? 12 : 0);
        },

        /** An hour is disabled when none of its minutes fit the bounds. */
        hourDisabled(h) {
            if (!this.min && !this.max) return false;
            const h24 = this.h24For(h);
            return this.outOfRange(`${this.pad(h24)}:00`) && this.outOfRange(`${this.pad(h24)}:59`);
        },

        minuteDisabled(m) {
            if ((!this.min && !this.max) || this.hour === null) return false;
            return this.outOfRange(`${this.pad(this.h24For(this.hour))}:${this.pad(m)}`);
        },

        /**
         * Parse what was typed: "9", "9:30", "0930", "930", "9.30", "9:30 pm",
         * "21:30", "9pm". Returns 24h `HH:MM` or null.
         */
        parseTyped(text) {
            const raw = String(text).trim().toLowerCase();
            if (raw === '') return '';
            const match = raw.match(/^(\d{1,2})(?:[:.h]?(\d{2}))?\s*(am|pm|a|p)?$/);
            if (!match) return null;
            let hour = Number(match[1]);
            const minute = Number(match[2] ?? 0);
            const period = match[3] ? match[3][0] : null;
            if (minute > 59) return null;
            if (period) {
                if (hour < 1 || hour > 12) return null;
                hour = (hour % 12) + (period === 'p' ? 12 : 0);
            } else if (hour > 23) {
                return null;
            }
            return `${this.pad(hour)}:${this.pad(minute)}`;
        },

        /** Enter or blur on the field: adopt the typed time or flag it. */
        commitTyped() {
            const parsed = this.parseTyped(this.typed);
            if (parsed === null || (parsed && this.outOfRange(parsed))) {
                this.typedInvalid = true;
                return;
            }
            this.typedInvalid = false;
            if (parsed === '') {
                this.hour = null;
                this.minute = null;
                this.value = '';
                this.label = '';
                this.$dispatch('change', { value: '' });
                return;
            }
            if (parsed === this.value) {
                this.typed = this.label;
                return;
            }
            this.parse(parsed);
            this.commit();
        },

        /** The current time, rounded down to the minute step. */
        selectNow() {
            const now = new Date();
            const minute = Math.floor(now.getMinutes() / this.minuteStep) * this.minuteStep;
            this.parse(`${this.pad(now.getHours())}:${this.pad(minute)}`);
            this.commit();
            this.closeAndFocus();
        },

        get hours() {
            return this.use12Hour
                ? Array.from({ length: 12 }, (_, i) => i + 1)
                : Array.from({ length: 24 }, (_, i) => i);
        },
        get minutes() {
            const out = [];
            for (let m = 0; m < 60; m += this.minuteStep) {
                out.push(m);
            }
            return out;
        },
        get periods() {
            return ['AM', 'PM'];
        },

        pad(n) {
            return String(n).padStart(2, '0');
        },
        padHour(h) {
            return this.use12Hour ? String(h) : this.pad(h);
        },

        /** Parse an incoming 24h `HH:MM` into the local hour/minute/period state. */
        parse(iso) {
            const [hh, mm] = iso.split(':').map(Number);
            this.minute = mm;
            if (this.use12Hour) {
                this.period = hh >= 12 ? 'PM' : 'AM';
                this.hour = hh % 12 === 0 ? 12 : hh % 12;
            } else {
                this.hour = hh;
            }
        },

        /** Build the 24h value + the display label once hour & minute are chosen. */
        commit() {
            if (this.hour === null || this.minute === null) {
                return;
            }
            let h24 = this.hour;
            if (this.use12Hour) {
                h24 = this.hour % 12;
                if (this.period === 'PM') {
                    h24 += 12;
                }
            }
            this.value = `${this.pad(h24)}:${this.pad(this.minute)}`;
            this.label = this.format();
            this.typed = this.label;
            this.typedInvalid = false;
            this.$dispatch('change', { value: this.value });
        },

        format() {
            if (this.hour === null || this.minute === null) {
                return '';
            }
            return this.use12Hour
                ? `${this.hour}:${this.pad(this.minute)} ${this.period}`
                : `${this.pad(this.hour)}:${this.pad(this.minute)}`;
        },

        selectHour(h) {
            this.hour = h;
            this.commit();
        },
        selectMinute(m) {
            this.minute = m;
            this.commit();
        },
        selectPeriod(p) {
            this.period = p;
            this.commit();
        },

        toggle() {
            this.open ? this.close() : this.show();
        },
        show(focusColumn = null) {
            this.open = true;
            this.$nextTick(() => {
                // Bring the chosen hour and minute into view before anything else.
                for (const column of ['hour', 'minute']) {
                    const selected = this.selectedFor(column);
                    if (selected !== null) {
                        this.$refs[`${column}s`]?.querySelector(`[data-option-value="${selected}"]`)?.scrollIntoView({ block: 'center' });
                    }
                }
                if (focusColumn) this.focusSelected(focusColumn);
            });
        },
        close() {
            this.open = false;
        },
        closeAndFocus() {
            if (!this.open) {
                return;
            }
            this.open = false;
            this.$nextTick(() => this.$refs.trigger?.focus());
        },

        valuesFor(column) {
            return column === 'hour' ? this.hours : this.minutes;
        },
        selectedFor(column) {
            return column === 'hour' ? this.hour : this.minute;
        },
        optionTabIndex(column, value) {
            const selected = this.selectedFor(column);
            const first = this.valuesFor(column)[0];
            return (selected === null ? value === first : value === selected) ? 0 : -1;
        },
        focusSelected(column) {
            const selected = this.selectedFor(column) ?? this.valuesFor(column)[0];
            this.$refs[`${column}s`]?.querySelector(`[data-option-value="${selected}"]`)?.focus();
        },
        onListKey(event, column) {
            const values = this.valuesFor(column);
            const current = Number(event.target.dataset.optionValue);
            const index = Math.max(0, values.indexOf(current));
            let next = null;
            switch (event.key) {
                case 'ArrowDown':
                    next = (index + 1) % values.length;
                    break;
                case 'ArrowUp':
                    next = (index - 1 + values.length) % values.length;
                    break;
                case 'Home':
                    next = 0;
                    break;
                case 'End':
                    next = values.length - 1;
                    break;
                case 'PageDown':
                    next = Math.min(values.length - 1, index + 5);
                    break;
                case 'PageUp':
                    next = Math.max(0, index - 5);
                    break;
                default:
                    return;
            }
            event.preventDefault();
            const value = values[next];
            if (column === 'hour') {
                this.selectHour(value);
            } else {
                this.selectMinute(value);
            }
            this.$nextTick(() => this.focusSelected(column));
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.4.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0