tooltip and date picker
This commit is contained in:
@@ -0,0 +1,135 @@
|
|||||||
|
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 { 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 (
|
||||||
|
<div className={`cv-datepicker ${className || ''}`}>
|
||||||
|
<button className="cv-select-trigger" ref={triggerRef} onClick={() => setOpen(!open)} type="button">
|
||||||
|
<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')}><</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')}>></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">
|
||||||
|
{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}
|
||||||
|
className={`cv-cal-day ${isSelected ? 'cv-cal-day-selected' : ''} ${isToday ? 'cv-cal-day-today' : ''}`}
|
||||||
|
onClick={() => pick(d)}
|
||||||
|
type="button"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useRef, useCallback, useEffect, useId } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string
|
||||||
|
children: React.ReactNode
|
||||||
|
delay?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TipPos = { top: number; left: number; arrowDir: 'down' | 'up' }
|
||||||
|
|
||||||
|
export default function Tooltip({ text, children, delay = 400 }: Props) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [pos, setPos] = useState<TipPos>({ top: 0, left: 0, arrowDir: 'down' })
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const tipRef = useRef<HTMLDivElement>(null)
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const tipId = useId()
|
||||||
|
|
||||||
|
const show = useCallback(() => {
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
if (!ref.current) return
|
||||||
|
const rect = ref.current.getBoundingClientRect()
|
||||||
|
const tipW = 200
|
||||||
|
const tipH = 32
|
||||||
|
|
||||||
|
let top = rect.top - tipH - 8
|
||||||
|
let left = rect.left + rect.width / 2
|
||||||
|
let arrowDir: 'down' | 'up' = 'down'
|
||||||
|
|
||||||
|
if (top < 8) {
|
||||||
|
top = rect.bottom + 8
|
||||||
|
arrowDir = 'up'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left - tipW / 2 < 8) left = tipW / 2 + 8
|
||||||
|
if (left + tipW / 2 > window.innerWidth - 8) left = window.innerWidth - tipW / 2 - 8
|
||||||
|
|
||||||
|
setPos({ top, left, arrowDir })
|
||||||
|
setVisible(true)
|
||||||
|
}, delay)
|
||||||
|
}, [delay])
|
||||||
|
|
||||||
|
const hide = useCallback(() => {
|
||||||
|
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||||
|
setVisible(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') hide() }
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [visible, hide])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (timer.current) clearTimeout(timer.current) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!text) return <>{children}</>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onMouseEnter={show}
|
||||||
|
onMouseLeave={hide}
|
||||||
|
onFocus={show}
|
||||||
|
onBlur={hide}
|
||||||
|
aria-describedby={visible ? tipId : undefined}
|
||||||
|
className="cv-tooltip-wrap"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{visible && createPortal(
|
||||||
|
<div
|
||||||
|
ref={tipRef}
|
||||||
|
id={tipId}
|
||||||
|
role="tooltip"
|
||||||
|
className={`cv-tooltip cv-tooltip-${pos.arrowDir}`}
|
||||||
|
style={{ top: pos.top, left: pos.left }}
|
||||||
|
onMouseEnter={() => { if (timer.current) clearTimeout(timer.current) }}
|
||||||
|
onMouseLeave={hide}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user