restructure project, add claudemd
This commit is contained in:
211
web/kit/Calendar.tsx
Normal file
211
web/kit/Calendar.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
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-white border border-neutral-200 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-neutral-200 bg-neutral-50";
|
||||
|
||||
export const CAL_NAV_BTN = "bg-transparent border-0 p-1 cursor-pointer text-text-muted rounded-sm flex items-center justify-center hover:bg-neutral-100 hover:text-text-body";
|
||||
|
||||
export const CAL_MY_PICKER = "text-sm font-semibold text-text-heading mx-3 whitespace-nowrap";
|
||||
export const CAL_MY_MONTH = "text-lg font-heading mx-4 flex-1 text-center font-semibold text-text-heading 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-neutral-200";
|
||||
export const CAL_WEEKDAY_PICKER = "text-center text-xs font-semibold text-text-muted p-1";
|
||||
export const CAL_WEEKDAY_MONTH = "text-center text-xs font-semibold text-text-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-text-body 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-neutral-200 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-neutral-200 rounded-sm bg-white text-text-heading 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-neutral-50 cursor-default";
|
||||
else c += " hover:bg-neutral-50";
|
||||
if (selected) c += " bg-primary/10 text-text-body";
|
||||
return c;
|
||||
}
|
||||
let c = CAL_DAY_PICKER_BASE;
|
||||
if (empty) 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;
|
||||
};
|
||||
|
||||
const dayNumberClass = (date: Date | null): string => {
|
||||
if (isMonth()) {
|
||||
const base = "text-sm font-semibold text-text-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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user