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
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m50s
This commit is contained in:
@@ -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 <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.
|
||||
*
|
||||
* 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 (
|
||||
<div className="dtp-wrapper" ref={wrapperRef}>
|
||||
<div>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">
|
||||
{label}{required && ' *'}
|
||||
</label>
|
||||
)}
|
||||
<div className="dtp-input-wrap">
|
||||
<Icon size={15} className="dtp-icon" />
|
||||
<ReactDatePicker
|
||||
selected={selected}
|
||||
onChange={handleChange}
|
||||
showTimeSelect
|
||||
timeFormat="HH:mm"
|
||||
timeIntervals={15}
|
||||
dateFormat="dd.MM.yyyy HH:mm"
|
||||
locale={de}
|
||||
minDate={minDate}
|
||||
<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}
|
||||
popperPlacement="bottom-start"
|
||||
popperProps={{ strategy: 'fixed' }}
|
||||
onCalendarOpen={handleCalendarOpen}
|
||||
popperModifiers={[
|
||||
{ name: 'offset', options: { offset: [0, 4] } },
|
||||
{ name: 'preventOverflow', options: { rootBoundary: 'viewport', tether: false, altAxis: true } },
|
||||
]}
|
||||
customInput={<CustomInput />}
|
||||
calendarClassName="dtp-calendar"
|
||||
dayClassName={(date) => 'dtp-day'}
|
||||
timeClassName={() => 'dtp-time-item'}
|
||||
renderCustomHeader={CalendarHeader}
|
||||
wrapperClassName="dtp-dp-wrapper"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
307
src/index.css
307
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user