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"> <div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label> <Label htmlFor="birthday">Birthday</Label>
<DatePicker <DatePicker
variant="input"
id="birthday" id="birthday"
value={formData.birthday} value={formData.birthday}
onChange={(v) => set('birthday', v)} onChange={(v) => set('birthday', v)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,7 @@ const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// ── Props ── // ── Props ──
export interface DatePickerProps { export interface DatePickerProps {
variant?: 'button' | 'input';
mode?: 'date' | 'datetime'; mode?: 'date' | 'datetime';
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
@ -53,6 +54,7 @@ export interface DatePickerProps {
const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>( const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
( (
{ {
variant = 'button',
mode = 'date', mode = 'date',
value, value,
onChange, onChange,
@ -99,19 +101,22 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const [hour, setHour] = React.useState(parsed?.hour ?? 0); const [hour, setHour] = React.useState(parsed?.hour ?? 0);
const [minute, setMinute] = React.useState(parsed?.minute ?? 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 popupRef = React.useRef<HTMLDivElement>(null);
const [pos, setPos] = React.useState<{ top: number; left: number; flipped: boolean }>({ const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
top: 0,
left: 0,
flipped: false,
});
// 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!); 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(() => { React.useEffect(() => {
if (open) return;
const p = parseDateValue(); const p = parseDateValue();
if (p) { if (p) {
setViewYear(p.year); setViewYear(p.year);
@ -120,12 +125,13 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
setMinute(p.minute); setMinute(p.minute);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // 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(() => { const updatePosition = React.useCallback(() => {
if (!triggerRef.current) return; const el = variant === 'input' ? wrapperRef.current : triggerRef.current;
const rect = triggerRef.current.getBoundingClientRect(); if (!el) return;
const rect = el.getBoundingClientRect();
const popupHeight = mode === 'datetime' ? 370 : 320; const popupHeight = mode === 'datetime' ? 370 : 320;
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const flipped = spaceBelow < popupHeight && rect.top > popupHeight; const flipped = spaceBelow < popupHeight && rect.top > popupHeight;
@ -133,9 +139,8 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
setPos({ setPos({
top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4, top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4,
left: Math.min(rect.left, window.innerWidth - 290), left: Math.min(rect.left, window.innerWidth - 290),
flipped,
}); });
}, [mode]); }, [mode, variant]);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
@ -148,21 +153,33 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
}; };
}, [open, updatePosition]); }, [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 // Dismiss on click outside
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if ( if (popupRef.current?.contains(e.target as Node)) return;
popupRef.current?.contains(e.target as Node) || if (variant === 'button' && triggerRef.current?.contains(e.target as Node)) return;
triggerRef.current?.contains(e.target as Node) if (variant === 'input' && wrapperRef.current?.contains(e.target as Node)) return;
) closePopup(false);
return;
closePopup();
}; };
document.addEventListener('mousedown', handler); document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [open, variant, closePopup]);
}, [open]);
// Dismiss on Escape // Dismiss on Escape
React.useEffect(() => { React.useEffect(() => {
@ -170,19 +187,34 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.stopPropagation(); e.stopPropagation();
closePopup(); closePopup(true);
} }
}; };
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps }, [open, closePopup]);
}, [open]);
const closePopup = () => { // Input variant: smart blur — only fires onBlur when focus truly leaves the
setOpen(false); // component group (input + icon + popup). Uses a short timeout to let focus
// Fire onBlur once when popup closes // 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?.(); onBlur?.();
}, 10);
}, [onBlur]);
// Cleanup blur timeout on unmount
React.useEffect(() => {
return () => {
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);
}; };
}, []);
const openPopup = () => { const openPopup = () => {
if (disabled) return; if (disabled) return;
@ -198,7 +230,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
}; };
const togglePopup = () => { const togglePopup = () => {
if (open) closePopup(); if (open) closePopup(true);
else openPopup(); else openPopup();
}; };
@ -221,7 +253,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`); onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`);
} else { } else {
onChange(dateStr); onChange(dateStr);
closePopup(); closePopup(true);
} }
}; };
@ -266,7 +298,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const isSelected = (d: number) => const isSelected = (d: number) =>
parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year; parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year;
// Display text // Display text (button variant only)
const displayText = (() => { const displayText = (() => {
if (!parsed) return ''; if (!parsed) return '';
const monthName = MONTH_NAMES[parsed.month]; const monthName = MONTH_NAMES[parsed.month];
@ -281,68 +313,13 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const yearOptions: number[] = []; const yearOptions: number[] = [];
for (let y = startYear; y <= endYear; y++) yearOptions.push(y); for (let y = startYear; y <= endYear; y++) yearOptions.push(y);
return ( // ── Shared popup ──
<> const popup = open
{/* Hidden input for native form validation + autofill */} ? createPortal(
<input
type="hidden"
name={name}
autoComplete={autoComplete}
value={value}
required={required}
/>
{required && (
<input
tabIndex={-1}
aria-hidden
className="absolute w-0 h-0 opacity-0 pointer-events-none"
value={value}
required
onChange={() => {}}
style={{ position: 'absolute' }}
/>
)}
{/* Trigger button */}
<button
ref={triggerRef}
type="button"
id={id}
disabled={disabled}
onClick={togglePopup}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePopup();
}
// Don't forward Enter to parent when popup is open
if (open) return;
onKeyDown?.(e);
}}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
!value && 'text-muted-foreground',
className
)}
>
<span className="truncate">
{displayText || placeholder || (mode === 'datetime' ? 'Pick date & time' : 'Pick a date')}
</span>
<Calendar className="h-4 w-4 shrink-0 opacity-70" />
</button>
{/* Popup (portalled) */}
{open &&
createPortal(
<div <div
ref={popupRef} ref={popupRef}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
style={{ style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 60 }}
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" className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in"
> >
{/* Month/Year Nav */} {/* Month/Year Nav */}
@ -361,9 +338,9 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
onChange={(e) => setViewMonth(parseInt(e.target.value, 10))} 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" className="appearance-none bg-transparent text-sm font-medium cursor-pointer hover:text-accent focus:outline-none pr-1"
> >
{MONTH_NAMES.map((name, i) => ( {MONTH_NAMES.map((n, i) => (
<option key={i} value={i} className="bg-card text-foreground"> <option key={i} value={i} className="bg-card text-foreground">
{name} {n}
</option> </option>
))} ))}
</select> </select>
@ -456,7 +433,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
</select> </select>
<button <button
type="button" type="button"
onClick={closePopup} 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" 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 Done
@ -465,7 +442,107 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
)} )}
</div>, </div>,
document.body 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 */}
<input
type="hidden"
name={name}
autoComplete={autoComplete}
value={value}
required={required}
/>
{required && (
<input
tabIndex={-1}
aria-hidden
className="absolute w-0 h-0 opacity-0 pointer-events-none"
value={value}
required
onChange={() => {}}
style={{ position: 'absolute' }}
/>
)}
{/* Trigger button */}
<button
ref={triggerRef}
type="button"
id={id}
disabled={disabled}
onClick={togglePopup}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePopup();
}
// Don't forward Enter to parent when popup is open
if (open) return;
onKeyDown?.(e);
}}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
!value && 'text-muted-foreground',
className
)}
>
<span className="truncate">
{displayText || placeholder || (mode === 'datetime' ? 'Pick date & time' : 'Pick a date')}
</span>
<Calendar className="h-4 w-4 shrink-0 opacity-70" />
</button>
{popup}
</> </>
); );
} }