Files
web-ui/src/components/DatePicker.tsx
Beatrice Dellacà 1523f7be2c
All checks were successful
continuous-integration/drone/push Build is passing
add unit tests
2026-02-24 17:55:26 +01:00

1893 lines
62 KiB
TypeScript

import {
CalendarDaysIcon,
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
} from '@heroicons/react/24/solid';
import { createPortal } from 'react-dom';
import {
type ClipboardEvent,
type ChangeEvent,
type ChangeEventHandler,
type FocusEvent,
type FocusEventHandler,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
type Ref,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ComponentSize } from './types';
type DatePickerKind = 'date' | 'time' | 'date-time';
type Layout = 'stacked' | 'inline';
type PopupPlacement = 'top' | 'bottom';
type SegmentKind = 'day' | 'month' | 'year' | 'hour' | 'minute';
type FormatToken = 'dd' | 'mm' | 'yyyy' | 'HH';
type PickerValue = {
date: Date;
hour: number;
minute: number;
};
type PopupPosition = {
top: number;
left: number;
minWidth: number;
placement: PopupPlacement;
};
type RawFormatPart =
| {
type: 'literal';
value: string;
}
| {
type: 'token';
token: FormatToken;
};
type FormatPart =
| {
type: 'literal';
value: string;
start: number;
end: number;
}
| {
type: 'segment';
kind: SegmentKind;
token: FormatToken;
length: number;
start: number;
end: number;
segmentIndex: number;
};
type FormatSegment = Extract<FormatPart, { type: 'segment' }>;
type FormatConfig = {
type: DatePickerKind;
format: string;
parts: FormatPart[];
segments: FormatSegment[];
totalLength: number;
literalChars: Set<string>;
};
const HOURS = Array.from({ length: 24 }, (_, idx) => idx);
const MINUTES = Array.from({ length: 60 }, (_, idx) => idx);
const YEAR_WINDOW = 50;
const POPUP_GAP = 6;
const POPUP_MARGIN = 8;
const SEGMENT_EDIT_TIMEOUT_MS = 1500;
const DEFAULT_FORMAT: Record<DatePickerKind, string> = {
date: 'yyyy/mm/dd',
time: 'HH:mm',
'date-time': 'yyyy/mm/dd HH:mm',
};
export type DatePickerProps = {
label?: string;
placeholder?: string;
type: DatePickerKind;
size?: ComponentSize;
width?: ComponentSize;
layout?: Layout;
value: string;
format?: string;
min?: string;
max?: string;
name?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
inputRef?: Ref<HTMLInputElement>;
disabled?: boolean;
required?: boolean;
error?: string;
rightIcon?: ReactNode;
className?: string;
inputClassName?: string;
};
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function pad4(value: number): string {
return String(value).padStart(4, '0');
}
function clampNumber(value: number, minValue: number, maxValue: number): number {
return Math.min(maxValue, Math.max(minValue, value));
}
function createDateAtLocalMidnight(year: number, monthIndex: number, day: number): Date {
const candidate = new Date(0);
candidate.setHours(0, 0, 0, 0);
candidate.setFullYear(year, monthIndex, day);
return candidate;
}
function createDateTimeFromPickerValue(value: PickerValue): Date {
const candidate = createDateAtLocalMidnight(
value.date.getFullYear(),
value.date.getMonth(),
value.date.getDate(),
);
candidate.setHours(value.hour, value.minute, 0, 0);
return candidate;
}
function startOfDay(value: Date): Date {
const candidate = new Date(value);
candidate.setHours(0, 0, 0, 0);
return candidate;
}
function startOfMonth(value: Date): Date {
const candidate = startOfDay(value);
candidate.setDate(1);
return candidate;
}
function isSameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function createValidatedDate(year: number, month: number, day: number): Date | null {
const candidate = createDateAtLocalMidnight(year, month - 1, day);
if (
candidate.getFullYear() !== year ||
candidate.getMonth() !== month - 1 ||
candidate.getDate() !== day
) {
return null;
}
return candidate;
}
function daysInMonth(year: number, month: number): number {
return createDateAtLocalMidnight(year, month, 0).getDate();
}
function clonePickerValue(value: PickerValue): PickerValue {
return {
date: startOfDay(value.date),
hour: value.hour,
minute: value.minute,
};
}
function resolveLocale(): string {
if (typeof navigator === 'undefined') {
return 'en-US';
}
return navigator.languages?.[0] ?? navigator.language ?? 'en-US';
}
function resolveWeekStart(locale: string): number {
if (typeof Intl === 'undefined' || typeof Intl.Locale !== 'function') {
return 0;
}
try {
const localeInfo = new Intl.Locale(locale) as Intl.Locale & {
weekInfo?: { firstDay?: number };
};
const firstDay = localeInfo.weekInfo?.firstDay;
if (typeof firstDay !== 'number') {
return 0;
}
if (firstDay === 7) {
return 0;
}
if (firstDay >= 1 && firstDay <= 6) {
return firstDay;
}
} catch {
return 0;
}
return 0;
}
function buildMonthGrid(viewMonth: Date, weekStart: number): Date[] {
const monthStart = startOfMonth(viewMonth);
const dayOffset = (monthStart.getDay() - weekStart + 7) % 7;
const gridStart = createDateAtLocalMidnight(
monthStart.getFullYear(),
monthStart.getMonth(),
monthStart.getDate() - dayOffset,
);
return Array.from({ length: 42 }, (_, index) => {
return createDateAtLocalMidnight(
gridStart.getFullYear(),
gridStart.getMonth(),
gridStart.getDate() + index,
);
});
}
function joinClassNames(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ').trim();
}
function assignRef(ref: Ref<HTMLInputElement> | undefined, node: HTMLInputElement | null): void {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(node);
return;
}
(ref as { current: HTMLInputElement | null }).current = node;
}
function tokenizeFormat(format: string): RawFormatPart[] {
const result: RawFormatPart[] = [];
let pointer = 0;
let literalBuffer = '';
const flushLiteral = () => {
if (literalBuffer.length > 0) {
result.push({ type: 'literal', value: literalBuffer });
literalBuffer = '';
}
};
while (pointer < format.length) {
if (format.startsWith('yyyy', pointer)) {
flushLiteral();
result.push({ type: 'token', token: 'yyyy' });
pointer += 4;
continue;
}
if (format.startsWith('dd', pointer)) {
flushLiteral();
result.push({ type: 'token', token: 'dd' });
pointer += 2;
continue;
}
if (format.startsWith('HH', pointer)) {
flushLiteral();
result.push({ type: 'token', token: 'HH' });
pointer += 2;
continue;
}
if (format.startsWith('mm', pointer)) {
flushLiteral();
result.push({ type: 'token', token: 'mm' });
pointer += 2;
continue;
}
literalBuffer += format[pointer];
pointer += 1;
}
flushLiteral();
return result;
}
function buildFormatConfigOrNull(type: DatePickerKind, format: string): FormatConfig | null {
const rawParts = tokenizeFormat(format);
if (rawParts.length === 0) {
return null;
}
const parts: FormatPart[] = [];
const literalChars = new Set<string>();
const counts: Record<SegmentKind, number> = {
day: 0,
month: 0,
year: 0,
hour: 0,
minute: 0,
};
let hourSeen = false;
let segmentIndex = 0;
let offset = 0;
for (const part of rawParts) {
if (part.type === 'literal') {
for (const char of part.value) {
literalChars.add(char);
}
parts.push({
type: 'literal',
value: part.value,
start: offset,
end: offset + part.value.length,
});
offset += part.value.length;
continue;
}
let kind: SegmentKind;
if (part.token === 'dd') {
kind = 'day';
} else if (part.token === 'yyyy') {
kind = 'year';
} else if (part.token === 'HH') {
kind = 'hour';
hourSeen = true;
} else if (type === 'date') {
kind = 'month';
} else if (type === 'time') {
kind = 'minute';
} else {
kind = hourSeen ? 'minute' : 'month';
}
counts[kind] += 1;
const length = part.token.length;
parts.push({
type: 'segment',
kind,
token: part.token,
length,
start: offset,
end: offset + length,
segmentIndex,
});
segmentIndex += 1;
offset += length;
}
const isValid =
(type === 'date' &&
counts.day === 1 &&
counts.month === 1 &&
counts.year === 1 &&
counts.hour === 0 &&
counts.minute === 0) ||
(type === 'time' &&
counts.day === 0 &&
counts.month === 0 &&
counts.year === 0 &&
counts.hour === 1 &&
counts.minute === 1) ||
(type === 'date-time' &&
counts.day === 1 &&
counts.month === 1 &&
counts.year === 1 &&
counts.hour === 1 &&
counts.minute === 1);
if (!isValid) {
return null;
}
const segments = parts.filter((part): part is FormatSegment => part.type === 'segment');
/* c8 ignore start -- validated token counts always yield at least one segment. */
if (segments.length === 0) {
return null;
}
/* c8 ignore stop */
return {
type,
format,
parts,
segments,
totalLength: offset,
literalChars,
};
}
function buildFormatConfig(type: DatePickerKind, requestedFormat?: string): FormatConfig {
if (requestedFormat != null && requestedFormat.trim() !== '') {
const explicit = buildFormatConfigOrNull(type, requestedFormat.trim());
if (explicit) {
return explicit;
}
}
const fallback = buildFormatConfigOrNull(type, DEFAULT_FORMAT[type]);
/* c8 ignore start -- static defaults are valid for all supported picker types. */
if (!fallback) {
throw new Error('Failed to initialize DatePicker format configuration.');
}
/* c8 ignore stop */
return fallback;
}
function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): PickerValue | null {
const value = rawValue.trim();
if (value.length !== config.totalLength) {
return null;
}
let year: number | null = null;
let month: number | null = null;
let day: number | null = null;
let hour: number | null = null;
let minute: number | null = null;
for (const part of config.parts) {
const chunk = value.slice(part.start, part.end);
if (part.type === 'literal') {
if (chunk !== part.value) {
return null;
}
continue;
}
if (!/^\d+$/.test(chunk)) {
return null;
}
const numeric = Number(chunk);
/* c8 ignore start -- numeric chunks are finite after /^\d+$/ validation. */
if (!Number.isFinite(numeric)) {
return null;
}
/* c8 ignore stop */
if (part.kind === 'year') {
year = numeric;
} else if (part.kind === 'month') {
month = numeric;
} else if (part.kind === 'day') {
day = numeric;
} else if (part.kind === 'hour') {
hour = numeric;
} else {
minute = numeric;
}
}
if (config.type !== 'time') {
/* c8 ignore start -- date/month/year segments are guaranteed for non-time validated formats. */
if (year == null || month == null || day == null) {
return null;
}
/* c8 ignore stop */
/* c8 ignore start -- 'yyyy' token bounds parsed year to 0..9999. */
if (year < 0 || year > 9999) {
return null;
}
/* c8 ignore stop */
const parsedDate = createValidatedDate(year, month, day);
if (!parsedDate) {
return null;
}
if (config.type === 'date') {
return {
date: parsedDate,
hour: 0,
minute: 0,
};
}
/* c8 ignore start -- date-time format validation guarantees hour/minute segments. */
if (hour == null || minute == null) {
return null;
}
/* c8 ignore stop */
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null;
}
return {
date: parsedDate,
hour,
minute,
};
}
/* c8 ignore start -- time format validation guarantees hour/minute segments. */
if (hour == null || minute == null) {
return null;
}
/* c8 ignore stop */
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null;
}
return {
date: startOfDay(new Date()),
hour,
minute,
};
}
function formatPickerValueWithFormat(value: PickerValue, config: FormatConfig): string {
const fields: Record<SegmentKind, string> = {
day: pad2(value.date.getDate()),
month: pad2(value.date.getMonth() + 1),
year: pad4(value.date.getFullYear()),
hour: pad2(value.hour),
minute: pad2(value.minute),
};
return config.parts
.map((part) => {
if (part.type === 'literal') {
return part.value;
}
return fields[part.kind];
})
.join('');
}
function comparePickerValue(left: PickerValue, right: PickerValue, type: DatePickerKind): number {
if (type === 'time') {
const leftTotal = left.hour * 60 + left.minute;
const rightTotal = right.hour * 60 + right.minute;
return leftTotal - rightTotal;
}
if (type === 'date') {
return startOfDay(left.date).getTime() - startOfDay(right.date).getTime();
}
const leftDateTime = createDateTimeFromPickerValue(left).getTime();
const rightDateTime = createDateTimeFromPickerValue(right).getTime();
return leftDateTime - rightDateTime;
}
function normalizeRange(
minValue: PickerValue | null,
maxValue: PickerValue | null,
type: DatePickerKind,
): { minValue: PickerValue | null; maxValue: PickerValue | null } {
if (minValue == null || maxValue == null) {
return {
minValue,
maxValue,
};
}
if (comparePickerValue(minValue, maxValue, type) <= 0) {
return { minValue, maxValue };
}
return {
minValue: maxValue,
maxValue: minValue,
};
}
function clampPickerToRange(
candidate: PickerValue,
minValue: PickerValue | null,
maxValue: PickerValue | null,
type: DatePickerKind,
): PickerValue {
if (minValue && comparePickerValue(candidate, minValue, type) < 0) {
return clonePickerValue(minValue);
}
if (maxValue && comparePickerValue(candidate, maxValue, type) > 0) {
return clonePickerValue(maxValue);
}
return clonePickerValue(candidate);
}
function isWithinRange(
candidate: PickerValue,
minValue: PickerValue | null,
maxValue: PickerValue | null,
type: DatePickerKind,
): boolean {
if (minValue && comparePickerValue(candidate, minValue, type) < 0) {
return false;
}
if (maxValue && comparePickerValue(candidate, maxValue, type) > 0) {
return false;
}
return true;
}
function applySegmentDigits(
baseValue: PickerValue,
kind: SegmentKind,
digits: string,
): PickerValue {
const parsedDigits = Number(digits);
if (!Number.isFinite(parsedDigits)) {
return clonePickerValue(baseValue);
}
let year = baseValue.date.getFullYear();
let month = baseValue.date.getMonth() + 1;
let day = baseValue.date.getDate();
let hour = baseValue.hour;
let minute = baseValue.minute;
if (kind === 'year') {
year = clampNumber(parsedDigits, 0, 9999);
} else if (kind === 'month') {
month = clampNumber(parsedDigits, 1, 12);
} else if (kind === 'day') {
const maxDay = daysInMonth(year, month);
day = clampNumber(parsedDigits, 1, maxDay);
} else if (kind === 'hour') {
hour = clampNumber(parsedDigits, 0, 23);
} else {
minute = clampNumber(parsedDigits, 0, 59);
}
const maxDayForCurrentMonth = daysInMonth(year, month);
day = clampNumber(day, 1, maxDayForCurrentMonth);
const nextDate =
createValidatedDate(year, month, day) ?? createDateAtLocalMidnight(year, month - 1, day);
return {
date: nextDate,
hour,
minute,
};
}
function findSegmentIndexByCaret(segments: FormatSegment[], caretPosition: number | null): number {
if (segments.length === 0) {
return 0;
}
if (caretPosition == null) {
return 0;
}
for (const segment of segments) {
if (caretPosition >= segment.start && caretPosition <= segment.end) {
return segment.segmentIndex;
}
if (caretPosition < segment.start) {
return segment.segmentIndex;
}
}
return segments[segments.length - 1].segmentIndex;
}
function isDateSelectableForRange(
dateValue: Date,
type: DatePickerKind,
minValue: PickerValue | null,
maxValue: PickerValue | null,
): boolean {
if (type === 'date') {
return isWithinRange(
{
date: startOfDay(dateValue),
hour: 0,
minute: 0,
},
minValue,
maxValue,
type,
);
}
const dayStart: PickerValue = {
date: startOfDay(dateValue),
hour: 0,
minute: 0,
};
const dayEnd: PickerValue = {
date: startOfDay(dateValue),
hour: 23,
minute: 59,
};
if (minValue && comparePickerValue(dayEnd, minValue, type) < 0) {
return false;
}
if (maxValue && comparePickerValue(dayStart, maxValue, type) > 0) {
return false;
}
return true;
}
function isHourSelectableForRange(
dateValue: Date,
hour: number,
type: DatePickerKind,
minValue: PickerValue | null,
maxValue: PickerValue | null,
): boolean {
const candidateStart: PickerValue = {
date: startOfDay(dateValue),
hour,
minute: 0,
};
const candidateEnd: PickerValue = {
date: startOfDay(dateValue),
hour,
minute: 59,
};
if (minValue && comparePickerValue(candidateEnd, minValue, type) < 0) {
return false;
}
if (maxValue && comparePickerValue(candidateStart, maxValue, type) > 0) {
return false;
}
return true;
}
// eslint-disable-next-line react-refresh/only-export-components -- test-only export of pure helpers.
export const __datePickerTestUtils = {
pad2,
pad4,
clampNumber,
createDateAtLocalMidnight,
createDateTimeFromPickerValue,
startOfDay,
startOfMonth,
isSameDay,
createValidatedDate,
daysInMonth,
clonePickerValue,
resolveLocale,
resolveWeekStart,
buildMonthGrid,
joinClassNames,
assignRef,
tokenizeFormat,
buildFormatConfigOrNull,
buildFormatConfig,
parsePickerValueWithFormat,
formatPickerValueWithFormat,
comparePickerValue,
normalizeRange,
clampPickerToRange,
isWithinRange,
applySegmentDigits,
findSegmentIndexByCaret,
isDateSelectableForRange,
isHourSelectableForRange,
} as const;
export function DatePicker({
label,
placeholder = '',
type,
size = 'md',
width = 'md',
layout = 'stacked',
value,
format,
min,
max,
name,
onChange,
onBlur,
inputRef,
disabled = false,
required = false,
error,
rightIcon,
className = '',
inputClassName = '',
}: Readonly<DatePickerProps>) {
const internalInputRef = useRef<HTMLInputElement | null>(null);
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const changeHandledRef = useRef(false);
const bufferedDigitsRef = useRef<{
segmentIndex: number;
digits: string;
timestamp: number;
} | null>(null);
const activeSegmentIndexRef = useRef(0);
const pendingSelectionTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [viewMonth, setViewMonth] = useState<Date>(() => startOfMonth(new Date()));
const [activeChooser, setActiveChooser] = useState<'month' | 'year' | null>(null);
const [popupPosition, setPopupPosition] = useState<PopupPosition | null>(null);
const closePicker = useCallback(() => {
setIsOpen(false);
setActiveChooser(null);
setPopupPosition(null);
}, []);
const locale = useMemo(() => resolveLocale(), []);
const weekStart = useMemo(() => resolveWeekStart(locale), [locale]);
const formatConfig = useMemo(() => buildFormatConfig(type, format), [type, format]);
const fallbackValue = useMemo<PickerValue>(() => {
const today = startOfDay(new Date());
return {
date: today,
hour: 0,
minute: 0,
};
}, []);
const parsedValue = useMemo(
() => parsePickerValueWithFormat(value, formatConfig),
[formatConfig, value],
);
const selectedValue = parsedValue ?? fallbackValue;
const parsedMinValue = useMemo(() => {
if (min == null || min.trim() === '') {
return null;
}
return parsePickerValueWithFormat(min, formatConfig);
}, [formatConfig, min]);
const parsedMaxValue = useMemo(() => {
if (max == null || max.trim() === '') {
return null;
}
return parsePickerValueWithFormat(max, formatConfig);
}, [formatConfig, max]);
const { minValue: normalizedMinValue, maxValue: normalizedMaxValue } = useMemo(() => {
return normalizeRange(parsedMinValue, parsedMaxValue, type);
}, [parsedMaxValue, parsedMinValue, type]);
const clampedSelectedValue = useMemo(() => {
return clampPickerToRange(selectedValue, normalizedMinValue, normalizedMaxValue, type);
}, [normalizedMaxValue, normalizedMinValue, selectedValue, type]);
const selectedDate = clampedSelectedValue.date;
const selectedHour = clampedSelectedValue.hour;
const selectedMinute = clampedSelectedValue.minute;
const displayValue = useMemo(() => {
return formatPickerValueWithFormat(clampedSelectedValue, formatConfig);
}, [clampedSelectedValue, formatConfig]);
const containerWidthClass = {
sm: 'max-w-xs',
md: 'max-w-sm',
lg: 'max-w-md',
full: 'max-w-none',
}[width];
const inputSizeClass = {
sm: 'h-8 !text-xs',
md: 'h-10 text-sm',
lg: 'h-12 text-sm',
full: 'h-10 text-sm',
}[size];
const wrapperClass =
layout === 'inline' ? 'inline-flex w-auto items-center gap-2' : 'block w-full gap-1';
const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : '';
const inputWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1';
const defaultRightIcon =
type === 'time' ? (
<ClockIcon className="h-4 w-4" aria-hidden="true" />
) : (
<CalendarDaysIcon className="h-4 w-4" aria-hidden="true" />
);
const resolvedRightIcon = rightIcon ?? defaultRightIcon;
const hasTrailingIcon = Boolean(resolvedRightIcon);
const weekdayLabels = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short' });
const sundayReference = new Date(2024, 0, 7);
return Array.from({ length: 7 }, (_, offset) => {
const dayIndex = (weekStart + offset) % 7;
const current = new Date(
sundayReference.getFullYear(),
sundayReference.getMonth(),
sundayReference.getDate() + dayIndex,
);
return formatter.format(current);
});
}, [locale, weekStart]);
const monthLabels = useMemo(() => {
const formatter = new Intl.DateTimeFormat(locale, { month: 'long' });
return Array.from({ length: 12 }, (_, monthIndex) => {
return formatter.format(new Date(2024, monthIndex, 1));
});
}, [locale]);
const yearOptions = useMemo(() => {
const centerYear = viewMonth.getFullYear();
const startYear = Math.max(0, centerYear - YEAR_WINDOW);
const endYear = Math.min(9999, centerYear + YEAR_WINDOW);
return Array.from({ length: endYear - startYear + 1 }, (_, idx) => startYear + idx);
}, [viewMonth]);
const monthGrid = useMemo(() => buildMonthGrid(viewMonth, weekStart), [viewMonth, weekStart]);
const recalculatePopupPosition = useCallback(() => {
/* c8 ignore start -- guard protects partial mount/layout states that are not deterministic to unit test. */
if (!isOpen || !inputWrapperRef.current || !popupRef.current || !globalThis.window) {
return;
}
/* c8 ignore stop */
const anchorRect = inputWrapperRef.current.getBoundingClientRect();
const popupRect = popupRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = anchorRect.left;
if (left + popupRect.width > viewportWidth - POPUP_MARGIN) {
left = viewportWidth - POPUP_MARGIN - popupRect.width;
}
left = Math.max(POPUP_MARGIN, left);
const spaceBelow = viewportHeight - anchorRect.bottom - POPUP_MARGIN;
const spaceAbove = anchorRect.top - POPUP_MARGIN;
const placeAbove = spaceBelow < popupRect.height && spaceAbove > spaceBelow;
let top = placeAbove
? anchorRect.top - popupRect.height - POPUP_GAP
: anchorRect.bottom + POPUP_GAP;
top = Math.max(
POPUP_MARGIN,
Math.min(top, viewportHeight - POPUP_MARGIN - popupRect.height),
);
setPopupPosition({
top,
left,
minWidth: anchorRect.width,
placement: placeAbove ? 'top' : 'bottom',
});
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
return;
}
recalculatePopupPosition();
/* c8 ignore start -- browser/window is always present in jsdom runtime. */
if (!globalThis.window) {
return;
}
/* c8 ignore stop */
const handleWindowChange = () => {
recalculatePopupPosition();
};
window.addEventListener('resize', handleWindowChange);
window.addEventListener('scroll', handleWindowChange, true);
return () => {
window.removeEventListener('resize', handleWindowChange);
window.removeEventListener('scroll', handleWindowChange, true);
};
}, [isOpen, recalculatePopupPosition]);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const eventTarget = event.target as Node | null;
/* c8 ignore start -- pointer events always provide a target in browser runtimes. */
if (!eventTarget) {
return;
}
/* c8 ignore stop */
if (
popupRef.current?.contains(eventTarget) ||
inputWrapperRef.current?.contains(eventTarget)
) {
return;
}
closePicker();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closePicker();
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('touchstart', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('touchstart', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [closePicker, isOpen]);
useEffect(() => {
return () => {
if (pendingSelectionTimerRef.current != null) {
globalThis.window.clearTimeout(pendingSelectionTimerRef.current);
}
};
}, []);
const selectSegment = useCallback(
(segmentIndex: number) => {
const segments = formatConfig.segments;
/* c8 ignore start -- format configuration always includes at least one segment. */
if (segments.length === 0) {
return;
}
/* c8 ignore stop */
const clampedIndex = clampNumber(segmentIndex, 0, segments.length - 1);
activeSegmentIndexRef.current = clampedIndex;
/* c8 ignore start -- browser/window is always present in jsdom runtime. */
if (!globalThis.window) {
return;
}
/* c8 ignore stop */
if (pendingSelectionTimerRef.current != null) {
globalThis.window.clearTimeout(pendingSelectionTimerRef.current);
}
pendingSelectionTimerRef.current = globalThis.window.setTimeout(() => {
const inputNode = internalInputRef.current;
const targetSegment = segments[clampedIndex];
/* c8 ignore start -- input node and target segment are available while mounted. */
if (!inputNode || !targetSegment) {
return;
}
/* c8 ignore stop */
inputNode.setSelectionRange(targetSegment.start, targetSegment.end);
}, 0);
},
[formatConfig.segments],
);
const resolveCurrentSegmentIndex = useCallback(() => {
const segments = formatConfig.segments;
/* c8 ignore start -- format configuration always includes at least one segment. */
if (segments.length === 0) {
return 0;
}
/* c8 ignore stop */
const inputNode = internalInputRef.current;
/* c8 ignore start -- interactions only run when input node is mounted. */
if (!inputNode) {
return activeSegmentIndexRef.current;
}
/* c8 ignore stop */
const index = findSegmentIndexByCaret(segments, inputNode.selectionStart);
activeSegmentIndexRef.current = index;
return index;
}, [formatConfig.segments]);
const setInputNode = useCallback(
(node: HTMLInputElement | null) => {
internalInputRef.current = node;
assignRef(inputRef, node);
},
[inputRef],
);
const handleInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
changeHandledRef.current = true;
onChange?.(event);
},
[onChange],
);
const commitValue = useCallback(
(nextValue: string) => {
if (disabled || nextValue === displayValue) {
return;
}
const inputNode = internalInputRef.current;
/* c8 ignore start -- commits run only from active mounted input interactions. */
if (!inputNode) {
return;
}
/* c8 ignore stop */
changeHandledRef.current = false;
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
if (valueSetter) {
valueSetter.call(inputNode, nextValue);
} else {
inputNode.value = nextValue;
}
inputNode.dispatchEvent(new Event('change', { bubbles: true }));
if (!changeHandledRef.current && onChange) {
onChange({
target: inputNode,
currentTarget: inputNode,
} as ChangeEvent<HTMLInputElement>);
}
},
[disabled, displayValue, onChange],
);
const commitSegmentDigits = useCallback(
(
segmentIndex: number,
digits: string,
moveToNext: boolean,
options?: {
clearBuffer?: boolean;
},
) => {
const segment = formatConfig.segments[segmentIndex];
/* c8 ignore start -- segmentIndex is always resolved from known format segments. */
if (!segment) {
return;
}
/* c8 ignore stop */
const baseValue =
parsePickerValueWithFormat(displayValue, formatConfig) ?? clampedSelectedValue;
const nextUnclamped = applySegmentDigits(baseValue, segment.kind, digits);
const nextClamped = clampPickerToRange(
nextUnclamped,
normalizedMinValue,
normalizedMaxValue,
type,
);
const nextValue = formatPickerValueWithFormat(nextClamped, formatConfig);
commitValue(nextValue);
if (options?.clearBuffer !== false) {
bufferedDigitsRef.current = null;
}
if (moveToNext) {
selectSegment(segmentIndex + 1);
} else {
selectSegment(segmentIndex);
}
},
[
clampedSelectedValue,
commitValue,
displayValue,
formatConfig,
normalizedMaxValue,
normalizedMinValue,
selectSegment,
type,
],
);
const finalizeBufferedDigits = useCallback(
(segmentIndex: number, moveToNext: boolean) => {
const buffered = bufferedDigitsRef.current;
const segment = formatConfig.segments[segmentIndex];
if (buffered?.segmentIndex !== segmentIndex || !segment) {
if (moveToNext) {
selectSegment(segmentIndex + 1);
}
return;
}
const padded = buffered.digits.padStart(segment.length, '0');
commitSegmentDigits(segmentIndex, padded, moveToNext);
},
[commitSegmentDigits, formatConfig.segments, selectSegment],
);
const openPicker = useCallback(() => {
/* c8 ignore start -- disabled inputs cannot trigger picker open interactions. */
if (disabled) {
return;
}
/* c8 ignore stop */
if (type !== 'time') {
setViewMonth(startOfMonth(selectedDate));
}
setActiveChooser(null);
setIsOpen(true);
}, [disabled, selectedDate, type]);
const togglePicker = useCallback(() => {
/* c8 ignore start -- disabled icon button cannot trigger click events. */
if (disabled) {
return;
}
/* c8 ignore stop */
if (isOpen) {
closePicker();
return;
}
openPicker();
}, [closePicker, disabled, isOpen, openPicker]);
const goToPreviousMonth = useCallback(() => {
setViewMonth((prev) => {
if (prev.getFullYear() === 0 && prev.getMonth() === 0) {
return prev;
}
return createDateAtLocalMidnight(prev.getFullYear(), prev.getMonth() - 1, 1);
});
}, []);
const goToNextMonth = useCallback(() => {
setViewMonth((prev) => {
if (prev.getFullYear() === 9999 && prev.getMonth() === 11) {
return prev;
}
return createDateAtLocalMidnight(prev.getFullYear(), prev.getMonth() + 1, 1);
});
}, []);
const selectMonth = useCallback((month: number) => {
setViewMonth((prev) => createDateAtLocalMidnight(prev.getFullYear(), month, 1));
setActiveChooser(null);
}, []);
const selectYear = useCallback((year: number) => {
setViewMonth((prev) => createDateAtLocalMidnight(year, prev.getMonth(), 1));
setActiveChooser(null);
}, []);
const handleDateCommit = useCallback(
(nextDate: Date) => {
const normalizedDate = startOfDay(nextDate);
if (type === 'date') {
const candidate: PickerValue = {
date: normalizedDate,
hour: 0,
minute: 0,
};
/* c8 ignore start -- out-of-range date cells are disabled in the calendar UI. */
if (!isWithinRange(candidate, normalizedMinValue, normalizedMaxValue, type)) {
return;
}
/* c8 ignore stop */
commitValue(formatPickerValueWithFormat(candidate, formatConfig));
closePicker();
return;
}
const candidate: PickerValue = {
date: normalizedDate,
hour: selectedHour,
minute: selectedMinute,
};
const nextValue = clampPickerToRange(
candidate,
normalizedMinValue,
normalizedMaxValue,
type,
);
commitValue(formatPickerValueWithFormat(nextValue, formatConfig));
setViewMonth(startOfMonth(normalizedDate));
},
[
closePicker,
commitValue,
formatConfig,
normalizedMaxValue,
normalizedMinValue,
selectedHour,
selectedMinute,
type,
],
);
const handleHourCommit = useCallback(
(hour: number) => {
const baseCandidate: PickerValue = {
date: selectedDate,
hour,
minute: selectedMinute,
};
const nextValue = clampPickerToRange(
baseCandidate,
normalizedMinValue,
normalizedMaxValue,
type,
);
commitValue(formatPickerValueWithFormat(nextValue, formatConfig));
},
[
commitValue,
formatConfig,
normalizedMaxValue,
normalizedMinValue,
selectedDate,
selectedMinute,
type,
],
);
const handleMinuteCommit = useCallback(
(minute: number) => {
const baseCandidate: PickerValue = {
date: selectedDate,
hour: selectedHour,
minute,
};
const nextValue = clampPickerToRange(
baseCandidate,
normalizedMinValue,
normalizedMaxValue,
type,
);
commitValue(formatPickerValueWithFormat(nextValue, formatConfig));
},
[
commitValue,
formatConfig,
normalizedMaxValue,
normalizedMinValue,
selectedDate,
selectedHour,
type,
],
);
const handleInputFocus = useCallback(() => {
if (disabled) {
return;
}
selectSegment(0);
}, [disabled, selectSegment]);
const handleInputMouseUp = useCallback(() => {
/* c8 ignore start -- disabled inputs do not emit mouse interaction events. */
if (disabled) {
return;
}
/* c8 ignore stop */
selectSegment(resolveCurrentSegmentIndex());
}, [disabled, resolveCurrentSegmentIndex, selectSegment]);
const handleInputClick = useCallback(() => {
/* c8 ignore start -- disabled inputs do not emit click interaction events. */
if (disabled) {
return;
}
/* c8 ignore stop */
selectSegment(resolveCurrentSegmentIndex());
}, [disabled, resolveCurrentSegmentIndex, selectSegment]);
const handleInputPaste = useCallback(
(event: ClipboardEvent<HTMLInputElement>) => {
if (disabled) {
return;
}
const pasted = event.clipboardData.getData('text').trim();
const parsed = parsePickerValueWithFormat(pasted, formatConfig);
if (!parsed) {
event.preventDefault();
return;
}
const clamped = clampPickerToRange(
parsed,
normalizedMinValue,
normalizedMaxValue,
type,
);
event.preventDefault();
commitValue(formatPickerValueWithFormat(clamped, formatConfig));
},
[commitValue, disabled, formatConfig, normalizedMaxValue, normalizedMinValue, type],
);
const handleInputKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (disabled) {
return;
}
const key = event.key;
const now = Date.now();
const activeBuffer = bufferedDigitsRef.current;
let segmentIndex = resolveCurrentSegmentIndex();
if (
activeBuffer &&
now - activeBuffer.timestamp <= SEGMENT_EDIT_TIMEOUT_MS &&
formatConfig.segments[activeBuffer.segmentIndex]
) {
segmentIndex = activeBuffer.segmentIndex;
}
const segment = formatConfig.segments[segmentIndex];
/* c8 ignore start -- segment index resolution always maps to a valid segment. */
if (!segment) {
return;
}
/* c8 ignore stop */
if (key === 'ArrowDown' && !isOpen) {
event.preventDefault();
openPicker();
return;
}
if (key === 'ArrowLeft') {
event.preventDefault();
finalizeBufferedDigits(segmentIndex, false);
selectSegment(segmentIndex - 1);
return;
}
if (key === 'ArrowRight') {
event.preventDefault();
finalizeBufferedDigits(segmentIndex, false);
selectSegment(segmentIndex + 1);
return;
}
if (key === 'Tab') {
event.preventDefault();
finalizeBufferedDigits(segmentIndex, false);
selectSegment(segmentIndex + (event.shiftKey ? -1 : 1));
return;
}
if (key === 'Backspace' || key === 'Delete') {
event.preventDefault();
bufferedDigitsRef.current = null;
if (segment.kind === 'year') {
commitSegmentDigits(segmentIndex, pad4(selectedDate.getFullYear()), false);
return;
}
if (segment.kind === 'month' || segment.kind === 'day') {
commitSegmentDigits(segmentIndex, '01', false);
return;
}
commitSegmentDigits(segmentIndex, '00', false);
return;
}
if (key.length === 1 && formatConfig.literalChars.has(key)) {
event.preventDefault();
finalizeBufferedDigits(segmentIndex, true);
return;
}
if (/^\d$/.test(key)) {
event.preventDefault();
const buffered = bufferedDigitsRef.current;
let digits = '';
if (
buffered &&
buffered.segmentIndex === segmentIndex &&
now - buffered.timestamp <= SEGMENT_EDIT_TIMEOUT_MS
) {
digits = buffered.digits;
}
/* c8 ignore start -- buffered digits are reset on segment completion. */
if (digits.length >= segment.length) {
digits = key;
} else {
digits += key;
}
/* c8 ignore stop */
bufferedDigitsRef.current = {
segmentIndex,
digits,
timestamp: now,
};
if (digits.length === segment.length) {
commitSegmentDigits(segmentIndex, digits, true);
} else {
commitSegmentDigits(segmentIndex, digits, false, {
clearBuffer: false,
});
}
return;
}
if (key === 'Enter') {
event.preventDefault();
finalizeBufferedDigits(segmentIndex, false);
return;
}
if (key.length === 1) {
event.preventDefault();
}
},
[
commitSegmentDigits,
disabled,
finalizeBufferedDigits,
formatConfig.literalChars,
formatConfig.segments,
isOpen,
openPicker,
resolveCurrentSegmentIndex,
selectSegment,
selectedDate,
],
);
const handleInputBlur = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
const segmentIndex = resolveCurrentSegmentIndex();
finalizeBufferedDigits(segmentIndex, false);
bufferedDigitsRef.current = null;
onBlur?.(event);
},
[finalizeBufferedDigits, onBlur, resolveCurrentSegmentIndex],
);
const calendarVisible = type !== 'time';
const timeVisible = type !== 'date';
const popupContent =
isOpen && !disabled && typeof document !== 'undefined'
? createPortal(
<div
ref={popupRef}
role="dialog"
aria-label={
type === 'date'
? 'Date picker popup'
: type === 'time'
? 'Time picker popup'
: 'Date and time picker popup'
}
className={joinClassNames(
'datepicker-popup',
popupPosition?.placement === 'top'
? 'datepicker-popup-top'
: 'datepicker-popup-bottom',
)}
style={{
top: popupPosition?.top ?? 0,
left: popupPosition?.left ?? 0,
minWidth: popupPosition?.minWidth,
visibility: popupPosition ? 'visible' : 'hidden',
}}
>
{calendarVisible ? (
<div className="datepicker-panel">
<div className="datepicker-calendar-nav">
<button
type="button"
className="datepicker-nav-btn"
aria-label="Previous month"
onClick={goToPreviousMonth}
>
<ChevronLeftIcon className="h-4 w-4" aria-hidden="true" />
</button>
<div className="datepicker-heading-controls">
<div className="datepicker-chooser">
<button
type="button"
className="datepicker-chooser-btn"
onClick={() =>
setActiveChooser((prev) =>
prev === 'month' ? null : 'month',
)
}
>
{monthLabels[viewMonth.getMonth()]}
</button>
{activeChooser === 'month' ? (
<div
className="datepicker-chooser-menu"
role="listbox"
aria-label="Choose month"
>
{monthLabels.map((monthLabel, monthIndex) => (
<button
key={monthLabel}
type="button"
className={joinClassNames(
'datepicker-chooser-option',
monthIndex === viewMonth.getMonth() &&
'is-selected',
)}
onClick={() => selectMonth(monthIndex)}
>
{monthLabel}
</button>
))}
</div>
) : null}
</div>
<div className="datepicker-chooser">
<button
type="button"
className="datepicker-chooser-btn"
onClick={() =>
setActiveChooser((prev) =>
prev === 'year' ? null : 'year',
)
}
>
{viewMonth.getFullYear()}
</button>
{activeChooser === 'year' ? (
<div
className="datepicker-chooser-menu"
role="listbox"
aria-label="Choose year"
>
{yearOptions.map((year) => (
<button
key={year}
type="button"
className={joinClassNames(
'datepicker-chooser-option',
year === viewMonth.getFullYear() &&
'is-selected',
)}
onClick={() => selectYear(year)}
>
{year}
</button>
))}
</div>
) : null}
</div>
</div>
<button
type="button"
className="datepicker-nav-btn"
aria-label="Next month"
onClick={goToNextMonth}
>
<ChevronRightIcon className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="datepicker-weekdays" role="presentation">
{weekdayLabels.map((weekday) => (
<span key={weekday} className="datepicker-weekday">
{weekday}
</span>
))}
</div>
<div className="datepicker-grid" role="grid">
{monthGrid.map((dateCell) => {
const currentMonth =
dateCell.getMonth() === viewMonth.getMonth() &&
dateCell.getFullYear() === viewMonth.getFullYear();
const today = isSameDay(dateCell, startOfDay(new Date()));
const selected = isSameDay(dateCell, selectedDate);
const dayDisabled = !isDateSelectableForRange(
dateCell,
type,
normalizedMinValue,
normalizedMaxValue,
);
return (
<button
key={dateCell.toISOString()}
type="button"
className={joinClassNames(
'datepicker-day',
!currentMonth && 'is-outside-month',
today && 'is-today',
selected && 'is-selected',
)}
onClick={() => handleDateCommit(dateCell)}
disabled={dayDisabled}
>
{dateCell.getDate()}
</button>
);
})}
</div>
</div>
) : null}
{timeVisible ? (
<div className="datepicker-panel">
<div className="datepicker-time-root">
<div className="datepicker-time-column">
<span className="datepicker-time-title">Hours</span>
<div
className="datepicker-time-list"
role="listbox"
aria-label="Hours"
>
{HOURS.map((hour) => {
const hourDisabled = !isHourSelectableForRange(
selectedDate,
hour,
type,
normalizedMinValue,
normalizedMaxValue,
);
return (
<button
key={hour}
type="button"
className={joinClassNames(
'datepicker-time-option',
hour === selectedHour && 'is-selected',
)}
onClick={() => handleHourCommit(hour)}
disabled={hourDisabled}
>
{pad2(hour)}
</button>
);
})}
</div>
</div>
<div className="datepicker-time-column">
<span className="datepicker-time-title">Minutes</span>
<div
className="datepicker-time-list"
role="listbox"
aria-label="Minutes"
>
{MINUTES.map((minute) => {
const minuteCandidate: PickerValue = {
date: selectedDate,
hour: selectedHour,
minute,
};
const minuteDisabled = !isWithinRange(
minuteCandidate,
normalizedMinValue,
normalizedMaxValue,
type,
);
return (
<button
key={minute}
type="button"
className={joinClassNames(
'datepicker-time-option',
minute === selectedMinute &&
'is-selected',
)}
onClick={() => handleMinuteCommit(minute)}
disabled={minuteDisabled}
>
{pad2(minute)}
</button>
);
})}
</div>
</div>
</div>
</div>
) : null}
</div>,
document.body,
)
: null;
return (
<label
className={`${wrapperClass} text-sm font-medium ${disabled ? 'ui-label-disabled' : 'ui-label'} ${containerWidthClass} ${className}`.trim()}
>
{label ? <span className={labelClass}>{label}</span> : null}
<div ref={inputWrapperRef} className={inputWrapperClass}>
<input
type="text"
value={displayValue}
name={name}
onChange={handleInputChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onMouseUp={handleInputMouseUp}
onClick={handleInputClick}
onKeyDown={handleInputKeyDown}
onPaste={handleInputPaste}
ref={setInputNode}
placeholder={placeholder}
disabled={disabled}
required={required}
autoComplete="off"
className={`field w-full ${hasTrailingIcon ? 'pr-10' : ''} ${inputSizeClass} ${error ? 'border-red-400/70 focus:border-red-400 focus:ring-red-400/30' : ''} ${inputClassName}`.trim()}
/>
{resolvedRightIcon ? (
<button
type="button"
className="datepicker-icon-btn"
onClick={togglePicker}
disabled={disabled}
aria-label={isOpen ? 'Close date picker' : 'Open date picker'}
>
{resolvedRightIcon}
</button>
) : null}
</div>
{error ? (
<span className="mt-1 block text-xs" style={{ color: 'var(--error-text)' }}>
{error}
</span>
) : null}
{popupContent}
</label>
);
}