Files
TREK/client/src/components/shared/CustomDateTimePicker.tsx
T
Maurice d06168d94a feat(native): the app answers a finger like a native app, not like a web page
After a tap, WebKit and Chrome keep :hover on what was tapped and send a
made-up mouseover, so buttons and rows stayed tinted until the next tap.
While a finger drives the app <html> carries data-touch-input: a PostCSS
step guards every :hover rule with it (no specificity change), and the
made-up mouseover and mouseout stop before any handler. A trackpad or mouse
clears the mark and gets hover back; a browser never sets it.

A control without a press of its own dims the moment a finger lands on it,
after a 60 ms wait so a scroll does not flash rows, and only the innermost
pressed control. A focus: ring no longer stays round a tapped button.

Fields get a fitting return key ("done", "go" in a form, "search" on the
search fields), and addresses, codes and numbers are no longer capitalised
or corrected. Ticking off packing items and tasks, a picker chip, a select
option, a picked date and dragging a plan row now give the haptics a native
list gives.

Confirmations ask through the system where the binary has the new
@capacitor/action-sheet and @capacitor/dialog plugins: a sheet with the
action in red when it destroys something, an alert otherwise, also for
"discard changes" in a sheet. An older app, a browser and a question with a
list under it keep the HTML dialog, as does a system that could not ask.

iOS no longer lifts a page preview on a long press on a link.
2026-11-09 08:54:51 +01:00

