242 lines
7.2 KiB
TypeScript
242 lines
7.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
interface Props {
|
|
value: string; // "YYYY-MM-DD"
|
|
onChange: (value: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
const MONTH_NAMES = [
|
|
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
|
];
|
|
|
|
const WEEKDAY_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
|
|
|
function pad(n: number): string {
|
|
return String(n).padStart(2, '0');
|
|
}
|
|
|
|
function monthKey(date: Date): string {
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
|
|
}
|
|
|
|
function monthLabel(ym: string): string {
|
|
const [ys, ms] = ym.split('-');
|
|
const y = Number(ys);
|
|
const m = Number(ms);
|
|
return `${MONTH_NAMES[m - 1]} ${y}`;
|
|
}
|
|
|
|
function shiftMonth(ym: string, delta: number): string {
|
|
const [ys, ms] = ym.split('-');
|
|
const d = new Date(Number(ys), Number(ms) - 1 + delta, 1);
|
|
return monthKey(d);
|
|
}
|
|
|
|
function parseISODate(v: string): Date | null {
|
|
if (!isValidDateString(v)) return null;
|
|
const [ys, ms, ds] = v.split('-');
|
|
return new Date(Number(ys), Number(ms) - 1, Number(ds));
|
|
}
|
|
|
|
function buildMonthGrid(ym: string): Array<number | null> {
|
|
const [ys, ms] = ym.split('-');
|
|
const y = Number(ys);
|
|
const m = Number(ms);
|
|
const first = new Date(y, m - 1, 1);
|
|
const days = new Date(y, m, 0).getDate();
|
|
const mondayStartOffset = (first.getDay() + 6) % 7;
|
|
const cells: Array<number | null> = Array(mondayStartOffset).fill(null);
|
|
for (let d = 1; d <= days; d += 1) cells.push(d);
|
|
while (cells.length % 7 !== 0) cells.push(null);
|
|
return cells;
|
|
}
|
|
|
|
function isValidDateString(v: string): boolean {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) return false;
|
|
const [ys, ms, ds] = v.split('-');
|
|
const y = Number(ys);
|
|
const m = Number(ms);
|
|
const d = Number(ds);
|
|
if (m < 1 || m > 12 || d < 1 || d > 31) return false;
|
|
|
|
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
return (
|
|
dt.getUTCFullYear() === y &&
|
|
dt.getUTCMonth() === m - 1 &&
|
|
dt.getUTCDate() === d
|
|
);
|
|
}
|
|
|
|
function normalize(v: string): string {
|
|
const digits = v.replace(/\D/g, '').slice(0, 8);
|
|
const y = digits.slice(0, 4);
|
|
const m = digits.slice(4, 6);
|
|
const d = digits.slice(6, 8);
|
|
|
|
if (digits.length <= 4) return y;
|
|
if (digits.length <= 6) return `${y}-${m}`;
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
export default function DateInput({ value, onChange, className = '' }: Props) {
|
|
const [local, setLocal] = useState(value);
|
|
const [error, setError] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const [viewMonth, setViewMonth] = useState(monthKey(new Date()));
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
|
|
const selectedDate = useMemo(() => parseISODate(value), [value]);
|
|
const dayCells = useMemo(() => buildMonthGrid(viewMonth), [viewMonth]);
|
|
|
|
useEffect(() => {
|
|
setLocal(value);
|
|
setError(false);
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
const date = parseISODate(value);
|
|
if (date) setViewMonth(monthKey(date));
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
function onDocMouseDown(ev: MouseEvent) {
|
|
if (!wrapperRef.current) return;
|
|
if (!wrapperRef.current.contains(ev.target as Node)) setOpen(false);
|
|
}
|
|
function onDocKeyDown(ev: KeyboardEvent) {
|
|
if (ev.key === 'Escape') setOpen(false);
|
|
}
|
|
document.addEventListener('mousedown', onDocMouseDown);
|
|
document.addEventListener('keydown', onDocKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDocMouseDown);
|
|
document.removeEventListener('keydown', onDocKeyDown);
|
|
};
|
|
}, []);
|
|
|
|
function handleChange(raw: string) {
|
|
setError(false);
|
|
setLocal(normalize(raw));
|
|
}
|
|
|
|
function handleBlur() {
|
|
if (local === '') {
|
|
setLocal(value);
|
|
setError(false);
|
|
return;
|
|
}
|
|
|
|
if (isValidDateString(local)) {
|
|
setError(false);
|
|
onChange(local);
|
|
return;
|
|
}
|
|
|
|
setError(true);
|
|
}
|
|
|
|
function selectDay(day: number) {
|
|
const [ys, ms] = viewMonth.split('-');
|
|
const iso = `${ys}-${ms}-${pad(day)}`;
|
|
setLocal(iso);
|
|
setError(false);
|
|
onChange(iso);
|
|
setOpen(false);
|
|
}
|
|
|
|
function isSelected(day: number): boolean {
|
|
if (!selectedDate) return false;
|
|
const [ys, ms] = viewMonth.split('-').map(Number);
|
|
return selectedDate.getFullYear() === ys && selectedDate.getMonth() === ms - 1 && selectedDate.getDate() === day;
|
|
}
|
|
|
|
return (
|
|
<div ref={wrapperRef} className={`relative ${className}`}>
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={local}
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
onBlur={handleBlur}
|
|
placeholder="YYYY-MM-DD"
|
|
maxLength={10}
|
|
className={`w-full px-2 py-1 border-2 rounded-lg bg-white text-sm text-gray-900 font-mono text-center focus:outline-none ${
|
|
error ? 'border-red-500 focus:border-red-500' : 'border-gray-400 focus:border-blue-500'
|
|
}`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="px-2 py-1 border-2 border-gray-400 rounded-lg bg-white text-xs text-gray-700 hover:bg-gray-50"
|
|
aria-label="Kalender oeffnen"
|
|
>
|
|
Kalender
|
|
</button>
|
|
</div>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 top-full mt-1 z-20 w-64 border-2 border-gray-300 rounded-lg bg-white shadow-lg p-2">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setViewMonth((m) => shiftMonth(m, -1))}
|
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
|
|
aria-label="Vorheriger Monat"
|
|
>
|
|
{'<'}
|
|
</button>
|
|
<div className="text-sm font-medium">{monthLabel(viewMonth)}</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setViewMonth((m) => shiftMonth(m, 1))}
|
|
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-sm"
|
|
aria-label="Naechster Monat"
|
|
>
|
|
{'>'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-1 mb-1">
|
|
{WEEKDAY_SHORT.map((wd) => (
|
|
<div key={wd} className="text-[11px] text-center text-gray-500 py-1">
|
|
{wd}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{dayCells.map((day, idx) => (
|
|
<button
|
|
key={`${viewMonth}-${idx}`}
|
|
type="button"
|
|
disabled={day === null}
|
|
onClick={() => day !== null && selectDay(day)}
|
|
className={`h-8 rounded text-sm ${
|
|
day === null
|
|
? 'text-transparent cursor-default'
|
|
: isSelected(day)
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-50 hover:bg-gray-100 text-gray-800'
|
|
}`}
|
|
>
|
|
{day ?? '-'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="absolute left-0 top-full mt-0.5 text-xs text-red-600 whitespace-nowrap z-10">
|
|
Ungueltiges Datum (YYYY-MM-DD)
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|