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} ); }