diff --git a/package.json b/package.json index 600e739..f6dab3f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "next-themes": "^0.3.0", "react": "^18", "react-countup": "^6.5.3", + "react-day-picker": "8.10.1", "react-dom": "^18", "react-file-icon": "^1.5.0", "react-hook-form": "^7.53.0", diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx new file mode 100644 index 0000000..88e8ca8 --- /dev/null +++ b/src/app/calendar/page.tsx @@ -0,0 +1,23 @@ +"use client"; +import React, { useState } from "react"; +import { DateTimePicker } from "@/components/ui/datetime-picker"; +import { Label } from "@/components/ui/label"; + +const DatetimePickerHourCycle = () => { + const [date12, setDate12] = useState(undefined); + const [date24, setDate24] = useState(undefined); + return ( +
+
+ + +
+
+ + +
+
+ ); +}; + +export default DatetimePickerHourCycle; diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..61d2b45 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/src/components/ui/datetime-picker.tsx b/src/components/ui/datetime-picker.tsx new file mode 100644 index 0000000..e815969 --- /dev/null +++ b/src/components/ui/datetime-picker.tsx @@ -0,0 +1,742 @@ +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 };