'use client'; import * as React from 'react'; import { ChevronLeftIcon, ChevronRightIcon } from '@/icons'; import { cn } from '@/lib/utils'; import { focusRing } from '@/lib/cva-presets'; import { addMonths, addYears, formatDate, isSameDay, isSameMonth, isSameYear, isWithin, monthGrid, monthNames, startOfDay, weekStartOf, weekdayNames, type DateGranularity, } from './date-utils'; import { cellIdFor, useCalendarKeyboard } from './use-calendar-keyboard'; export interface CalendarProps { /** Granularity of the panel: days, months or years. */ picker?: DateGranularity; /** The selected date, or the range endpoints when `range` is set. */ value?: Date | null; /** Both ends of the range, for highlighting the span. */ range?: [Date | null, Date | null]; /** Previewed while the pointer moves over the grid mid-range. */ hovered?: Date | null; onHoveredChange?: (date: Date | null) => void; onSelect: (date: Date) => void; /** Month the panel is showing. Controlled, so two panels can be linked. */ month: Date; onMonthChange: (month: Date) => void; disabledDate?: (date: Date) => boolean; locale?: string; className?: string; /** Accessible name of the grid, e.g. "Start date". */ label?: string; } /** Years shown at once in the year view. */ const YEAR_PAGE = 12; /** * The picker's panel. * * Selection lives with the caller — the panel only reports what was chosen and * which month it is showing, which is what lets a range picker drive two of * them from one state. */ function Calendar({ picker = 'date', value = null, range, hovered = null, onHoveredChange, onSelect, month, onMonthChange, disabledDate, locale = 'en-US', className, label, }: CalendarProps) { const gridId = React.useId(); const weekStart = React.useMemo(() => weekStartOf(locale), [locale]); const gridRef = React.useRef(null); const { focused, setFocused, handleKeyDown } = useCalendarKeyboard({ picker, value, month, onMonthChange, weekStart, disabledDate, onSelect, gridRef, gridId, }); const cellId = (date: Date) => cellIdFor(gridId, date); const header = (
{picker === 'date' ? formatDate(month, 'MMMM yyyy', locale) : picker === 'month' ? formatDate(month, 'yyyy', locale) : `${yearPageStart(month)}–${yearPageStart(month) + YEAR_PAGE - 1}`}
); const cellClasses = (state: { selected: boolean; inRange: boolean; outside: boolean; today: boolean; disabled: boolean; }) => cn( 'relative inline-flex items-center justify-center rounded-md text-sm', 'transition-colors duration-(--ui-duration-fast) ease-(--ui-ease-standard)', focusRing, state.disabled ? 'cursor-not-allowed text-muted-foreground/40' : 'hover:bg-accent hover:text-accent-foreground', state.outside && !state.disabled && 'text-muted-foreground', state.inRange && !state.selected && 'rounded-none bg-accent text-accent-foreground', state.today && !state.selected && 'font-semibold text-primary', state.selected && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground' ); const today = startOfDay(new Date()); const body = () => { if (picker === 'date') { const days = monthGrid(month, weekStart); return ( <>
{weekdayNames(locale, weekStart).map((name) => ( {name} ))}
{days.map((day) => { const disabled = disabledDate?.(day) ?? false; const selected = isSameDay(day, value) || isSameDay(day, range?.[0]) || isSameDay(day, range?.[1]); /* Mid-drag, the hovered cell stands in for the missing end so the span previews before it is committed. */ const spanEnd = range?.[1] ?? (range?.[0] ? hovered : null); return ( ); })}
); } if (picker === 'month') { const months = monthNames(locale, 'short'); return (
{months.map((name, index) => { const cell = new Date(month.getFullYear(), index, 1); const disabled = disabledDate?.(cell) ?? false; return ( ); })}
); } const first = yearPageStart(month); return (
{Array.from({ length: YEAR_PAGE }, (_, index) => { const cell = new Date(first + index, 0, 1); const disabled = disabledDate?.(cell) ?? false; return ( ); })}
); }; return (
onHoveredChange?.(null)} > {header} {body()}
); } /** Year pages are aligned to multiples of 12, so paging is stable. */ const yearPageStart = (date: Date) => Math.floor(date.getFullYear() / YEAR_PAGE) * YEAR_PAGE; export { Calendar };