Files
cruciverb/frontend/src/components/ui/DatePicker.tsx
T

171 lines
6.4 KiB
TypeScript

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<HTMLButtonElement>(null)
const calRef = useRef<HTMLDivElement>(null)
const dayRefs = useRef<(HTMLButtonElement | null)[]>([])
const [focusDay, setFocusDay] = useState<number | null>(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 (
<div className={`cv-datepicker ${className || ''}`}>
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button"
aria-haspopup="dialog" aria-expanded={open}>
<span>{value}</span>
<IconChevronDownFilled size={10} style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} />
</button>
{open && createPortal(
<div className="cv-calendar" ref={calRef} style={{ top: pos.top, left: pos.left }}
role="dialog" aria-label={t('datepicker.calendarLabel', { month: MONTHS[viewMonth - 1], year: viewYear })}>
<div className="cv-cal-header">
<button className="cv-cal-nav" onClick={prevMonth} type="button" aria-label={t('datepicker.prevMonth')}>&lt;</button>
<span className="cv-cal-title">{MONTHS[viewMonth - 1]} {viewYear}</span>
<button className="cv-cal-nav" onClick={nextMonth} type="button" aria-label={t('datepicker.nextMonth')}>&gt;</button>
</div>
<div className="cv-cal-days" role="grid">
{WEEKDAYS.map(d => <span key={d} className="cv-cal-dayname">{d}</span>)}
</div>
<div className="cv-cal-grid" role="grid" onKeyDown={onGridKeyDown}>
{Array.from({ length: offset }, (_, i) => <span key={`pad-${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 (
<button
key={d}
ref={el => { dayRefs.current[d - 1] = el }}
className={`cv-cal-day ${isSelected ? 'cv-cal-day-selected' : ''} ${isToday ? 'cv-cal-day-today' : ''}`}
onClick={() => pick(d)}
onFocus={() => setFocusDay(d)}
type="button"
aria-selected={isSelected}
>
{d}
</button>
)
})}
</div>
</div>,
document.body,
)}
</div>
)
}