// Tremor Date Picker [v1.0.4] "use client"; import * as React from "react"; import { Time } from "@internationalized/date"; import * as PopoverPrimitives from "@radix-ui/react-popover"; import { AriaTimeFieldProps, TimeValue, useDateSegment, useTimeField, } from "@react-aria/datepicker"; import { useTimeFieldState, type DateFieldState, type DateSegment, } from "@react-stately/datepicker"; import { RiCalendar2Fill, RiSubtractFill } from "@remixicon/react"; import { format, type Locale } from "date-fns"; import { enUS } from "date-fns/locale"; import { tv, VariantProps } from "tailwind-variants"; import { cx, focusInput, focusRing, hasErrorInput } from "../../lib/utils"; import { Button } from "../Button"; import { Calendar as CalendarPrimitive, type Matcher } from "../Calendar"; //#region TimeInput // ============================================================================ const isBrowserLocaleClockType24h = () => { const language = typeof window !== "undefined" ? window.navigator.language : "en-US"; const hr = new Intl.DateTimeFormat(language, { hour: "numeric", }).format(); return Number.isInteger(Number(hr)); }; type TimeSegmentProps = { segment: DateSegment; state: DateFieldState; }; const TimeSegment = ({ segment, state }: TimeSegmentProps) => { const ref = React.useRef(null); const { segmentProps } = useDateSegment(segment, state, ref); const isColon = segment.type === "literal" && segment.text === ":"; const isSpace = segment.type === "literal" && segment.text === " "; const isDecorator = isColon || isSpace; return (
{segment.isPlaceholder ? "" : segment.text}
); }; type TimeInputProps = Omit< AriaTimeFieldProps, "label" | "shouldForceLeadingZeros" | "description" | "errorMessage" >; const TimeInput = React.forwardRef( ({ hourCycle, ...props }: TimeInputProps, ref) => { const innerRef = React.useRef(null); React.useImperativeHandle( ref, () => innerRef?.current, ); const locale = window !== undefined ? window.navigator.language : "en-US"; const state = useTimeFieldState({ hourCycle: hourCycle, locale: locale, shouldForceLeadingZeros: true, autoFocus: true, ...props, }); const { fieldProps } = useTimeField( { ...props, hourCycle: hourCycle, shouldForceLeadingZeros: true, }, state, innerRef, ); return (
{state.segments.map((segment, i) => ( ))}
); }, ); TimeInput.displayName = "TimeInput"; //#region Trigger // ============================================================================ const triggerStyles = tv({ base: [ // base "onvo-peer onvo-flex onvo-w-full onvo-cursor-pointer onvo-appearance-none onvo-items-center onvo-gap-x-2 onvo-truncate onvo-rounded-md onvo-border onvo-px-3 onvo-py-1.5 onvo-shadow-sm onvo-outline-none onvo-transition-all sm:onvo-text-sm", // background color "onvo-bg-white dark:onvo-bg-gray-950", // border color "onvo-border-gray-200 dark:onvo-border-gray-800", // text color "onvo-text-gray-900 dark:onvo-text-gray-50", // placeholder color "onvo-placeholder-gray-400 dark:onvo-placeholder-gray-500", // hover "hover:onvo-bg-gray-50 hover:dark:onvo-bg-gray-950/50", // disabled "disabled:onvo-pointer-events-none", "disabled:onvo-bg-gray-100 disabled:onvo-text-gray-400", "disabled:dark:onvo-border-gray-800 disabled:dark:onvo-bg-gray-800 disabled:dark:onvo-text-gray-500", // focus focusInput, // invalid (optional) // "aria-[invalid=true]:dark:ring-red-400/20 aria-[invalid=true]:ring-2 aria-[invalid=true]:ring-red-200 aria-[invalid=true]:border-red-500 invalid:ring-2 invalid:ring-red-200 invalid:border-red-500" ], variants: { hasError: { true: hasErrorInput, }, }, }); interface TriggerProps extends React.ComponentProps<"button">, VariantProps { placeholder?: string; } const Trigger = React.forwardRef( ( { className, children, placeholder, hasError, ...props }: TriggerProps, forwardedRef, ) => { return ( ); }, ); Trigger.displayName = "DatePicker.Trigger"; //#region Popover // ============================================================================ const CalendarPopover = React.forwardRef< React.ElementRef, React.ComponentProps >(({ align, className, children, ...props }, forwardedRef) => { return ( e.preventDefault()} className={cx( // base "onvo-relative onvo-z-50 onvo-w-fit onvo-rounded-md onvo-border onvo-text-sm onvo-shadow-xl onvo-shadow-black/[2.5%]", // widths "onvo-min-w-[calc(var(--radix-select-trigger-width)-2px)] onvo-max-w-[95vw]", // border color "onvo-border-gray-200 dark:onvo-border-gray-800", // background color "onvo-bg-white dark:onvo-bg-gray-950", // transition "onvo-will-change-[transform,opacity]", "data-[state=closed]:onvo-animate-hide", "data-[state=open]:data-[side=bottom]:onvo-animate-slideDownAndFade data-[state=open]:data-[side=left]:onvo-animate-slideLeftAndFade data-[state=open]:data-[side=right]:onvo-animate-slideRightAndFade data-[state=open]:data-[side=top]:onvo-animate-slideUpAndFade", className, )} {...props} > {children} ); }); CalendarPopover.displayName = "DatePicker.CalendarPopover"; //#region Preset // ============================================================================ type DateRange = { from: Date | undefined; to?: Date | undefined; }; interface Preset { label: string; } interface DatePreset extends Preset { date: Date; } interface DateRangePreset extends Preset { dateRange: DateRange; } type PresetContainerProps = { presets: TPreset[]; onSelect: (value: TValue) => void; currentValue?: TValue; }; const PresetContainer = ({ // Available preset configurations presets, // Event handler when a preset is selected onSelect, // Currently selected preset currentValue, }: PresetContainerProps) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const isDateRangePresets = (preset: any): preset is DateRangePreset => { return "dateRange" in preset; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const isDatePresets = (preset: any): preset is DatePreset => { return "date" in preset; }; const handleClick = (preset: TPreset) => { if (isDateRangePresets(preset)) { onSelect(preset.dateRange as TValue); } else if (isDatePresets(preset)) { onSelect(preset.date as TValue); } }; const compareDates = (date1: Date, date2: Date) => { return ( date1.getDate() === date2.getDate() && date1.getMonth() === date2.getMonth() && date1.getFullYear() === date2.getFullYear() ); }; const compareRanges = (range1: DateRange, range2: DateRange) => { const from1 = range1.from; const from2 = range2.from; let equalFrom = false; if (from1 && from2) { const sameFrom = compareDates(from1, from2); if (sameFrom) { equalFrom = true; } } const to1 = range1.to; const to2 = range2.to; let equalTo = false; if (to1 && to2) { const sameTo = compareDates(to1, to2); if (sameTo) { equalTo = true; } } return equalFrom && equalTo; }; const matchesCurrent = (preset: TPreset) => { if (isDateRangePresets(preset)) { const value = currentValue as DateRange | undefined; return value && compareRanges(value, preset.dateRange); } else if (isDatePresets(preset)) { const value = currentValue as Date | undefined; return value && compareDates(value, preset.date); } return false; }; return (
    {presets.map((preset, index) => { return (
  • ); })}
); }; PresetContainer.displayName = "DatePicker.PresetContainer"; //#region Date Picker Shared // ============================================================================ const formatDate = ( date: Date, locale: Locale, includeTime?: boolean, ): string => { const usesAmPm = !isBrowserLocaleClockType24h(); let dateString: string; if (includeTime) { dateString = usesAmPm ? format(date, "dd MMM, yyyy h:mm a", { locale }) : format(date, "dd MMM, yyyy HH:mm", { locale }); } else { dateString = format(date, "dd MMM, yyyy", { locale }); } return dateString; }; type CalendarProps = { fromYear?: number; toYear?: number; fromMonth?: Date; toMonth?: Date; fromDay?: Date; toDay?: Date; fromDate?: Date; toDate?: Date; locale?: Locale; }; type Translations = { cancel?: string; apply?: string; start?: string; end?: string; range?: string; }; interface PickerProps extends CalendarProps { className?: string; disabled?: boolean; disabledDays?: Matcher | Matcher[] | undefined; required?: boolean; showTimePicker?: boolean; placeholder?: string; enableYearNavigation?: boolean; disableNavigation?: boolean; hasError?: boolean; id?: string; // Customize the date picker for different languages. translations?: Translations; align?: "center" | "end" | "start"; "aria-invalid"?: boolean; "aria-label"?: string; "aria-labelledby"?: string; "aria-required"?: boolean; } //#region Single Date Picker // ============================================================================ interface SingleProps extends Omit { presets?: DatePreset[]; defaultValue?: Date; value?: Date; onChange?: (date: Date | undefined) => void; translations?: Omit; } const SingleDatePicker = ({ defaultValue, value, onChange, presets, disabled, disabledDays, disableNavigation, className, showTimePicker, placeholder = "Select date", hasError, translations, enableYearNavigation = false, locale = enUS, align = "center", ...props }: SingleProps) => { const [open, setOpen] = React.useState(false); const [date, setDate] = React.useState( value ?? defaultValue ?? undefined, ); const [month, setMonth] = React.useState(date); const [time, setTime] = React.useState( value ? new Time(value.getHours(), value.getMinutes()) : defaultValue ? new Time(defaultValue.getHours(), defaultValue.getMinutes()) : new Time(0, 0), ); const initialDate = React.useMemo(() => { return date; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); React.useEffect(() => { setDate(value ?? defaultValue ?? undefined); }, [value, defaultValue]); React.useEffect(() => { if (date) { setMonth(date); } }, [date]); React.useEffect(() => { if (!open) { setMonth(date); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const onCancel = () => { setDate(initialDate); setTime( initialDate ? new Time(initialDate.getHours(), initialDate.getMinutes()) : new Time(0, 0), ); setOpen(false); }; const onOpenChange = (open: boolean) => { if (!open) { onCancel(); } setOpen(open); }; const onDateChange = (date: Date | undefined) => { const newDate = date; if (showTimePicker) { if (newDate && !time) { setTime(new Time(0, 0)); } if (newDate && time) { newDate.setHours(time.hour); newDate.setMinutes(time.minute); } } setDate(newDate); }; const onTimeChange = (time: TimeValue | null) => { if (!time) return; setTime(time); if (!date) { return; } const newDate = new Date(date.getTime()); if (!time) { newDate.setHours(0); newDate.setMinutes(0); } else { newDate.setHours(time.hour); newDate.setMinutes(time.minute); } setDate(newDate); }; const formattedDate = React.useMemo(() => { if (!date) { return null; } return formatDate(date, locale, showTimePicker); }, [date, locale, showTimePicker]); const onApply = () => { setOpen(false); onChange?.(date); }; React.useEffect(() => { setDate(value ?? defaultValue ?? undefined); setTime( value ? new Time(value.getHours(), value.getMinutes()) : defaultValue ? new Time(defaultValue.getHours(), defaultValue.getMinutes()) : new Time(0, 0), ); }, [value, defaultValue]); return ( {formattedDate}
{presets && presets.length > 0 && (
)}
{showTimePicker && (
)}
); }; //#region Range Date Picker // ============================================================================ interface RangeProps extends PickerProps { presets?: DateRangePreset[]; defaultValue?: DateRange; value?: DateRange; onChange?: (dateRange: DateRange | undefined) => void; maxDays?: number; } const RangeDatePicker = ({ defaultValue, value, onChange, presets, disabled, disableNavigation, disabledDays, enableYearNavigation = false, locale = enUS, showTimePicker, placeholder = "Select date range", hasError, translations, align = "center", className, maxDays, ...props }: RangeProps) => { const [open, setOpen] = React.useState(false); const [range, setRange] = React.useState( value ?? defaultValue ?? undefined, ); const [month, setMonth] = React.useState(range?.from); const [startTime, setStartTime] = React.useState( value?.from ? new Time(value.from.getHours(), value.from.getMinutes()) : defaultValue?.from ? new Time(defaultValue.from.getHours(), defaultValue.from.getMinutes()) : new Time(0, 0), ); const [endTime, setEndTime] = React.useState( value?.to ? new Time(value.to.getHours(), value.to.getMinutes()) : defaultValue?.to ? new Time(defaultValue.to.getHours(), defaultValue.to.getMinutes()) : new Time(0, 0), ); const initialRange = React.useMemo(() => { return range; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); React.useEffect(() => { setRange(value ?? defaultValue ?? undefined); }, [value, defaultValue]); React.useEffect(() => { if (range) { setMonth(range.from); } }, [range]); React.useEffect(() => { if (!open) { setMonth(range?.from); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const onRangeChange = (range: DateRange | undefined) => { const newRange = range; if (showTimePicker) { if (newRange?.from && !startTime) { setStartTime(new Time(0, 0)); } if (newRange?.to && !endTime) { setEndTime(new Time(0, 0)); } if (newRange?.from && startTime) { newRange.from.setHours(startTime.hour); newRange.from.setMinutes(startTime.minute); } if (newRange?.to && endTime) { newRange.to.setHours(endTime.hour); newRange.to.setMinutes(endTime.minute); } } setRange(newRange); }; const onCancel = () => { setRange(initialRange); setStartTime( initialRange?.from ? new Time(initialRange.from.getHours(), initialRange.from.getMinutes()) : new Time(0, 0), ); setEndTime( initialRange?.to ? new Time(initialRange.to.getHours(), initialRange.to.getMinutes()) : new Time(0, 0), ); setOpen(false); }; const onOpenChange = (open: boolean) => { if (!open) { onCancel(); } setOpen(open); }; const onTimeChange = (time: TimeValue | null, pos: "start" | "end") => { if (!time) return; switch (pos) { case "start": setStartTime(time); break; case "end": setEndTime(time); break; } if (!range) { return; } if (pos === "start") { if (!range.from) { return; } const newDate = new Date(range.from.getTime()); if (!time) { newDate.setHours(0); newDate.setMinutes(0); } else { newDate.setHours(time.hour); newDate.setMinutes(time.minute); } setRange({ ...range, from: newDate, }); } if (pos === "end") { if (!range.to) { return; } const newDate = new Date(range.to.getTime()); if (!time) { newDate.setHours(0); newDate.setMinutes(0); } else { newDate.setHours(time.hour); newDate.setMinutes(time.minute); } setRange({ ...range, to: newDate, }); } }; React.useEffect(() => { setRange(value ?? defaultValue ?? undefined); setStartTime( value?.from ? new Time(value.from.getHours(), value.from.getMinutes()) : defaultValue?.from ? new Time( defaultValue.from.getHours(), defaultValue.from.getMinutes(), ) : new Time(0, 0), ); setEndTime( value?.to ? new Time(value.to.getHours(), value.to.getMinutes()) : defaultValue?.to ? new Time(defaultValue.to.getHours(), defaultValue.to.getMinutes()) : new Time(0, 0), ); }, [value, defaultValue]); const displayRange = React.useMemo(() => { if (!range) { return null; } return `${range.from ? formatDate(range.from, locale, showTimePicker) : ""} - ${ range.to ? formatDate(range.to, locale, showTimePicker) : "" }`; }, [range, locale, showTimePicker]); const onApply = () => { setOpen(false); onChange?.(range); }; return ( {displayRange}
{presets && presets.length > 0 && (
)}
{showTimePicker && (
{translations?.start ?? "Start"}: onTimeChange(v, "start")} aria-label="Start date time" isDisabled={!range?.from} isRequired={props.required} />
{translations?.end ?? "End"}: onTimeChange(v, "end")} aria-label="End date time" isDisabled={!range?.to} isRequired={props.required} />
)}

