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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-03 02:43:45 +08:00
parent 013f9ec010
commit 4dc3c856b0
10 changed files with 254 additions and 168 deletions

View File

@ -167,6 +167,7 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
<div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label>
<DatePicker
variant="input"
id="birthday"
value={formData.birthday}
onChange={(v) => set('birthday', v)}

View File

@ -123,6 +123,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<DatePicker
variant="input"
id="due_date"
value={formData.due_date}
onChange={(v) => setFormData({ ...formData, due_date: v })}

View File

@ -352,6 +352,7 @@ export default function TaskDetailPanel({
</div>
{isEditing ? (
<DatePicker
variant="input"
value={editState.due_date}
onChange={(v) => setEditState((s) => ({ ...s, due_date: v }))}
className="h-8 text-xs"

View File

@ -156,6 +156,7 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<DatePicker
variant="input"
id="due_date"
value={formData.due_date}
onChange={(v) => setFormData({ ...formData, due_date: v })}

View File

@ -342,6 +342,7 @@ export default function ReminderDetailPanel({
<div className="space-y-1">
<Label htmlFor="reminder-at">Remind At</Label>
<DatePicker
variant="input"
id="reminder-at"
mode="datetime"
value={editState.remind_at}

View File

@ -98,6 +98,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
<div className="space-y-2">
<Label htmlFor="remind_at">Remind At</Label>
<DatePicker
variant="input"
id="remind_at"
mode="datetime"
value={formData.remind_at}

View File

@ -355,6 +355,7 @@ export default function SettingsPage() {
<div className="space-y-2">
<Label htmlFor="date_of_birth">Date of Birth</Label>
<DatePicker
variant="input"
id="date_of_birth"
value={dateOfBirth}
onChange={(v) => setDateOfBirth(v)}

View File

@ -387,6 +387,7 @@ export default function TodoDetailPanel({
<div className="space-y-1">
<Label htmlFor="todo-due-date">Due Date</Label>
<DatePicker
variant="input"
id="todo-due-date"
value={editState.due_date}
onChange={(v) => updateField('due_date', v)}

View File

@ -131,6 +131,7 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<DatePicker
variant="input"
id="due_date"
value={formData.due_date}
onChange={(v) => setFormData({ ...formData, due_date: v })}

View File

@ -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<HTMLButtonElement, DatePickerProps>(
(
{
variant = 'button',
mode = 'date',
value,
onChange,
@ -99,19 +101,22 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const [hour, setHour] = React.useState(parsed?.hour ?? 0);
const [minute, setMinute] = React.useState(parsed?.minute ?? 0);
const triggerRef = React.useRef<HTMLButtonElement>(null);
// Refs
const triggerRef = React.useRef<HTMLButtonElement>(null); // button variant
const wrapperRef = React.useRef<HTMLDivElement>(null); // input variant
const inputElRef = React.useRef<HTMLInputElement>(null); // input variant
const popupRef = React.useRef<HTMLDivElement>(null);
const [pos, setPos] = React.useState<{ top: number; left: number; flipped: boolean }>({
top: 0,
left: 0,
flipped: false,
});
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
// 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<HTMLButtonElement, DatePickerProps>(
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<HTMLButtonElement, DatePickerProps>(
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<HTMLButtonElement, DatePickerProps>(
};
}, [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<HTMLButtonElement, DatePickerProps>(
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<HTMLButtonElement, DatePickerProps>(
};
const togglePopup = () => {
if (open) closePopup();
if (open) closePopup(true);
else openPopup();
};
@ -221,7 +253,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`);
} else {
onChange(dateStr);
closePopup();
closePopup(true);
}
};
@ -266,7 +298,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
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<HTMLButtonElement, DatePickerProps>(
const yearOptions: number[] = [];
for (let y = startYear; y <= endYear; y++) yearOptions.push(y);
// ── Shared popup ──
const popup = open
? createPortal(
<div
ref={popupRef}
onMouseDown={(e) => 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 */}
<div className="flex items-center justify-between px-3 pt-3 pb-2">
<button
type="button"
onClick={prevMonth}
className="p-1 rounded-md hover:bg-accent/10 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-1.5">
<select
value={viewMonth}
onChange={(e) => setViewMonth(parseInt(e.target.value, 10))}
className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none pr-1"
>
{MONTH_NAMES.map((n, i) => (
<option key={i} value={i} className="bg-card text-foreground">
{n}
</option>
))}
</select>
<select
value={viewYear}
onChange={(e) => setViewYear(parseInt(e.target.value, 10))}
className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none"
>
{yearOptions.map((y) => (
<option key={y} value={y} className="bg-card text-foreground">
{y}
</option>
))}
</select>
</div>
<button
type="button"
onClick={nextMonth}
className="p-1 rounded-md hover:bg-accent/10 transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 px-3 pb-1">
{DAY_HEADERS.map((d) => (
<div
key={d}
className="text-center text-[11px] font-medium text-muted-foreground py-1"
>
{d}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7 px-3 pb-3">
{cells.map((day, i) =>
day === null ? (
<div key={`empty-${i}`} />
) : (
<button
key={day}
type="button"
disabled={isDayDisabled(viewYear, viewMonth, day)}
onClick={() => selectDay(day)}
className={cn(
'h-8 w-full rounded-md text-sm transition-colors',
'hover:bg-accent/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring',
isSelected(day) && 'bg-accent text-accent-foreground font-medium',
isToday(day) && !isSelected(day) && 'border border-accent/50 text-accent',
isDayDisabled(viewYear, viewMonth, day) &&
'opacity-30 cursor-not-allowed hover:bg-transparent'
)}
>
{day}
</button>
)
)}
</div>
{/* Time selectors (datetime mode only) */}
{mode === 'datetime' && (
<div className="flex items-center gap-2 px-3 pb-3 border-t border-border pt-2">
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<select
value={hour}
onChange={(e) => handleTimeChange(parseInt(e.target.value, 10), minute)}
className="flex-1 appearance-none bg-secondary rounded-md px-2 py-1 text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i} className="bg-card text-foreground">
{pad(i)}
</option>
))}
</select>
<span className="text-muted-foreground font-medium">:</span>
<select
value={minute}
onChange={(e) => handleTimeChange(hour, parseInt(e.target.value, 10))}
className="flex-1 appearance-none bg-secondary rounded-md px-2 py-1 text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
{Array.from({ length: 60 }, (_, i) => (
<option key={i} value={i} className="bg-card text-foreground">
{pad(i)}
</option>
))}
</select>
<button
type="button"
onClick={() => closePopup(true)}
className="ml-auto px-2 py-1 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
Done
</button>
</div>
)}
</div>,
document.body
)
: null;
// ── Input variant: typeable input + calendar icon trigger ──
if (variant === 'input') {
return (
<>
<div ref={wrapperRef} className="relative">
<input
ref={inputElRef}
type="text"
id={id}
name={name}
autoComplete={autoComplete}
value={value}
onChange={(e) => 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
)}
/>
<button
type="button"
disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={togglePopup}
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded p-0.5 hover:bg-accent/10 transition-colors disabled:opacity-50"
tabIndex={-1}
aria-label="Open calendar"
>
<Calendar className="h-4 w-4 opacity-70" />
</button>
</div>
{popup}
</>
);
}
// ── Button variant: non-editable trigger (registration DOB) ──
return (
<>
{/* Hidden input for native form validation + autofill */}
@ -331,141 +542,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
<Calendar className="h-4 w-4 shrink-0 opacity-70" />
</button>
{/* Popup (portalled) */}
{open &&
createPortal(
<div
ref={popupRef}
onMouseDown={(e) => 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 */}
<div className="flex items-center justify-between px-3 pt-3 pb-2">
<button
type="button"
onClick={prevMonth}
className="p-1 rounded-md hover:bg-accent/10 transition-colors"
>
<ChevronLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-1.5">
<select
value={viewMonth}
onChange={(e) => setViewMonth(parseInt(e.target.value, 10))}
className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none pr-1"
>
{MONTH_NAMES.map((name, i) => (
<option key={i} value={i} className="bg-card text-foreground">
{name}
</option>
))}
</select>
<select
value={viewYear}
onChange={(e) => setViewYear(parseInt(e.target.value, 10))}
className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none"
>
{yearOptions.map((y) => (
<option key={y} value={y} className="bg-card text-foreground">
{y}
</option>
))}
</select>
</div>
<button
type="button"
onClick={nextMonth}
className="p-1 rounded-md hover:bg-accent/10 transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 px-3 pb-1">
{DAY_HEADERS.map((d) => (
<div
key={d}
className="text-center text-[11px] font-medium text-muted-foreground py-1"
>
{d}
</div>
))}
</div>
{/* Day grid */}
<div className="grid grid-cols-7 px-3 pb-3">
{cells.map((day, i) =>
day === null ? (
<div key={`empty-${i}`} />
) : (
<button
key={day}
type="button"
disabled={isDayDisabled(viewYear, viewMonth, day)}
onClick={() => selectDay(day)}
className={cn(
'h-8 w-full rounded-md text-sm transition-colors',
'hover:bg-accent/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-ring',
isSelected(day) && 'bg-accent text-accent-foreground font-medium',
isToday(day) && !isSelected(day) && 'border border-accent/50 text-accent',
isDayDisabled(viewYear, viewMonth, day) &&
'opacity-30 cursor-not-allowed hover:bg-transparent'
)}
>
{day}
</button>
)
)}
</div>
{/* Time selectors (datetime mode only) */}
{mode === 'datetime' && (
<div className="flex items-center gap-2 px-3 pb-3 border-t border-border pt-2">
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<select
value={hour}
onChange={(e) => handleTimeChange(parseInt(e.target.value, 10), minute)}
className="flex-1 appearance-none bg-secondary rounded-md px-2 py-1 text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i} className="bg-card text-foreground">
{pad(i)}
</option>
))}
</select>
<span className="text-muted-foreground font-medium">:</span>
<select
value={minute}
onChange={(e) => handleTimeChange(hour, parseInt(e.target.value, 10))}
className="flex-1 appearance-none bg-secondary rounded-md px-2 py-1 text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
{Array.from({ length: 60 }, (_, i) => (
<option key={i} value={i} className="bg-card text-foreground">
{pad(i)}
</option>
))}
</select>
<button
type="button"
onClick={closePopup}
className="ml-auto px-2 py-1 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
Done
</button>
</div>
)}
</div>,
document.body
)}
{popup}
</>
);
}