From 013f9ec0102367613b3db3a86bf9e39441cef7ce Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 02:30:52 +0800 Subject: [PATCH] Add custom DatePicker component, replace all native date inputs Custom date-picker.tsx with date/datetime modes, portal popup with month/year dropdowns, min/max constraints, and hidden input for form validation. Replaces all 10 native and across LockScreen, SettingsPage, PersonForm, TodoForm, TodoDetailPanel, TaskForm, TaskDetailPanel, ProjectForm, ReminderForm, and ReminderDetailPanel. Adds Chromium calendar icon invert CSS fallback. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/auth/LockScreen.tsx | 8 +- frontend/src/components/people/PersonForm.tsx | 6 +- .../src/components/projects/ProjectForm.tsx | 6 +- .../components/projects/TaskDetailPanel.tsx | 6 +- frontend/src/components/projects/TaskForm.tsx | 6 +- .../reminders/ReminderDetailPanel.tsx | 7 +- .../src/components/reminders/ReminderForm.tsx | 7 +- .../src/components/settings/SettingsPage.tsx | 6 +- .../src/components/todos/TodoDetailPanel.tsx | 6 +- frontend/src/components/todos/TodoForm.tsx | 6 +- frontend/src/components/ui/date-picker.tsx | 475 ++++++++++++++++++ frontend/src/index.css | 13 + 12 files changed, 522 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/ui/date-picker.tsx diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 6235e92..9b5383a 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -6,6 +6,7 @@ import { useAuth } from '@/hooks/useAuth'; import api, { getErrorMessage } from '@/lib/api'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; @@ -641,13 +642,14 @@ export default function LockScreen() {
- setRegDateOfBirth(e.target.value)} + onChange={(v) => setRegDateOfBirth(v)} required + name="bday" autoComplete="bday" + max={new Date().toISOString().slice(0, 10)} />
diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index c08df8c..792efbb 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -13,6 +13,7 @@ import { SheetFooter, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -165,11 +166,10 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
- set('birthday', e.target.value)} + onChange={(v) => set('birthday', v)} />
diff --git a/frontend/src/components/projects/ProjectForm.tsx b/frontend/src/components/projects/ProjectForm.tsx index 6d8adc2..ad71109 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.tsx @@ -12,6 +12,7 @@ import { SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -121,11 +122,10 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
- setFormData({ ...formData, due_date: e.target.value })} + onChange={(v) => setFormData({ ...formData, due_date: v })} />
diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx index f3a36f6..8ee2ebf 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Select } from '@/components/ui/select'; const taskStatusColors: Record = { @@ -350,10 +351,9 @@ export default function TaskDetailPanel({ Due Date
{isEditing ? ( - setEditState((s) => ({ ...s, due_date: e.target.value }))} + onChange={(v) => setEditState((s) => ({ ...s, due_date: v }))} className="h-8 text-xs" /> ) : ( diff --git a/frontend/src/components/projects/TaskForm.tsx b/frontend/src/components/projects/TaskForm.tsx index 75d6af4..feb3dcf 100644 --- a/frontend/src/components/projects/TaskForm.tsx +++ b/frontend/src/components/projects/TaskForm.tsx @@ -12,6 +12,7 @@ import { SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -154,11 +155,10 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
- setFormData({ ...formData, due_date: e.target.value })} + onChange={(v) => setFormData({ ...formData, due_date: v })} />
diff --git a/frontend/src/components/reminders/ReminderDetailPanel.tsx b/frontend/src/components/reminders/ReminderDetailPanel.tsx index 1d60e03..1358008 100644 --- a/frontend/src/components/reminders/ReminderDetailPanel.tsx +++ b/frontend/src/components/reminders/ReminderDetailPanel.tsx @@ -12,6 +12,7 @@ import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -340,11 +341,11 @@ export default function ReminderDetailPanel({
- updateField('remind_at', e.target.value)} + onChange={(v) => updateField('remind_at', v)} className="text-xs" />
diff --git a/frontend/src/components/reminders/ReminderForm.tsx b/frontend/src/components/reminders/ReminderForm.tsx index 435f06d..c6a8d2f 100644 --- a/frontend/src/components/reminders/ReminderForm.tsx +++ b/frontend/src/components/reminders/ReminderForm.tsx @@ -12,6 +12,7 @@ import { SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -96,11 +97,11 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
- setFormData({ ...formData, remind_at: e.target.value })} + onChange={(v) => setFormData({ ...formData, remind_at: v })} />
diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 6f7d70c..5e480cd 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -18,6 +18,7 @@ import { import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import api from '@/lib/api'; @@ -353,11 +354,10 @@ export default function SettingsPage() {
- setDateOfBirth(e.target.value)} + onChange={(v) => setDateOfBirth(v)} onBlur={() => handleProfileSave('date_of_birth')} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} /> diff --git a/frontend/src/components/todos/TodoDetailPanel.tsx b/frontend/src/components/todos/TodoDetailPanel.tsx index ce6c88b..dd30a4c 100644 --- a/frontend/src/components/todos/TodoDetailPanel.tsx +++ b/frontend/src/components/todos/TodoDetailPanel.tsx @@ -13,6 +13,7 @@ import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; @@ -385,11 +386,10 @@ export default function TodoDetailPanel({
- updateField('due_date', e.target.value)} + onChange={(v) => updateField('due_date', v)} className="text-xs" />
diff --git a/frontend/src/components/todos/TodoForm.tsx b/frontend/src/components/todos/TodoForm.tsx index 29913ef..0e35ff7 100644 --- a/frontend/src/components/todos/TodoForm.tsx +++ b/frontend/src/components/todos/TodoForm.tsx @@ -12,6 +12,7 @@ import { SheetClose, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Select } from '@/components/ui/select'; import { Label } from '@/components/ui/label'; @@ -129,11 +130,10 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
- setFormData({ ...formData, due_date: e.target.value })} + onChange={(v) => setFormData({ ...formData, due_date: v })} />
diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx new file mode 100644 index 0000000..b365216 --- /dev/null +++ b/frontend/src/components/ui/date-picker.tsx @@ -0,0 +1,475 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +// ── Helpers ── + +function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfWeek(year: number, month: number): number { + return new Date(year, month, 1).getDay(); +} + +function pad(n: number): string { + return n.toString().padStart(2, '0'); +} + +function formatDate(y: number, m: number, d: number): string { + return `${y}-${pad(m + 1)}-${pad(d)}`; +} + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + +// ── Props ── + +export interface DatePickerProps { + mode?: 'date' | 'datetime'; + value: string; + onChange: (value: string) => void; + onBlur?: () => void; + onKeyDown?: (e: React.KeyboardEvent) => void; + id?: string; + name?: string; + autoComplete?: string; + required?: boolean; + disabled?: boolean; + className?: string; + placeholder?: string; + min?: string; + max?: string; + yearRange?: [number, number]; +} + +// ── Component ── + +const DatePicker = React.forwardRef( + ( + { + mode = 'date', + value, + onChange, + onBlur, + onKeyDown, + id, + name, + autoComplete, + required, + disabled, + className, + placeholder, + min, + max, + yearRange, + }, + ref + ) => { + const currentYear = new Date().getFullYear(); + const [startYear, endYear] = yearRange ?? [1900, currentYear + 20]; + + // Parse current value + const parseDateValue = () => { + if (!value) return null; + const parts = value.split('T'); + const dateParts = parts[0]?.split('-'); + if (!dateParts || dateParts.length !== 3) return null; + const y = parseInt(dateParts[0], 10); + const m = parseInt(dateParts[1], 10) - 1; + const d = parseInt(dateParts[2], 10); + if (isNaN(y) || isNaN(m) || isNaN(d)) return null; + const timeParts = parts[1]?.split(':'); + const hour = timeParts ? parseInt(timeParts[0], 10) : 0; + const minute = timeParts ? parseInt(timeParts[1], 10) : 0; + return { year: y, month: m, day: d, hour, minute }; + }; + + const parsed = parseDateValue(); + const today = new Date(); + + const [open, setOpen] = React.useState(false); + const [viewYear, setViewYear] = React.useState(parsed?.year ?? today.getFullYear()); + const [viewMonth, setViewMonth] = React.useState(parsed?.month ?? today.getMonth()); + const [hour, setHour] = React.useState(parsed?.hour ?? 0); + const [minute, setMinute] = React.useState(parsed?.minute ?? 0); + + const triggerRef = React.useRef(null); + const popupRef = React.useRef(null); + const [pos, setPos] = React.useState<{ top: number; left: number; flipped: boolean }>({ + top: 0, + left: 0, + flipped: false, + }); + + // Merge forwarded ref with internal ref + React.useImperativeHandle(ref, () => triggerRef.current!); + + // Sync internal state when value changes externally + React.useEffect(() => { + const p = parseDateValue(); + if (p) { + setViewYear(p.year); + setViewMonth(p.month); + setHour(p.hour); + setMinute(p.minute); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + // Position popup + const updatePosition = React.useCallback(() => { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const popupHeight = mode === 'datetime' ? 370 : 320; + const spaceBelow = window.innerHeight - rect.bottom; + const flipped = spaceBelow < popupHeight && rect.top > popupHeight; + + setPos({ + top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4, + left: Math.min(rect.left, window.innerWidth - 290), + flipped, + }); + }, [mode]); + + React.useEffect(() => { + if (!open) return; + updatePosition(); + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [open, updatePosition]); + + // Dismiss on click outside + React.useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if ( + popupRef.current?.contains(e.target as Node) || + triggerRef.current?.contains(e.target as Node) + ) + return; + closePopup(); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + // Dismiss on Escape + React.useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + closePopup(); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const closePopup = () => { + setOpen(false); + // Fire onBlur once when popup closes + onBlur?.(); + }; + + const openPopup = () => { + if (disabled) return; + // Re-sync view to current value when opening + const p = parseDateValue(); + if (p) { + setViewYear(p.year); + setViewMonth(p.month); + setHour(p.hour); + setMinute(p.minute); + } + setOpen(true); + }; + + const togglePopup = () => { + if (open) closePopup(); + else openPopup(); + }; + + // Min/Max date boundaries + const minDate = min ? new Date(min + 'T00:00:00') : null; + const maxDate = max ? new Date(max + 'T00:00:00') : null; + + const isDayDisabled = (y: number, m: number, d: number) => { + const date = new Date(y, m, d); + if (minDate && date < minDate) return true; + if (maxDate && date > maxDate) return true; + return false; + }; + + // Select a day + const selectDay = (day: number) => { + if (isDayDisabled(viewYear, viewMonth, day)) return; + const dateStr = formatDate(viewYear, viewMonth, day); + if (mode === 'datetime') { + onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`); + } else { + onChange(dateStr); + closePopup(); + } + }; + + const handleTimeChange = (newHour: number, newMinute: number) => { + setHour(newHour); + setMinute(newMinute); + if (parsed) { + const dateStr = formatDate(parsed.year, parsed.month, parsed.day); + onChange(`${dateStr}T${pad(newHour)}:${pad(newMinute)}`); + } + }; + + // Navigation + const prevMonth = () => { + if (viewMonth === 0) { + setViewMonth(11); + setViewYear((y) => y - 1); + } else { + setViewMonth((m) => m - 1); + } + }; + + const nextMonth = () => { + if (viewMonth === 11) { + setViewMonth(0); + setViewYear((y) => y + 1); + } else { + setViewMonth((m) => m + 1); + } + }; + + // Build calendar grid + const daysInMonth = getDaysInMonth(viewYear, viewMonth); + const firstDay = getFirstDayOfWeek(viewYear, viewMonth); + const cells: (number | null)[] = []; + for (let i = 0; i < firstDay; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + + const isToday = (d: number) => + d === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear(); + + const isSelected = (d: number) => + parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year; + + // Display text + const displayText = (() => { + if (!parsed) return ''; + const monthName = MONTH_NAMES[parsed.month]; + const base = `${monthName} ${parsed.day}, ${parsed.year}`; + if (mode === 'datetime') { + return `${base} ${pad(parsed.hour)}:${pad(parsed.minute)}`; + } + return base; + })(); + + // Year options + const yearOptions: number[] = []; + for (let y = startYear; y <= endYear; y++) yearOptions.push(y); + + return ( + <> + {/* Hidden input for native form validation + autofill */} + + {required && ( + {}} + style={{ position: 'absolute' }} + /> + )} + + {/* Trigger button */} + + + {/* Popup (portalled) */} + {open && + createPortal( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: pos.top, + left: pos.left, + zIndex: 60, + }} + className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in" + > + {/* Month/Year Nav */} +
+ + +
+ + +
+ + +
+ + {/* Day headers */} +
+ {DAY_HEADERS.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Day grid */} +
+ {cells.map((day, i) => + day === null ? ( +
+ ) : ( + + ) + )} +
+ + {/* Time selectors (datetime mode only) */} + {mode === 'datetime' && ( +
+ + + : + + +
+ )} +
, + document.body + )} + + ); + } +); +DatePicker.displayName = 'DatePicker'; + +export { DatePicker }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 1753ba3..9dfb4e7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -193,6 +193,13 @@ font-weight: 600; } +/* ── Chromium native date picker icon fix (safety net) ── */ +input[type="date"]::-webkit-calendar-picker-indicator, +input[type="datetime-local"]::-webkit-calendar-picker-indicator { + filter: invert(1); + cursor: pointer; +} + /* ── Form validation — red outline only after submit attempt ── */ form[data-submitted] input:invalid, form[data-submitted] select:invalid, @@ -201,6 +208,12 @@ form[data-submitted] textarea:invalid { box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25); } +/* DatePicker trigger inherits red border from its hidden required sibling */ +form[data-submitted] input:invalid + button { + border-color: hsl(0 62.8% 50%); + box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25); +} + /* ── Ambient background animations ── */ @keyframes drift-1 {