mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
adbe3a40f6
The admin reset link is a component of its own instead of one made during render, the version line of the plugin panels moved into small modules, the new settings rows hand their save on as void, the date picker takes an accessible name for fields without a visible label (the Vacay hire date), the Vacay and poll tests type through the shared picker, and the theme, size and lint baselines are lowered to what the files hold now.
740 lines
29 KiB
TypeScript
740 lines
29 KiB
TypeScript
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';
|
||
|
||
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;
|
||
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
|
||
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
|
||
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';
|