744 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Calendar, ChevronLeft, ChevronRight, Keyboard } from 'lucide-react';
import { localIsoDate } from '../../utils/localDate';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '../../i18n';
import { dateInputPattern } from '../../utils/dateInputPattern';
import { type DateField, dateFieldOrder, dateLayout, formatDisplayDate } from '../../utils/dateFormat';
import { useDateFormat } from '../../hooks/useDateFormat';
import { useRemeasureSignal } from '../../hooks/useAnchoredPosition';
import { useWeekStartDay } from '../../hooks/useWeekStartDay';
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek';
import { selectionHaptic } from '../../native/haptics';
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
const YEAR_PAGE_SIZE = 12;
type CalendarView = 'days' | 'months' | 'years';
interface CustomDatePickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
style?: React.CSSProperties;
compact?: boolean;
borderless?: boolean;
/** Read-only contexts (a shared journey, a locked form) — same as CustomTimePicker. */
disabled?: boolean;
// Optional inclusive ISO (YYYY-MM-DD) bounds. Dates outside the range are
// disabled in the calendar and rejected on manual entry.
min?: string;
max?: string;
/** What the field is, for a picker without a visible label around it; the trigger reads it with its date. */
ariaLabel?: string;
}
export function CustomDatePicker({
value,
onChange,
placeholder,
style = {},
compact = false,
borderless = false,
disabled = false,
min,
max,
ariaLabel,
}: CustomDatePickerProps) {
const { locale, t } = useTranslation();
const weekStart = useWeekStartDay();
const dateFormat = useDateFormat();
const [open, setOpen] = useState(false);
const [view, setView] = useState<CalendarView>('days');
const [yearPageStart, setYearPageStart] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
// Re-renders the open calendar whenever its anchor may have moved (#1999).
const reanchor = useRemeasureSignal(open);
const parsed = value ? new Date(value + 'T00:00:00Z') : null;
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth());
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return;
if (dropRef.current?.contains(e.target as Node)) return;
setOpen(false);
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
useEffect(() => {
if (open) {
if (parsed) {
setViewYear(parsed.getUTCFullYear());
setViewMonth(parsed.getUTCMonth());
} else if (min || max) {
// No value yet: open on the first in-range month so the user isn't
// greeted by an all-disabled calendar.
const anchor = new Date((min || max) + 'T00:00:00Z');
setViewYear(anchor.getUTCFullYear());
setViewMonth(anchor.getUTCMonth());
}
setView('days');
}
}, [open]);
const prevMonth = () => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear((y) => y - 1);
} else setViewMonth((m) => m - 1);
};
const nextMonth = () => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear((y) => y + 1);
} else setViewMonth((m) => m + 1);
};
const handlePrev = () => {
if (view === 'days') prevMonth();
else if (view === 'months') setViewYear((y) => y - 1);
else setYearPageStart((s) => s - YEAR_PAGE_SIZE);
};
const handleNext = () => {
if (view === 'days') nextMonth();
else if (view === 'months') setViewYear((y) => y + 1);
else setYearPageStart((s) => s + YEAR_PAGE_SIZE);
};
// prevAriaLabel / nextAriaLabel:
const prevAriaLabel =
view === 'days'
? t('common.datepicker.prevMonth')
: view === 'months'
? t('common.datepicker.prevYear')
: t('common.datepicker.prevYears');
const nextAriaLabel =
view === 'days'
? t('common.datepicker.nextMonth')
: view === 'months'
? t('common.datepicker.nextYear')
: t('common.datepicker.nextYears');
// headerAriaLabel:
const headerAriaLabel =
view === 'days'
? t('common.datepicker.selectMonth')
: view === 'months'
? t('common.datepicker.selectYear')
: undefined;
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' });
const yearRangeLabel = `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`;
const headerLabel = view === 'days' ? monthLabel : view === 'months' ? String(viewYear) : yearRangeLabel;
const handleHeaderClick = () => {
if (view === 'days') {
setView('months');
} else if (view === 'months') {
setYearPageStart(Math.floor(viewYear / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE);
setView('years');
}
};
const days = daysInMonth(viewYear, viewMonth);
const startDay = leadingBlanks(viewYear, viewMonth, weekStart);
const weekdays = weekdayLabels(locale, weekStart);
const monthNames = Array.from({ length: 12 }, (_, i) =>
new Date(viewYear, i).toLocaleDateString(locale, { month: 'short' })
);
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i);
// A chosen date format (#2971) writes the field in numbers, the whole year
// included; 'auto' keeps the language's short month as before.
const fieldOptions: Intl.DateTimeFormatOptions = dateLayout(dateFormat)
? { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }
: compact
? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' }
: { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' };
const displayValue = parsed ? formatDisplayDate(parsed, locale, fieldOptions, dateFormat) : null;
const inputValue = parsed
? formatDisplayDate(parsed, locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }, dateFormat)
: '';
// Inclusive range check on ISO (YYYY-MM-DD) strings — lexicographic order
// matches chronological order for this format.
const isOutOfRange = (iso: string) => (!!min && iso < min) || (!!max && iso > max);
const selectDay = (day: number) => {
const y = String(viewYear);
const m = String(viewMonth + 1).padStart(2, '0');
const d = String(day).padStart(2, '0');
const iso = `${y}-${m}-${d}`;
if (isOutOfRange(iso)) return;
if (iso !== value) selectionHaptic();
onChange(iso);
setOpen(false);
};
const selectMonth = (month: number) => {
setViewMonth(month);
setView('days');
};
const selectYear = (year: number) => {
setViewYear(year);
setView('months');
};
const selectedDay =
parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null;
const today = new Date();
const isToday = (d: number) =>
today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d;
const [textInput, setTextInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const handleTextSubmit = () => {
setIsTyping(false);
if (!textInput.trim()) return;
const input = textInput.trim();
// Try ISO first — always works
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
if (isOutOfRange(input)) return;
onChange(input);
return;
}
// The order of the chosen date format (#2971), or the active locale's. A
// four-digit first number is a year in every format, so 2026/11/04 or
// 2026.11.04 reads as year, month, day whichever format is set.
const yearFirst = /^\D*\d{4}\D/.test(input);
const order: readonly DateField[] = yearFirst ? ['year', 'month', 'day'] : dateFieldOrder(locale, dateFormat);
// Strip non-numeric chars (handles RTL marks, dots, slashes, spaces)
const nums = input
.replace(/[^\d]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean);
if (nums.length !== 3) return;
// nachher:
const get = (field: 'day' | 'month' | 'year') => Number.parseInt(nums[order.indexOf(field)]);
let d = get('day'),
m = get('month');
const y = get('year');
if (Number.isNaN(d) || Number.isNaN(m) || Number.isNaN(y)) return;
// If locale order gives impossible month but valid swap, correct it
if (m > 12 && d <= 12) {
const tmp = m;
m = d;
d = tmp;
}
const year = y < 100 ? 2000 + y : y;
if (m < 1 || m > 12 || d < 1 || d > 31) return;
// 31.02. parses fine as numbers but is no day at all; without the round-trip it would
// travel on as '2026-02-31' and come back out of new Date() as 3 March.
const probe = new Date(Date.UTC(year, m - 1, d));
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) return;
const iso = `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
if (isOutOfRange(iso)) return;
onChange(iso);
};
const gridCellStyle = (selected: boolean, current: boolean): React.CSSProperties => ({
borderRadius: 8,
border: 'none',
background: selected ? 'var(--accent)' : 'transparent',
color: selected ? 'var(--accent-text)' : 'var(--text-primary)',
fontWeight: selected ? 700 : current ? 600 : 400,
cursor: 'pointer',
outline: current && !selected ? '2px solid var(--border-primary)' : 'none',
outlineOffset: -2,
transition: 'background 0.1s',
});
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
{isTyping ? (
<input
autoFocus
type="text"
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleTextSubmit();
if (e.key === 'Escape') setIsTyping(false);
}}
onBlur={handleTextSubmit}
placeholder={dateInputPattern(locale, dateFormat)}
aria-label={t('common.datepicker.enterManually')}
style={{
width: '100%',
padding: '8px 14px',
borderRadius: 10,
border: '1px solid var(--text-faint)',
background: 'var(--bg-input)',
color: 'var(--text-primary)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
outline: 'none',
}}
/>
) : (
// `stretch`, not `center`: the keyboard button's own padding made it a
// few pixels shorter than the date trigger beside it, and the two sat in
// a row with mismatched heights. Stretching means it tracks the trigger
// whatever the type scale does to that one.
<div style={{ display: 'flex', gap: 4, alignItems: 'stretch' }}>
{/* Calendar trigger */}
<button
data-field-trigger={compact || borderless ? undefined : ''}
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-label={
ariaLabel
? [ariaLabel, displayValue].filter(Boolean).join(': ')
: displayValue || placeholder || t('common.date')
}
aria-expanded={open}
aria-haspopup="dialog"
style={{
flex: 1,
// Without this the trigger never goes below its text width, so the
// ellipsis on the label below is unreachable and the button
// overflows its column instead. A long locale date ("12. Sept.
// 2026") in a two-column grid is exactly that case (#2267).
minWidth: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: compact ? 4 : 8,
padding: compact ? '4px 6px' : '8px 14px',
borderRadius: compact ? 4 : 10,
border: borderless ? 'none' : '1px solid var(--border-primary)',
background: borderless ? 'transparent' : 'var(--bg-input)',
color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
outline: 'none',
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--text-faint)')}
onMouseLeave={(e) => {
if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)';
}}
>
{!compact && <Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />}
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{displayValue || placeholder || t('common.date')}
</span>
</button>
{/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */}
{!compact && !borderless && (
<button
data-field-trigger=""
type="button"
onClick={() => {
setTextInput(inputValue || '');
setIsTyping(true);
}}
aria-label={t('common.datepicker.enterManually')}
title={t('common.datepicker.typeDate')}
style={{
background: 'none',
border: '1px solid var(--border-primary)',
// Same corner as the trigger it stands next to.
borderRadius: 10,
cursor: 'pointer',
padding: '0 9px',
display: 'flex',
alignItems: 'center',
color: 'var(--text-faint)',
flexShrink: 0,
transition: 'color 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--text-primary)';
e.currentTarget.style.borderColor = 'var(--text-faint)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-faint)';
e.currentTarget.style.borderColor = 'var(--border-primary)';
}}
>
<Keyboard size={13} />
</button>
)}
</div>
)}
{open &&
createPortal(
<div
ref={dropRef}
role="dialog"
aria-label={t('common.datepicker.dialog')}
style={{
position: 'fixed',
// reanchor ticks on scroll / resize / keyboard so this recomputes
// against a fresh rect instead of the one measured on open (#1999).
...((): { top?: number; bottom?: number; left: number } => {
void reanchor;
const r = ref.current?.getBoundingClientRect();
if (!r) return { top: 0, left: 0 };
const w = 268,
pad = 8,
// Only used to decide *whether* the popup fits below the trigger —
// its real height varies with the month's row count and the view
// (days/months/years), so it can't be known before it renders.
h = 360;
const vw = window.innerWidth;
const vh = window.visualViewport?.height ?? window.innerHeight;
let left = r.left;
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad);
if (vw < 360) left = Math.max(pad, (vw - w) / 2);
const below = r.bottom + 4;
if (below + h <= vh - pad) return { top: below, left };
// Flipped: anchor from the trigger's own top edge via `bottom`
// instead of subtracting a guessed height from `top`. A guessed
// `top` leaves a gap sized by however wrong the guess was — in a
// short modal (e.g. the settle-up form) that gap was big enough to
// land the popup over unrelated fields instead of the trigger it
// belongs to. Anchoring the opposite edge means the popup always
// sits flush against the trigger no matter its real height.
if (r.top - 4 - h >= pad) return { bottom: vh - r.top + 4, left };
// No room on either side (a phone in landscape): keep the top edge
// inside the viewport and let the popup cover the trigger instead.
return { top: Math.max(pad, Math.min(below, vh - h - pad)), left };
})(),
zIndex: 99999,
background: 'var(--bg-card)',
border: '1px solid var(--border-primary)',
borderRadius: 14,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
padding: 12,
width: 268,
maxWidth: 'calc(100vw - 16px)',
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
}}
>
{/* ── Header ── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<button
type="button"
onClick={handlePrev}
aria-label={prevAriaLabel}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
borderRadius: 6,
display: 'flex',
color: 'var(--text-faint)',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<ChevronLeft size={16} />
</button>
{/* Clickable label — drills down (days → months → years) */}
{view !== 'years' ? (
<button
type="button"
onClick={handleHeaderClick}
aria-label={headerAriaLabel}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '2px 8px',
borderRadius: 6,
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontWeight: 600,
color: 'var(--text-primary)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
{headerLabel}
</button>
) : (
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>{headerLabel}</span>
)}
<button
type="button"
onClick={handleNext}
aria-label={nextAriaLabel}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
borderRadius: 6,
display: 'flex',
color: 'var(--text-faint)',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<ChevronRight size={16} />
</button>
</div>
{/* ── Days view ── */}
{view === 'days' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
{weekdays.map((d, i) => (
<div
key={i}
style={{
textAlign: 'center',
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
fontWeight: 600,
color: 'var(--text-faint)',
padding: '2px 0',
}}
>
{d}
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{Array.from({ length: startDay }, (_, i) => (
<div key={`e-${i}`} />
))}
{Array.from({ length: days }, (_, i) => {
const d = i + 1;
const sel = d === selectedDay;
const td = isToday(d);
const isoDate = `${viewYear}-${String(viewMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const disabled = isOutOfRange(isoDate);
const ariaLabel = new Date(isoDate + 'T00:00:00Z').toLocaleDateString(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
return (
<button
key={d}
type="button"
onClick={() => selectDay(d)}
disabled={disabled}
aria-label={ariaLabel}
aria-pressed={sel}
style={{
width: 32,
height: 32,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...gridCellStyle(sel, td),
...(disabled ? { opacity: 0.3, cursor: 'not-allowed' } : {}),
}}
onMouseEnter={(e) => {
if (!sel && !disabled) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!sel) e.currentTarget.style.background = 'transparent';
}}
>
{d}
</button>
);
})}
</div>
</>
)}
{/* ── Months view ── */}
{view === 'months' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}>
{monthNames.map((name, i) => {
const sel = !!(parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === i);
const cur = today.getFullYear() === viewYear && today.getMonth() === i;
const ariaLabel = new Date(viewYear, i).toLocaleDateString(locale, {
month: 'long',
year: 'numeric',
});
return (
<button
key={i}
type="button"
onClick={() => selectMonth(i)}
aria-label={ariaLabel}
aria-pressed={sel}
style={{
padding: '10px 4px',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
...gridCellStyle(sel, cur),
}}
onMouseEnter={(e) => {
if (!sel) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!sel) (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
{name}
</button>
);
})}
</div>
)}
{/* ── Years view ── */}
{view === 'years' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}>
{years.map((year) => {
const sel = !!(parsed && parsed.getUTCFullYear() === year);
const cur = today.getFullYear() === year;
return (
<button
key={year}
type="button"
onClick={() => selectYear(year)}
aria-label={String(year)}
aria-pressed={sel}
style={{
padding: '10px 4px',
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
...gridCellStyle(sel, cur),
}}
onMouseEnter={(e) => {
if (!sel) e.currentTarget.style.background = 'var(--bg-hover)';
}}
onMouseLeave={(e) => {
if (!sel) (e.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
{year}
</button>
);
})}
</div>
)}
{/* ── Footer: keyboard trigger (compact) + clear ── */}
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{/* In compact/borderless mode, the keyboard icon lives here instead of next to the trigger */}
{compact || borderless ? (
<button
type="button"
onClick={() => {
setOpen(false);
setTextInput(inputValue || '');
setIsTyping(true);
}}
aria-label={t('common.datepicker.enterManually')}
title={t('common.datepicker.typeDate')}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '3px 6px',
borderRadius: 6,
display: 'flex',
color: 'var(--text-faint)',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
<Keyboard size={13} />
</button>
) : (
<div />
)}
{value && (
<button
type="button"
onClick={() => {
onChange('');
setOpen(false);
}}
aria-label={t('common.datepicker.clearDate')}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
color: 'var(--text-faint)',
padding: '3px 8px',
borderRadius: 6,
}}
onMouseEnter={(e) => (e.currentTarget.style.color = '#ef4444')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-faint)')}
>
✕
</button>
)}
</div>
</div>,
document.body
)}
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
);
}
interface CustomDateTimePickerProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
style?: React.CSSProperties;
/** Earliest and latest day (YYYY-MM-DD), as CustomDatePicker takes them. */
min?: string;
max?: string;
}
export function CustomDateTimePicker({ value, onChange, placeholder, style = {}, min, max }: CustomDateTimePickerProps) {
const { locale } = useTranslation();
const [datePart, timePart] = (value || '').split('T');
const handleDateChange = (d: string) => {
onChange(d ? `${d}T${timePart || '12:00'}` : '');
};
const handleTimeChange = (t: string) => {
const d = datePart || localIsoDate();
onChange(t ? `${d}T${t}` : d);
};
return (
<div style={{ display: 'flex', gap: 8, ...style }}>
<CustomDatePicker
value={datePart || ''}
onChange={handleDateChange}
style={{ flex: 1, minWidth: 0 }}
min={min}
max={max}
/>
<div style={{ width: 110, flexShrink: 0 }}>
<CustomTimePicker value={timePart || ''} onChange={handleTimeChange} />
</div>
</div>
);
}
import CustomTimePicker from './CustomTimePicker';