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>.
281 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|