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>.
This commit is contained in:
456
docs/plans/2026-02-15-custom-date-picker-implementation.md
Normal file
456
docs/plans/2026-02-15-custom-date-picker-implementation.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# Custom Date Picker — Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace the native HTML date input with a fully custom calendar widget that matches the app's dark OKLCH theme.
|
||||
|
||||
**Architecture:** Create a new `CalendarPopover` component (calendar grid + month/year selectors + footer) using date-fns for date math and Radix Popover for positioning. Rewrite `DueDatePicker` to use it instead of `<input type="date">`. No new dependencies.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, date-fns v4, Framer Motion 12, Tailwind 4, Radix Popover
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create CalendarPopover component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/card-detail/CalendarPopover.tsx`
|
||||
|
||||
**Step 1: Create the file with the complete component**
|
||||
|
||||
Create `src/components/card-detail/CalendarPopover.tsx` with:
|
||||
|
||||
```tsx
|
||||
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";
|
||||
import { springs } from "@/lib/motion";
|
||||
|
||||
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 6×7 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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/card-detail/CalendarPopover.tsx
|
||||
git commit -m "feat: create custom CalendarPopover component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rewrite DueDatePicker to use CalendarPopover
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/card-detail/DueDatePicker.tsx` (full rewrite)
|
||||
|
||||
**Step 1: Replace the entire file**
|
||||
|
||||
Replace `src/components/card-detail/DueDatePicker.tsx` with:
|
||||
|
||||
```tsx
|
||||
import { format, formatDistanceToNow, isPast, isToday } from "date-fns";
|
||||
import { X } from "lucide-react";
|
||||
import { useBoardStore } from "@/stores/board-store";
|
||||
import { CalendarPopover } from "@/components/card-detail/CalendarPopover";
|
||||
|
||||
interface DueDatePickerProps {
|
||||
cardId: string;
|
||||
dueDate: string | null;
|
||||
}
|
||||
|
||||
export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
|
||||
const updateCard = useBoardStore((s) => s.updateCard);
|
||||
|
||||
const dateObj = dueDate ? new Date(dueDate) : null;
|
||||
const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj);
|
||||
|
||||
function handleSelect(date: Date) {
|
||||
updateCard(cardId, { dueDate: format(date, "yyyy-MM-dd") });
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
updateCard(cardId, { dueDate: null });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Header with clear button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||||
Due Date
|
||||
</h4>
|
||||
{dueDate && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="rounded p-0.5 text-pylon-text-secondary transition-colors hover:bg-pylon-danger/10 hover:text-pylon-danger"
|
||||
aria-label="Clear due date"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clickable date display → opens calendar */}
|
||||
<CalendarPopover
|
||||
selectedDate={dateObj}
|
||||
onSelect={handleSelect}
|
||||
onClear={handleClear}
|
||||
>
|
||||
<button className="flex w-full items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-pylon-column/60">
|
||||
{dateObj ? (
|
||||
<>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
overdue ? "text-pylon-danger" : "text-pylon-text"
|
||||
}`}
|
||||
>
|
||||
{format(dateObj, "MMM d, yyyy")}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
overdue ? "text-pylon-danger" : "text-pylon-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{overdue
|
||||
? `overdue by ${formatDistanceToNow(dateObj)}`
|
||||
: isToday(dateObj)
|
||||
? "today"
|
||||
: `in ${formatDistanceToNow(dateObj)}`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm italic text-pylon-text-secondary/60">
|
||||
Click to set date...
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</CalendarPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/card-detail/DueDatePicker.tsx
|
||||
git commit -m "feat: rewrite DueDatePicker to use custom CalendarPopover"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Visual verification and final commit
|
||||
|
||||
**Step 1: Run TypeScript check**
|
||||
|
||||
Run: `npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
**Step 2: Run the dev server**
|
||||
|
||||
Run: `npx tauri dev`
|
||||
|
||||
Verify:
|
||||
- Open a card → Due Date cell shows "Click to set date..." or the current date
|
||||
- Click the cell → calendar popover appears below
|
||||
- Calendar shows correct month with today highlighted (ring)
|
||||
- Click a date → it's selected (filled accent), popover closes, cell shows formatted date
|
||||
- Click month name → month selector grid appears, click a month → returns to days
|
||||
- Click year → year selector grid appears, click a year → returns to days
|
||||
- Left/right arrows navigate months
|
||||
- "Today" button selects today and closes
|
||||
- "Clear" button in popover footer removes the date and closes
|
||||
- × button in cell header clears the date without opening calendar
|
||||
- Past dates are dimmed but clickable
|
||||
- Overdue dates show in red
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: custom date picker with calendar popover complete"
|
||||
```
|
||||
Reference in New Issue
Block a user