'use client'; import React, { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { Surface, Size, cn, surfaceClasses, useEffectiveSurface, FieldShell, inputBase, focusRing, sizeHeight, toneMap, } from '../common'; import { useControllableState } from '../hooks/useControllableState'; import { PixelPopover } from '../overlay-foundation/PixelPopover'; /* ────────────────────────────────────────────────────────────────────────── Date helpers — pure, no deps. ────────────────────────────────────────────────────────────────────────── */ 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 { const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); return out; } 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 defaultFormat(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(); // 0=Sun // Start grid 'firstWeekday' days before the 1st of the month 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; } /* ────────────────────────────────────────────────────────────────────────── PixelDatePicker ────────────────────────────────────────────────────────────────────────── */ export interface PixelDatePickerProps { value?: Date | null; defaultValue?: Date; onChange?: (date: Date | null) => void; min?: Date; max?: Date; disabledDates?: Date[] | ((d: Date) => boolean); format?: (d: Date) => string; placeholder?: string; clearable?: boolean; presets?: { label: string; value: Date }[]; surface?: Surface; size?: Size; label?: string; hint?: string; error?: string; name?: string; id?: string; /** Hook for tests + custom triggers. */ ['data-testid']?: string; } export const PixelDatePicker = forwardRef< HTMLButtonElement, PixelDatePickerProps >(function PixelDatePicker( { value, defaultValue, onChange, min, max, disabledDates, format = defaultFormat, placeholder = 'Select date', clearable = false, presets, surface: surfaceProp, size = 'md', label, hint, error, name, id, ['data-testid']: dataTestId, }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const reactId = useId(); const triggerId = id ?? `pxl-date-${reactId}`; const s = surfaceClasses(surface); const [current, setCurrent] = useControllableState({ value, defaultValue: defaultValue ?? null, onChange, }); const [open, setOpen] = useState(false); // The month/year currently displayed inside the calendar grid. const initialView = current ?? defaultValue ?? new Date(); const [viewYear, setViewYear] = useState(initialView.getFullYear()); const [viewMonth, setViewMonth] = useState(initialView.getMonth()); // Keep view in sync with the controlled/uncontrolled value when it changes // externally (consumer rerenders with a date in a different month) AND // when the popover opens (jump to the value's month on each open). const currentTime = current ? current.getTime() : null; useEffect(() => { if (!current) return; setViewYear(current.getFullYear()); setViewMonth(current.getMonth()); // Only re-run when the date value's day-instant changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTime]); useEffect(() => { if (!open || !current) return; setViewYear(current.getFullYear()); setViewMonth(current.getMonth()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // Roving tabindex anchor — the date that should currently own focus. const [focusedDate, setFocusedDate] = useState(() => current ?? new Date()); const dayBtnRefs = useRef>(new Map()); const shouldRefocusRef = useRef(false); // Reset focused day when reopening. useEffect(() => { if (open) { setFocusedDate(current ?? new Date()); } }, [open, current]); // After arrow nav, move actual DOM focus onto the new focusedDate cell. useEffect(() => { if (!open || !shouldRefocusRef.current) return; const key = toISO(focusedDate); const btn = dayBtnRefs.current.get(key); if (btn) { btn.focus(); shouldRefocusRef.current = false; } }, [focusedDate, open]); const minStart = useMemo(() => (min ? startOfDay(min) : null), [min]); const maxStart = useMemo(() => (max ? startOfDay(max) : null), [max]); const isDisabled = (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; }; const today = startOfDay(new Date()); const cells = useMemo( () => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth], ); const pickDate = (d: Date) => { if (isDisabled(d)) return; setCurrent(startOfDay(d)); setOpen(false); }; const clear = () => { setCurrent(null); }; const goPrev = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); } else { setViewMonth((m) => m - 1); } }; const goNext = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); } else { setViewMonth((m) => m + 1); } }; // Pre-split cells into 6 weeks of 7 days for explicit `role="row"` wrapping. 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]); const moveFocusedDate = useCallback( (deltaDays: number) => { setFocusedDate((prev) => { const next = new Date( prev.getFullYear(), prev.getMonth(), prev.getDate() + deltaDays, ); shouldRefocusRef.current = true; // Page the view to the new date's month so the cell exists. setViewYear(next.getFullYear()); setViewMonth(next.getMonth()); return next; }); }, [], ); const jumpFocusedDate = useCallback( (next: Date) => { shouldRefocusRef.current = true; setFocusedDate(next); setViewYear(next.getFullYear()); setViewMonth(next.getMonth()); }, [], ); const handleGridKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowLeft': e.preventDefault(); moveFocusedDate(-1); return; case 'ArrowRight': e.preventDefault(); moveFocusedDate(1); return; case 'ArrowUp': e.preventDefault(); moveFocusedDate(-7); return; case 'ArrowDown': e.preventDefault(); moveFocusedDate(7); return; case 'Home': e.preventDefault(); moveFocusedDate(-focusedDate.getDay()); return; case 'End': e.preventDefault(); moveFocusedDate(6 - focusedDate.getDay()); return; case 'PageUp': { e.preventDefault(); jumpFocusedDate( new Date(focusedDate.getFullYear(), focusedDate.getMonth() - 1, focusedDate.getDate()), ); return; } case 'PageDown': { e.preventDefault(); jumpFocusedDate( new Date(focusedDate.getFullYear(), focusedDate.getMonth() + 1, focusedDate.getDate()), ); return; } case 'Enter': case ' ': { e.preventDefault(); pickDate(focusedDate); return; } default: return; } }, [focusedDate, moveFocusedDate, jumpFocusedDate, pickDate], ); const triggerText = current ? format(current) : placeholder; const isPlaceholder = !current; const triggerClasses = cn( inputBase, s.font, s.border, s.radius, s.transition, sizeHeight[size], focusRing, toneMap.neutral.ring, error ? 'border-retro-red/60' : 'border-retro-border-strong', 'inline-flex items-center justify-between px-3 text-left', isPlaceholder && 'text-retro-muted', ); return ( {presets && presets.length > 0 && (
{presets.map((p) => ( ))}
)}
{MONTH_NAMES[viewMonth]} {viewYear}
{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 = current ? sameDay(cell.date, current) : false; const isFocused = sameDay(cell.date, focusedDate); const iso = toISO(cell.date); return ( ); })}
))}
{clearable && current && (
)}
{name && ( )}
); }); PixelDatePicker.displayName = 'PixelDatePicker';