move tsx kit -> uikit

This commit is contained in:
2026-07-13 09:17:11 -04:00
parent 98978e4930
commit 9243d142e9
37 changed files with 20 additions and 19 deletions

542
web/uikit/DatePicker.tsx Normal file
View File

@@ -0,0 +1,542 @@
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()]}&nbsp;{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>
);
}