From 269974b0eb5aa9f0fa0000dec8f619c4f29c32d6 Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 24 Jan 2026 19:40:16 +0200 Subject: [PATCH] tooltip and date picker --- frontend/src/components/ui/DatePicker.tsx | 135 ++++++++++++++++++++++ frontend/src/components/ui/Tooltip.tsx | 89 ++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 frontend/src/components/ui/DatePicker.tsx create mode 100644 frontend/src/components/ui/Tooltip.tsx diff --git a/frontend/src/components/ui/DatePicker.tsx b/frontend/src/components/ui/DatePicker.tsx new file mode 100644 index 0000000..56285ea --- /dev/null +++ b/frontend/src/components/ui/DatePicker.tsx @@ -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(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, + )} +
+ ) +} diff --git a/frontend/src/components/ui/Tooltip.tsx b/frontend/src/components/ui/Tooltip.tsx new file mode 100644 index 0000000..b4e6c1f --- /dev/null +++ b/frontend/src/components/ui/Tooltip.tsx @@ -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({ top: 0, left: 0, arrowDir: 'down' }) + const ref = useRef(null) + const tipRef = useRef(null) + const timer = useRef | 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 ( +
+ {children} + {visible && createPortal( + , + document.body, + )} +
+ ) +}