Files
kjol/go/jsruntime/uikit/Calendar.tsx

212 lines
8.8 KiB
TypeScript

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 (
<div class={rootCls()} attr:key={key()}>
<div class={headerCls()}>
<button class={CAL_NAV_BTN} onclick={prevMonth}>
<Icon icon="chevron-left" size={16}/>
</button>
<span class={myCls()}>{MONTHS[currentMonth()] + " " + currentYear()}</span>
<button class={CAL_NAV_BTN} onclick={nextMonth}>
<Icon icon="chevron-right" size={16}/>
</button>
</div>
<div class={weekdaysCls()}>
<For each={DAYS}>{(day) => <div class={weekdayCls()}>{day}</div>}</For>
</div>
<div class={daysCls()}>
<For each={days()}>{(date, idx) => (
<button class={dayClass(date, idx())} onclick={() => selectDate(date)} disabled={!date}>
<span class={dayNumberClass(date)}>{date ? date.getDate() : ""}</span>
{date && props.renderDay ? props.renderDay(toDateKey(date), date) : ""}
</button>
)}</For>
</div>
</div>
);
}