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
// 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,68 +313,13 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
const yearOptions: number[] = [];
for (let y = startYear; y <= endYear; y++) yearOptions.push(y);
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 (portalled) */}
{open &&
createPortal(
// ── Shared popup ──
const popup = open
? createPortal(
<div
ref={popupRef}
onMouseDown={(e) => e.stopPropagation()}
style={{
position: 'fixed',
top: pos.top,
left: pos.left,
zIndex: 60,
}}
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 */}
@ -361,9 +338,9 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
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) => (
{MONTH_NAMES.map((n, i) => (
<option key={i} value={i} className="bg-card text-foreground">
{name}
{n}
</option>
))}
</select>
@ -456,7 +433,7 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
</select>
<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"
>
Done
@ -465,7 +442,107 @@ const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
)}
</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 */}
<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}
</>
);
}