import { Calendar, ChevronLeft, ChevronRight, Keyboard } from 'lucide-react'; import { localIsoDate } from '../../utils/localDate'; import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from '../../i18n'; import { useRemeasureSignal } from '../../hooks/useAnchoredPosition'; import { useWeekStartDay } from '../../hooks/useWeekStartDay'; import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'; function daysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate(); } const YEAR_PAGE_SIZE = 12; type CalendarView = 'days' | 'months' | 'years'; interface CustomDatePickerProps { value: string; onChange: (value: string) => void; placeholder?: string; style?: React.CSSProperties; compact?: boolean; borderless?: boolean; /** Read-only contexts (a shared journey, a locked form) — same as CustomTimePicker. */ disabled?: boolean; // Optional inclusive ISO (YYYY-MM-DD) bounds. Dates outside the range are // disabled in the calendar and rejected on manual entry. min?: string; max?: string; } export function CustomDatePicker({ value, onChange, placeholder, style = {}, compact = false, borderless = false, disabled = false, min, max, }: CustomDatePickerProps) { const { locale, t } = useTranslation(); const weekStart = useWeekStartDay(); const [open, setOpen] = useState(false); const [view, setView] = useState('days'); const [yearPageStart, setYearPageStart] = useState(0); const ref = useRef(null); const dropRef = useRef(null); // Re-renders the open calendar whenever its anchor may have moved (#1999). const reanchor = useRemeasureSignal(open); const parsed = value ? new Date(value + 'T00:00:00Z') : null; const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear()); const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth()); useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current?.contains(e.target as Node)) return; if (dropRef.current?.contains(e.target as Node)) return; setOpen(false); }; if (open) document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open]); useEffect(() => { if (open) { if (parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()); } else if (min || max) { // No value yet: open on the first in-range month so the user isn't // greeted by an all-disabled calendar. const anchor = new Date((min || max) + 'T00:00:00Z'); setViewYear(anchor.getUTCFullYear()); setViewMonth(anchor.getUTCMonth()); } setView('days'); } }, [open]); const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); } else setViewMonth((m) => m - 1); }; const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); } else setViewMonth((m) => m + 1); }; const handlePrev = () => { if (view === 'days') prevMonth(); else if (view === 'months') setViewYear((y) => y - 1); else setYearPageStart((s) => s - YEAR_PAGE_SIZE); }; const handleNext = () => { if (view === 'days') nextMonth(); else if (view === 'months') setViewYear((y) => y + 1); else setYearPageStart((s) => s + YEAR_PAGE_SIZE); }; // prevAriaLabel / nextAriaLabel: const prevAriaLabel = view === 'days' ? t('common.datepicker.prevMonth') : view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears'); const nextAriaLabel = view === 'days' ? t('common.datepicker.nextMonth') : view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears'); // headerAriaLabel: const headerAriaLabel = view === 'days' ? t('common.datepicker.selectMonth') : view === 'months' ? t('common.datepicker.selectYear') : undefined; const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' }); const yearRangeLabel = `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`; const headerLabel = view === 'days' ? monthLabel : view === 'months' ? String(viewYear) : yearRangeLabel; const handleHeaderClick = () => { if (view === 'days') { setView('months'); } else if (view === 'months') { setYearPageStart(Math.floor(viewYear / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE); setView('years'); } }; const days = daysInMonth(viewYear, viewMonth); const startDay = leadingBlanks(viewYear, viewMonth, weekStart); const weekdays = weekdayLabels(locale, weekStart); const monthNames = Array.from({ length: 12 }, (_, i) => new Date(viewYear, i).toLocaleDateString(locale, { month: 'short' }) ); const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i); const displayValue = parsed ? parsed.toLocaleDateString( locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' } ) : null; const inputValue = parsed ? parsed.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC', }) : ''; // Inclusive range check on ISO (YYYY-MM-DD) strings — lexicographic order // matches chronological order for this format. const isOutOfRange = (iso: string) => (!!min && iso < min) || (!!max && iso > max); const selectDay = (day: number) => { const y = String(viewYear); const m = String(viewMonth + 1).padStart(2, '0'); const d = String(day).padStart(2, '0'); const iso = `${y}-${m}-${d}`; if (isOutOfRange(iso)) return; onChange(iso); setOpen(false); }; const selectMonth = (month: number) => { setViewMonth(month); setView('days'); }; const selectYear = (year: number) => { setViewYear(year); setView('months'); }; const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null; const today = new Date(); const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d; const [textInput, setTextInput] = useState(''); const [isTyping, setIsTyping] = useState(false); const handleTextSubmit = () => { setIsTyping(false); if (!textInput.trim()) return; const input = textInput.trim(); // Try ISO first — always works if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { if (isOutOfRange(input)) return; onChange(input); return; } // Determine field order for active locale via Intl const parts = new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'numeric', year: 'numeric' }).formatToParts( new Date(2001, 5, 15) ); // known date: June 15, 2001 const order = parts .filter((p) => ['day', 'month', 'year'].includes(p.type)) .map((p) => p.type as 'day' | 'month' | 'year'); // Strip non-numeric chars (handles RTL marks, dots, slashes, spaces) const nums = input .replace(/[^\d]+/g, ' ') .trim() .split(/\s+/) .filter(Boolean); if (nums.length !== 3) return; // nachher: const get = (field: 'day' | 'month' | 'year') => Number.parseInt(nums[order.indexOf(field)]); let d = get('day'), m = get('month'); const y = get('year'); if (Number.isNaN(d) || Number.isNaN(m) || Number.isNaN(y)) return; // If locale order gives impossible month but valid swap, correct it if (m > 12 && d <= 12) { const tmp = m; m = d; d = tmp; } const year = y < 100 ? 2000 + y : y; if (m < 1 || m > 12 || d < 1 || d > 31) return; // 31.02. parses fine as numbers but is no day at all; without the round-trip it would // travel on as '2026-02-31' and come back out of new Date() as 3 March. const probe = new Date(Date.UTC(year, m - 1, d)); if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) return; const iso = `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; if (isOutOfRange(iso)) return; onChange(iso); }; const gridCellStyle = (selected: boolean, current: boolean): React.CSSProperties => ({ borderRadius: 8, border: 'none', background: selected ? 'var(--accent)' : 'transparent', color: selected ? 'var(--accent-text)' : 'var(--text-primary)', fontWeight: selected ? 700 : current ? 600 : 400, cursor: 'pointer', outline: current && !selected ? '2px solid var(--border-primary)' : 'none', outlineOffset: -2, transition: 'background 0.1s', }); return (
{isTyping ? ( setTextInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleTextSubmit(); if (e.key === 'Escape') setIsTyping(false); }} onBlur={handleTextSubmit} placeholder="DD.MM.YYYY" aria-label="Enter date manually" style={{ width: '100%', padding: '8px 14px', borderRadius: 10, border: '1px solid var(--text-faint)', background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', }} /> ) : ( // `stretch`, not `center`: the keyboard button's own padding made it a // few pixels shorter than the date trigger beside it, and the two sat in // a row with mismatched heights. Stretching means it tracks the trigger // whatever the type scale does to that one.
{/* Calendar trigger */} {/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */} {!compact && !borderless && ( )}
)} {open && createPortal(
{ void reanchor; const r = ref.current?.getBoundingClientRect(); if (!r) return { top: 0, left: 0 }; const w = 268, pad = 8, // Only used to decide *whether* the popup fits below the trigger — // its real height varies with the month's row count and the view // (days/months/years), so it can't be known before it renders. h = 360; const vw = window.innerWidth; const vh = window.visualViewport?.height ?? window.innerHeight; let left = r.left; if (left + w > vw - pad) left = Math.max(pad, vw - w - pad); if (vw < 360) left = Math.max(pad, (vw - w) / 2); const below = r.bottom + 4; if (below + h <= vh - pad) return { top: below, left }; // Flipped: anchor from the trigger's own top edge via `bottom` // instead of subtracting a guessed height from `top`. A guessed // `top` leaves a gap sized by however wrong the guess was — in a // short modal (e.g. the settle-up form) that gap was big enough to // land the popup over unrelated fields instead of the trigger it // belongs to. Anchoring the opposite edge means the popup always // sits flush against the trigger no matter its real height. if (r.top - 4 - h >= pad) return { bottom: vh - r.top + 4, left }; // No room on either side (a phone in landscape): keep the top edge // inside the viewport and let the popup cover the trigger instead. return { top: Math.max(pad, Math.min(below, vh - h - pad)), left }; })(), zIndex: 99999, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)', padding: 12, width: 268, maxWidth: 'calc(100vw - 16px)', animation: 'selectIn 0.15s ease-out', backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)', }} > {/* ── Header ── */}
{/* Clickable label — drills down (days → months → years) */} {view !== 'years' ? ( ) : ( {headerLabel} )}
{/* ── Days view ── */} {view === 'days' && ( <>
{weekdays.map((d, i) => (
{d}
))}
{Array.from({ length: startDay }, (_, i) => (
))} {Array.from({ length: days }, (_, i) => { const d = i + 1; const sel = d === selectedDay; const td = isToday(d); const isoDate = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const disabled = isOutOfRange(isoDate); const ariaLabel = new Date(isoDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC', }); return ( ); })}
)} {/* ── Months view ── */} {view === 'months' && (
{monthNames.map((name, i) => { const sel = !!(parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === i); const cur = today.getFullYear() === viewYear && today.getMonth() === i; const ariaLabel = new Date(viewYear, i).toLocaleDateString(locale, { month: 'long', year: 'numeric', }); return ( ); })}
)} {/* ── Years view ── */} {view === 'years' && (
{years.map((year) => { const sel = !!(parsed && parsed.getUTCFullYear() === year); const cur = today.getFullYear() === year; return ( ); })}
)} {/* ── Footer: keyboard trigger (compact) + clear ── */}
{/* In compact/borderless mode, the keyboard icon lives here instead of next to the trigger */} {compact || borderless ? ( ) : (
)} {value && ( )}
, document.body )}
); } interface CustomDateTimePickerProps { value: string; onChange: (value: string) => void; placeholder?: string; style?: React.CSSProperties; } export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) { const { locale } = useTranslation(); const [datePart, timePart] = (value || '').split('T'); const handleDateChange = (d: string) => { onChange(d ? `${d}T${timePart || '12:00'}` : ''); }; const handleTimeChange = (t: string) => { const d = datePart || localIsoDate(); onChange(t ? `${d}T${t}` : d); }; return (
); } import CustomTimePicker from './CustomTimePicker';