UMBRA/frontend/src/components/ui/date-picker.tsx
Kyle Pope 63b3a3a073 Fix Firefox DatePicker popup positioning at top-left
When Firefox input variant falls through to button variant, the
positioning logic, close handler, and click-outside handler still
checked variant==='input' and used wrapperRef (which is unattached).
Introduced usesNativeInput flag (input variant + not Firefox) so all
three handlers correctly use triggerRef for Firefox fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:56:05 +08:00

539 lines
20 KiB
TypeScript

import * as React from 'react';
import { createPortal } from 'react-dom';
import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
// ── Browser detection (stable — checked once at module load) ──
const isFirefox = typeof navigator !== 'undefined' && /Firefox\//i.test(navigator.userAgent);
// ── Helpers ──
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfWeek(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
function pad(n: number): string {
return n.toString().padStart(2, '0');
}
function formatDate(y: number, m: number, d: number): string {
return `${y}-${pad(m + 1)}-${pad(d)}`;
}
function to12Hour(h24: number): { hour: number; ampm: 'AM' | 'PM' } {
if (h24 === 0) return { hour: 12, ampm: 'AM' };
if (h24 < 12) return { hour: h24, ampm: 'AM' };
if (h24 === 12) return { hour: 12, ampm: 'PM' };
return { hour: h24 - 12, ampm: 'PM' };
}
function to24Hour(h12: number, ampm: string): number {
const isPM = ampm.toUpperCase() === 'PM';
if (h12 === 12) return isPM ? 12 : 0;
return isPM ? h12 + 12 : h12;
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const HOUR_OPTIONS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
// ── Props ──
export interface DatePickerProps {
variant?: 'button' | 'input';
mode?: 'date' | 'datetime';
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
id?: string;
name?: string;
autoComplete?: string;
required?: boolean;
disabled?: boolean;
className?: string;
placeholder?: string;
min?: string;
max?: string;
yearRange?: [number, number];
}
// ── Component ──
const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
(
{
variant = 'button',
mode = 'date',
value,
onChange,
onBlur,
onKeyDown,
id,
name,
autoComplete,
required,
disabled,
className,
placeholder,
min,
max,
yearRange,
},
ref
) => {
const currentYear = new Date().getFullYear();
const [startYear, endYear] = yearRange ?? [1900, currentYear + 20];
// Parse ISO value into parts
const parseDateValue = () => {
if (!value) return null;
const parts = value.split('T');
const dateParts = parts[0]?.split('-');
if (!dateParts || dateParts.length !== 3) return null;
const y = parseInt(dateParts[0], 10);
const m = parseInt(dateParts[1], 10) - 1;
const d = parseInt(dateParts[2], 10);
if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
const timeParts = parts[1]?.split(':');
const hour = timeParts ? parseInt(timeParts[0], 10) : 0;
const minute = timeParts ? parseInt(timeParts[1], 10) : 0;
return { year: y, month: m, day: d, hour, minute };
};
const parsed = parseDateValue();
const today = new Date();
const [open, setOpen] = React.useState(false);
const [viewYear, setViewYear] = React.useState(parsed?.year ?? today.getFullYear());
const [viewMonth, setViewMonth] = React.useState(parsed?.month ?? today.getMonth());
const [hour, setHour] = React.useState(parsed?.hour ?? 0);
const [minute, setMinute] = React.useState(parsed?.minute ?? 0);
// Refs
const triggerRef = React.useRef<HTMLButtonElement>(null);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const inputElRef = React.useRef<HTMLInputElement>(null);
const popupRef = React.useRef<HTMLDivElement>(null);
const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout>>();
const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
React.useImperativeHandle(ref, () => triggerRef.current!);
// Sync popup view state when value changes (only when popup is closed)
React.useEffect(() => {
if (open) return;
const p = parseDateValue();
if (p) {
setViewYear(p.year);
setViewMonth(p.month);
setHour(p.hour);
setMinute(p.minute);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value, open]);
// Position popup
// Firefox + input variant falls through to button variant, so use triggerRef
const usesNativeInput = variant === 'input' && !isFirefox;
const updatePosition = React.useCallback(() => {
const el = usesNativeInput ? 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;
setPos({
top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4,
left: Math.min(rect.left, window.innerWidth - 290),
});
}, [mode, usesNativeInput]);
React.useEffect(() => {
if (!open) return;
updatePosition();
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [open, updatePosition]);
const closePopup = React.useCallback(
(refocusTrigger = true) => {
setOpen(false);
if (!usesNativeInput) {
onBlur?.();
} else if (refocusTrigger) {
setTimeout(() => inputElRef.current?.focus(), 0);
}
},
[usesNativeInput, onBlur]
);
// Dismiss on click outside
React.useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (popupRef.current?.contains(e.target as Node)) return;
if (!usesNativeInput && triggerRef.current?.contains(e.target as Node)) return;
if (usesNativeInput && wrapperRef.current?.contains(e.target as Node)) return;
closePopup(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open, usesNativeInput, closePopup]);
// Dismiss on Escape
React.useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
closePopup(true);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, closePopup]);
// Input variant: smart blur — only fires when focus truly leaves the component
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]);
React.useEffect(() => {
return () => {
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);
};
}, []);
const openPopup = () => {
if (disabled) return;
const p = parseDateValue();
if (p) {
setViewYear(p.year);
setViewMonth(p.month);
setHour(p.hour);
setMinute(p.minute);
}
setOpen(true);
};
const togglePopup = () => {
if (open) closePopup(true);
else openPopup();
};
// Min/Max boundaries
const minDate = min ? new Date(min + 'T00:00:00') : null;
const maxDate = max ? new Date(max + 'T00:00:00') : null;
const isDayDisabled = (y: number, m: number, d: number) => {
const date = new Date(y, m, d);
if (minDate && date < minDate) return true;
if (maxDate && date > maxDate) return true;
return false;
};
const selectDay = (day: number) => {
if (isDayDisabled(viewYear, viewMonth, day)) return;
const dateStr = formatDate(viewYear, viewMonth, day);
if (mode === 'datetime') {
onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`);
} else {
onChange(dateStr);
closePopup(true);
}
};
const handleTimeChange = (newHour: number, newMinute: number) => {
setHour(newHour);
setMinute(newMinute);
if (parsed) {
const dateStr = formatDate(parsed.year, parsed.month, parsed.day);
onChange(`${dateStr}T${pad(newHour)}:${pad(newMinute)}`);
}
};
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); }
else setViewMonth((m) => m - 1);
};
const nextMonth = () => {
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); }
else setViewMonth((m) => m + 1);
};
// Calendar grid
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfWeek(viewYear, viewMonth);
const cells: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
const isTodayDay = (d: number) =>
d === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear();
const isSelected = (d: number) =>
parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year;
// 12-hour display values for time selectors
const { hour: h12, ampm: currentAmpm } = to12Hour(hour);
// Button variant display text
const displayText = (() => {
if (!parsed) return '';
const monthName = MONTH_NAMES[parsed.month];
const base = `${monthName} ${parsed.day}, ${parsed.year}`;
if (mode === 'datetime') {
const { hour: dh, ampm: da } = to12Hour(parsed.hour);
return `${base} ${dh}:${pad(parsed.minute)} ${da}`;
}
return base;
})();
// Year options
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',
isTodayDay(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 — 12-hour with AM/PM */}
{mode === 'datetime' && (
<div className="flex items-center gap-1.5 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={h12}
onChange={(e) => handleTimeChange(to24Hour(parseInt(e.target.value, 10), currentAmpm), minute)}
className="w-14 appearance-none bg-secondary rounded-md px-2 py-1 text-sm text-center focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
{HOUR_OPTIONS.map((h) => (
<option key={h} value={h} className="bg-card text-foreground">{h}</option>
))}
</select>
<span className="text-muted-foreground font-medium">:</span>
<select
value={minute}
onChange={(e) => handleTimeChange(hour, parseInt(e.target.value, 10))}
className="w-14 appearance-none bg-secondary rounded-md px-2 py-1 text-sm text-center 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>
<select
value={currentAmpm}
onChange={(e) => handleTimeChange(to24Hour(h12, e.target.value), minute)}
className="w-16 appearance-none bg-secondary rounded-md px-2 py-1 text-sm text-center focus:outline-none focus-visible:ring-1 focus-visible:ring-ring cursor-pointer"
>
<option value="AM" className="bg-card text-foreground">AM</option>
<option value="PM" className="bg-card text-foreground">PM</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 (Chromium only) ──
// Firefox: falls through to the button variant below because Firefox has no
// CSS pseudo-element to hide its native calendar icon (Mozilla bug 1830890).
// Chromium: uses native type="date"/"datetime-local" for segmented editing UX,
// with the native icon hidden via CSS in index.css (.datepicker-wrapper rule).
if (variant === 'input' && !isFirefox) {
return (
<>
<div ref={wrapperRef} className="datepicker-wrapper relative">
<input
ref={inputElRef}
type={mode === 'datetime' ? 'datetime-local' : 'date'}
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}
min={min}
max={max}
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-px top-px bottom-px w-9 flex items-center justify-center rounded-r-md bg-background 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 (
<>
<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' }}
/>
)}
<button
ref={triggerRef}
type="button"
id={id}
disabled={disabled}
onClick={togglePopup}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePopup();
}
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}
</>
);
}
);
DatePicker.displayName = 'DatePicker';
export { DatePicker };