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.
Preview
Enter a time such as 9:30 or 21:30.
<div class="w-full max-w-xs">
<x-ui.time-picker name="meeting_time" :placeholder="__('Pick a time')" />
</div>
Installation
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:
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.
-
resources/views/components/ui/time-picker.blade.php -
resources/js/ui/time-picker.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
<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.
{{-- 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>
/**
* 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