'use client'; import * as React from 'react'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { useDayPicker, type MonthCaptionProps } from 'react-day-picker'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Calendar, CalendarDayButton } from '@/components/ui/calendar'; type CalendarView = 'days' | 'months' | 'years'; const MONTH_LABELS = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ] as const; const PICKER_WIDTH = 'w-[280px]'; const PICKER_SHELL_CLASS = 'group/calendar p-3 text-popover-foreground [--cell-size:--spacing(8)]'; const PICKER_HEADER_CLASS = 'grid min-h-8 grid-cols-[2rem_1fr_2rem] items-center gap-1 border-b border-border pb-2'; const PICKER_NAV_BUTTON_CLASS = 'size-8 shrink-0 p-0'; const GRID_BUTTON_CLASS = 'h-9 w-full rounded-lg font-medium text-foreground hover:bg-accent/60 hover:text-accent-foreground'; const GRID_BUTTON_ACTIVE_CLASS = 'bg-muted text-foreground hover:bg-muted hover:text-foreground'; const GRID_BUTTON_DISABLED_CLASS = 'text-muted-foreground opacity-50 hover:bg-transparent hover:text-muted-foreground'; function getMonthIndex(date: Date) { return date.getFullYear() * 12 + date.getMonth(); } function isYearOutsideRange(year: number, startMonth?: Date, endMonth?: Date) { if (startMonth && year < startMonth.getFullYear()) return true; if (endMonth && year > endMonth.getFullYear()) return true; return false; } function isMonthOutsideRange( year: number, month: number, startMonth?: Date, endMonth?: Date, ) { const monthIndex = year * 12 + month; if (startMonth && monthIndex < getMonthIndex(startMonth)) return true; if (endMonth && monthIndex > getMonthIndex(endMonth)) return true; return false; } function clampMonthToRange( year: number, month: number, startMonth?: Date, endMonth?: Date, ) { let nextMonth = month; if (startMonth && year === startMonth.getFullYear()) { nextMonth = Math.max(nextMonth, startMonth.getMonth()); } if (endMonth && year === endMonth.getFullYear()) { nextMonth = Math.min(nextMonth, endMonth.getMonth()); } return Math.min(11, Math.max(0, nextMonth)); } type CalendarGridPickerContextValue = { view: CalendarView; setView: (view: CalendarView) => void; pickerYear: number; setPickerYear: React.Dispatch>; decadeStart: number; setDecadeStart: React.Dispatch>; }; const CalendarGridPickerContext = React.createContext(null); function useCalendarGridPicker() { const context = React.useContext(CalendarGridPickerContext); if (!context) { throw new Error( 'useCalendarGridPicker must be used within CalendarGridPicker', ); } return context; } function PickerNav({ label, onPrevious, onNext, onLabelClick, disablePrevious = false, disableNext = false, }: { label: string; onPrevious: () => void; onNext: () => void; onLabelClick?: () => void; disablePrevious?: boolean; disableNext?: boolean; }) { return (
{onLabelClick ? ( ) : ( {label} )}
); } function MonthGrid({ year, activeMonth, onSelect, startMonth, endMonth, }: { year: number; activeMonth: number; onSelect: (month: number) => void; startMonth?: Date; endMonth?: Date; }) { return (
{MONTH_LABELS.map((label, month) => { const isDisabled = isMonthOutsideRange( year, month, startMonth, endMonth, ); return ( ); })}
); } function YearGrid({ decadeStart, activeYear, onSelect, startMonth, endMonth, }: { decadeStart: number; activeYear: number; onSelect: (year: number) => void; startMonth?: Date; endMonth?: Date; }) { const years = React.useMemo( () => Array.from({ length: 10 }, (_, index) => decadeStart + index), [decadeStart], ); return (
{years.map((year) => { const isDisabled = isYearOutsideRange(year, startMonth, endMonth); return ( ); })}
); } function CalendarMonthCaption({ calendarMonth, className, children: _children, displayIndex: _displayIndex, ...props }: MonthCaptionProps) { const { goToMonth, previousMonth, nextMonth } = useDayPicker(); const { setView, setPickerYear, setDecadeStart } = useCalendarGridPicker(); const date = calendarMonth.date; const monthName = date.toLocaleString('default', { month: 'long' }); const year = date.getFullYear(); return (
); } function CalendarGridPickerPanel({ month, onMonthChange, startMonth, endMonth, }: { month: Date; onMonthChange: (month: Date) => void; startMonth?: Date; endMonth?: Date; }) { const { view, setView, pickerYear, setPickerYear, decadeStart, setDecadeStart, } = useCalendarGridPicker(); const minYear = startMonth?.getFullYear(); const maxYear = endMonth?.getFullYear(); const activeMonth = clampMonthToRange( pickerYear, month.getMonth(), startMonth, endMonth, ); if (view === 'months') { return (
setPickerYear((year) => year - 1)} onNext={() => setPickerYear((year) => year + 1)} onLabelClick={() => { setDecadeStart(Math.floor(pickerYear / 10) * 10); setView('years'); }} disablePrevious={minYear !== undefined && pickerYear <= minYear} disableNext={maxYear !== undefined && pickerYear >= maxYear} /> { onMonthChange(new Date(pickerYear, nextMonth, 1)); setView('days'); }} />
); } if (view === 'years') { return (
setDecadeStart((start) => start - 10)} onNext={() => setDecadeStart((start) => start + 10)} disablePrevious={minYear !== undefined && decadeStart <= minYear} disableNext={maxYear !== undefined && decadeStart + 10 > maxYear} /> { const nextMonth = clampMonthToRange( year, activeMonth, startMonth, endMonth, ); setPickerYear(year); onMonthChange(new Date(year, nextMonth, 1)); setView('months'); }} />
); } return null; } export type CalendarGridPickerProps = Omit< Extract, { mode: 'single' }>, 'captionLayout' | 'navLayout' | 'hideNavigation' > & { onViewChange?: (view: CalendarView) => void; }; function CalendarGridPicker({ className, classNames, components, formatters, month, defaultMonth, onMonthChange, onViewChange, startMonth, endMonth, ...props }: CalendarGridPickerProps) { const initialMonth = month ?? defaultMonth ?? new Date(); const [view, setView] = React.useState('days'); const [pickerYear, setPickerYear] = React.useState( initialMonth.getFullYear(), ); const [decadeStart, setDecadeStart] = React.useState( Math.floor(initialMonth.getFullYear() / 10) * 10, ); const [internalMonth, setInternalMonth] = React.useState(initialMonth); const currentMonth = month ?? internalMonth; React.useEffect(() => { if (month) { setPickerYear(month.getFullYear()); setDecadeStart(Math.floor(month.getFullYear() / 10) * 10); } }, [month]); const handleViewChange = React.useCallback( (nextView: CalendarView) => { setView(nextView); onViewChange?.(nextView); }, [onViewChange], ); const handleMonthChange = React.useCallback( (nextMonth: Date) => { if (!month) setInternalMonth(nextMonth); setPickerYear(nextMonth.getFullYear()); setDecadeStart(Math.floor(nextMonth.getFullYear() / 10) * 10); onMonthChange?.(nextMonth); }, [month, onMonthChange], ); const contextValue = React.useMemo( () => ({ view, setView: handleViewChange, pickerYear, setPickerYear, decadeStart, setDecadeStart, }), [view, handleViewChange, pickerYear, decadeStart], ); if (view !== 'days') { return (
); } return ( date.toLocaleString('en-US', { weekday: 'short' }).slice(0, 2), ...formatters, }} classNames={{ month: 'flex w-full flex-col gap-2', month_caption: 'p-0', weekdays: 'flex w-full', weekday: 'flex-1 text-center text-[0.8rem] font-medium text-muted-foreground select-none', week: 'mt-1 flex w-full', ...classNames, }} components={{ MonthCaption: CalendarMonthCaption, DayButton: (dayButtonProps) => ( ), ...components, }} {...(props as Extract< React.ComponentProps, { mode: 'single' } >)} /> ); } export { CalendarGridPicker, type CalendarView };