{translations?.range ?? "Range"}: {" "} {displayRange}{" "} {range?.to && range?.from && (

setRange(undefined)} className="onvo-underline onvo-cursor-pointer" > Reset

)}

); }; //#region Preset Validation // ============================================================================ const validatePresets = ( presets: DateRangePreset[] | DatePreset[], rules: PickerProps, ) => { const { toYear, fromYear, fromMonth, toMonth, fromDay, toDay } = rules; if (presets && presets.length > 0) { const fromYearToUse = fromYear; const toYearToUse = toYear; presets.forEach((preset) => { if ("date" in preset) { const presetYear = preset.date.getFullYear(); if (fromYear && presetYear < fromYear) { throw new Error( `Preset ${preset.label} is before fromYear ${fromYearToUse}.`, ); } if (toYear && presetYear > toYear) { throw new Error( `Preset ${preset.label} is after toYear ${toYearToUse}.`, ); } if (fromMonth) { const presetMonth = preset.date.getMonth(); if (presetMonth < fromMonth.getMonth()) { throw new Error( `Preset ${preset.label} is before fromMonth ${fromMonth}.`, ); } } if (toMonth) { const presetMonth = preset.date.getMonth(); if (presetMonth > toMonth.getMonth()) { throw new Error( `Preset ${preset.label} is after toMonth ${toMonth}.`, ); } } if (fromDay) { const presetDay = preset.date.getDate(); if (presetDay < fromDay.getDate()) { throw new Error( `Preset ${preset.label} is before fromDay ${fromDay}.`, ); } } if (toDay) { const presetDay = preset.date.getDate(); if (presetDay > toDay.getDate()) { throw new Error( `Preset ${preset.label} is after toDay ${format( toDay, "MMM dd, yyyy", )}.`, ); } } } if ("dateRange" in preset) { const presetFromYear = preset.dateRange.from?.getFullYear(); const presetToYear = preset.dateRange.to?.getFullYear(); if (presetFromYear && fromYear && presetFromYear < fromYear) { throw new Error( `Preset ${preset.label}'s 'from' is before fromYear ${fromYearToUse}.`, ); } if (presetToYear && toYear && presetToYear > toYear) { throw new Error( `Preset ${preset.label}'s 'to' is after toYear ${toYearToUse}.`, ); } if (fromMonth) { const presetMonth = preset.dateRange.from?.getMonth(); if (presetMonth && presetMonth < fromMonth.getMonth()) { throw new Error( `Preset ${preset.label}'s 'from' is before fromMonth ${format( fromMonth, "MMM, yyyy", )}.`, ); } } if (toMonth) { const presetMonth = preset.dateRange.to?.getMonth(); if (presetMonth && presetMonth > toMonth.getMonth()) { throw new Error( `Preset ${preset.label}'s 'to' is after toMonth ${format( toMonth, "MMM, yyyy", )}.`, ); } } if (fromDay) { const presetDay = preset.dateRange.from?.getDate(); if (presetDay && presetDay < fromDay.getDate()) { throw new Error( `Preset ${ preset.dateRange.from }'s 'from' is before fromDay ${format(fromDay, "MMM dd, yyyy")}.`, ); } } if (toDay) { const presetDay = preset.dateRange.to?.getDate(); if (presetDay && presetDay > toDay.getDate()) { throw new Error( `Preset ${preset.label}'s 'to' is after toDay ${format( toDay, "MMM dd, yyyy", )}.`, ); } } } }); } }; //#region Types & Exports // ============================================================================ type SingleDatePickerProps = { presets?: DatePreset[]; defaultValue?: Date; value?: Date; onChange?: (date: Date | undefined) => void; } & PickerProps; const DatePicker = ({ presets, ...props }: SingleDatePickerProps) => { if (presets) { validatePresets(presets, props); } return ; }; DatePicker.displayName = "DatePicker"; type RangeDatePickerProps = { presets?: DateRangePreset[]; defaultValue?: DateRange; value?: DateRange; onChange?: (dateRange: DateRange | undefined) => void; maxDays?: number; } & PickerProps; const DateRangePicker = ({ presets, ...props }: RangeDatePickerProps) => { if (presets) { validatePresets(presets, props); } return ; }; DateRangePicker.displayName = "DateRangePicker"; export { DatePicker, DateRangePicker, type DatePreset, type DateRangePreset, type DateRange, };