react-datetime-picker
Version:
A date range picker for your React app.
505 lines (504 loc) • 22 kB
JavaScript
'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useRef, useState } from 'react';
import { getDate, getHours, getHoursMinutesSeconds, getMinutes, getMonthHuman, getSeconds, getYear, } from '@wojtekmaj/date-utils';
import DayInput from 'react-date-picker/dist/DateInput/DayInput';
import MonthInput from 'react-date-picker/dist/DateInput/MonthInput';
import MonthSelect from 'react-date-picker/dist/DateInput/MonthSelect';
import YearInput from 'react-date-picker/dist/DateInput/YearInput';
import AmPm from 'react-time-picker/dist/TimeInput/AmPm';
import Hour12Input from 'react-time-picker/dist/TimeInput/Hour12Input';
import Hour24Input from 'react-time-picker/dist/TimeInput/Hour24Input';
import MinuteInput from 'react-time-picker/dist/TimeInput/MinuteInput';
import SecondInput from 'react-time-picker/dist/TimeInput/SecondInput';
import NativeInput from './DateTimeInput/NativeInput.js';
import Divider from './Divider.js';
import { formatDate, getFormatter, getNumberFormatter } from './shared/dateFormatter.js';
import { convert12to24, convert24to12 } from './shared/dates.js';
import { between, getAmPmLabels } from './shared/utils.js';
const getFormatterOptionsCache = {};
const defaultMinDate = new Date();
defaultMinDate.setFullYear(1, 0, 1);
defaultMinDate.setHours(0, 0, 0, 0);
const defaultMaxDate = new Date(8.64e15);
const allViews = ['hour', 'minute', 'second'];
function toDate(value) {
if (value instanceof Date) {
return value;
}
return new Date(value);
}
function isSameDate(date, year, month, day) {
return (year === getYear(date).toString() &&
month === getMonthHuman(date).toString() &&
day === getDate(date).toString());
}
function getValue(value, index) {
const rawValue = Array.isArray(value) ? value[index] : value;
if (!rawValue) {
return null;
}
const valueDate = toDate(rawValue);
if (Number.isNaN(valueDate.getTime())) {
throw new Error(`Invalid date: ${value}`);
}
return valueDate;
}
function getDetailValue({ value, minDate, maxDate }, index) {
const valuePiece = getValue(value, index);
if (!valuePiece) {
return null;
}
return between(valuePiece, minDate, maxDate);
}
const getDetailValueFrom = (args) => getDetailValue(args, 0);
function isInternalInput(element) {
return element.dataset.input === 'true';
}
function findInput(element, property) {
let nextElement = element;
do {
nextElement = nextElement[property];
} while (nextElement && !isInternalInput(nextElement));
return nextElement;
}
function focus(element) {
if (element) {
element.focus();
}
}
function renderCustomInputs(placeholder, elementFunctions, allowMultipleInstances) {
const usedFunctions = [];
const pattern = new RegExp(Object.keys(elementFunctions)
.map((el) => `${el}+`)
.join('|'), 'g');
const matches = placeholder.match(pattern);
return placeholder.split(pattern).reduce((arr, element, index) => {
const divider = element && (
// biome-ignore lint/suspicious/noArrayIndexKey: index is stable here
_jsx(Divider, { children: element }, `separator_${index}`));
arr.push(divider);
const currentMatch = matches === null || matches === void 0 ? void 0 : matches[index];
if (currentMatch) {
const renderFunction = elementFunctions[currentMatch] ||
elementFunctions[Object.keys(elementFunctions).find((elementFunction) => currentMatch.match(elementFunction))];
if (!renderFunction) {
return arr;
}
if (!allowMultipleInstances && usedFunctions.includes(renderFunction)) {
arr.push(currentMatch);
}
else {
arr.push(renderFunction(currentMatch, index));
usedFunctions.push(renderFunction);
}
}
return arr;
}, []);
}
const formatNumber = getNumberFormatter({ useGrouping: false });
export default function DateTimeInput({ amPmAriaLabel, autoFocus, className, dayAriaLabel, dayPlaceholder, disabled, format, hourAriaLabel, hourPlaceholder, isWidgetOpen: isWidgetOpenProps, locale, maxDate, maxDetail = 'minute', minDate, minuteAriaLabel, minutePlaceholder, monthAriaLabel, monthPlaceholder, name = 'datetime', nativeInputAriaLabel, onChange: onChangeProps, onInvalidChange, required, secondAriaLabel, secondPlaceholder, showLeadingZeros, value: valueProps, yearAriaLabel, yearPlaceholder, }) {
const [amPm, setAmPm] = useState(null);
const [year, setYear] = useState(null);
const [month, setMonth] = useState(null);
const [day, setDay] = useState(null);
const [hour, setHour] = useState(null);
const [minute, setMinute] = useState(null);
const [second, setSecond] = useState(null);
const [value, setValue] = useState(null);
const amPmInput = useRef(null);
const yearInput = useRef(null);
const monthInput = useRef(null);
const monthSelect = useRef(null);
const dayInput = useRef(null);
const hour12Input = useRef(null);
const hour24Input = useRef(null);
const minuteInput = useRef(null);
const secondInput = useRef(null);
const [isWidgetOpen, setIsWidgetOpenOpen] = useState(isWidgetOpenProps);
const lastPressedKey = useRef(undefined);
useEffect(() => {
setIsWidgetOpenOpen(isWidgetOpenProps);
}, [isWidgetOpenProps]);
// biome-ignore lint/correctness/useExhaustiveDependencies: useEffect intentionally triggered on props change
useEffect(() => {
const nextValue = getDetailValueFrom({
value: valueProps,
minDate,
maxDate,
});
if (nextValue) {
setAmPm(convert24to12(getHours(nextValue))[1]);
setYear(getYear(nextValue).toString());
setMonth(getMonthHuman(nextValue).toString());
setDay(getDate(nextValue).toString());
setHour(getHours(nextValue).toString());
setMinute(getMinutes(nextValue).toString());
setSecond(getSeconds(nextValue).toString());
setValue(toDate(nextValue));
}
else {
setAmPm(null);
setYear(null);
setMonth(null);
setDay(null);
setHour(null);
setMinute(null);
setSecond(null);
setValue(null);
}
}, [
valueProps,
minDate,
maxDate,
// Toggling widget visibility resets values
isWidgetOpen,
]);
const valueType = maxDetail;
const formatTime = (() => {
const level = allViews.indexOf(maxDetail);
const formatterOptions = getFormatterOptionsCache[level] ||
(() => {
const options = { hour: 'numeric' };
if (level >= 1) {
options.minute = 'numeric';
}
if (level >= 2) {
options.second = 'numeric';
}
getFormatterOptionsCache[level] = options;
return options;
})();
return getFormatter(formatterOptions);
})();
const datePlaceholder = (() => {
const year = 2017;
const monthIndex = 11;
const day = 11;
const date = new Date(year, monthIndex, day);
const formattedDate = formatDate(locale, date);
const datePieces = ['year', 'month', 'day'];
const datePieceReplacements = ['y', 'M', 'd'];
function formatDatePiece(name, dateToFormat) {
const formatterOptions = getFormatterOptionsCache[name] ||
(() => {
const options = { [name]: 'numeric' };
getFormatterOptionsCache[name] = options;
return options;
})();
return getFormatter(formatterOptions)(locale, dateToFormat).match(/\d{1,}/);
}
let placeholder = formattedDate;
datePieces.forEach((datePiece, index) => {
const match = formatDatePiece(datePiece, date);
if (match) {
const formattedDatePiece = match[0];
const datePieceReplacement = datePieceReplacements[index];
placeholder = placeholder.replace(formattedDatePiece, datePieceReplacement);
}
});
// See https://github.com/wojtekmaj/react-date-picker/issues/396
placeholder = placeholder.replace('17', 'y');
return placeholder;
})();
const timePlaceholder = (() => {
const hour24 = 21;
const hour12 = 9;
const minute = 13;
const second = 14;
const date = new Date(2017, 0, 1, hour24, minute, second);
return formatTime(locale, date)
.replace(formatNumber(locale, hour12), 'h')
.replace(formatNumber(locale, hour24), 'H')
.replace(formatNumber(locale, minute), 'mm')
.replace(formatNumber(locale, second), 'ss')
.replace(new RegExp(getAmPmLabels(locale).join('|')), 'a');
})();
const placeholder = format || `${datePlaceholder}\u00a0${timePlaceholder}`;
const dateDivider = (() => {
const dividers = datePlaceholder.match(/[^0-9a-z]/i);
return dividers ? dividers[0] : null;
})();
const timeDivider = (() => {
const dividers = timePlaceholder.match(/[^0-9a-z]/i);
return dividers ? dividers[0] : null;
})();
const maxTime = (() => {
if (!maxDate) {
return undefined;
}
if (!isSameDate(maxDate, year, month, day)) {
return undefined;
}
return getHoursMinutesSeconds(maxDate || defaultMaxDate);
})();
const minTime = (() => {
if (!minDate) {
return undefined;
}
if (!isSameDate(minDate, year, month, day)) {
return undefined;
}
return getHoursMinutesSeconds(minDate || defaultMinDate);
})();
function onClick(event) {
if (event.target === event.currentTarget) {
// Wrapper was directly clicked
const firstInput = event.target.children[1];
focus(firstInput);
}
}
function onKeyDown(event) {
lastPressedKey.current = event.key;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowRight':
case dateDivider:
case timeDivider: {
event.preventDefault();
const { target: input } = event;
const property = event.key === 'ArrowLeft' ? 'previousElementSibling' : 'nextElementSibling';
const nextInput = findInput(input, property);
focus(nextInput);
break;
}
default:
}
}
function onKeyUp(event) {
const { key, target: input } = event;
const isLastPressedKey = lastPressedKey.current === key;
if (!isLastPressedKey) {
return;
}
const isNumberKey = !Number.isNaN(Number(key));
if (!isNumberKey) {
return;
}
const max = input.getAttribute('max');
if (!max) {
return;
}
const { value } = input;
/**
* Given 1, the smallest possible number the user could type by adding another digit is 10.
* 10 would be a valid value given max = 12, so we won't jump to the next input.
* However, given 2, smallers possible number would be 20, and thus keeping the focus in
* this field doesn't make sense.
*/
if (Number(value) * 10 > Number(max) || value.length >= max.length) {
const property = 'nextElementSibling';
const nextInput = findInput(input, property);
focus(nextInput);
}
}
/**
* Called after internal onChange. Checks input validity. If all fields are valid,
* calls props.onChange.
*/
function onChangeExternal() {
if (!onChangeProps) {
return;
}
function filterBoolean(value) {
return Boolean(value);
}
const formElements = [
amPmInput.current,
dayInput.current,
monthInput.current,
monthSelect.current,
yearInput.current,
hour12Input.current,
hour24Input.current,
minuteInput.current,
secondInput.current,
].filter(filterBoolean);
const formElementsWithoutSelect = formElements.slice(1);
const values = {};
for (const formElement of formElements) {
values[formElement.name] =
formElement.type === 'number' ? formElement.valueAsNumber : formElement.value;
}
const isEveryValueEmpty = formElementsWithoutSelect.every((formElement) => !formElement.value);
if (isEveryValueEmpty) {
onChangeProps(null, false);
return;
}
const isEveryValueFilled = formElements.every((formElement) => formElement.value);
const isEveryValueValid = formElements.every((formElement) => formElement.validity.valid);
if (isEveryValueFilled && isEveryValueValid) {
const year = Number(values.year || new Date().getFullYear());
const monthIndex = Number(values.month || 1) - 1;
const day = Number(values.day || 1);
const hour = Number(values.hour24 ||
(values.hour12 && values.amPm && convert12to24(values.hour12, values.amPm)) ||
0);
const minute = Number(values.minute || 0);
const second = Number(values.second || 0);
const proposedValue = new Date();
proposedValue.setFullYear(year, monthIndex, day);
proposedValue.setHours(hour, minute, second, 0);
onChangeProps(proposedValue, false);
return;
}
if (!onInvalidChange) {
return;
}
onInvalidChange();
}
/**
* Called when non-native date input is changed.
*/
function onChange(event) {
const { name, value } = event.target;
switch (name) {
case 'amPm':
setAmPm(value);
break;
case 'year':
setYear(value);
break;
case 'month':
setMonth(value);
break;
case 'day':
setDay(value);
break;
case 'hour12':
setHour(value ? convert12to24(value, amPm || 'am').toString() : '');
break;
case 'hour24':
setHour(value);
break;
case 'minute':
setMinute(value);
break;
case 'second':
setSecond(value);
break;
}
onChangeExternal();
}
/**
* Called when native date input is changed.
*/
function onChangeNative(event) {
const { value } = event.target;
if (!onChangeProps) {
return;
}
const processedValue = (() => {
if (!value) {
return null;
}
const [valueDate, valueTime] = value.split('T');
const [yearString, monthString, dayString] = valueDate.split('-');
const year = Number(yearString);
const monthIndex = Number(monthString) - 1 || 0;
const day = Number(dayString) || 1;
const [hourString, minuteString, secondString] = valueTime.split(':');
const hour = Number(hourString) || 0;
const minute = Number(minuteString) || 0;
const second = Number(secondString) || 0;
const proposedValue = new Date();
proposedValue.setFullYear(year, monthIndex, day);
proposedValue.setHours(hour, minute, second, 0);
return proposedValue;
})();
onChangeProps(processedValue, false);
}
const commonInputProps = {
className,
disabled,
maxDate: maxDate || defaultMaxDate,
minDate: minDate || defaultMinDate,
onChange,
onKeyDown,
onKeyUp,
// This is only for showing validity when editing
required: Boolean(required || isWidgetOpen),
};
const commonTimeInputProps = {
maxTime,
minTime,
};
function renderDay(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZerosFromFormat = currentMatch && currentMatch.length === 2;
return (_jsx(DayInput, { ...commonInputProps, ariaLabel: dayAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: dayInput, month: month, placeholder: dayPlaceholder, showLeadingZeros: showLeadingZerosFromFormat || showLeadingZeros, value: day, year: year }, "day"));
}
function renderMonth(currentMatch, index) {
if (currentMatch && currentMatch.length > 4) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
if (currentMatch.length > 2) {
return (_jsx(MonthSelect, { ...commonInputProps, ariaLabel: monthAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: monthSelect, locale: locale, placeholder: monthPlaceholder, short: currentMatch.length === 3, value: month, year: year }, "month"));
}
const showLeadingZerosFromFormat = currentMatch && currentMatch.length === 2;
return (_jsx(MonthInput, { ...commonInputProps, ariaLabel: monthAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: monthInput, placeholder: monthPlaceholder, showLeadingZeros: showLeadingZerosFromFormat || showLeadingZeros, value: month, year: year }, "month"));
}
function renderYear(_currentMatch, index) {
return (_jsx(YearInput, { ...commonInputProps, ariaLabel: yearAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: yearInput, placeholder: yearPlaceholder, value: year, valueType: "day" }, "year"));
}
function renderHour12(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false;
return (_jsx(Hour12Input, { ...commonInputProps, ...commonTimeInputProps, amPm: amPm, ariaLabel: hourAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: hour12Input, placeholder: hourPlaceholder, showLeadingZeros: showLeadingZeros, value: hour }, "hour12"));
}
function renderHour24(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false;
return (_jsx(Hour24Input, { ...commonInputProps, ...commonTimeInputProps, ariaLabel: hourAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: hour24Input, placeholder: hourPlaceholder, showLeadingZeros: showLeadingZeros, value: hour }, "hour24"));
}
function renderHour(currentMatch, index) {
if (/h/.test(currentMatch)) {
return renderHour12(currentMatch, index);
}
return renderHour24(currentMatch, index);
}
function renderMinute(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false;
return (_jsx(MinuteInput, { ...commonInputProps, ...commonTimeInputProps, ariaLabel: minuteAriaLabel, autoFocus: index === 0 && autoFocus, hour: hour, inputRef: minuteInput, placeholder: minutePlaceholder, showLeadingZeros: showLeadingZeros, value: minute }, "minute"));
}
function renderSecond(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZeros = currentMatch ? currentMatch.length === 2 : true;
return (_jsx(SecondInput, { ...commonInputProps, ...commonTimeInputProps, ariaLabel: secondAriaLabel, autoFocus: index === 0 && autoFocus, hour: hour, inputRef: secondInput, minute: minute, placeholder: secondPlaceholder, showLeadingZeros: showLeadingZeros, value: second }, "second"));
}
function renderAmPm(_currentMatch, index) {
return (_jsx(AmPm, { ...commonInputProps, ...commonTimeInputProps, ariaLabel: amPmAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: amPmInput, locale: locale, onChange: onChange, value: amPm }, "ampm"));
}
function renderCustomInputsInternal() {
const elementFunctions = {
d: renderDay,
M: renderMonth,
y: renderYear,
h: renderHour,
H: renderHour,
m: renderMinute,
s: renderSecond,
a: renderAmPm,
};
const allowMultipleInstances = typeof format !== 'undefined';
return renderCustomInputs(placeholder, elementFunctions, allowMultipleInstances);
}
function renderNativeInput() {
return (_jsx(NativeInput, { ariaLabel: nativeInputAriaLabel, disabled: disabled, maxDate: maxDate || defaultMaxDate, minDate: minDate || defaultMinDate, name: name, onChange: onChangeNative, required: required, value: value, valueType: valueType }, "datetime"));
}
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: This interaction is designed for mouse users only
// biome-ignore lint/a11y/noStaticElementInteractions: This interaction is designed for mouse users only
_jsxs("div", { className: className, onClick: onClick, children: [renderNativeInput(), renderCustomInputsInternal()] }));
}