import { useState, useRef, useEffect, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' import { IconChevronDownFilled } from '@tabler/icons-react' interface Props { value: string // YYYY-MM-DD onChange: (value: string) => void className?: string } // i18n translations loaded in component function parse(s: string) { const [y, m, d] = s.split('-').map(Number) return { year: y, month: m, day: d } } function fmt(y: number, m: number, d: number) { return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` } function daysInMonth(y: number, m: number) { return new Date(y, m, 0).getDate() } function firstDayOfWeek(y: number, m: number) { const d = new Date(y, m - 1, 1).getDay() return d === 0 ? 6 : d - 1 } export default function DatePicker({ value, onChange, className }: Props) { const { t } = useTranslation() // SAFETY: these i18n keys return arrays only when returnObjects is set, so each value is a string[] const MONTHS = t('datepicker.months', { returnObjects: true }) as string[] // SAFETY: same contract as MONTHS - the returnObjects flag makes this i18n key return a string[] const WEEKDAYS = t('datepicker.weekdays', { returnObjects: true }) as string[] const [open, setOpen] = useState(false) const triggerRef = useRef(null) const calRef = useRef(null) const { year, month } = parse(value) const [viewYear, setViewYear] = useState(year) const [viewMonth, setViewMonth] = useState(month) const [pos, setPos] = useState({ top: 0, left: 0 }) const updatePos = useCallback(() => { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() setPos({ top: rect.bottom + 4, left: rect.left }) }, []) useEffect(() => { if (!open) return updatePos() function onDown(e: MouseEvent) { const target = e.target if (target instanceof Node && calRef.current && !calRef.current.contains(target) && triggerRef.current && !triggerRef.current.contains(target)) { setOpen(false) } } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false) } window.addEventListener('scroll', updatePos, true) document.addEventListener('mousedown', onDown) document.addEventListener('keydown', onKey) return () => { window.removeEventListener('scroll', updatePos, true) document.removeEventListener('mousedown', onDown) document.removeEventListener('keydown', onKey) } }, [open, updatePos]) const prevMonth = () => { if (viewMonth === 1) { setViewMonth(12); setViewYear(viewYear - 1) } else setViewMonth(viewMonth - 1) } const nextMonth = () => { if (viewMonth === 12) { setViewMonth(1); setViewYear(viewYear + 1) } else setViewMonth(viewMonth + 1) } const days = daysInMonth(viewYear, viewMonth) const offset = firstDayOfWeek(viewYear, viewMonth) const today = new Date().toISOString().split('T')[0] const pick = (d: number) => { onChange(fmt(viewYear, viewMonth, d)) setOpen(false) } return (
{open && createPortal(
{MONTHS[viewMonth - 1]} {viewYear}
{WEEKDAYS.map(d => {d})}
{Array.from({ length: offset }, (_, i) => )} {Array.from({ length: days }, (_, i) => { const d = i + 1 const dateStr = fmt(viewYear, viewMonth, d) const isSelected = dateStr === value const isToday = dateStr === today return ( ) })}
, document.body, )}
) }