543 lines
19 KiB
TypeScript
543 lines
19 KiB
TypeScript
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<string>;
|
|
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 (
|
|
<div class={CAL_PICKER_ROOT} attr:key={key()}>
|
|
<div class={CAL_HEADER_PICKER}>
|
|
<button type="button" class={CAL_NAV_BTN} onclick={goPrev}>
|
|
<Icon icon="chevron-left" size={16}/>
|
|
</button>
|
|
<span class={CAL_MY_PICKER}>{MONTHS[currentMonth()]} {currentYear()}</span>
|
|
<button type="button" class={CAL_NAV_BTN} onclick={goNext}>
|
|
<Icon icon="chevron-right" size={16}/>
|
|
</button>
|
|
</div>
|
|
<div class={CAL_WEEKDAYS_PICKER}>
|
|
<For each={DAYS}>{(day) => <div class={CAL_WEEKDAY_PICKER}>{day}</div>}</For>
|
|
</div>
|
|
<div class={CAL_DAYS_PICKER}>
|
|
<For each={getDays()}>{(date) => (
|
|
<button type="button" class={dayClass(date, isSelected(date), isToday(date))} onclick={(e: MouseEvent) => { e.stopPropagation(); selectDate(date); }} disabled={!date}>
|
|
{date ? date.getDate() : ""}
|
|
</button>
|
|
)}</For>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string>;
|
|
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 (
|
|
<div class={DATE_PICKER_WRAP} ref={(el: HTMLDivElement) => containerRef = el}>
|
|
<div class={DATE_PICKER_FIELD} ref={(el: HTMLDivElement) => fieldRef = el} onclick={openCalendar}>
|
|
<FormInput
|
|
type="text"
|
|
small={props.small}
|
|
value={inputValue()}
|
|
placeholder={props.placeholder || "Select date"}
|
|
onfocus={handleInputFocus}
|
|
oninput={handleInput}
|
|
onblur={handleInputBlur}
|
|
class={inputCls()}
|
|
/>
|
|
<Show when={props.clearable && hasValue()}>
|
|
<button type="button" class={DATE_PICKER_CLEAR_BTN} onclick={handleClear} aria-label="Clear date">
|
|
<Icon icon="xmark" size={14} class="block leading-none"/>
|
|
</button>
|
|
</Show>
|
|
<button type="button" class={DATE_PICKER_ICON_BTN} onclick={toggleCalendar} aria-label="Open calendar">
|
|
<Icon icon="calendar" size={16} class="block leading-none"/>
|
|
</button>
|
|
</div>
|
|
<Show when={open()}>
|
|
<Portal>
|
|
<div
|
|
ref={(el: HTMLDivElement) => dropdownRef = el}
|
|
data-floating-content="true"
|
|
class={DATE_PICKER_DROPDOWN}
|
|
style={dropdownStyle()}
|
|
onclick={(e: MouseEvent) => e.stopPropagation()}
|
|
>
|
|
<CalendarDropdown selected={localValue} onSelect={handleSelect}/>
|
|
</div>
|
|
</Portal>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div class={CAL_PICKER_ROOT} attr:key={key()}>
|
|
<div class={CAL_HEADER_PICKER}>
|
|
<button type="button" class={CAL_NAV_BTN} onclick={goPrev}>
|
|
<Icon icon="chevron-left" size={16}/>
|
|
</button>
|
|
<select class={CAL_SELECT} value={currentMonth()} onchange={handleMonthChange}>
|
|
<For each={MONTHS_SHORT}>{(m, i) => <option value={i()}>{m}</option>}</For>
|
|
</select>
|
|
<select class={CAL_SELECT} value={currentYearView()} onchange={handleYearChange}>
|
|
<For each={years}>{(y) => <option value={y}>{y}</option>}</For>
|
|
</select>
|
|
<button type="button" class={CAL_NAV_BTN} onclick={goNext}>
|
|
<Icon icon="chevron-right" size={16}/>
|
|
</button>
|
|
</div>
|
|
<div class={CAL_WEEKDAYS_PICKER}>
|
|
<For each={DAYS}>{(day) => <div class={CAL_WEEKDAY_PICKER}>{day}</div>}</For>
|
|
</div>
|
|
<div class={CAL_DAYS_PICKER}>
|
|
<For each={getDays()}>{(date) => (
|
|
<button type="button" class={dayClass(date, isSelected(date), isToday(date))} onclick={(e: MouseEvent) => { e.stopPropagation(); selectDate(date); }} disabled={!date}>
|
|
{date ? date.getDate() : ""}
|
|
</button>
|
|
)}</For>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div class={DATE_PICKER_WRAP} ref={(el: HTMLDivElement) => containerRef = el}>
|
|
<div class={DATE_PICKER_FIELD} onclick={openCalendar}>
|
|
<FormInput
|
|
type="text"
|
|
value={inputValue()}
|
|
placeholder={props.placeholder || "Select date of birth"}
|
|
onfocus={(_e: FocusEvent) => { 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"
|
|
/>
|
|
<button type="button" class={DATE_PICKER_ICON_BTN} onclick={toggleCalendar} aria-label="Open calendar">
|
|
<Icon icon="calendar" size={16} class="block leading-none"/>
|
|
</button>
|
|
</div>
|
|
<Show when={open()}>
|
|
<div class={DATE_PICKER_DROPDOWN} onclick={(e: MouseEvent) => e.stopPropagation()}>
|
|
<CalendarDropdownDOB selected={isoValue} onSelect={handleSelect}/>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|