import { createSignal, createEffect, Show, onMount, onCleanup, For } from "solid-js"; import { Portal } from "solid-js/web"; import { Icon } from "./Icons.tsx"; import { FormInput } from "./Forms.tsx"; import { readAccessor, accessor, type MaybeAccessor } from "../utils/accessors.ts"; import { CAL_PICKER_ROOT, CAL_HEADER_PICKER, CAL_NAV_BTN, CAL_MY_PICKER, CAL_WEEKDAYS_PICKER, CAL_WEEKDAY_PICKER, CAL_DAYS_PICKER, CAL_DAY_PICKER_BASE, CAL_SELECT, } from "./Calendar.tsx"; const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; 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 dayClass(date: Date | null, selected: boolean, today: boolean): string { let c = CAL_DAY_PICKER_BASE; if (!date) c += " cursor-default"; else c += " hover:bg-neutral-100"; if (today) c += " font-bold text-primary"; if (selected) c += " !bg-primary !text-white"; return c; } /** Parse typed or pasted text into YYYY-MM-DD, or "" if invalid. */ function parseDateInput(text: string): string { const trimmed = text.trim(); if (!trimmed) return ""; if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { const [y, m, d] = trimmed.split("-").map((n) => parseInt(n, 10)); const iso = new Date(y, m - 1, d); if (!isNaN(iso.getTime()) && iso.getFullYear() === y && iso.getMonth() === m - 1 && iso.getDate() === d) { return trimmed; } } const parsed = new Date(trimmed); if (!isNaN(parsed.getTime())) { const y = parsed.getFullYear(); const m = String(parsed.getMonth() + 1).padStart(2, "0"); const d = String(parsed.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } return ""; } function formatDisplayDate(iso: string): string { if (!iso) return ""; const parts = iso.split("-"); if (parts.length < 3) return iso; const d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10)); if (isNaN(d.getTime())) return ""; return d.toLocaleDateString(); } interface CalendarDropdownProps { selected?: MaybeAccessor; onSelect?: (key: string) => void; } function CalendarDropdown(props: CalendarDropdownProps) { const today = new Date(); const selected = () => readAccessor(props.selected, ""); const [viewDate, setViewDate] = createSignal(selected() ? new Date(selected()) : today); const [key, setKey] = createSignal(0); createEffect(() => { const v = selected(); if (v) { const d = new Date(v); if (!isNaN(d.getTime())) { setViewDate(d); } } }); const currentMonth = () => viewDate().getMonth(); const currentYear = () => viewDate().getFullYear(); const getDays = () => { 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 isSelected = (date: Date | null): boolean => { if (!date) return false; const v = selected(); if (!v) return false; const sel = new Date(v); 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; const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); props.onSelect?.(`${y}-${m}-${d}`); }; const goPrev = (e: MouseEvent) => { e.stopPropagation(); setViewDate(new Date(currentYear(), currentMonth() - 1, 1)); setKey(k => k + 1); }; const goNext = (e: MouseEvent) => { e.stopPropagation(); setViewDate(new Date(currentYear(), currentMonth() + 1, 1)); setKey(k => k + 1); }; return (
{MONTHS[currentMonth()]} {currentYear()}
{(day) =>
{day}
}
{(date) => ( )}
); } const DATE_PICKER_WRAP = "relative w-full min-w-0"; const DATE_PICKER_FIELD = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"; const DATE_PICKER_DROPDOWN = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"; const DATE_PICKER_ICON_BTN = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"; const DATE_PICKER_CLEAR_BTN = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"; interface DatePickerProps { value?: MaybeAccessor; onchange?: (value: string) => void; placeholder?: string; small?: boolean; clearable?: boolean; } export function DatePicker(props: DatePickerProps) { const [open, setOpen] = createSignal(false); const [localValue, setLocalValue] = createSignal(""); const [editing, setEditing] = createSignal(false); const [draft, setDraft] = createSignal(""); const [dropdownPos, setDropdownPos] = createSignal({ top: 0, left: 0, width: 0 }); let containerRef: HTMLDivElement | undefined; let fieldRef: HTMLDivElement | undefined; let dropdownRef: HTMLDivElement | undefined; const externalValue = () => readAccessor(props.value, ""); const updateDropdownPos = () => { if (!fieldRef) return; const rect = fieldRef.getBoundingClientRect(); setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); }; const dropdownStyle = () => { const pos = dropdownPos(); const maxW = Math.min(400, window.innerWidth - pos.left - 8); return `position:fixed;top:${pos.top}px;left:${pos.left}px;min-width:${Math.max(pos.width, 16 * 16)}px;width:max-content;max-width:${maxW}px;z-index:200;`; }; createEffect(() => { if (open()) { updateDropdownPos(); let rafId: number; const trackPosition = () => { updateDropdownPos(); rafId = requestAnimationFrame(trackPosition); }; rafId = requestAnimationFrame(trackPosition); onCleanup(() => cancelAnimationFrame(rafId)); } }); createEffect(() => { if (!editing()) { setLocalValue(externalValue()); } }); const hasValue = () => !!localValue(); const displayValue = () => formatDisplayDate(localValue()); const inputValue = () => editing() ? draft() : displayValue(); const commitValue = (raw: string) => { const parsed = parseDateInput(raw); setLocalValue(parsed); setDraft(parsed ? formatDisplayDate(parsed) : ""); if (typeof props.onchange === "function") props.onchange(parsed); }; const handleSelect = (dateStr: string) => { setEditing(false); setLocalValue(dateStr); setDraft(formatDisplayDate(dateStr)); if (typeof props.onchange === "function") props.onchange(dateStr); setOpen(false); }; const handleClear = (e: MouseEvent) => { e.stopPropagation(); setEditing(false); setLocalValue(""); setDraft(""); if (typeof props.onchange === "function") props.onchange(""); setOpen(false); }; const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; const inContainer = containerRef?.contains(target); const inDropdown = dropdownRef?.contains(target); if (!inContainer && !inDropdown) { if (editing()) { commitValue(draft()); setEditing(false); } setOpen(false); } }; onMount(() => { document.addEventListener("click", handleClickOutside); }); onCleanup(() => { document.removeEventListener("click", handleClickOutside); }); const openCalendar = (_e: MouseEvent) => { updateDropdownPos(); setOpen(true); }; const toggleCalendar = (e: MouseEvent) => { e.stopPropagation(); if (!open()) updateDropdownPos(); setOpen((v) => !v); }; const handleInputFocus = (_e: FocusEvent) => { setEditing(true); setDraft(displayValue()); }; const handleInput = (e: InputEvent & { currentTarget: HTMLInputElement }) => { setDraft(e.currentTarget.value); }; const handleInputBlur = (_e: FocusEvent) => { commitValue(draft()); setEditing(false); }; const inputCls = () => "w-full" + (props.clearable && hasValue() ? " pr-14" : " pr-9"); return (
containerRef = el}>
fieldRef = el} onclick={openCalendar}>
dropdownRef = el} data-floating-content="true" class={DATE_PICKER_DROPDOWN} style={dropdownStyle()} onclick={(e: MouseEvent) => e.stopPropagation()} >
); } const MONTHS_SHORT = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; function CalendarDropdownDOB(props: CalendarDropdownProps) { const today = new Date(); const currentYear = today.getFullYear(); const years = Array.from({ length: 120 }, (_, i) => currentYear - i); const [viewDate, setViewDate] = createSignal(props.selected ? new Date(props.selected as string) : today); const [key, setKey] = createSignal(0); createEffect(() => { if (props.selected) { const d = new Date(props.selected as string); if (!isNaN(d.getTime())) { setViewDate(d); } } }); const currentMonth = () => viewDate().getMonth(); const currentYearView = () => viewDate().getFullYear(); const getDays = () => { const year = currentYearView(); 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 handleMonthChange = (e: Event) => { const month = parseInt((e.target as HTMLSelectElement).value); if (!isNaN(month)) { setViewDate(new Date(currentYearView(), month, 1)); } }; const handleYearChange = (e: Event) => { const year = parseInt((e.target as HTMLSelectElement).value); if (!isNaN(year)) { setViewDate(new Date(year, currentMonth(), 1)); } }; const isSelected = (date: Date | null): boolean => { if (!date || !props.selected) return false; const sel = new Date(props.selected as string); 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; const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); props.onSelect?.(`${y}-${m}-${d}`); }; const goPrev = (e: MouseEvent) => { e.stopPropagation(); setViewDate(new Date(currentYearView(), currentMonth() - 1, 1)); setKey(k => k + 1); }; const goNext = (e: MouseEvent) => { e.stopPropagation(); setViewDate(new Date(currentYearView(), currentMonth() + 1, 1)); setKey(k => k + 1); }; return (
{(day) =>
{day}
}
{(date) => ( )}
); } export function DateOfBirthPicker(props: DatePickerProps) { const [open, setOpen] = createSignal(false); const [editing, setEditing] = createSignal(false); const [draft, setDraft] = createSignal(""); let containerRef: HTMLDivElement | undefined; const isoValue = () => readAccessor(props.value, ""); const displayValue = () => formatDisplayDate(isoValue()); const inputValue = () => editing() ? draft() : displayValue(); const commitValue = (raw: string) => { const parsed = parseDateInput(raw); setDraft(parsed ? formatDisplayDate(parsed) : ""); props.onchange?.(parsed); }; const handleSelect = (dateStr: string) => { setEditing(false); setDraft(formatDisplayDate(dateStr)); props.onchange?.(dateStr); setOpen(false); }; const handleClickOutside = (e: MouseEvent) => { if (containerRef && !containerRef.contains(e.target as Node)) { if (editing()) { commitValue(draft()); setEditing(false); } setOpen(false); } }; onMount(() => { document.addEventListener("click", handleClickOutside); }); onCleanup(() => { document.removeEventListener("click", handleClickOutside); }); const openCalendar = (_e: MouseEvent) => { setOpen(true); }; const toggleCalendar = (e: MouseEvent) => { e.stopPropagation(); setOpen((v) => !v); }; return (
containerRef = el}>
{ setEditing(true); setDraft(displayValue()); }} oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDraft(e.currentTarget.value)} onblur={(_e: FocusEvent) => { commitValue(draft()); setEditing(false); }} class="w-full pr-9" />
e.stopPropagation()}>
); }