import { Button, buttonVariants } from "@/components/ui/button"; import type { CalendarProps } from "@/components/ui/calendar"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { add, format } from "date-fns"; import { type Locale, enUS } from "date-fns/locale"; import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react"; import { Clock } from "lucide-react"; import * as React from "react"; import { useImperativeHandle, useRef } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { DayPicker } from "react-day-picker"; // ---------- utils start ---------- /** * regular expression to check for valid hour format (01-23) */ function isValidHour(value: string) { return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value); } /** * regular expression to check for valid 12 hour format (01-12) */ function isValid12Hour(value: string) { return /^(0[1-9]|1[0-2])$/.test(value); } /** * regular expression to check for valid minute format (00-59) */ function isValidMinuteOrSecond(value: string) { return /^[0-5][0-9]$/.test(value); } type GetValidNumberConfig = { max: number; min?: number; loop?: boolean }; function getValidNumber(value: string, { max, min = 0, loop = false }: GetValidNumberConfig) { let numericValue = parseInt(value, 10); if (!Number.isNaN(numericValue)) { if (!loop) { if (numericValue > max) numericValue = max; if (numericValue < min) numericValue = min; } else { if (numericValue > max) numericValue = min; if (numericValue < min) numericValue = max; } return numericValue.toString().padStart(2, "0"); } return "00"; } function getValidHour(value: string) { if (isValidHour(value)) return value; return getValidNumber(value, { max: 23 }); } function getValid12Hour(value: string) { if (isValid12Hour(value)) return value; return getValidNumber(value, { min: 1, max: 12 }); } function getValidMinuteOrSecond(value: string) { if (isValidMinuteOrSecond(value)) return value; return getValidNumber(value, { max: 59 }); } type GetValidArrowNumberConfig = { min: number; max: number; step: number; }; function getValidArrowNumber(value: string, { min, max, step }: GetValidArrowNumberConfig) { let numericValue = parseInt(value, 10); if (!Number.isNaN(numericValue)) { numericValue += step; return getValidNumber(String(numericValue), { min, max, loop: true }); } return "00"; } function getValidArrowHour(value: string, step: number) { return getValidArrowNumber(value, { min: 0, max: 23, step }); } function getValidArrow12Hour(value: string, step: number) { return getValidArrowNumber(value, { min: 1, max: 12, step }); } function getValidArrowMinuteOrSecond(value: string, step: number) { return getValidArrowNumber(value, { min: 0, max: 59, step }); } function setMinutes(date: Date, value: string) { const minutes = getValidMinuteOrSecond(value); date.setMinutes(parseInt(minutes, 10)); return date; } function setSeconds(date: Date, value: string) { const seconds = getValidMinuteOrSecond(value); date.setSeconds(parseInt(seconds, 10)); return date; } function setHours(date: Date, value: string) { const hours = getValidHour(value); date.setHours(parseInt(hours, 10)); return date; } function set12Hours(date: Date, value: string, period: Period) { const hours = parseInt(getValid12Hour(value), 10); const convertedHours = convert12HourTo24Hour(hours, period); date.setHours(convertedHours); return date; } type TimePickerType = "minutes" | "seconds" | "hours" | "12hours"; type Period = "AM" | "PM"; function setDateByType(date: Date, value: string, type: TimePickerType, period?: Period) { switch (type) { case "minutes": return setMinutes(date, value); case "seconds": return setSeconds(date, value); case "hours": return setHours(date, value); case "12hours": { if (!period) return date; return set12Hours(date, value, period); } default: return date; } } function getDateByType(date: Date | null, type: TimePickerType) { if (!date) return "00"; switch (type) { case "minutes": return getValidMinuteOrSecond(String(date.getMinutes())); case "seconds": return getValidMinuteOrSecond(String(date.getSeconds())); case "hours": return getValidHour(String(date.getHours())); case "12hours": return getValid12Hour(String(display12HourValue(date.getHours()))); default: return "00"; } } function getArrowByType(value: string, step: number, type: TimePickerType) { switch (type) { case "minutes": return getValidArrowMinuteOrSecond(value, step); case "seconds": return getValidArrowMinuteOrSecond(value, step); case "hours": return getValidArrowHour(value, step); case "12hours": return getValidArrow12Hour(value, step); default: return "00"; } } /** * handles value change of 12-hour input * 12:00 PM is 12:00 * 12:00 AM is 00:00 */ function convert12HourTo24Hour(hour: number, period: Period) { if (period === "PM") { if (hour <= 11) { return hour + 12; } return hour; } if (period === "AM") { if (hour === 12) return 0; return hour; } return hour; } /** * time is stored in the 24-hour form, * but needs to be displayed to the user * in its 12-hour representation */ function display12HourValue(hours: number) { if (hours === 0 || hours === 12) return "12"; if (hours >= 22) return `${hours - 12}`; if (hours % 12 > 9) return `${hours}`; return `0${hours % 12}`; } function genMonths(locale: Pick) { return Array.from({ length: 12 }, (_, i) => ({ value: i, label: format(new Date(2021, i), "MMMM", { locale }), })); } function genYears(yearRange = 50) { const today = new Date(); return Array.from({ length: yearRange * 2 + 1 }, (_, i) => ({ value: today.getFullYear() - yearRange + i, label: (today.getFullYear() - yearRange + i).toString(), })); } // ---------- utils end ---------- function Calendar({ className, classNames, showOutsideDays = true, yearRange = 50, ...props }: CalendarProps & { yearRange?: number }) { const MONTHS = React.useMemo(() => { let locale: Pick = enUS; const { options, localize, formatLong } = props.locale || {}; if (options && localize && formatLong) { locale = { options, localize, formatLong, }; } return genMonths(locale); }, []); const YEARS = React.useMemo(() => genYears(yearRange), []); return ( props.orientation === "left" ? : , MonthCaption: ({ calendarMonth }) => { return (
); }, }} {...props} /> ); } Calendar.displayName = "Calendar"; interface PeriodSelectorProps { period: Period; setPeriod?: (m: Period) => void; date?: Date | null; onDateChange?: (date: Date | undefined) => void; onRightFocus?: () => void; onLeftFocus?: () => void; } const TimePeriodSelect = React.forwardRef( ({ period, setPeriod, date, onDateChange, onLeftFocus, onRightFocus }, ref) => { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowRight") onRightFocus?.(); if (e.key === "ArrowLeft") onLeftFocus?.(); }; const handleValueChange = (value: Period) => { setPeriod?.(value); /** * trigger an update whenever the user switches between AM and PM; * otherwise user must manually change the hour each time */ if (date) { const tempDate = new Date(date); const hours = display12HourValue(date.getHours()); onDateChange?.(setDateByType(tempDate, hours.toString(), "12hours", period === "AM" ? "PM" : "AM")); } }; return (
); } ); TimePeriodSelect.displayName = "TimePeriodSelect"; interface TimePickerInputProps extends React.InputHTMLAttributes { picker: TimePickerType; date?: Date | null; onDateChange?: (date: Date | undefined) => void; period?: Period; onRightFocus?: () => void; onLeftFocus?: () => void; } const TimePickerInput = React.forwardRef( ( { className, type = "tel", value, id, name, date = new Date(new Date().setHours(0, 0, 0, 0)), onDateChange, onChange, onKeyDown, picker, period, onLeftFocus, onRightFocus, ...props }, ref ) => { const [flag, setFlag] = React.useState(false); const [prevIntKey, setPrevIntKey] = React.useState("0"); /** * allow the user to enter the second digit within 2 seconds * otherwise start again with entering first digit */ React.useEffect(() => { if (flag) { const timer = setTimeout(() => { setFlag(false); }, 2000); return () => clearTimeout(timer); } }, [flag]); const calculatedValue = React.useMemo(() => { return getDateByType(date, picker); }, [date, picker]); const calculateNewValue = (key: string) => { /* * If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1. * The second entered digit will break the condition and the value will be set to 10-12. */ if (picker === "12hours") { if (flag && calculatedValue.slice(1, 2) === "1" && prevIntKey === "0") return `0${key}`; } return !flag ? `0${key}` : calculatedValue.slice(1, 2) + key; }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Tab") return; e.preventDefault(); if (e.key === "ArrowRight") onRightFocus?.(); if (e.key === "ArrowLeft") onLeftFocus?.(); if (["ArrowUp", "ArrowDown"].includes(e.key)) { const step = e.key === "ArrowUp" ? 1 : -1; const newValue = getArrowByType(calculatedValue, step, picker); if (flag) setFlag(false); const tempDate = date ? new Date(date) : new Date(); onDateChange?.(setDateByType(tempDate, newValue, picker, period)); } if (e.key >= "0" && e.key <= "9") { if (picker === "12hours") setPrevIntKey(e.key); const newValue = calculateNewValue(e.key); if (flag) onRightFocus?.(); setFlag((prev) => !prev); const tempDate = date ? new Date(date) : new Date(); onDateChange?.(setDateByType(tempDate, newValue, picker, period)); } }; return ( { e.preventDefault(); onChange?.(e); }} type={type} inputMode="decimal" onKeyDown={(e) => { onKeyDown?.(e); handleKeyDown(e); }} {...props} /> ); } ); TimePickerInput.displayName = "TimePickerInput"; interface TimePickerProps { date?: Date | null; onChange?: (date: Date | undefined) => void; hourCycle?: 12 | 24; /** * Determines the smallest unit that is displayed in the datetime picker. * Default is 'second'. * */ granularity?: Granularity; } interface TimePickerRef { minuteRef: HTMLInputElement | null; hourRef: HTMLInputElement | null; secondRef: HTMLInputElement | null; } const TimePicker = React.forwardRef( ({ date, onChange, hourCycle = 24, granularity = "second" }, ref) => { const minuteRef = React.useRef(null); const hourRef = React.useRef(null); const secondRef = React.useRef(null); const periodRef = React.useRef(null); const [period, setPeriod] = React.useState(date && date.getHours() >= 12 ? "PM" : "AM"); useImperativeHandle( ref, () => ({ minuteRef: minuteRef.current, hourRef: hourRef.current, secondRef: secondRef.current, periodRef: periodRef.current, }), [minuteRef, hourRef, secondRef] ); return (
minuteRef?.current?.focus()} /> {(granularity === "minute" || granularity === "second") && ( <> : hourRef?.current?.focus()} onRightFocus={() => secondRef?.current?.focus()} /> )} {granularity === "second" && ( <> : minuteRef?.current?.focus()} onRightFocus={() => periodRef?.current?.focus()} /> )} {hourCycle === 12 && (
{ onChange?.(date); if (date && date?.getHours() >= 12) { setPeriod("PM"); } else { setPeriod("AM"); } }} ref={periodRef} onLeftFocus={() => secondRef?.current?.focus()} />
)}
); } ); TimePicker.displayName = "TimePicker"; type Granularity = "day" | "hour" | "minute" | "second"; type DateTimePickerProps = { value?: Date; onChange?: (date: Date | undefined) => void; disabled?: boolean; /** showing `AM/PM` or not. */ hourCycle?: 12 | 24; placeholder?: string; /** * The year range will be: `This year + yearRange` and `this year - yearRange`. * Default is 50. * For example: * This year is 2024, The year dropdown will be 1974 to 2024 which is generated by `2024 - 50 = 1974` and `2024 + 50 = 2074`. * */ yearRange?: number; /** * The format is derived from the `date-fns` documentation. * @reference https://date-fns.org/v3.6.0/docs/format **/ displayFormat?: { hour24?: string; hour12?: string }; /** * The granularity prop allows you to control the smallest unit that is displayed by DateTimePicker. * By default, the value is `second` which shows all time inputs. **/ granularity?: Granularity; className?: string; } & Pick; type DateTimePickerRef = { value?: Date; } & Omit; const DateTimePicker = React.forwardRef, DateTimePickerProps>( ( { locale = enUS, value, onChange, hourCycle = 24, yearRange = 50, disabled = false, displayFormat, granularity = "second", placeholder = "Pick a date", className, ...props }, ref ) => { const [month, setMonth] = React.useState(value ?? new Date()); const buttonRef = useRef(null); /** * carry over the current time when a user clicks a new day * instead of resetting to 00:00 */ const handleSelect = (newDay: Date | undefined) => { if (!newDay) return; if (!value) { onChange?.(newDay); setMonth(newDay); return; } const diff = newDay.getTime() - value.getTime(); const diffInDays = diff / (1000 * 60 * 60 * 24); const newDateFull = add(value, { days: Math.ceil(diffInDays) }); onChange?.(newDateFull); setMonth(newDateFull); }; useImperativeHandle( ref, () => ({ ...buttonRef.current, value, }), [value] ); const initHourFormat = { hour24: displayFormat?.hour24 ?? `PPP HH:mm${!granularity || granularity === "second" ? ":ss" : ""}`, hour12: displayFormat?.hour12 ?? `PP hh:mm${!granularity || granularity === "second" ? ":ss" : ""} b`, }; let loc = enUS; const { options, localize, formatLong } = locale; if (options && localize && formatLong) { loc = { ...enUS, options, localize, formatLong, }; } return ( handleSelect(d)} onMonthChange={handleSelect} yearRange={yearRange} locale={locale} {...props} /> {granularity !== "day" && (
)}
); } ); DateTimePicker.displayName = "DateTimePicker"; export { DateTimePicker, TimePickerInput, TimePicker }; export type { TimePickerType, DateTimePickerProps, DateTimePickerRef };