"use client"; import * as Popover from "@radix-ui/react-popover"; import { ArrowUpDown, Calendar, ChevronLeft, ChevronRight } from "lucide-react"; import * as React from "react"; import { DayPicker, type DateRange, type DayPickerProps, type Matcher, } from "react-day-picker"; import { useMdUp } from "../../hooks"; import { cn } from "../../utils/cn"; import { Button } from "./button"; import { FieldWrapper } from "./field-wrapper"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./select"; import type { SortDirection } from "./sort-column-item"; // ============================================================================ // Types // ============================================================================ export type DatePickerMode = "single" | "range"; export interface DatePickerBaseProps { /** Placeholder text when no date is selected */ placeholder?: string; /** Format function for displaying the date */ formatDate?: (date: Date) => string; /** Whether the picker is disabled */ disabled?: boolean; /** Additional class name for the trigger button */ className?: string; /** Number of months to display */ numberOfMonths?: 1 | 2; /** Minimum selectable date */ fromDate?: Date; /** Maximum selectable date */ toDate?: Date; /** Locale for formatting */ locale?: DayPickerProps["locale"]; /** Label text displayed above the picker */ label?: string; /** Error message displayed below the picker */ error?: string; /** When true, renders red error border */ invalid?: boolean; } export interface SingleDatePickerProps extends DatePickerBaseProps { mode: "single"; value?: Date; onChange?: (date: Date | undefined) => void; } export interface RangeDatePickerProps extends DatePickerBaseProps { mode: "range"; value?: DateRange; onChange?: (range: DateRange | undefined) => void; } export type DatePickerProps = SingleDatePickerProps | RangeDatePickerProps; // ============================================================================ // Helper functions // ============================================================================ const defaultFormatDate = (date: Date): string => { return date.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric", }); }; const formatDateRange = ( range: DateRange | undefined, formatFn: (date: Date) => string ): string => { if (!range?.from) return ""; if (!range.to) return formatFn(range.from); return `${formatFn(range.from)} - ${formatFn(range.to)}`; }; // ============================================================================ // Calendar Components // ============================================================================ interface CalendarNavButtonProps { direction: "left" | "right"; onClick?: () => void; /** Set at a `fromDate` / `toDate` bound — there is nothing selectable past it. */ disabled?: boolean; "aria-label"?: string; } /** The DS icon button, at its standard size in every placement — month * navigation is a primary control, not chrome to be shrunk to fit. What has * to give instead is the calendar's WIDTH: a `bare` host states one wide * enough for the caption to sit between the two buttons (see the meeting * scheduler's `CALENDAR_W`). */ function CalendarNavButton({ direction, onClick, disabled, "aria-label": ariaLabel }: CalendarNavButtonProps) { return ( ); return ( {picker} ); } // ============================================================================ // DatePickerInput Component (with time selector styling from Figma) // ============================================================================ export interface DatePickerInputProps extends DatePickerBaseProps { mode?: "single"; value?: Date; onChange?: (date: Date | undefined) => void; /** Show time selector next to date */ showTime?: boolean; /** Use 24-hour format instead of 12-hour */ use24HourFormat?: boolean; } // Generate hour options const generateHourOptions = (use24Hour: boolean): string[] => { if (use24Hour) { return Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, "0")); } return Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, "0")); }; // Generate minute options (00, 01, 02, ... 59) const generateMinuteOptions = (): string[] => { return Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, "0")); }; export function DatePickerInput({ placeholder = "Select date", formatDate = defaultFormatDate, disabled = false, className, numberOfMonths = 1, fromDate, toDate, locale, value, onChange, showTime = false, use24HourFormat = false, label, error, invalid = false, }: DatePickerInputProps) { const [open, setOpen] = React.useState(false); const isInvalid = invalid || !!error; const displayValue = value ? formatDate(value) : ""; // Extract time from value const hour = React.useMemo(() => { if (!value) return ""; const hours = value.getHours(); if (use24HourFormat) { return hours.toString().padStart(2, "0"); } const hour12 = hours % 12 || 12; return hour12.toString().padStart(2, "0"); }, [value, use24HourFormat]); const minute = React.useMemo(() => { if (!value) return ""; return value.getMinutes().toString().padStart(2, "0"); }, [value]); const period = React.useMemo((): "AM" | "PM" => { if (!value) return "AM"; return value.getHours() >= 12 ? "PM" : "AM"; }, [value]); const handleSelect = (date: Date | DateRange | undefined) => { const newDate = date as Date | undefined; if (newDate && value) { // Preserve time when selecting a new date newDate.setHours(value.getHours(), value.getMinutes(), 0, 0); } onChange?.(newDate); if (newDate) { setOpen(false); } }; const handleHourChange = (newHour: string) => { const date = value ? new Date(value) : new Date(); let hours = parseInt(newHour, 10); if (!use24HourFormat) { const isPM = period === "PM"; if (hours === 12) { hours = isPM ? 12 : 0; } else { hours = isPM ? hours + 12 : hours; } } date.setHours(hours); onChange?.(date); }; const handleMinuteChange = (newMinute: string) => { const date = value ? new Date(value) : new Date(); date.setMinutes(parseInt(newMinute, 10)); onChange?.(date); }; const handlePeriodChange = (newPeriod: "AM" | "PM") => { const date = value ? new Date(value) : new Date(); let hours = date.getHours(); if (newPeriod === "AM" && hours >= 12) { hours -= 12; } else if (newPeriod === "PM" && hours < 12) { hours += 12; } date.setHours(hours); onChange?.(date); }; const hourOptions = React.useMemo(() => generateHourOptions(use24HourFormat), [use24HourFormat]); const minuteOptions = React.useMemo(() => generateMinuteOptions(), []); const content = (
{/* Date Picker */} {/* Time Selects (optional) */} {showTime && (
{/* Hour Select */} : {/* Minute Select */} {/* AM/PM Select (only for 12-hour format) */} {!use24HourFormat && ( )}
)}
); return ( {content} ); } // ============================================================================ // DatePickerInputSimple Component (with single time selector) // ============================================================================ export interface DatePickerInputSimpleProps extends DatePickerBaseProps { mode?: "single"; value?: Date; onChange?: (date: Date | undefined) => void; /** Show time selector next to date */ showTime?: boolean; /** Time interval in minutes (default: 30) */ timeInterval?: number; /** Use 24-hour format instead of 12-hour */ use24HourFormat?: boolean; } // Generate time options with specified interval const generateTimeOptions = ( intervalMinutes: number, use24Hour: boolean ): { value: string; label: string }[] => { const options: { value: string; label: string }[] = []; const totalMinutesInDay = 24 * 60; for (let minutes = 0; minutes < totalMinutesInDay; minutes += intervalMinutes) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; const value = `${hours.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")}`; let label: string; if (use24Hour) { label = value; } else { const hour12 = hours % 12 || 12; const period = hours >= 12 ? "PM" : "AM"; label = `${hour12.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")} ${period}`; } options.push({ value, label }); } return options; }; export function DatePickerInputSimple({ placeholder = "Select date", formatDate = defaultFormatDate, disabled = false, className, numberOfMonths = 1, fromDate, toDate, locale, value, onChange, showTime = false, timeInterval = 30, use24HourFormat = false, label, error, invalid = false, }: DatePickerInputSimpleProps) { const [open, setOpen] = React.useState(false); const isInvalid = invalid || !!error; const displayValue = value ? formatDate(value) : ""; // Get current time value as HH:MM string const timeValue = React.useMemo(() => { if (!value) return ""; const hours = value.getHours(); const minutes = value.getMinutes(); return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; }, [value]); // Get display label for current time const timeDisplayLabel = React.useMemo(() => { if (!value) return ""; const hours = value.getHours(); const minutes = value.getMinutes(); if (use24HourFormat) { return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; } const hour12 = hours % 12 || 12; const period = hours >= 12 ? "PM" : "AM"; return `${hour12.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")} ${period}`; }, [value, use24HourFormat]); const handleSelect = (date: Date | DateRange | undefined) => { const newDate = date as Date | undefined; if (newDate && value) { // Preserve time when selecting a new date newDate.setHours(value.getHours(), value.getMinutes(), 0, 0); } onChange?.(newDate); if (newDate) { setOpen(false); } }; const handleTimeChange = (newTime: string) => { const [hours, minutes] = newTime.split(":").map(Number); const date = value ? new Date(value) : new Date(); date.setHours(hours, minutes, 0, 0); onChange?.(date); }; const timeOptions = React.useMemo( () => generateTimeOptions(timeInterval, use24HourFormat), [timeInterval, use24HourFormat] ); const content = (
{/* Date Picker */} {/* Single Time Select (optional) */} {showTime && ( )}
); return ( {content} ); } // ============================================================================ // DateFilterPanel Component (sort select + fluid calendar) // ============================================================================ export interface DateFilterPanelProps { /** Selection mode for the calendar. Defaults to "range". */ mode?: DatePickerMode; /** Current sort direction shown in the select. */ sort: SortDirection; onSortChange: (sort: SortDirection) => void; /** Current calendar selection. */ selected: Date | DateRange | undefined; onSelect: (value: Date | DateRange | undefined) => void; /** Minimum selectable date. */ fromDate?: Date; /** Maximum selectable date. */ toDate?: Date; /** Locale for the calendar. */ locale?: DayPickerProps["locale"]; /** Label for the ascending sort option. */ ascLabel?: string; /** Label for the descending sort option. */ descLabel?: string; className?: string; } /** * DateFilterPanel — the controlled sort-direction select + fluid calendar * block shared by DateFilterMenu (popover) and FilterModal (mobile "Sort and * Filter"). Owns no state: the consumer drafts and commits the values. */ export function DateFilterPanel({ mode = "range", sort, onSortChange, selected, onSelect, fromDate, toDate, locale, ascLabel = "Sort by Ascending", descLabel = "Sort by Descending", className, }: DateFilterPanelProps) { return (
{/* Sort direction selector */} {/* Calendar */}
); } // ============================================================================ // DateFilterMenu Component (sort + calendar filter popover from Figma) // ============================================================================ export interface DateFilterResult { /** Selected sort direction */ sort: SortDirection; /** Selected single date (mode === "single") */ date?: Date; /** Selected date range (mode === "range") */ range?: DateRange; } export interface DateFilterMenuProps { /** Selection mode for the calendar. Defaults to "range". */ mode?: DatePickerMode; /** Current (applied) sort direction. Defaults to "desc". */ sort?: SortDirection; /** Baseline sort direction — Reset restores it, and a draft that differs * from it counts as an active change (shows Reset). Defaults to "desc". */ defaultSort?: SortDirection; /** Current (applied) single date — used when mode === "single". */ date?: Date; /** Current (applied) range — used when mode === "range". */ range?: DateRange; /** Fired when the user presses Apply with the drafted selection. Also fired * by Reset with a cleared selection so the consumer refetches unfiltered data. */ onApply?: (result: DateFilterResult) => void; /** Fired when the menu closes (Close button, outside click, Esc). */ onClose?: () => void; /** Custom trigger element (rendered via Radix `asChild` — must accept a ref, * e.g. a native button). Defaults to the outline calendar icon Button. */ trigger?: React.ReactNode; /** Disable the trigger. */ disabled?: boolean; /** Minimum selectable date. */ fromDate?: Date; /** Maximum selectable date. */ toDate?: Date; /** Locale for the calendar. */ locale?: DayPickerProps["locale"]; /** Popover alignment relative to the trigger. */ align?: "start" | "center" | "end"; /** Label for the ascending sort option. */ ascLabel?: string; /** Label for the descending sort option. */ descLabel?: string; /** Additional class name for the trigger button. */ className?: string; /** Accessible label for the trigger. */ "aria-label"?: string; } /** * DateFilterMenu — a calendar-icon-triggered popover combining a sort-direction * selector and a date / date-range calendar, with Close/Reset and Apply actions. * The sort and date selection are drafted internally and committed via * `onApply`. While a date is selected, Close is replaced by Reset, which * clears and commits the empty selection (fires `onApply`) so the consumer * drops the filter. */ export function DateFilterMenu({ mode = "range", sort = "desc", defaultSort = "desc", date, range, onApply, onClose, trigger, disabled = false, fromDate, toDate, locale, align = "start", ascLabel = "Sort by Ascending", descLabel = "Sort by Descending", className, "aria-label": ariaLabel = "Open date filter", }: DateFilterMenuProps) { const [open, setOpen] = React.useState(false); // Drafted selection — initialized from props each time the menu opens. const [draftSort, setDraftSort] = React.useState(sort); const [draftSelected, setDraftSelected] = React.useState< Date | DateRange | undefined >(mode === "single" ? date : range); const resetDraft = React.useCallback(() => { setDraftSort(sort); setDraftSelected(mode === "single" ? date : range); }, [sort, date, range, mode]); const handleOpenChange = (next: boolean) => { if (next) { resetDraft(); } else { onClose?.(); } setOpen(next); }; const handleApply = () => { const result: DateFilterResult = mode === "single" ? { sort: draftSort, date: draftSelected as Date | undefined } : { sort: draftSort, range: draftSelected as DateRange | undefined }; onApply?.(result); setOpen(false); }; const handleClose = () => { onClose?.(); setOpen(false); }; // Anything to reset? A calendar selection or a non-default sort — drives Close vs Reset. const hasSelection = mode === "single" ? Boolean(draftSelected) : Boolean((draftSelected as DateRange | undefined)?.from); const hasChanges = hasSelection || draftSort !== defaultSort; // Reset restores the defaults and commits them so the consumer drops the // filter and refetches; the menu stays open with the button back to Close. const handleReset = () => { setDraftSort(defaultSort); setDraftSelected(undefined); onApply?.( mode === "single" ? { sort: defaultSort, date: undefined } : { sort: defaultSort, range: undefined } ); }; return ( {trigger ?? ( ); } // ============================================================================ // Exports // ============================================================================ export { type DateRange };