Time picker
A time field in parts — hour, minutes and the period where the clock has one — that types and spins, with a list of slots at a step.
A time is stored as "HH:mm" on the 24-hour clock, so two times compare as strings and travel through a form or an address unchanged; the field shows it on the clock of hourCycle, or of locale ("en-US" unless told — never the runtime's own, so the server and the browser draw the same field). Each part is a spinbutton — Hour, Minutes, and AM/PM on a 12-hour clock — in one group named by your aria-label or aria-labelledby. Typing moves on after two digits or after one no second could follow (a 9 in the hour, a 3 on a 24-hour clock), and a digit that could still take a second one waits, drawn but not yet the value, so onValueChange hears 09:30 and never 09:03 on the way; leaving the part, or Enter, takes it as it stands. The arrows spin one part and never carry into the next — the minutes by step, landing on its grid first — Home and End go to a part's ends, Backspace or Delete clears it (the period too: a cleared period leaves the field holding no time until one is typed, where a period never typed reads as the morning), and a whole time pasted into any part ("9.30pm", "0930") is read with parseTime. ArrowLeft and ArrowRight walk the parts in the order they are drawn, which is left to right on a right-to-left page too. A time outside min and max marks the parts aria-invalid; say why in the text your aria-describedby points at. slots adds a clock button at the end that opens the times as a listbox on the one already chosen: an array of your own, or true for every step minutes (every 30 when the step is 1) from min to max. name posts the value from a hidden input. Controlled by the presence of the value key, not by its contents: value={undefined} is a time not set, so it stays controlled — what is typed or picked is reported and your value is still what shows. The arithmetic lives in time-picker/time with no React — parseTime, formatTime (which writes its own colon and space, so no ICU literal reaches a hydrated page), timeSlots, stepSegment, timeToMinutes, minutesToTime and localeHourCycle — and a server action reads a posted time with it; how the parts spin, clear and take a digit is time-picker/segments, as plain. No time zones: the field answers "when in the day", and the day is another field's.
Install
npx shadcn@latest add @vibra/time-pickerNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { TimePicker, formatTime, type TimeValue } from "@/components/ui/time-picker"
export default function TimePickerDemo() {
const id = React.useId()
const [standUp, setStandUp] = React.useState<TimeValue | undefined>("09:30")
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<div className="flex flex-col gap-2">
<span id={`${id}-standup`} className="text-sm leading-none font-medium">
Stand-up starts
</span>
<TimePicker
aria-labelledby={`${id}-standup`}
aria-describedby={`${id}-standup-hint`}
value={standUp}
onValueChange={setStandUp}
/>
<p id={`${id}-standup-hint`} className="text-xs text-muted-foreground">
Type 930 or 9.30pm, or spin a part with the arrow keys.
</p>
<p role="status" className="text-sm text-muted-foreground">
{standUp ? `Every weekday at ${formatTime(standUp, { hourCycle: "h12" })}` : "No time set"}
</p>
</div>
<div className="flex flex-col gap-2">
<span id={`${id}-depot`} className="text-sm leading-none font-medium">
Depot opens (24-hour clock)
</span>
<TimePicker aria-labelledby={`${id}-depot`} locale="en-GB" defaultValue="06:45" />
</div>
</div>
)
}With slots
Opening hours in 15-minute slots from the clock button; typing a time still works.
"use client"
import * as React from "react"
import { TimePicker } from "@/components/ui/time-picker"
export default function TimePickerSlots() {
const id = React.useId()
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<span id={`${id}-label`} className="text-sm leading-none font-medium">
Appointment time
</span>
<TimePicker
aria-labelledby={`${id}-label`}
aria-describedby={`${id}-hint`}
name="appointment-time"
defaultValue="10:15"
min="09:00"
max="17:00"
step={15}
slots
/>
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
Open 9:00 AM to 5:00 PM, in 15-minute slots. Pick one from the clock, or type a time.
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | undefined | — | The time, "HH:mm" on the 24-hour clock. Passing the key at all — even as undefined — makes the field controlled. |
| defaultValue | string | — | Where a field that holds its own time starts. |
| onValueChange | (value: string | undefined) => void | — | Called with each whole time, and with undefined once a part is cleared. |
| hourCycle | "h12" | "h23" | the locale's | Twelve hours with AM/PM, or twenty-four. |
| locale | string | "en-US" | Picks the hour cycle when none is given, and the words for AM and PM. |
| step | number | 1 | Minutes the arrows move the minutes by, and the gap between slots. |
| min | string | — | The earliest time that is valid, "HH:mm"; earlier marks the parts invalid, and slots start here. |
| max | string | — | The latest time that is valid, "HH:mm"; later marks the parts invalid, and slots end here. |
| slots | string[] | boolean | — | A clock button that opens times to pick from: your own list, or true for every step from min to max. |
| name | string | — | Posts the value with a form, from a hidden input. |
| disabled | boolean | false | Dims the field; nothing in it takes the focus. |
| invalid | boolean | false | Marks the parts aria-invalid whatever the time, for an error your form found. |
| aria-label | string | — | Names the group the parts sit in, e.g. Starts. |
| aria-labelledby | string | — | Names the group by a visible label's id. |
| aria-describedby | string | — | Describes the group: the hours that are open, or why a time is refused. |
| className | string | — | Merged onto the root; the rest of the div's props are spread onto it. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { ClockIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
PART_NAMES,
clearPart,
jumpPart,
partRange,
partsOf,
spinPart,
typeDigitInto,
valueOf,
type Part,
type Parts,
} from "@/components/ui/time-picker/segments"
import { TimeSlots } from "@/components/ui/time-picker/slots"
import {
formatTime,
localeHourCycle,
minutesToTime,
parseTime,
stepSegment,
timeSlots,
timeToMinutes,
type TimeValue,
} from "@/components/ui/time-picker/time"
// One import builds the field and reads what it holds. A server module reads
// a posted time from time-picker/time, which has no client boundary.
export { formatTime, localeHourCycle, minutesToTime, parseTime, stepSegment, timeSlots, timeToMinutes, type TimeValue }
export type TimePickerProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
value?: TimeValue | undefined
defaultValue?: TimeValue
onValueChange?: (value: TimeValue | undefined) => void
hourCycle?: "h12" | "h23"
locale?: string
step?: number
min?: TimeValue
max?: TimeValue
slots?: TimeValue[] | boolean
name?: string
disabled?: boolean
invalid?: boolean
"aria-label"?: string
"aria-labelledby"?: string
"aria-describedby"?: string
}
type Typed = { part: Part; text: string }
const pad = (value: number) => String(value).padStart(2, "0")
/**
* A time field in parts — hour, minutes and, on a 12-hour clock, the period —
* that types and spins like the native one, with a list of slots beside it
* when it is given some. Controlled by the presence of the `value` key. How
* the parts spin, clear and take a digit is segments.ts; this holds the
* state and the focus.
*/
function TimePicker(props: TimePickerProps) {
const {
value,
defaultValue,
onValueChange,
hourCycle,
locale = "en-US",
step = 1,
min,
max,
slots,
name,
disabled = false,
invalid = false,
className,
...rest
} = props
const cycle = React.useMemo(() => hourCycle ?? localeHourCycle(locale), [hourCycle, locale])
const order: Part[] = cycle === "h12" ? ["hour", "minute", "period"] : ["hour", "minute"]
const size = Math.max(1, Math.round(step))
// The key, not its contents: undefined is a time's own "not set", so
// value={undefined} stays controlled (CONVENTIONS, controlled by presence).
const controlled = "value" in props
const [internal, setInternal] = React.useState<TimeValue | undefined>(defaultValue)
const current = controlled ? value : internal
// Parts that spell no whole time yet are the field's own draft; once they
// do, the field shows the value again. The time it last reported tells a
// caller's echo, which keeps a draft, from a change of its own, which
// replaces it.
const [draft, setDraft] = React.useState<Parts | null>(null)
const [reported, setReported] = React.useState<{ value: TimeValue | undefined } | null>(null)
const [seen, setSeen] = React.useState(current)
if (current !== seen) {
setSeen(current)
if (reported?.value !== current) setDraft(null)
}
// A digit that a second one could still extend — "2" on a 24-hour clock,
// "3" in the minutes — waits, drawn but not yet the value, so no caller
// hears 09:03 on the way to 09:30. The ref is what handlers read; the state
// is what the field draws.
const [typed, setTyped] = React.useState<Typed | null>(null)
const pending = React.useRef<Typed | null>(null)
const setPending = (next: Typed | null) => {
pending.current = next
setTyped(next)
}
const hourRef = React.useRef<HTMLInputElement>(null)
const minuteRef = React.useRef<HTMLInputElement>(null)
const periodRef = React.useRef<HTMLInputElement>(null)
const refs = { hour: hourRef, minute: minuteRef, period: periodRef }
const shown = draft ?? partsOf(current, cycle)
const outOfRange = current !== undefined && ((min !== undefined && current < min) || (max !== undefined && current > max))
const isInvalid = invalid || outOfRange
// The period's words come from the locale through formatTime, which always
// writes "12:00 " ahead of them.
const periodLabel = (period: "am" | "pm") =>
formatTime(period === "am" ? "00:00" : "12:00", { hourCycle: "h12", locale }).slice("12:00 ".length)
function update(next: Parts) {
const nextValue = valueOf(next, cycle)
setDraft(nextValue === undefined ? next : null)
if (nextValue === current) return
if (!controlled) setInternal(nextValue)
setReported({ value: nextValue })
onValueChange?.(nextValue)
}
function focusPart(part: Part | undefined) {
if (part) refs[part].current?.focus()
}
const neighbour = (part: Part, offset: 1 | -1) => order[order.indexOf(part) + offset]
/** The parts with a waiting digit taken as it stands, when it makes a number in range. */
function settled(): Parts {
const entry = pending.current
if (!entry) return shown
const number = Number(entry.text)
const [low, high] = partRange(entry.part, cycle)
return number >= low && number <= high ? { ...shown, [entry.part]: number } : shown
}
/** Takes a waiting digit as the part's value: when focus leaves it, or on Enter. */
function settle() {
if (!pending.current) return
const next = settled()
setPending(null)
update(next)
}
function typeChar(part: Part, char: string) {
if (part === "period") {
if (/^[ap]$/i.test(char)) update({ ...shown, period: char.toLowerCase() === "a" ? "am" : "pm" })
return
}
if (!/^\d$/.test(char)) return
const entry = typeDigitInto(pending.current?.part === part ? pending.current.text : "", char, partRange(part, cycle))
if (!entry.done) return setPending({ part, text: entry.text })
setPending(null)
if (entry.number !== null) update({ ...shown, [part]: entry.number })
focusPart(neighbour(part, 1))
}
/** Moves to a new set of parts made from the settled ones, dropping a waiting digit. */
function move(make: (parts: Parts) => Parts, from: Parts = shown) {
setPending(null)
update(make(from))
}
function onKeyDown(part: Part, event: React.KeyboardEvent<HTMLInputElement>) {
const { key } = event
if (event.altKey || event.ctrlKey || event.metaKey) return
// Enter keeps its default — a form still submits — with the waiting digit taken first.
if (key === "Enter") return settle()
if (key === "ArrowUp" || key === "ArrowDown") move((parts) => spinPart(parts, part, key === "ArrowUp" ? 1 : -1, cycle, size), settled())
// The parts are drawn left to right on every page, so the keys follow the
// drawing; leaving a part settles what was typed in it (onBlur).
else if (key === "ArrowRight" || key === "ArrowLeft") focusPart(neighbour(part, key === "ArrowRight" ? 1 : -1))
else if (key === "Backspace" || key === "Delete") move((parts) => clearPart(parts, part))
else if (key === "Home" || key === "End") move((parts) => jumpPart(parts, part, key === "Home" ? "first" : "last", cycle, size))
else if (key.length === 1) typeChar(part, key)
else return
event.preventDefault()
}
// A phone's keyboard may type without key events; what it typed arrives here.
function onInput(part: Part, event: React.ChangeEvent<HTMLInputElement>) {
const input = event.nativeEvent as InputEvent
if (input.inputType?.startsWith("delete")) move((parts) => clearPart(parts, part))
else for (const char of input.data ?? "") typeChar(part, char)
}
// A whole time pasted into any part — "9.30pm", "0930" — is read as one.
function onPaste(event: React.ClipboardEvent<HTMLInputElement>) {
event.preventDefault()
const pasted = parseTime(event.clipboardData.getData("text"))
if (pasted) move(() => partsOf(pasted, cycle))
}
/** What one part holds and draws: its number, the text in the field, and the words a screen reader hears. */
function display(part: Part) {
const period = shown.period === "am" || shown.period === "pm" ? shown.period : null
const number = part === "period" ? (period === null ? null : period === "pm" ? 1 : 0) : shown[part]
const words = number === null ? null : part === "period" ? periodLabel(period!) : String(number)
const text = typed?.part === part ? typed.text.padStart(2, "0") : number === null ? "" : part === "period" ? words! : pad(number)
return { number, text, words: words ?? "Empty" }
}
const slotList =
slots === true
? timeSlots({ start: min ?? "00:00", end: "24:00", step: size > 1 ? size : 30 }).filter((slot) => max === undefined || slot <= max)
: Array.isArray(slots)
? slots
: null
return (
<div
role="group"
data-slot="time-picker"
data-hour-cycle={cycle}
data-invalid={isInvalid || undefined}
data-disabled={disabled || undefined}
className={cn(
"inline-flex h-8 w-fit min-w-0 items-center gap-1 rounded-lg border border-input bg-card ps-2 pe-1 transition-colors has-[input:focus-visible]:focus-outline data-disabled:opacity-50 data-invalid:border-destructive",
!slotList && "pe-2",
className
)}
{...rest}
>
{/* A time is written left to right on a right-to-left page too. */}
<div dir="ltr" data-slot="time-picker-segments" className="flex items-center">
{order.map((part) => {
const { number, text, words } = display(part)
const [low, high] = partRange(part, cycle)
return (
<React.Fragment key={part}>
{part === "minute" ? (
<span aria-hidden="true" className="px-px text-sm text-muted-foreground">
:
</span>
) : null}
<input
ref={refs[part]}
type="text"
role="spinbutton"
inputMode={part === "period" ? "text" : "numeric"}
autoComplete="off"
spellCheck={false}
data-slot="time-picker-segment"
data-segment={part}
aria-label={PART_NAMES[part]}
aria-valuemin={low}
aria-valuemax={high}
aria-valuenow={number ?? undefined}
aria-valuetext={words}
aria-invalid={isInvalid || undefined}
disabled={disabled}
placeholder="--"
value={text}
onKeyDown={(event) => onKeyDown(part, event)}
onChange={(event) => onInput(part, event)}
onPaste={onPaste}
onBlur={settle}
className={cn(
"h-6 rounded-sm bg-transparent p-0 text-center text-sm tabular-nums caret-transparent outline-hidden selection:bg-transparent placeholder:text-muted-foreground focus:bg-muted focus:text-foreground disabled:cursor-not-allowed",
part === "period" ? "ms-1 w-8" : "w-6"
)}
/>
</React.Fragment>
)
})}
</div>
{slotList ? (
<TimeSlots
slots={slotList}
current={current}
label={(slot) => formatTime(slot, { hourCycle: cycle, locale })}
disabled={disabled}
onPick={(slot) => move(() => partsOf(slot, cycle))}
/>
) : (
<ClockIcon aria-hidden="true" className="ms-1 size-4 shrink-0 text-muted-foreground" />
)}
{name ? <input type="hidden" name={name} value={current ?? ""} disabled={disabled} /> : null}
</div>
)
}
export { TimePicker }import { minutesToTime, stepSegment, timeToMinutes, type TimeValue } from "@/components/ui/time-picker/time"
// The time field's parts as data: what each holds, how it spins, clears and
// takes a typed digit. No React here; the field keeps the state and the focus.
export type HourCycle = "h12" | "h23"
export type Part = "hour" | "minute" | "period"
/**
* What the parts hold, the hour on the field's own clock. A period of null was
* never typed and reads as the morning, so 9, 3, 0 makes 09:30; "cleared" was
* emptied on purpose, and the field holds no time until a period is typed.
*/
export type Parts = { hour: number | null; minute: number | null; period: "am" | "pm" | "cleared" | null }
export const EMPTY_PARTS: Parts = { hour: null, minute: null, period: null }
/** The name each part's spinbutton is read out by. */
export const PART_NAMES: Record<Part, string> = { hour: "Hour", minute: "Minutes", period: "AM/PM" }
const pad = (value: number) => String(value).padStart(2, "0")
/** A stored time as the parts show it. */
export function partsOf(value: TimeValue | undefined, cycle: HourCycle): Parts {
if (!value) return EMPTY_PARTS
const minutes = timeToMinutes(value)
const hour = Math.floor(minutes / 60)
if (cycle === "h23") return { hour, minute: minutes % 60, period: null }
return { hour: hour % 12 || 12, minute: minutes % 60, period: hour >= 12 ? "pm" : "am" }
}
/** The time the parts spell, or undefined while the hour or the minutes are empty or the period was cleared. */
export function valueOf(parts: Parts, cycle: HourCycle): TimeValue | undefined {
if (parts.hour === null || parts.minute === null || parts.period === "cleared") return undefined
const hour = cycle === "h23" ? parts.hour : (parts.hour % 12) + (parts.period === "pm" ? 12 : 0)
return `${pad(hour)}:${pad(parts.minute)}`
}
/** The values a part runs between: 1–12 or 0–23 for the hour, 0–59 for the minutes, 0–1 for the period. */
export function partRange(part: Part, cycle: HourCycle): [number, number] {
if (part === "hour") return cycle === "h12" ? [1, 12] : [0, 23]
return part === "minute" ? [0, 59] : [0, 1]
}
/** The last minute on the step's grid: 45 for a quarter of an hour, 56 for seven minutes. */
const lastMinute = (step: number) => Math.floor(59 / step) * step
/**
* One part moved by one: each wraps inside itself and never carries into the
* next — 11 on a 12-hour clock goes up to 12 in the same half of the day, the
* minutes move by `step`, landing on its grid first — and an empty part starts
* at its first value going up and its last going down.
*/
export function spinPart(parts: Parts, part: Part, delta: 1 | -1, cycle: HourCycle, step: number): Parts {
if (part === "period") {
const period = parts.period === "am" ? "pm" : parts.period === "pm" ? "am" : delta > 0 ? "am" : "pm"
return { ...parts, period }
}
if (part === "hour") {
const twelve = cycle === "h12"
const hour =
parts.hour === null
? delta > 0 ? (twelve ? 12 : 0) : twelve ? 11 : 23
: twelve ? ((parts.hour + delta + 11) % 12) + 1 : (parts.hour + delta + 24) % 24
return { ...parts, hour }
}
const minute =
parts.minute === null
? delta > 0 ? 0 : lastMinute(step)
: timeToMinutes(stepSegment(minutesToTime(parts.minute), "minute", delta, step))
return { ...parts, minute }
}
/** A part sent to its first or last value: Home and End. */
export function jumpPart(parts: Parts, part: Part, end: "first" | "last", cycle: HourCycle, step: number): Parts {
if (part === "period") return { ...parts, period: end === "first" ? "am" : "pm" }
const [low, high] = partRange(part, cycle)
return { ...parts, [part]: end === "first" ? low : part === "minute" ? lastMinute(step) : high }
}
/** A part emptied: the period is marked cleared, so it stops reading as the morning; a field emptied of every part is as good as new. */
export function clearPart(parts: Parts, part: Part): Parts {
const next: Parts = part === "period" ? { ...parts, period: "cleared" } : { ...parts, [part]: null }
return next.hour === null && next.minute === null && (next.period === null || next.period === "cleared") ? EMPTY_PARTS : next
}
/**
* A digit typed into a part after what was typed there already: the two run
* on while they make a number in range, and start over from the new digit when
* they do not. The part is done after two digits, or after one no second could
* follow (a 9 in the hour); `number` is null while the digits make no value
* the part can hold (a 0 in a 12-hour hour).
*/
export function typeDigitInto(typed: string, digit: string, [low, high]: [number, number]): { text: string; number: number | null; done: boolean } {
let text = typed + digit
if (Number(text) > high) text = digit
const value = Number(text)
return { text, number: value >= low && value <= high ? value : null, done: text.length === 2 || value * 10 > high }
}"use client"
import * as React from "react"
import { ClockIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Command, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import type { TimeValue } from "@/components/ui/time-picker/time"
/** The clock button at the end of a time field and the list of times it opens, on the one already chosen. */
export function TimeSlots({
slots,
current,
label,
disabled,
onPick,
}: {
slots: TimeValue[]
current: TimeValue | undefined
label: (slot: TimeValue) => string
disabled: boolean
onPick: (slot: TimeValue) => void
}) {
const [open, setOpen] = React.useState(false)
const panelRef = React.useRef<HTMLDivElement>(null)
// The list opens on the time already chosen, scrolled to it.
React.useEffect(() => {
if (!open) return
const frame = requestAnimationFrame(() =>
panelRef.current?.querySelector('[cmdk-item][aria-selected="true"]')?.scrollIntoView?.({ block: "nearest" })
)
return () => cancelAnimationFrame(frame)
}, [open])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Pick a time"
disabled={disabled}
className="text-muted-foreground"
>
<ClockIcon />
</Button>
}
/>
{/* COUPLED TO registry/vibra/ui/popover.tsx: the list pads itself, so
the panel's p-2.5 and w-72 come off. */}
<PopoverContent
ref={panelRef}
align="end"
aria-label="Pick a time"
// The list itself takes the focus, so the arrow keys move through the
// times straight away and Enter picks one.
initialFocus={() => panelRef.current?.querySelector<HTMLElement>("[cmdk-list]") ?? true}
className="w-40 gap-0 p-0"
>
<Command defaultValue={current} loop>
<CommandList label="Times" className="max-h-64">
{slots.map((slot) => (
<CommandItem
key={slot}
value={slot}
// CommandItem draws its own check for a checked row.
data-checked={slot === current || undefined}
onSelect={() => {
onPick(slot)
setOpen(false)
}}
>
<span className="tabular-nums">{label(slot)}</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}/**
* A time of day as the kit stores it: "HH:mm" on the 24-hour clock, zero-padded,
* so two values compare as strings ("09:30" < "14:00") and travel through a form
* or an address unchanged. No date and no zone — a time picker answers "when in
* the day", and the day is someone else's field.
*/
export type TimeValue = string
const MINUTES_PER_DAY = 24 * 60
const pad = (value: number) => String(value).padStart(2, "0")
/**
* Reads a time the way a person types one: "9", "930", "0930", "9:30", "9.30",
* with or without "am", "pm", "a.m." or "p.m.". A period makes the hour 1–12;
* without one the hour is 0–23. Anything else — "24:00", "9:60", "13 pm", a word,
* nothing — is null rather than a guess.
*/
export function parseTime(text: string): TimeValue | null {
const match = text.trim().match(/^(\d{1,2})(?:[:.]?(\d{2}))?\s*(a\.?m\.?|p\.?m\.?)?$/i)
if (!match) return null
const [, hourText, minuteText = "00", period] = match
let hour = Number(hourText)
const minute = Number(minuteText)
if (minute > 59) return null
if (period) {
if (hour < 1 || hour > 12) return null
hour = (hour % 12) + (period[0].toLowerCase() === "p" ? 12 : 0)
} else if (hour > 23) {
return null
}
return `${pad(hour)}:${pad(minute)}`
}
/** "13:45" → 825. "24:00" reads as 1440, so it can close a day of slots. */
export function timeToMinutes(value: TimeValue): number {
const [hour, minute] = value.split(":").map(Number)
return hour * 60 + minute
}
/** 825 → "13:45", wrapping round the day: 1445 is "00:05" and -15 is "23:45". */
export function minutesToTime(minutes: number): TimeValue {
const inDay = ((Math.round(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY
return `${pad(Math.floor(inDay / 60))}:${pad(inDay % 60)}`
}
// One formatter per locale: only its day period is read, never its literals.
const periodFormatters = new Map<string, Intl.DateTimeFormat>()
/** The words a locale writes for the morning and the afternoon, e.g. "AM" and "PM". */
function dayPeriod(hour: number, locale: string): string {
let formatter = periodFormatters.get(locale)
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale, { hour: "numeric", hour12: true, timeZone: "UTC" })
periodFormatters.set(locale, formatter)
}
const parts = formatter.formatToParts(new Date(Date.UTC(2000, 0, 1, hour)))
return parts.find((part) => part.type === "dayPeriod")?.value ?? (hour < 12 ? "AM" : "PM")
}
/**
* Writes a time for a reader: "9:30 PM" on the 12-hour clock, "21:30" on the
* 24-hour one. Only the period's words come from Intl; the colon and the space
* are written here, so the server and the browser print the same string
* whatever their ICU joins the parts with (CONVENTIONS, block rule 13).
*/
export function formatTime(value: TimeValue, options: { hourCycle: "h12" | "h23"; locale?: string }): string {
const minutes = timeToMinutes(value)
const hour = Math.floor(minutes / 60) % 24
const minute = pad(minutes % 60)
if (options.hourCycle === "h23") return `${pad(hour)}:${minute}`
return `${hour % 12 || 12}:${minute} ${dayPeriod(hour, options.locale ?? "en-US")}`
}
/**
* The times from `start` up to, but not including, `end`, every `step` minutes:
* a booking grid's slots. "24:00" as the end runs the slots to midnight.
*/
export function timeSlots(options: { start: TimeValue; end: TimeValue; step: number }): TimeValue[] {
const step = Math.max(1, Math.round(options.step))
const end = timeToMinutes(options.end)
const slots: TimeValue[] = []
for (let minutes = timeToMinutes(options.start); minutes < end; minutes += step) slots.push(minutesToTime(minutes))
return slots
}
/**
* Moves one part of a time and leaves the others: the hour wraps from 23 to 00
* without touching the minutes, the minutes wrap from 45 to 00 without touching
* the hour, and the period swaps morning and afternoon. Minutes off the `step`
* grid land on it first — 09:07 goes up to 09:15 and down to 09:00.
*/
export function stepSegment(value: TimeValue, segment: "hour" | "minute" | "period", delta: 1 | -1, step = 1): TimeValue {
const total = timeToMinutes(value)
// A whole hour or half a day either way is a whole number of hours, so the
// wrap round midnight keeps the minutes as they were.
if (segment === "period") return minutesToTime(total + 12 * 60)
if (segment === "hour") return minutesToTime(total + delta * 60)
const hour = Math.floor(total / 60)
const minute = total % 60
const size = Math.max(1, Math.round(step))
const next =
minute % size === 0
? minute + delta * size
: delta > 0
? Math.ceil(minute / size) * size
: Math.floor(minute / size) * size
return minutesToTime(hour * 60 + (((next % 60) + 60) % 60))
}
/**
* Whether a locale reads the clock in twelve hours or twenty-four. "en-US" —
* the kit's default, never the runtime's own — so a page rendered on the server
* draws the same field the browser hydrates.
*/
export function localeHourCycle(locale = "en-US"): "h12" | "h23" {
const resolved = new Intl.DateTimeFormat(locale, { hour: "numeric" }).resolvedOptions()
if (resolved.hourCycle) return resolved.hourCycle === "h11" || resolved.hourCycle === "h12" ? "h12" : "h23"
return resolved.hour12 ? "h12" : "h23"
}