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 dayRefs = useRef<(HTMLButtonElement | null)[]>([]) const [focusDay, setFocusDay] = useState(null) const { year, month } = parse(value) const [viewYear, setViewYear] = useState(year) const [viewMonth, setViewMonth] = useState(month) const [pos, setPos] = useState({ top: 0, left: 0 }) // the day of the selected value, when it falls inside the current view const selected = parse(value) const selectedInView = selected.year === viewYear && selected.month === viewMonth ? selected.day : 1 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') { setFocusDay(null); setOpen(false); triggerRef.current?.focus() } } 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)) setFocusDay(null) setOpen(false) } const focusDayRef = (d: number) => { setFocusDay(d) dayRefs.current[d - 1]?.focus() } const onGridKeyDown = (e: { key: string; preventDefault(): void }) => { const cur = focusDay != null ? focusDay : selectedInView let next = cur if (e.key === 'ArrowRight') next = cur + 1 else if (e.key === 'ArrowLeft') next = cur - 1 else if (e.key === 'ArrowDown') next = cur + 7 else if (e.key === 'ArrowUp') next = cur - 7 else return e.preventDefault() if (next < 1 || next > days) return focusDayRef(next) } useEffect(() => { if (!open) return const d = focusDay != null && focusDay >= 1 && focusDay <= days ? focusDay : selectedInView dayRefs.current[d - 1]?.focus() }, [open, days, selectedInView]) 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, )}
) }