'use client'; import React, { forwardRef, useCallback, useMemo, useRef, useState } from 'react'; import { Surface, cn, surfaceClasses, useEffectiveSurface, toneMap, } from '../common'; import { useControllableState } from '../hooks/useControllableState'; /* ────────────────────────────────────────────────────────────────────────── Date helpers — pure, no deps. (Mirror PixelDatePicker semantics.) ────────────────────────────────────────────────────────────────────────── */ const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const WEEKDAY_SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; function startOfDay(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } function sameDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } function toISO(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; } function formatDateLabel(d: Date): string { return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; } interface DayCell { date: Date; inMonth: boolean; } function buildMonthGrid(year: number, month: number): DayCell[] { const first = new Date(year, month, 1); const firstWeekday = first.getDay(); const gridStart = new Date(year, month, 1 - firstWeekday); const cells: DayCell[] = []; for (let i = 0; i < 42; i++) { const d = new Date( gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i, ); cells.push({ date: d, inMonth: d.getMonth() === month }); } return cells; } /* ────────────────────────────────────────────────────────────────────────── PixelCalendarGrid — standalone month grid. Usable inline, or composed inside DatePicker / DateRangePicker. ────────────────────────────────────────────────────────────────────────── */ export interface PixelCalendarGridProps { value?: Date | null; defaultValue?: Date | null; onChange?: (date: Date) => void; minDate?: Date; maxDate?: Date; disabledDates?: Date[] | ((d: Date) => boolean); renderDay?: (d: Date) => React.ReactNode; month?: Date; onMonthChange?: (m: Date) => void; surface?: Surface; rangePreview?: { from?: Date; to?: Date; hover?: Date }; /** Optional hook for tests. */ ['data-testid']?: string; } export const PixelCalendarGrid = forwardRef( function PixelCalendarGrid( { value: valueProp, defaultValue, onChange, minDate, maxDate, disabledDates, renderDay, month, onMonthChange, surface: surfaceProp, rangePreview, ['data-testid']: dataTestId, }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const [value, setValue] = useControllableState({ value: valueProp, defaultValue: defaultValue ?? null, onChange: onChange ? ((next) => { if (next) onChange(next); }) : undefined, }); // Uncontrolled fallback for month when `month` not provided. const initial = month ?? value ?? new Date(); const [internalMonth, setInternalMonth] = useState( new Date(initial.getFullYear(), initial.getMonth(), 1), ); const viewDate = month ?? internalMonth; const viewYear = viewDate.getFullYear(); const viewMonth = viewDate.getMonth(); const setView = useCallback( (next: Date) => { const firstOfMonth = new Date(next.getFullYear(), next.getMonth(), 1); if (month === undefined) setInternalMonth(firstOfMonth); onMonthChange?.(firstOfMonth); }, [month, onMonthChange], ); const goPrev = () => { setView(new Date(viewYear, viewMonth - 1, 1)); }; const goNext = () => { setView(new Date(viewYear, viewMonth + 1, 1)); }; const minStart = useMemo(() => (minDate ? startOfDay(minDate) : null), [minDate]); const maxStart = useMemo(() => (maxDate ? startOfDay(maxDate) : null), [maxDate]); const isDisabled = useCallback( (d: Date): boolean => { const ds = startOfDay(d); if (minStart && ds.getTime() < minStart.getTime()) return true; if (maxStart && ds.getTime() > maxStart.getTime()) return true; if (Array.isArray(disabledDates)) { if (disabledDates.some((x) => sameDay(x, d))) return true; } else if (typeof disabledDates === 'function') { if (disabledDates(d)) return true; } return false; }, [minStart, maxStart, disabledDates], ); const today = useMemo(() => startOfDay(new Date()), []); const cells = useMemo( () => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth], ); // Roving tabindex anchor. const [focusedDate, setFocusedDate] = useState( () => value ?? new Date(viewYear, viewMonth, 1), ); const dayBtnRefs = useRef>(new Map()); const shouldRefocusRef = useRef(false); React.useEffect(() => { if (!shouldRefocusRef.current) return; const key = toISO(focusedDate); const btn = dayBtnRefs.current.get(key); if (btn) { btn.focus(); shouldRefocusRef.current = false; } }, [focusedDate]); const moveFocus = useCallback( (deltaDays: number) => { setFocusedDate((prev) => { const next = new Date( prev.getFullYear(), prev.getMonth(), prev.getDate() + deltaDays, ); shouldRefocusRef.current = true; if ( next.getFullYear() !== viewYear || next.getMonth() !== viewMonth ) { setView(new Date(next.getFullYear(), next.getMonth(), 1)); } return next; }); }, [viewYear, viewMonth, setView], ); const jumpFocus = useCallback( (next: Date) => { shouldRefocusRef.current = true; setFocusedDate(next); if ( next.getFullYear() !== viewYear || next.getMonth() !== viewMonth ) { setView(new Date(next.getFullYear(), next.getMonth(), 1)); } }, [viewYear, viewMonth, setView], ); const pickDate = (d: Date) => { if (isDisabled(d)) return; setValue(startOfDay(d)); }; // Range preview math. const previewBounds = useMemo<{ start: Date; end: Date } | null>(() => { if (!rangePreview) return null; const { from, to, hover } = rangePreview; if (!from) return null; const second = to ?? hover; if (!second) return null; const a = startOfDay(from); const b = startOfDay(second); const start = a.getTime() <= b.getTime() ? a : b; const end = a.getTime() <= b.getTime() ? b : a; return { start, end }; }, [rangePreview]); const inRange = useCallback( (d: Date): boolean => { if (!previewBounds) return false; const t = startOfDay(d).getTime(); return ( t >= previewBounds.start.getTime() && t <= previewBounds.end.getTime() ); }, [previewBounds], ); const isRangeEndpoint = useCallback( (d: Date): boolean => { if (!previewBounds) return false; return ( sameDay(d, previewBounds.start) || sameDay(d, previewBounds.end) ); }, [previewBounds], ); const handleGridKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowLeft': e.preventDefault(); moveFocus(-1); return; case 'ArrowRight': e.preventDefault(); moveFocus(1); return; case 'ArrowUp': e.preventDefault(); moveFocus(-7); return; case 'ArrowDown': e.preventDefault(); moveFocus(7); return; case 'Home': e.preventDefault(); moveFocus(-focusedDate.getDay()); return; case 'End': e.preventDefault(); moveFocus(6 - focusedDate.getDay()); return; case 'PageUp': e.preventDefault(); jumpFocus( new Date( focusedDate.getFullYear(), focusedDate.getMonth() - 1, focusedDate.getDate(), ), ); return; case 'PageDown': e.preventDefault(); jumpFocus( new Date( focusedDate.getFullYear(), focusedDate.getMonth() + 1, focusedDate.getDate(), ), ); return; case 'Enter': case ' ': e.preventDefault(); pickDate(focusedDate); return; default: return; } }, [focusedDate, moveFocus, jumpFocus], ); const weekRows = useMemo(() => { const rows: DayCell[][] = []; for (let i = 0; i < 6; i++) rows.push(cells.slice(i * 7, i * 7 + 7)); return rows; }, [cells]); return (
{MONTH_NAMES[viewMonth]} {viewYear}
{/* role="grid" only allows row/rowgroup children, and columnheader cells must live inside a row — wrap the weekday header strip in its own display:contents row like the week rows below. */}
{WEEKDAY_SHORT.map((wd) => (
{wd}
))}
{weekRows.map((week, rowIdx) => (
{week.map((cell) => { const disabled = isDisabled(cell.date); const isToday = sameDay(cell.date, today); const isSelected = value ? sameDay(cell.date, value) : false; const isFocused = sameDay(cell.date, focusedDate); const cellInRange = inRange(cell.date); const cellEndpoint = isRangeEndpoint(cell.date); const iso = toISO(cell.date); return ( ); })}
))}
); }, ); PixelCalendarGrid.displayName = 'PixelCalendarGrid';