import * as React from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/lib/cn"; import { startOfMonth, addMonths, isSameDay, isSameMonth, getDayOfWeekMonFirst, stripTime, MONTH_NAMES_ID, MONTH_NAMES_EN, DAY_NAMES_ID_MON_FIRST, DAY_NAMES_EN_MON_FIRST, pickLocale, } from "@/lib/calendar-utils"; /** * DatePicker - iOS-style calendar for picking a single date. * * - 6 rows x 7 columns layout (Monday-first). Days from the previous or * next month are rendered with reduced opacity. * - Single-date selection, controlled or uncontrolled. * - Disabled dates: array or `(date: Date) => boolean` predicate. * - Default locale is `id-ID` (Indonesian month/day names). * - Horizontal swipe changes the month; prev/next buttons are the * keyboard / screen-reader fallback. * - Solid tones only (no gradients). * * Example: * */ export interface DatePickerProps { /** Controlled value. */ value?: Date; /** Default value for uncontrolled mode. */ defaultValue?: Date; /** Called whenever a date is picked. */ onChange?: (date: Date) => void; /** BCP-47 locale tag (default `id-ID`). */ locale?: string; /** Override month names (12 entries). */ monthNames?: string[]; /** Override short day names (Monday-first, 7 entries). */ dayNames?: string[]; /** Minimum selectable date (inclusive). */ minDate?: Date; /** Maximum selectable date (inclusive). */ maxDate?: Date; /** Disabled dates: array or predicate. */ disabledDates?: Date[] | ((date: Date) => boolean); className?: string; } export function DatePicker({ value, defaultValue, onChange, locale = "id-ID", monthNames, dayNames, minDate, maxDate, disabledDates, className, }: DatePickerProps) { const isControlled = value !== undefined; const [internalValue, setInternalValue] = React.useState( defaultValue ); const current = isControlled ? value : internalValue; const [viewMonth, setViewMonth] = React.useState(() => startOfMonth(current ?? new Date()) ); // Keep viewMonth in sync with the controlled value. React.useEffect(() => { if (current) setViewMonth(startOfMonth(current)); }, [current]); const months = monthNames ?? pickLocale(locale, MONTH_NAMES_ID, MONTH_NAMES_EN); const days = dayNames ?? pickLocale(locale, DAY_NAMES_ID_MON_FIRST, DAY_NAMES_EN_MON_FIRST); const minStripped = minDate ? stripTime(minDate) : null; const maxStripped = maxDate ? stripTime(maxDate) : null; const today = React.useMemo(() => stripTime(new Date()), []); const isDisabled = React.useCallback( (d: Date): boolean => { const s = stripTime(d); if (minStripped && s < minStripped) return true; if (maxStripped && s > maxStripped) return true; if (typeof disabledDates === "function") return disabledDates(d); if (Array.isArray(disabledDates)) { return disabledDates.some((x) => isSameDay(x, d)); } return false; }, [minStripped, maxStripped, disabledDates] ); // Build the 6x7 grid (42 cells) starting from the Monday of the week // that contains day-1. const cells = React.useMemo(() => { const list: { date: Date; inMonth: boolean }[] = []; const firstOfMonth = startOfMonth(viewMonth); const offset = getDayOfWeekMonFirst(firstOfMonth); const start = new Date( firstOfMonth.getFullYear(), firstOfMonth.getMonth(), 1 - offset ); for (let i = 0; i < 42; i++) { const d = new Date( start.getFullYear(), start.getMonth(), start.getDate() + i ); list.push({ date: d, inMonth: isSameMonth(d, viewMonth) }); } return list; }, [viewMonth]); const handleSelect = (d: Date) => { if (isDisabled(d)) return; if (!isControlled) setInternalValue(d); onChange?.(d); }; // Horizontal swipe changes month. const wrapperRef = React.useRef(null); const [width, setWidth] = React.useState(0); const [dragX, setDragX] = React.useState(0); const [dragging, setDragging] = React.useState(false); const startX = React.useRef(null); React.useEffect(() => { if (!wrapperRef.current) return; setWidth(wrapperRef.current.clientWidth); const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width) ); ro.observe(wrapperRef.current); return () => ro.disconnect(); }, []); const onTouchStart = (e: React.TouchEvent) => { if (e.touches.length !== 1) return; startX.current = e.touches[0].clientX; setDragging(true); }; const onTouchMove = (e: React.TouchEvent) => { if (!dragging || startX.current === null) return; setDragX(e.touches[0].clientX - startX.current); }; const onTouchEnd = () => { if (!dragging) return; setDragging(false); const w = width > 0 ? width : wrapperRef.current?.clientWidth ?? 0; const dx = dragX; setDragX(0); startX.current = null; if (w > 0 && Math.abs(dx) > w * 0.25) { setViewMonth((prev) => addMonths(prev, dx < 0 ? 1 : -1)); } }; const dragOffset = width > 0 ? (dragX / width) * 100 : 0; return (

{months[viewMonth.getMonth()]} {viewMonth.getFullYear()}

{days.map((d, i) => (
{d}
))}
{cells.map(({ date, inMonth }, i) => { const selected = isSameDay(date, current); const isToday = isSameDay(date, today); const disabled = isDisabled(date); const labelText = date.toLocaleDateString(locale, { weekday: "long", day: "numeric", month: "long", year: "numeric", }); return ( ); })}
); }