Skip to contentVibraUI
Inputs & filters

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-picker

Needs the @vibra registry in your components.json — set it up once.

Examples

With slots

Opening hours in 15-minute slots from the clock button; typing a time still works.

Props

PropTypeDefaultDescription
valuestring | undefined—The time, "HH:mm" on the 24-hour clock. Passing the key at all — even as undefined — makes the field controlled.
defaultValuestring—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'sTwelve hours with AM/PM, or twenty-four.
localestring"en-US"Picks the hour cycle when none is given, and the words for AM and PM.
stepnumber1Minutes the arrows move the minutes by, and the gap between slots.
minstring—The earliest time that is valid, "HH:mm"; earlier marks the parts invalid, and slots start here.
maxstring—The latest time that is valid, "HH:mm"; later marks the parts invalid, and slots end here.
slotsstring[] | boolean—A clock button that opens times to pick from: your own list, or true for every step from min to max.
namestring—Posts the value with a form, from a hidden input.
disabledbooleanfalseDims the field; nothing in it takes the focus.
invalidbooleanfalseMarks the parts aria-invalid whatever the time, for an error your form found.
aria-labelstring—Names the group the parts sit in, e.g. Starts.
aria-labelledbystring—Names the group by a visible label's id.
aria-describedbystring—Describes the group: the hours that are open, or why a time is refused.
classNamestring—Merged onto the root; the rest of the div's props are spread onto it.

Dependencies

Source

components/ui/time-picker/index.tsx
"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 }
components/ui/time-picker/segments.ts
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 }
}
components/ui/time-picker/slots.tsx
"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>
  )
}
components/ui/time-picker/time.ts
/**
 * 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"
}