'use client' import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react' import { cn } from '../../lib/cn' export interface TimeSlotPickerSlot { /** 24-hour `HH:mm`, in the timezone named by `timeZoneLabel`. */ time: string /** Taken or blocked slots stay visible, struck through, so the gaps make sense. */ available?: boolean } export interface TimeSlotPickerDay { /** ISO `yyyy-mm-dd`. */ date: string slots: TimeSlotPickerSlot[] } export interface TimeSlotPickerProps { /** Days in the strip, in order, each with its slots. */ days: TimeSlotPickerDay[] /** Controlled choice as `yyyy-mm-ddTHH:mm`, or empty. */ value?: string /** Starting choice when uncontrolled. */ defaultValue?: string /** Called with `yyyy-mm-ddTHH:mm` when a slot is chosen. */ onValueChange?: (value: string) => void /** Timezone the times are shown in, printed under the grid. Defaults to the browser's. */ timeZoneLabel?: string /** Slots per row. */ columns?: 2 | 3 | 4 /** BCP 47 locale for day and time formats. */ locale?: string /** Accessible name for the whole picker. */ label?: string /** Merged last, so it wins. */ className?: string } const toDate = (date: string, time = '00:00') => { const [y, m, d] = date.split('-').map(Number) const [h, min] = time.split(':').map(Number) return new Date(y, m - 1, d, h, min) } const COLS = { 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4' } /** * A booking slot picker: a strip of days, then that day's times. Two radio * groups, because they are two answers — the day only narrows the grid, the * time is the value. * * Unavailable slots stay in the grid, struck through and skipped by the arrow * keys, so a morning with one opening reads as a busy morning rather than a * short one. The timezone is always printed: a time without one is how people * turn up an hour late. */ export function TimeSlotPicker({ days, value, defaultValue = '', onValueChange, timeZoneLabel, columns = 3, locale = 'en-US', label = 'Choose a time', className, }: TimeSlotPickerProps) { const uid = useId() const [uncontrolled, setUncontrolled] = useState(defaultValue) const current = value ?? uncontrolled const [chosenDate, chosenTime] = current.split('T') const [dayIndex, setDayIndex] = useState(() => Math.max(0, days.findIndex((day) => day.date === chosenDate))) // A controlled value on another day brings that day into view. useEffect(() => { const index = days.findIndex((entry) => entry.date === chosenDate) if (index >= 0) setDayIndex(index) // eslint-disable-next-line react-hooks/exhaustive-deps }, [chosenDate]) const day = days[Math.min(dayIndex, days.length - 1)] const dayRefs = useRef<(HTMLButtonElement | null)[]>([]) const slotRefs = useRef<(HTMLButtonElement | null)[]>([]) const zone = timeZoneLabel ?? Intl.DateTimeFormat().resolvedOptions().timeZone const open = (slots: TimeSlotPickerSlot[]) => slots.filter((slot) => slot.available !== false).length const longDay = (date: string) => toDate(date).toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' }) const choose = (time: string) => { const next = `${day.date}T${time}` if (value === undefined) setUncontrolled(next) onValueChange?.(next) } /** Arrow movement shared by both groups; `step` maps a key to an index delta. */ const rove = (event: KeyboardEvent, index: number, count: number, step: Record, usable: (i: number) => boolean, act: (i: number) => void) => { const delta = step[event.key] if (delta === undefined) return event.preventDefault() for (let offset = 1; offset <= count; offset += 1) { const next = (((index + delta * offset) % count) + count) % count if (usable(next)) return act(next) } } const slots = day?.slots ?? [] const checkedSlot = chosenDate === day?.date ? slots.findIndex((slot) => slot.time === chosenTime) : -1 const slotStop = checkedSlot >= 0 ? checkedSlot : slots.findIndex((slot) => slot.available !== false) const time = (t: string) => toDate(day.date, t).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }) return (
{days.map((entry, index) => { const selected = index === dayIndex const date = toDate(entry.date) const count = open(entry.slots) return ( ) })}
{day && (
{longDay(day.date)} {slots.length === 0 || open(slots) === 0 ? (

No times left on this day. Try another.

) : (
{slots.map((slot, index) => { const unavailable = slot.available === false const checked = index === checkedSlot return ( ) })}
)}
)}

Times shown in {zone.replace(/_/g, ' ')}

) }