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:
parent
013f9ec010
commit
4dc3c856b0
@ -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)}
|
||||||
|
|||||||
@ -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 })}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 })}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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 })}
|
||||||
|
|||||||
@ -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.
|
||||||
onBlur?.();
|
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 = () => {
|
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,6 +313,185 @@ 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);
|
||||||
|
|
||||||
|
// ── 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hidden input for native form validation + autofill */}
|
{/* 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" />
|
<Calendar className="h-4 w-4 shrink-0 opacity-70" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Popup (portalled) */}
|
{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((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
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user