212 lines
8.8 KiB
TypeScript
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-ss font-semibold text-ink-muted p-1";
|
|
export const CAL_WEEKDAY_MONTH = "text-center text-ss 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-ss 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>
|
|
);
|
|
}
|