From 4dc3c856b0eb6f323cbf079cd26945d08ce84a42 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 3 Mar 2026 02:43:45 +0800 Subject: [PATCH] Add input variant to DatePicker for typeable date fields DatePicker now supports variant="button" (default, registration DOB) and variant="input" (typeable text input + calendar icon trigger). Input variant lets users type dates manually while the calendar icon opens the same popup picker. Smart blur management prevents onBlur from firing when focus moves between input, icon, and popup. 9 non-registration usages updated to variant="input". Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/people/PersonForm.tsx | 1 + .../src/components/projects/ProjectForm.tsx | 1 + .../components/projects/TaskDetailPanel.tsx | 1 + frontend/src/components/projects/TaskForm.tsx | 1 + .../reminders/ReminderDetailPanel.tsx | 1 + .../src/components/reminders/ReminderForm.tsx | 1 + .../src/components/settings/SettingsPage.tsx | 1 + .../src/components/todos/TodoDetailPanel.tsx | 1 + frontend/src/components/todos/TodoForm.tsx | 1 + frontend/src/components/ui/date-picker.tsx | 413 +++++++++++------- 10 files changed, 254 insertions(+), 168 deletions(-) diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index 792efbb..2683ae2 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -167,6 +167,7 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
set('birthday', v)} diff --git a/frontend/src/components/projects/ProjectForm.tsx b/frontend/src/components/projects/ProjectForm.tsx index ad71109..3c3329d 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.tsx @@ -123,6 +123,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
setFormData({ ...formData, due_date: v })} diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx index 8ee2ebf..d18ca84 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -352,6 +352,7 @@ export default function TaskDetailPanel({
{isEditing ? ( 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 feb3dcf..8809e32 100644 --- a/frontend/src/components/projects/TaskForm.tsx +++ b/frontend/src/components/projects/TaskForm.tsx @@ -156,6 +156,7 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
setFormData({ ...formData, due_date: v })} diff --git a/frontend/src/components/reminders/ReminderDetailPanel.tsx b/frontend/src/components/reminders/ReminderDetailPanel.tsx index 1358008..3cfff13 100644 --- a/frontend/src/components/reminders/ReminderDetailPanel.tsx +++ b/frontend/src/components/reminders/ReminderDetailPanel.tsx @@ -342,6 +342,7 @@ export default function ReminderDetailPanel({
setDateOfBirth(v)} diff --git a/frontend/src/components/todos/TodoDetailPanel.tsx b/frontend/src/components/todos/TodoDetailPanel.tsx index dd30a4c..e65021e 100644 --- a/frontend/src/components/todos/TodoDetailPanel.tsx +++ b/frontend/src/components/todos/TodoDetailPanel.tsx @@ -387,6 +387,7 @@ export default function TodoDetailPanel({
updateField('due_date', v)} diff --git a/frontend/src/components/todos/TodoForm.tsx b/frontend/src/components/todos/TodoForm.tsx index 0e35ff7..48cbfc1 100644 --- a/frontend/src/components/todos/TodoForm.tsx +++ b/frontend/src/components/todos/TodoForm.tsx @@ -131,6 +131,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
setFormData({ ...formData, due_date: v })} diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx index b365216..1326d66 100644 --- a/frontend/src/components/ui/date-picker.tsx +++ b/frontend/src/components/ui/date-picker.tsx @@ -31,6 +31,7 @@ const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; // ── Props ── export interface DatePickerProps { + variant?: 'button' | 'input'; mode?: 'date' | 'datetime'; value: string; onChange: (value: string) => void; @@ -53,6 +54,7 @@ export interface DatePickerProps { const DatePicker = React.forwardRef( ( { + variant = 'button', mode = 'date', value, onChange, @@ -99,19 +101,22 @@ const DatePicker = React.forwardRef( const [hour, setHour] = React.useState(parsed?.hour ?? 0); const [minute, setMinute] = React.useState(parsed?.minute ?? 0); - const triggerRef = React.useRef(null); + // Refs + const triggerRef = React.useRef(null); // button variant + const wrapperRef = React.useRef(null); // input variant + const inputElRef = React.useRef(null); // input variant const popupRef = React.useRef(null); - const [pos, setPos] = React.useState<{ top: number; left: number; flipped: boolean }>({ - top: 0, - left: 0, - flipped: false, - }); + const blurTimeoutRef = React.useRef>(); - // Merge forwarded ref with internal ref + const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); + + // Merge forwarded ref with internal ref (button variant only) React.useImperativeHandle(ref, () => triggerRef.current!); - // Sync internal state when value changes externally + // Sync internal state when value changes (only when popup is closed to avoid + // jumping the calendar view while the user is navigating months or typing) React.useEffect(() => { + if (open) return; const p = parseDateValue(); if (p) { setViewYear(p.year); @@ -120,12 +125,13 @@ const DatePicker = React.forwardRef( setMinute(p.minute); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); + }, [value, open]); - // Position popup + // Position popup relative to trigger (button) or wrapper (input) const updatePosition = React.useCallback(() => { - if (!triggerRef.current) return; - const rect = triggerRef.current.getBoundingClientRect(); + const el = variant === 'input' ? wrapperRef.current : triggerRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); const popupHeight = mode === 'datetime' ? 370 : 320; const spaceBelow = window.innerHeight - rect.bottom; const flipped = spaceBelow < popupHeight && rect.top > popupHeight; @@ -133,9 +139,8 @@ const DatePicker = React.forwardRef( setPos({ top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - 290), - flipped, }); - }, [mode]); + }, [mode, variant]); React.useEffect(() => { if (!open) return; @@ -148,21 +153,33 @@ const DatePicker = React.forwardRef( }; }, [open, updatePosition]); + // Close popup. refocusTrigger=true returns focus to the trigger/input + // (used for day selection, Escape, Done). false lets focus go wherever the + // user clicked (used for outside-click dismiss). + const closePopup = React.useCallback( + (refocusTrigger = true) => { + setOpen(false); + if (variant === 'button') { + onBlur?.(); + } else if (refocusTrigger) { + setTimeout(() => inputElRef.current?.focus(), 0); + } + }, + [variant, onBlur] + ); + // 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(); + if (popupRef.current?.contains(e.target as Node)) return; + if (variant === 'button' && triggerRef.current?.contains(e.target as Node)) return; + if (variant === 'input' && wrapperRef.current?.contains(e.target as Node)) return; + closePopup(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + }, [open, variant, closePopup]); // Dismiss on Escape React.useEffect(() => { @@ -170,19 +187,34 @@ const DatePicker = React.forwardRef( const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); - closePopup(); + closePopup(true); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + }, [open, closePopup]); - const closePopup = () => { - setOpen(false); - // Fire onBlur once when popup closes - onBlur?.(); - }; + // Input variant: smart blur — only fires onBlur when focus truly leaves the + // component group (input + icon + popup). Uses a short timeout to let focus + // settle on the new target before checking. + const handleInputBlur = React.useCallback(() => { + if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); + blurTimeoutRef.current = setTimeout(() => { + const active = document.activeElement; + if ( + popupRef.current?.contains(active) || + wrapperRef.current?.contains(active) + ) return; + onBlur?.(); + }, 10); + }, [onBlur]); + + // Cleanup blur timeout on unmount + React.useEffect(() => { + return () => { + if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); + }; + }, []); const openPopup = () => { if (disabled) return; @@ -198,7 +230,7 @@ const DatePicker = React.forwardRef( }; const togglePopup = () => { - if (open) closePopup(); + if (open) closePopup(true); else openPopup(); }; @@ -221,7 +253,7 @@ const DatePicker = React.forwardRef( onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`); } else { onChange(dateStr); - closePopup(); + closePopup(true); } }; @@ -266,7 +298,7 @@ const DatePicker = React.forwardRef( const isSelected = (d: number) => parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year; - // Display text + // Display text (button variant only) const displayText = (() => { if (!parsed) return ''; const monthName = MONTH_NAMES[parsed.month]; @@ -281,6 +313,185 @@ const DatePicker = React.forwardRef( const yearOptions: number[] = []; for (let y = startYear; y <= endYear; y++) yearOptions.push(y); + // ── Shared popup ── + const popup = 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 + ) + : null; + + // ── Input variant: typeable input + calendar icon trigger ── + if (variant === 'input') { + return ( + <> +
+ onChange(e.target.value)} + onBlur={handleInputBlur} + onKeyDown={(e) => { + if (open && e.key === 'Enter') { + e.preventDefault(); + return; + } + onKeyDown?.(e); + }} + required={required} + disabled={disabled} + placeholder={placeholder || (mode === 'datetime' ? 'YYYY-MM-DDThh:mm' : 'YYYY-MM-DD')} + className={cn( + 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-9 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', + className + )} + /> + +
+ {popup} + + ); + } + + // ── Button variant: non-editable trigger (registration DOB) ── return ( <> {/* Hidden input for native form validation + autofill */} @@ -331,141 +542,7 @@ const DatePicker = React.forwardRef( - {/* 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 - )} + {popup} ); }