'use client'; import * as React from 'react'; import { ChevronLeftIcon, ChevronRightIcon } from '@/icons'; import { cn } from '@/lib/utils'; import { focusRing } from '@/lib/cva-presets'; import { addDays, addMonths, addYears, formatDate, isSameDay, isSameMonth, isSameYear, isWithin, monthGrid, monthNames, startOfDay, startOfMonth, startOfYear, weekStartOf, weekdayNames, type DateGranularity, } from './date-utils'; 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]); /** * The cell the keyboard is on. Only one cell in the grid is tabbable — the * roving pattern — so Tab moves past the calendar rather than through 42 * days, and the arrow keys drive it from there. */ const [focused, setFocused] = React.useState(() => value ?? month); const gridRef = React.useRef(null); /* A cell is identified by its date, so focus survives the grid re-rendering around it when a keypress crosses into the next month. */ const cellId = (date: Date) => `${gridId}-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; /** * Set when a keypress moves the selection, so the effect below knows to chase * focus into the new cell. Paging past a month boundary unmounts the cell the * user was standing on, which drops focus to `` — the very next keypress * would then go nowhere. Restoring in an effect rather than a frame callback * means focus is back before the next event can arrive. */ const chasingFocus = React.useRef(false); const moveFocus = (next: Date) => { chasingFocus.current = true; setFocused(next); if (picker === 'date' && !isSameMonth(next, month)) onMonthChange(startOfMonth(next)); if (picker === 'month' && !isSameYear(next, month)) onMonthChange(startOfYear(next)); }; React.useEffect(() => { if (!chasingFocus.current) return; chasingFocus.current = false; const id = `${gridId}-${focused.getFullYear()}-${focused.getMonth()}-${focused.getDate()}`; gridRef.current?.querySelector(`[data-cell="${id}"]`)?.focus(); }, [focused, gridId, month]); const step = (units: number, unit: 'day' | 'month' | 'year') => { if (unit === 'day') return addDays(focused, units); if (unit === 'month') return addMonths(focused, units); return addYears(focused, units); }; const handleKeyDown = (event: React.KeyboardEvent) => { const unit = picker === 'date' ? 'day' : picker === 'month' ? 'month' : 'year'; /* One row is a week of days, a quarter of months, or three years. */ const row = picker === 'date' ? 7 : 3; const moves: Record Date> = { ArrowLeft: () => step(-1, unit), ArrowRight: () => step(1, unit), ArrowUp: () => step(-row, unit), ArrowDown: () => step(row, unit), PageUp: () => (picker === 'date' ? addMonths(focused, -1) : addYears(focused, -1)), PageDown: () => (picker === 'date' ? addMonths(focused, 1) : addYears(focused, 1)), Home: () => addDays(focused, -((focused.getDay() - weekStart + 7) % 7)), End: () => addDays(focused, 6 - ((focused.getDay() - weekStart + 7) % 7)), }; if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); if (!disabledDate?.(focused)) onSelect(focused); return; } const move = moves[event.key]; if (!move) return; /* Home and End are week-relative, so they only mean something in day view. */ if ((event.key === 'Home' || event.key === 'End') && picker !== 'date') return; event.preventDefault(); moveFocus(move()); }; 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 };