From 268f6d0c5a9c49759353774edd2f15824a81c64b Mon Sep 17 00:00:00 2001 From: Michelle Date: Wed, 4 Mar 2026 12:17:42 +0100 Subject: [PATCH] feat(DateTimePicker): replace custom date/time picker with native inputs for improved theming and accessibility --- src/components/DateTimePicker.jsx | 161 +++++----------- src/index.css | 307 +----------------------------- 2 files changed, 48 insertions(+), 420 deletions(-) diff --git a/src/components/DateTimePicker.jsx b/src/components/DateTimePicker.jsx index be607b9..03fa11e 100644 --- a/src/components/DateTimePicker.jsx +++ b/src/components/DateTimePicker.jsx @@ -1,19 +1,16 @@ -import ReactDatePicker from 'react-datepicker'; -import { de } from 'date-fns/locale'; 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 - * fully matches whatever theme is active. + * Themed DateTimePicker using native + . + * 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: - * value – ISO datetime string (or '') - * onChange – (isoString) => void + * value – local datetime string 'YYYY-MM-DDTHH:mm' (or '') + * onChange – (localDatetimeString) => void * label – string * required – bool - * minDate – Date | null + * minDate – Date | null (only the date part is enforced) * icon – 'calendar' (default) | 'clock' */ export default function DateTimePicker({ @@ -24,126 +21,58 @@ export default function DateTimePicker({ minDate = null, 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) => { - if (!date) { onChange(''); return; } - // Produce local datetime string yyyy-MM-ddTHH:mm - const y = date.getFullYear(); - 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 mi = String(date.getMinutes()).padStart(2, '0'); - onChange(`${y}-${mo}-${d}T${h}:${mi}`); + const handleDateChange = (e) => { + const d = e.target.value; // YYYY-MM-DD + const t = timePart || '09:00'; + onChange(d ? `${d}T${t}` : ''); + }; + + const handleTimeChange = (e) => { + const t = e.target.value; // HH:mm + const d = datePart || new Date().toISOString().slice(0, 10); + onChange(t ? `${d}T${t}` : ''); }; const Icon = icon === 'clock' ? Clock : CalendarIcon; - // react-datepicker calls scrollIntoView on the selected time item when the - // calendar opens. This scrolls the nearest scrollable ancestor (page or - // modal). We capture every scrollable ancestor's position just before open - // and restore it in the next animation frame — after scrollIntoView fires. - 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' }); - }); - }, []); + // 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 ( -
+
{label && ( )} -
- - } - calendarClassName="dtp-calendar" - dayClassName={(date) => 'dtp-day'} - timeClassName={() => 'dtp-time-item'} - renderCustomHeader={CalendarHeader} - wrapperClassName="dtp-dp-wrapper" - /> +
+
+ + +
+
+ + +
); } - -// ── Custom Input ───────────────────────────────────────────────────────────── -const CustomInput = forwardRef(({ value, onClick, onClear, ...rest }, ref) => ( - -)); -CustomInput.displayName = 'CustomInput'; - -// ── Custom Header ───────────────────────────────────────────────────────────── -function CalendarHeader({ - date, - decreaseMonth, increaseMonth, - prevMonthButtonDisabled, nextMonthButtonDisabled, -}) { - const label = date.toLocaleString('de', { month: 'long', year: 'numeric' }); - return ( -
- - {label} - -
- ); -} diff --git a/src/index.css b/src/index.css index be453d0..edbd2e4 100644 --- a/src/index.css +++ b/src/index.css @@ -741,311 +741,10 @@ } /* ═══════════════════════════════════════════════════════════════ - CUSTOM DATE/TIME PICKER — fully themed via CSS variables + NATIVE DATE/TIME INPUTS — themed via CSS variables + color-scheme ═══════════════════════════════════════════════════════════════ */ - -/* ── Wrapper & trigger button ─────────────────────────────────── */ -.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); +input[type="date"], +input[type="time"] { 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; -} -