feat(DateTimePicker): integrate flatpickr for enhanced date/time selection and theming
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s

This commit is contained in:
2026-03-04 12:33:51 +01:00
parent 268f6d0c5a
commit 014de634b1
4 changed files with 287 additions and 150 deletions

View File

@@ -1,16 +1,22 @@
import { useEffect, useRef } from 'react';
import flatpickr from 'flatpickr';
import { German } from 'flatpickr/dist/l10n/de.js';
import { Calendar as CalendarIcon, Clock } from 'lucide-react';
// Register German as default locale
flatpickr.localize(German);
/**
* Themed DateTimePicker using native <input type="date"> + <input type="time">.
* No popups, no Popper.js, no scroll issues — the browser-native date/time
* pickers handle everything and respect the OS dark/light mode via color-scheme.
* Themed DateTimePicker using flatpickr.
* flatpickr uses position:fixed for its calendar dropdown — no overflow,
* no scroll issues, no Popper.js needed. CSS variables drive all theming.
*
* Props:
* value local datetime string 'YYYY-MM-DDTHH:mm' (or '')
* onChange (localDatetimeString) => void
* label string
* required bool
* minDate Date | null (only the date part is enforced)
* minDate Date | null
* icon 'calendar' (default) | 'clock'
*/
export default function DateTimePicker({
@@ -21,28 +27,57 @@ export default function DateTimePicker({
minDate = null,
icon = 'calendar',
}) {
// Split 'YYYY-MM-DDTHH:mm' into date and time parts
const [datePart, timePart] = value ? value.split('T') : ['', ''];
const inputRef = useRef(null);
const fpRef = useRef(null);
const handleDateChange = (e) => {
const d = e.target.value; // YYYY-MM-DD
const t = timePart || '09:00';
onChange(d ? `${d}T${t}` : '');
};
useEffect(() => {
if (!inputRef.current) return;
const handleTimeChange = (e) => {
const t = e.target.value; // HH:mm
const d = datePart || new Date().toISOString().slice(0, 10);
onChange(t ? `${d}T${t}` : '');
};
fpRef.current = flatpickr(inputRef.current, {
enableTime: true,
time_24hr: true,
dateFormat: 'd.m.Y H:i',
minuteIncrement: 15,
minDate: minDate || undefined,
defaultDate: value || undefined,
appendTo: document.body, // portal to body → never clipped
static: false,
onChange: (selectedDates) => {
if (selectedDates.length === 0) { onChange(''); return; }
const d = selectedDates[0];
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
onChange(`${y}-${mo}-${day}T${h}:${mi}`);
},
});
return () => fpRef.current?.destroy();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Sync value from outside
useEffect(() => {
if (!fpRef.current) return;
const current = fpRef.current.selectedDates[0];
const incoming = value ? new Date(value) : null;
// Only setDate if actually different (avoid loops)
if (incoming && (!current || Math.abs(incoming - current) > 60000)) {
fpRef.current.setDate(incoming, false);
} else if (!incoming && current) {
fpRef.current.clear(false);
}
}, [value]);
// Sync minDate
useEffect(() => {
if (!fpRef.current) return;
fpRef.current.set('minDate', minDate || undefined);
}, [minDate]);
const Icon = icon === 'clock' ? Clock : CalendarIcon;
// Format minDate to YYYY-MM-DD for the native input
const minDateStr = minDate
? `${minDate.getFullYear()}-${String(minDate.getMonth() + 1).padStart(2, '0')}-${String(minDate.getDate()).padStart(2, '0')}`
: undefined;
return (
<div>
{label && (
@@ -50,28 +85,16 @@ export default function DateTimePicker({
{label}{required && ' *'}
</label>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<CalendarIcon size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
<input
type="date"
value={datePart}
onChange={handleDateChange}
min={minDateStr}
required={required}
className="input-field pl-8 text-sm w-full"
/>
</div>
<div className="relative w-[7rem]">
<Clock size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
<input
type="time"
value={timePart}
onChange={handleTimeChange}
required={required}
className="input-field pl-8 text-sm w-full"
/>
</div>
<div className="relative">
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-[1]" />
<input
ref={inputRef}
type="text"
required={required}
readOnly
placeholder="Datum & Uhrzeit wählen…"
className="input-field pl-9 text-sm w-full cursor-pointer"
/>
</div>
</div>
);

View File

@@ -741,10 +741,219 @@
}
/* ═══════════════════════════════════════════════════════════════
NATIVE DATE/TIME INPUTS — themed via CSS variables + color-scheme
FLATPICKR THEMED OVERRIDES — fully driven by CSS variables
═══════════════════════════════════════════════════════════════ */
input[type="date"],
input[type="time"] {
font-variant-numeric: tabular-nums;
/* Calendar container — appended to body */
.flatpickr-calendar {
background: var(--card-bg) !important;
border: 1px solid var(--border) !important;
border-radius: 0.75rem !important;
box-shadow: 0 10px 25px -5px var(--shadow-color), 0 4px 10px -6px var(--shadow-color) !important;
font-family: inherit !important;
color: var(--text-primary) !important;
z-index: 9999 !important;
overflow: hidden;
}
.flatpickr-calendar::before,
.flatpickr-calendar::after {
display: none !important; /* hide arrow */
}
/* ── Month navigation ─────────────────────────────────────────── */
.flatpickr-months {
background: var(--bg-secondary) !important;
border-bottom: 1px solid var(--border) !important;
padding: 0.25rem 0 !important;
}
.flatpickr-months .flatpickr-month {
background: transparent !important;
color: var(--text-primary) !important;
height: 2.25rem !important;
}
.flatpickr-current-month {
font-size: 0.875rem !important;
font-weight: 600 !important;
color: var(--text-primary) !important;
padding-top: 0.375rem !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
background: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border: none !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
appearance: none !important;
-webkit-appearance: none !important;
}
.flatpickr-current-month .flatpickr-monthDropdown-months option {
background: var(--card-bg) !important;
color: var(--text-primary) !important;
}
.flatpickr-current-month input.cur-year {
color: var(--text-primary) !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
}
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
color: var(--text-secondary) !important;
fill: var(--text-secondary) !important;
padding: 0.375rem 0.625rem !important;
transition: color 0.15s !important;
}
.flatpickr-months .flatpickr-prev-month:hover,
.flatpickr-months .flatpickr-next-month:hover {
color: var(--text-primary) !important;
fill: var(--text-primary) !important;
}
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
fill: inherit !important;
width: 12px !important;
height: 12px !important;
}
/* ── Day names row ────────────────────────────────────────────── */
.flatpickr-weekdays {
background: var(--bg-secondary) !important;
border-bottom: 1px solid var(--border) !important;
padding: 0.125rem 0 !important;
}
span.flatpickr-weekday {
color: var(--text-secondary) !important;
font-size: 0.6875rem !important;
font-weight: 600 !important;
text-transform: uppercase !important;
letter-spacing: 0.05em !important;
}
/* ── Days grid ────────────────────────────────────────────────── */
.flatpickr-days {
border: none !important;
}
.dayContainer {
padding: 0.25rem !important;
min-width: 15.5rem !important;
max-width: 15.5rem !important;
}
.flatpickr-day {
color: var(--text-primary) !important;
border: none !important;
border-radius: 0.375rem !important;
font-size: 0.8125rem !important;
line-height: 2.25rem !important;
height: 2.25rem !important;
max-width: 2.25rem !important;
transition: background 0.12s, color 0.12s !important;
}
.flatpickr-day:hover:not(.selected):not(.flatpickr-disabled) {
background: var(--hover-bg) !important;
border: none !important;
}
.flatpickr-day.selected,
.flatpickr-day.selected:hover {
background: var(--accent) !important;
color: var(--accent-text) !important;
border: none !important;
font-weight: 600 !important;
}
.flatpickr-day.today:not(.selected) {
font-weight: 700 !important;
color: var(--accent) !important;
border: none !important;
position: relative !important;
}
.flatpickr-day.today:not(.selected)::after {
content: '';
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay {
color: var(--text-secondary) !important;
opacity: 0.4 !important;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
color: var(--text-secondary) !important;
opacity: 0.3 !important;
cursor: not-allowed !important;
}
/* ── Time picker ──────────────────────────────────────────────── */
.flatpickr-time {
border-top: 1px solid var(--border) !important;
background: var(--bg-secondary) !important;
max-height: none !important;
height: auto !important;
}
.flatpickr-time input {
color: var(--text-primary) !important;
background: transparent !important;
font-size: 0.9375rem !important;
font-weight: 600 !important;
font-variant-numeric: tabular-nums !important;
}
.flatpickr-time input:hover,
.flatpickr-time input:focus {
background: var(--hover-bg) !important;
border-radius: 0.375rem !important;
}
.flatpickr-time .flatpickr-time-separator {
color: var(--text-secondary) !important;
font-weight: 600 !important;
}
.flatpickr-time .flatpickr-am-pm {
color: var(--text-primary) !important;
background: transparent !important;
}
.flatpickr-time .numInputWrapper span {
border: none !important;
}
.flatpickr-time .numInputWrapper span.arrowUp::after {
border-bottom-color: var(--text-secondary) !important;
}
.flatpickr-time .numInputWrapper span.arrowDown::after {
border-top-color: var(--text-secondary) !important;
}
.flatpickr-time .numInputWrapper:hover span.arrowUp::after {
border-bottom-color: var(--text-primary) !important;
}
.flatpickr-time .numInputWrapper:hover span.arrowDown::after {
border-top-color: var(--text-primary) !important;
}