import { createSignal, createMemo, For, createEffect, JSXElement } from "solid-js"; import { Icon } from "./Icons.tsx"; const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; const MONTHS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; // -- Shared Tailwind class constants (also used by DatePicker) -- export const CAL_PICKER_ROOT = "p-2 min-w-[240px]"; export const CAL_MONTH_ROOT = "p-0 min-w-0 w-full bg-surface border border-line rounded-default shadow-sm overflow-hidden"; export const CAL_HEADER_PICKER = "flex items-center justify-between mb-2 gap-1"; export const CAL_HEADER_MONTH = "flex items-center justify-between gap-1 py-3 px-4 border-b border-line bg-surface-muted"; export const CAL_NAV_BTN = "bg-transparent border-0 p-1 cursor-pointer text-ink-muted rounded-sm flex items-center justify-center hover:bg-surface-raised hover:text-ink"; export const CAL_MY_PICKER = "text-sm font-semibold text-ink mx-3 whitespace-nowrap"; export const CAL_MY_MONTH = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-ink whitespace-nowrap"; export const CAL_WEEKDAYS_PICKER = "grid grid-cols-7 gap-[2px] mb-1"; export const CAL_WEEKDAYS_MONTH = "grid grid-cols-7 border-b border-line"; export const CAL_WEEKDAY_PICKER = "text-center text-xs font-semibold text-ink-muted p-1"; export const CAL_WEEKDAY_MONTH = "text-center text-xs font-semibold text-ink-muted p-2 uppercase tracking-wider"; export const CAL_DAYS_PICKER = "grid grid-cols-7 gap-[2px]"; export const CAL_DAYS_MONTH = "grid grid-cols-7"; export const CAL_DAY_PICKER_BASE = "aspect-square flex items-center justify-center text-sm bg-transparent border-0 rounded-sm cursor-pointer text-ink p-0"; export const CAL_DAY_MONTH_BASE = "min-h-[6.5rem] flex flex-col items-stretch justify-start p-1.5 border-r border-b border-line text-left gap-1 text-xs bg-transparent cursor-pointer"; export const CAL_SELECT = "flex-1 py-1 px-2 text-sm font-semibold border border-line rounded-sm bg-surface text-ink cursor-pointer focus:outline-hidden focus:border-primary"; function getDaysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate(); } function getFirstDayOfMonth(year: number, month: number): number { return new Date(year, month, 1).getDay(); } function toDateKey(date: Date | null | undefined): string { if (!date) return ""; const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } export type CalendarVariant = "picker" | "month"; interface CalendarProps { selected?: string | Date; viewMonth?: string | Date; onSelect?: (key: string) => void; variant?: CalendarVariant; renderDay?: (key: string, date: Date) => JSXElement; _reset?: unknown; } export function Calendar(props: CalendarProps) { const today = new Date(); const [viewMonth, setViewMonth] = createSignal(new Date(today.getFullYear(), today.getMonth(), 1)); const [key, setKey] = createSignal(0); createEffect(() => { if (props.selected) { const d = new Date(props.selected); if (!isNaN(d.getTime())) { setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1)); } } }); createEffect(() => { props._reset; setKey(k => k + 1); setViewMonth(new Date(today.getFullYear(), today.getMonth(), 1)); }); // Sync viewMonth from parent only when the parent's value actually changes // to a different month. Tracking a stamp prevents the parent from clobbering // the user's local month navigation on unrelated re-renders. let lastPropStamp: number | null = null; createEffect(() => { const vm = props.viewMonth; if (!vm) return; const d = vm instanceof Date ? vm : new Date(vm); if (!(d instanceof Date) || isNaN(d.getTime())) return; const stamp = d.getFullYear() * 12 + d.getMonth(); if (stamp === lastPropStamp) return; lastPropStamp = stamp; setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1)); }); const currentMonth = createMemo(() => viewMonth().getMonth()); const currentYear = createMemo(() => viewMonth().getFullYear()); const days = createMemo(() => { const year = currentYear(); const month = currentMonth(); const daysInMonth = getDaysInMonth(year, month); const firstDay = getFirstDayOfMonth(year, month); const daysArray: (Date | null)[] = []; for (let i = 0; i < firstDay; i++) { daysArray.push(null); } for (let i = 1; i <= daysInMonth; i++) { daysArray.push(new Date(year, month, i)); } return daysArray; }); const prevMonth = () => setViewMonth(new Date(currentYear(), currentMonth() - 1, 1)); const nextMonth = () => setViewMonth(new Date(currentYear(), currentMonth() + 1, 1)); const isSelected = (date: Date | null): boolean => { if (!date || !props.selected) return false; const sel = new Date(props.selected); if (isNaN(sel.getTime())) return false; return date.getFullYear() === sel.getFullYear() && date.getMonth() === sel.getMonth() && date.getDate() === sel.getDate(); }; const isToday = (date: Date | null): boolean => { if (!date) return false; return date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); }; const selectDate = (date: Date | null) => { if (!date) return; props.onSelect?.(toDateKey(date)); }; const variant = (): CalendarVariant => props.variant || "picker"; const isMonth = () => variant() === "month"; const rootCls = () => isMonth() ? CAL_MONTH_ROOT : CAL_PICKER_ROOT; const headerCls = () => isMonth() ? CAL_HEADER_MONTH : CAL_HEADER_PICKER; const myCls = () => isMonth() ? CAL_MY_MONTH : CAL_MY_PICKER; const weekdaysCls = () => isMonth() ? CAL_WEEKDAYS_MONTH : CAL_WEEKDAYS_PICKER; const weekdayCls = () => isMonth() ? CAL_WEEKDAY_MONTH : CAL_WEEKDAY_PICKER; const daysCls = () => isMonth() ? CAL_DAYS_MONTH : CAL_DAYS_PICKER; // Day button class. nth-child(7n) (last column) skips right border in // month variant — we compute it from the array index since the Tailwind // compiler doesn't support [&:nth-child(7n)] arbitrary variants. const dayClass = (date: Date | null, idx: number): string => { const empty = !date; const selected = isSelected(date); const today = isToday(date); if (isMonth()) { let c = CAL_DAY_MONTH_BASE; if (idx % 7 === 6) c += " border-r-0"; if (empty) c += " bg-surface-muted cursor-default"; else c += " hover:bg-surface-muted"; if (selected) c += " bg-primary/10 text-ink"; return c; } let c = CAL_DAY_PICKER_BASE; if (empty) c += " cursor-default"; else c += " hover:bg-surface-raised"; if (today) c += " font-bold text-primary"; if (selected) c += " !bg-primary !text-white"; return c; }; const dayNumberClass = (date: Date | null): string => { if (isMonth()) { const base = "text-sm font-semibold text-ink-muted self-end px-1 py-0.5"; if (date && isToday(date)) { return "bg-primary text-white rounded-full w-6 h-6 inline-flex items-center justify-center p-0 self-end text-sm font-semibold"; } return base; } return "leading-none"; }; return (
{MONTHS[currentMonth()] + " " + currentYear()}
{(day) =>
{day}
}
{(date, idx) => ( )}
); }