feat(DateTimePicker): replace custom date/time picker with native inputs for improved theming and accessibility
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m50s

This commit is contained in:
2026-03-04 12:17:42 +01:00
parent 7018c5579f
commit 268f6d0c5a
2 changed files with 48 additions and 420 deletions

View File

@@ -1,19 +1,16 @@
import ReactDatePicker from 'react-datepicker';
import { de } from 'date-fns/locale';
import { Calendar as CalendarIcon, Clock } from 'lucide-react'; import { Calendar as CalendarIcon, Clock } from 'lucide-react';
import { forwardRef, useRef, useCallback } from 'react';
import 'react-datepicker/dist/react-datepicker.css';
/** /**
* Custom DateTimePicker that reads the app's CSS variables and * Themed DateTimePicker using native <input type="date"> + <input type="time">.
* fully matches whatever theme is active. * 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.
* *
* Props: * Props:
* value ISO datetime string (or '') * value local datetime string 'YYYY-MM-DDTHH:mm' (or '')
* onChange (isoString) => void * onChange (localDatetimeString) => void
* label string * label string
* required bool * required bool
* minDate Date | null * minDate Date | null (only the date part is enforced)
* icon 'calendar' (default) | 'clock' * icon 'calendar' (default) | 'clock'
*/ */
export default function DateTimePicker({ export default function DateTimePicker({
@@ -24,126 +21,58 @@ export default function DateTimePicker({
minDate = null, minDate = null,
icon = 'calendar', icon = 'calendar',
}) { }) {
const selected = value ? new Date(value) : null; // Split 'YYYY-MM-DDTHH:mm' into date and time parts
const [datePart, timePart] = value ? value.split('T') : ['', ''];
const handleChange = (date) => { const handleDateChange = (e) => {
if (!date) { onChange(''); return; } const d = e.target.value; // YYYY-MM-DD
// Produce local datetime string yyyy-MM-ddTHH:mm const t = timePart || '09:00';
const y = date.getFullYear(); onChange(d ? `${d}T${t}` : '');
const mo = String(date.getMonth() + 1).padStart(2, '0'); };
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0'); const handleTimeChange = (e) => {
const mi = String(date.getMinutes()).padStart(2, '0'); const t = e.target.value; // HH:mm
onChange(`${y}-${mo}-${d}T${h}:${mi}`); const d = datePart || new Date().toISOString().slice(0, 10);
onChange(t ? `${d}T${t}` : '');
}; };
const Icon = icon === 'clock' ? Clock : CalendarIcon; const Icon = icon === 'clock' ? Clock : CalendarIcon;
// react-datepicker calls scrollIntoView on the selected time item when the // Format minDate to YYYY-MM-DD for the native input
// calendar opens. This scrolls the nearest scrollable ancestor (page or const minDateStr = minDate
// modal). We capture every scrollable ancestor's position just before open ? `${minDate.getFullYear()}-${String(minDate.getMonth() + 1).padStart(2, '0')}-${String(minDate.getDate()).padStart(2, '0')}`
// and restore it in the next animation frame — after scrollIntoView fires. : undefined;
const wrapperRef = useRef(null);
const handleCalendarOpen = useCallback(() => {
const snapshots = [];
let el = wrapperRef.current?.parentElement;
while (el && el !== document.body) {
const { overflow, overflowY } = getComputedStyle(el);
if (/(auto|scroll)/.test(overflow + overflowY)) {
snapshots.push({ el, top: el.scrollTop, left: el.scrollLeft });
}
el = el.parentElement;
}
// also capture window scroll
const winY = window.scrollY;
requestAnimationFrame(() => {
snapshots.forEach(s => { s.el.scrollTop = s.top; s.el.scrollLeft = s.left; });
window.scrollTo({ top: winY, behavior: 'instant' });
});
}, []);
return ( return (
<div className="dtp-wrapper" ref={wrapperRef}> <div>
{label && ( {label && (
<label className="block text-sm font-medium text-th-text mb-1.5"> <label className="block text-sm font-medium text-th-text mb-1.5">
{label}{required && ' *'} {label}{required && ' *'}
</label> </label>
)} )}
<div className="dtp-input-wrap"> <div className="flex gap-2">
<Icon size={15} className="dtp-icon" /> <div className="relative flex-1">
<ReactDatePicker <CalendarIcon size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
selected={selected} <input
onChange={handleChange} type="date"
showTimeSelect value={datePart}
timeFormat="HH:mm" onChange={handleDateChange}
timeIntervals={15} min={minDateStr}
dateFormat="dd.MM.yyyy HH:mm" required={required}
locale={de} className="input-field pl-8 text-sm w-full"
minDate={minDate} />
required={required} </div>
popperPlacement="bottom-start" <div className="relative w-[7rem]">
popperProps={{ strategy: 'fixed' }} <Clock size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
onCalendarOpen={handleCalendarOpen} <input
popperModifiers={[ type="time"
{ name: 'offset', options: { offset: [0, 4] } }, value={timePart}
{ name: 'preventOverflow', options: { rootBoundary: 'viewport', tether: false, altAxis: true } }, onChange={handleTimeChange}
]} required={required}
customInput={<CustomInput />} className="input-field pl-8 text-sm w-full"
calendarClassName="dtp-calendar" />
dayClassName={(date) => 'dtp-day'} </div>
timeClassName={() => 'dtp-time-item'}
renderCustomHeader={CalendarHeader}
wrapperClassName="dtp-dp-wrapper"
/>
</div> </div>
</div> </div>
); );
} }
// ── Custom Input ─────────────────────────────────────────────────────────────
const CustomInput = forwardRef(({ value, onClick, onClear, ...rest }, ref) => (
<button
ref={ref}
type="button"
onClick={onClick}
className="dtp-custom-input"
{...rest}
>
<span className={value ? 'dtp-value' : 'dtp-placeholder'}>
{value || 'Datum & Uhrzeit wählen …'}
</span>
</button>
));
CustomInput.displayName = 'CustomInput';
// ── Custom Header ─────────────────────────────────────────────────────────────
function CalendarHeader({
date,
decreaseMonth, increaseMonth,
prevMonthButtonDisabled, nextMonthButtonDisabled,
}) {
const label = date.toLocaleString('de', { month: 'long', year: 'numeric' });
return (
<div className="dtp-header">
<button
type="button"
onClick={decreaseMonth}
disabled={prevMonthButtonDisabled}
className="dtp-nav-btn"
aria-label="Vorheriger Monat"
>
</button>
<span className="dtp-month-label">{label}</span>
<button
type="button"
onClick={increaseMonth}
disabled={nextMonthButtonDisabled}
className="dtp-nav-btn"
aria-label="Nächster Monat"
>
</button>
</div>
);
}

View File

@@ -741,311 +741,10 @@
} }
/* ═══════════════════════════════════════════════════════════════ /* ═══════════════════════════════════════════════════════════════
CUSTOM DATE/TIME PICKER — fully themed via CSS variables NATIVE DATE/TIME INPUTS — themed via CSS variables + color-scheme
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
input[type="date"],
/* ── Wrapper & trigger button ─────────────────────────────────── */ input[type="time"] {
.dtp-dp-wrapper {
width: 100%;
}
.dtp-input-wrap {
position: relative;
width: 100%;
}
.dtp-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-secondary);
pointer-events: none;
z-index: 1;
}
.dtp-custom-input {
width: 100%;
display: flex;
align-items: center;
padding: 0.625rem 0.875rem 0.625rem 2.5rem;
border-radius: 0.5rem;
background: var(--input-bg);
border: 1px solid var(--input-border);
color: var(--text-primary);
font-size: 0.875rem;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
cursor: pointer;
line-height: 1.5;
}
.dtp-custom-input:focus,
.dtp-custom-input:focus-visible {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px var(--ring);
}
.dtp-custom-input:hover {
border-color: var(--accent);
}
.dtp-placeholder {
color: var(--text-secondary);
}
.dtp-value {
color: var(--text-primary);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* ── Popper & calendar container ──────────────────────────────── */
.dtp-calendar.react-datepicker {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 0.75rem;
box-shadow: 0 10px 25px -5px var(--shadow-color), 0 4px 10px -6px var(--shadow-color);
font-family: inherit;
font-size: 0.8125rem;
color: var(--text-primary);
overflow: hidden;
display: flex;
}
/* hide the default triangle/arrow */
.react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle,
.react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle {
display: none;
}
/* ── Header (custom rendered) ─────────────────────────────────── */
.dtp-calendar .react-datepicker__header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0;
}
.dtp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.75rem 0.5rem;
}
.dtp-month-label {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
letter-spacing: 0.01em;
}
.dtp-nav-btn {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1.2rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
line-height: 1;
padding-bottom: 1px;
}
.dtp-nav-btn:hover:not(:disabled) {
background: var(--hover-bg);
color: var(--text-primary);
}
.dtp-nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
/* ── Day names row ────────────────────────────────────────────── */
.dtp-calendar .react-datepicker__day-names {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-around;
padding: 0.25rem 0.5rem 0.375rem;
margin: 0;
}
.dtp-calendar .react-datepicker__day-name {
color: var(--text-secondary);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
width: 2rem;
text-align: center;
line-height: 1.5;
}
/* ── Month / days grid ────────────────────────────────────────── */
.dtp-calendar .react-datepicker__month {
background: var(--card-bg);
padding: 0.5rem;
margin: 0;
}
.dtp-calendar .react-datepicker__week {
display: flex;
justify-content: space-around;
}
.dtp-calendar .react-datepicker__day {
width: 2rem;
height: 2rem;
line-height: 2rem;
border-radius: 0.375rem;
color: var(--text-primary);
font-size: 0.8125rem;
transition: background 0.12s, color 0.12s;
margin: 0.1rem;
text-align: center;
}
.dtp-calendar .react-datepicker__day:hover:not(.react-datepicker__day--selected):not(.react-datepicker__day--disabled) {
background: var(--hover-bg);
color: var(--text-primary);
border-radius: 0.375rem;
}
.dtp-calendar .react-datepicker__day--selected,
.dtp-calendar .react-datepicker__day--keyboard-selected {
background: var(--accent) !important;
color: var(--accent-text) !important;
border-radius: 0.375rem;
font-weight: 600;
}
.dtp-calendar .react-datepicker__day--today:not(.react-datepicker__day--selected) {
font-weight: 700;
color: var(--accent);
position: relative;
}
.dtp-calendar .react-datepicker__day--today:not(.react-datepicker__day--selected)::after {
content: '';
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.dtp-calendar .react-datepicker__day--outside-month {
color: var(--text-secondary);
opacity: 0.4;
}
.dtp-calendar .react-datepicker__day--disabled {
color: var(--text-secondary);
opacity: 0.3;
cursor: not-allowed;
}
/* ── Time column ──────────────────────────────────────────────── */
.dtp-calendar .react-datepicker__time-container {
border-left: 1px solid var(--border);
background: var(--card-bg);
width: 90px;
}
.dtp-calendar .react-datepicker__time-container .react-datepicker__header {
padding: 0.5rem;
text-align: center;
}
.dtp-calendar .react-datepicker__header--time {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0.5rem !important;
}
.dtp-calendar .react-datepicker__header--time .react-datepicker-time__header {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dtp-calendar .react-datepicker__time {
background: var(--card-bg);
}
.dtp-calendar .react-datepicker__time-box {
width: 90px !important;
}
.dtp-calendar .react-datepicker__time-list {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.dtp-calendar .react-datepicker__time-list-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0 !important;
height: auto !important;
font-size: 0.8125rem;
color: var(--text-primary);
transition: background 0.1s, color 0.1s;
border-radius: 0;
}
.dtp-calendar .react-datepicker__time-list-item:hover:not(.react-datepicker__time-list-item--selected) {
background: var(--hover-bg) !important;
color: var(--text-primary);
}
.dtp-calendar .react-datepicker__time-list-item--selected {
background: var(--accent) !important;
color: var(--accent-text) !important;
font-weight: 600;
}
.dtp-calendar .react-datepicker__time-list-item--disabled {
color: var(--text-secondary);
opacity: 0.3;
}
/* ── Clear button ─────────────────────────────────────────────── */
.react-datepicker__close-icon {
right: 0.5rem;
padding: 0;
}
.react-datepicker__close-icon::after {
background: var(--bg-tertiary) !important;
color: var(--text-secondary) !important;
font-size: 0.875rem;
width: 1.25rem;
height: 1.25rem;
line-height: 1.25rem;
border-radius: 0.25rem;
transition: background 0.15s, color 0.15s;
}
.react-datepicker__close-icon:hover::after {
background: var(--error) !important;
color: #fff !important;
}
/* ── Month navigation (react-datepicker default, hidden since custom header used) ── */
.dtp-calendar .react-datepicker__navigation {
display: none;
}