All checks were successful
continuous-integration/drone/push Build is passing
1893 lines
62 KiB
TypeScript
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>
|
|
);
|
|
}
|