import * as React from 'react'; import { createPortal } from 'react-dom'; import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; import { cn } from '@/lib/utils'; // ── Browser detection (stable — checked once at module load) ── const isFirefox = typeof navigator !== 'undefined' && /Firefox\//i.test(navigator.userAgent); // ── Helpers ── function getDaysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate(); } function getFirstDayOfWeek(year: number, month: number): number { return new Date(year, month, 1).getDay(); } function pad(n: number): string { return n.toString().padStart(2, '0'); } function formatDate(y: number, m: number, d: number): string { return `${y}-${pad(m + 1)}-${pad(d)}`; } function to12Hour(h24: number): { hour: number; ampm: 'AM' | 'PM' } { if (h24 === 0) return { hour: 12, ampm: 'AM' }; if (h24 < 12) return { hour: h24, ampm: 'AM' }; if (h24 === 12) return { hour: 12, ampm: 'PM' }; return { hour: h24 - 12, ampm: 'PM' }; } function to24Hour(h12: number, ampm: string): number { const isPM = ampm.toUpperCase() === 'PM'; if (h12 === 12) return isPM ? 12 : 0; return isPM ? h12 + 12 : h12; } const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; const HOUR_OPTIONS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; // ── Props ── export interface DatePickerProps { variant?: 'button' | 'input'; mode?: 'date' | 'datetime'; value: string; onChange: (value: string) => void; onBlur?: () => void; onKeyDown?: (e: React.KeyboardEvent) => void; id?: string; name?: string; autoComplete?: string; required?: boolean; disabled?: boolean; className?: string; placeholder?: string; min?: string; max?: string; yearRange?: [number, number]; } // ── Component ── const DatePicker = React.forwardRef( ( { variant = 'button', mode = 'date', value, onChange, onBlur, onKeyDown, id, name, autoComplete, required, disabled, className, placeholder, min, max, yearRange, }, ref ) => { const currentYear = new Date().getFullYear(); const [startYear, endYear] = yearRange ?? [1900, currentYear + 20]; // Parse ISO value into parts const parseDateValue = () => { if (!value) return null; const parts = value.split('T'); const dateParts = parts[0]?.split('-'); if (!dateParts || dateParts.length !== 3) return null; const y = parseInt(dateParts[0], 10); const m = parseInt(dateParts[1], 10) - 1; const d = parseInt(dateParts[2], 10); if (isNaN(y) || isNaN(m) || isNaN(d)) return null; const timeParts = parts[1]?.split(':'); const hour = timeParts ? parseInt(timeParts[0], 10) : 0; const minute = timeParts ? parseInt(timeParts[1], 10) : 0; return { year: y, month: m, day: d, hour, minute }; }; const parsed = parseDateValue(); const today = new Date(); const [open, setOpen] = React.useState(false); const [viewYear, setViewYear] = React.useState(parsed?.year ?? today.getFullYear()); const [viewMonth, setViewMonth] = React.useState(parsed?.month ?? today.getMonth()); const [hour, setHour] = React.useState(parsed?.hour ?? 0); const [minute, setMinute] = React.useState(parsed?.minute ?? 0); // Refs const triggerRef = React.useRef(null); const wrapperRef = React.useRef(null); const inputElRef = React.useRef(null); const popupRef = React.useRef(null); const blurTimeoutRef = React.useRef>(); const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); React.useImperativeHandle(ref, () => triggerRef.current!); // Sync popup view state when value changes (only when popup is closed) React.useEffect(() => { if (open) return; const p = parseDateValue(); if (p) { setViewYear(p.year); setViewMonth(p.month); setHour(p.hour); setMinute(p.minute); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, open]); // Position popup // Firefox + input variant falls through to button variant, so use triggerRef const usesNativeInput = variant === 'input' && !isFirefox; const updatePosition = React.useCallback(() => { const el = usesNativeInput ? wrapperRef.current : triggerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const popupHeight = mode === 'datetime' ? 370 : 320; const spaceBelow = window.innerHeight - rect.bottom; const flipped = spaceBelow < popupHeight && rect.top > popupHeight; setPos({ top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - 290), }); }, [mode, usesNativeInput]); React.useEffect(() => { if (!open) return; updatePosition(); window.addEventListener('scroll', updatePosition, true); window.addEventListener('resize', updatePosition); return () => { window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('resize', updatePosition); }; }, [open, updatePosition]); const closePopup = React.useCallback( (refocusTrigger = true) => { setOpen(false); if (!usesNativeInput) { onBlur?.(); } else if (refocusTrigger) { setTimeout(() => inputElRef.current?.focus(), 0); } }, [usesNativeInput, onBlur] ); // Dismiss on click outside React.useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (popupRef.current?.contains(e.target as Node)) return; if (!usesNativeInput && triggerRef.current?.contains(e.target as Node)) return; if (usesNativeInput && wrapperRef.current?.contains(e.target as Node)) return; closePopup(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open, usesNativeInput, closePopup]); // Dismiss on Escape React.useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); closePopup(true); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [open, closePopup]); // Input variant: smart blur — only fires when focus truly leaves the component const handleInputBlur = React.useCallback(() => { if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); blurTimeoutRef.current = setTimeout(() => { const active = document.activeElement; if ( popupRef.current?.contains(active) || wrapperRef.current?.contains(active) ) return; onBlur?.(); }, 10); }, [onBlur]); React.useEffect(() => { return () => { if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); }; }, []); const openPopup = () => { if (disabled) return; const p = parseDateValue(); if (p) { setViewYear(p.year); setViewMonth(p.month); setHour(p.hour); setMinute(p.minute); } setOpen(true); }; const togglePopup = () => { if (open) closePopup(true); else openPopup(); }; // Min/Max boundaries const minDate = min ? new Date(min + 'T00:00:00') : null; const maxDate = max ? new Date(max + 'T00:00:00') : null; const isDayDisabled = (y: number, m: number, d: number) => { const date = new Date(y, m, d); if (minDate && date < minDate) return true; if (maxDate && date > maxDate) return true; return false; }; const selectDay = (day: number) => { if (isDayDisabled(viewYear, viewMonth, day)) return; const dateStr = formatDate(viewYear, viewMonth, day); if (mode === 'datetime') { onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`); } else { onChange(dateStr); closePopup(true); } }; const handleTimeChange = (newHour: number, newMinute: number) => { setHour(newHour); setMinute(newMinute); if (parsed) { const dateStr = formatDate(parsed.year, parsed.month, parsed.day); onChange(`${dateStr}T${pad(newHour)}:${pad(newMinute)}`); } }; const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); } else setViewMonth((m) => m - 1); }; const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); } else setViewMonth((m) => m + 1); }; // Calendar grid const daysInMonth = getDaysInMonth(viewYear, viewMonth); const firstDay = getFirstDayOfWeek(viewYear, viewMonth); const cells: (number | null)[] = []; for (let i = 0; i < firstDay; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); const isTodayDay = (d: number) => d === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear(); const isSelected = (d: number) => parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year; // 12-hour display values for time selectors const { hour: h12, ampm: currentAmpm } = to12Hour(hour); // Button variant display text const displayText = (() => { if (!parsed) return ''; const monthName = MONTH_NAMES[parsed.month]; const base = `${monthName} ${parsed.day}, ${parsed.year}`; if (mode === 'datetime') { const { hour: dh, ampm: da } = to12Hour(parsed.hour); return `${base} ${dh}:${pad(parsed.minute)} ${da}`; } return base; })(); // Year options const yearOptions: number[] = []; for (let y = startYear; y <= endYear; y++) yearOptions.push(y); // ── Shared popup ── const popup = open ? createPortal(
e.stopPropagation()} style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }} className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in" > {/* Month/Year nav */}
{/* Day headers */}
{DAY_HEADERS.map((d) => (
{d}
))}
{/* Day grid */}
{cells.map((day, i) => day === null ? (
) : ( ) )}
{/* Time selectors — 12-hour with AM/PM */} {mode === 'datetime' && (
:
)}
, document.body ) : null; // ── Input variant (Chromium only) ── // Firefox: falls through to the button variant below because Firefox has no // CSS pseudo-element to hide its native calendar icon (Mozilla bug 1830890). // Chromium: uses native type="date"/"datetime-local" for segmented editing UX, // with the native icon hidden via CSS in index.css (.datepicker-wrapper rule). if (variant === 'input' && !isFirefox) { return ( <>
onChange(e.target.value)} onBlur={handleInputBlur} onKeyDown={(e) => { if (open && e.key === 'Enter') { e.preventDefault(); return; } onKeyDown?.(e); }} required={required} disabled={disabled} min={min} max={max} className={cn( 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', className )} />
{popup} ); } // ── Button variant: non-editable trigger (registration DOB) ── return ( <> {required && ( {}} style={{ position: 'absolute' }} /> )} {popup} ); } ); DatePicker.displayName = 'DatePicker'; export { DatePicker };