Files
openpylon/src/components/card-detail/CalendarPopover.tsx
Your Name bc12b5569a feat: custom calendar date picker replacing native input
Build fully custom CalendarPopover with date-fns + Radix Popover.
Month/year dropdown selectors, today button, clear button, past
dates dimmed. Rewrite DueDatePicker to use it instead of <input type=date>.
2026-02-15 22:04:31 +02:00

281 lines
8.9 KiB
TypeScript

import { useState, useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isSameDay,
isSameMonth,
isToday as isTodayFn,
isPast,
addMonths,
subMonths,
setMonth,
setYear,
getYear,
getMonth,
} from "date-fns";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
interface CalendarPopoverProps {
selectedDate: Date | null;
onSelect: (date: Date) => void;
onClear: () => void;
children: React.ReactNode;
}
type ViewMode = "days" | "months" | "years";
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
export function CalendarPopover({
selectedDate,
onSelect,
onClear,
children,
}: CalendarPopoverProps) {
const [open, setOpen] = useState(false);
const [viewDate, setViewDate] = useState(() => selectedDate ?? new Date());
const [viewMode, setViewMode] = useState<ViewMode>("days");
// Reset view when opening
function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
setViewDate(selectedDate ?? new Date());
setViewMode("days");
}
setOpen(nextOpen);
}
function handleSelectDate(date: Date) {
onSelect(date);
setOpen(false);
}
function handleToday() {
const today = new Date();
onSelect(today);
setOpen(false);
}
function handleClear() {
onClear();
setOpen(false);
}
// Build the 6x7 grid of days for the current viewDate month
const calendarDays = useMemo(() => {
const monthStart = startOfMonth(viewDate);
const monthEnd = endOfMonth(viewDate);
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [viewDate]);
// Year range for year selector: current year +/- 5
const yearRange = useMemo(() => {
const center = getYear(viewDate);
const years: number[] = [];
for (let y = center - 5; y <= center + 5; y++) {
years.push(y);
}
return years;
}, [viewDate]);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="w-[280px] bg-pylon-surface p-0 rounded-xl shadow-2xl border-border"
>
{/* Navigation header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<Button
variant="ghost"
size="icon-xs"
onClick={() => setViewDate((d) => subMonths(d, 1))}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ChevronLeft className="size-4" />
</Button>
<div className="flex items-center gap-1">
<button
onClick={() => setViewMode(viewMode === "months" ? "days" : "months")}
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
>
{format(viewDate, "MMMM")}
</button>
<button
onClick={() => setViewMode(viewMode === "years" ? "days" : "years")}
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
>
{format(viewDate, "yyyy")}
</button>
</div>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setViewDate((d) => addMonths(d, 1))}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<ChevronRight className="size-4" />
</Button>
</div>
{/* Body: days / months / years */}
<div className="p-3">
<AnimatePresence mode="wait">
{viewMode === "days" && (
<motion.div
key="days"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Weekday headers */}
<div className="mb-1 grid grid-cols-7">
{WEEKDAYS.map((wd) => (
<div
key={wd}
className="flex h-8 items-center justify-center font-mono text-[10px] uppercase tracking-wider text-pylon-text-secondary"
>
{wd}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7">
{calendarDays.map((day) => {
const inMonth = isSameMonth(day, viewDate);
const today = isTodayFn(day);
const selected = selectedDate != null && isSameDay(day, selectedDate);
const past = isPast(day) && !today;
if (!inMonth) {
return <div key={day.toISOString()} className="h-9" />;
}
return (
<button
key={day.toISOString()}
onClick={() => handleSelectDate(day)}
className={`flex h-9 items-center justify-center rounded-lg text-sm transition-colors
${selected
? "bg-pylon-accent font-medium text-white"
: today
? "font-medium text-pylon-accent ring-1 ring-pylon-accent"
: past
? "text-pylon-text opacity-50 hover:bg-pylon-column hover:opacity-75"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{format(day, "d")}
</button>
);
})}
</div>
</motion.div>
)}
{viewMode === "months" && (
<motion.div
key="months"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="grid grid-cols-3 gap-1"
>
{MONTH_NAMES.map((name, i) => (
<button
key={name}
onClick={() => {
setViewDate((d) => setMonth(d, i));
setViewMode("days");
}}
className={`rounded-lg py-2 text-sm transition-colors ${
getMonth(viewDate) === i
? "bg-pylon-accent font-medium text-white"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{name}
</button>
))}
</motion.div>
)}
{viewMode === "years" && (
<motion.div
key="years"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="grid grid-cols-3 gap-1"
>
{yearRange.map((year) => (
<button
key={year}
onClick={() => {
setViewDate((d) => setYear(d, year));
setViewMode("days");
}}
className={`rounded-lg py-2 text-sm transition-colors ${
getYear(viewDate) === year
? "bg-pylon-accent font-medium text-white"
: "text-pylon-text hover:bg-pylon-column"
}`}
>
{year}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={handleToday}
className="text-pylon-accent hover:text-pylon-accent"
>
Today
</Button>
<Button
variant="ghost"
size="xs"
onClick={handleClear}
className="text-pylon-text-secondary hover:text-pylon-danger"
>
Clear
</Button>
</div>
</PopoverContent>
</Popover>
);
}