'use client';
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import clsx from 'clsx';
import makeEventProps from 'make-event-props';
import Calendar from 'react-calendar';
import Clock from 'react-clock';
import Fit from 'react-fit';
import DateTimeInput from './DateTimeInput.js';
import type {
ClassName,
CloseReason,
Detail,
LooseValue,
OpenReason,
Value,
} from './shared/types.js';
const baseClassName = 'react-datetime-picker';
const outsideActionEvents = ['mousedown', 'focusin', 'touchstart'] as const;
const allViews = ['hour', 'minute', 'second'] as const;
const iconProps = {
xmlns: 'http://www.w3.org/2000/svg',
width: 19,
height: 19,
viewBox: '0 0 19 19',
stroke: 'black',
strokeWidth: 2,
};
const CalendarIcon = (
);
const ClearIcon = (
);
type ReactNodeLike = React.ReactNode | string | number | boolean | null | undefined;
type Icon = ReactNodeLike | ReactNodeLike[];
type IconOrRenderFunction = Icon | React.ComponentType | React.ReactElement;
type CalendarProps = Omit<
React.ComponentPropsWithoutRef,
'onChange' | 'selectRange' | 'value'
>;
type ClockProps = Omit, 'value'>;
type EventProps = ReturnType;
export type DateTimePickerProps = {
/**
* `aria-label` for the AM/PM select input.
*
* @example 'Select AM/PM'
*/
amPmAriaLabel?: string;
/**
* Automatically focuses the input on mount.
*
* @example true
*/
autoFocus?: boolean;
/**
* `aria-label` for the calendar button.
*
* @example 'Toggle calendar'
*/
calendarAriaLabel?: string;
/**
* Content of the calendar button. Setting the value explicitly to `null` will hide the icon.
*
* @example 'Calendar'
* @example
* @example CalendarIcon
*/
calendarIcon?: IconOrRenderFunction | null;
/**
* Props to pass to React-Calendar component.
*/
calendarProps?: CalendarProps;
/**
* Class name(s) that will be added along with `"react-datetime-picker"` to the main React-DateTime-Picker `
` element.
*
* @example 'class1 class2'
* @example ['class1', 'class2 class3']
*/
className?: ClassName;
/**
* `aria-label` for the clear button.
*
* @example 'Clear value'
*/
clearAriaLabel?: string;
/**
* Content of the clear button. Setting the value explicitly to `null` will hide the icon.
*
* @example 'Clear'
* @example
* @example ClearIcon
*/
clearIcon?: IconOrRenderFunction | null;
/**
* Props to pass to React-Clock component.
*/
clockProps?: ClockProps;
/**
* Whether to close the widgets on value selection. **Note**: It's recommended to use `shouldCloseWidgets` function instead.
*
* @default true
* @example false
*/
closeWidgets?: boolean;
/**
* `data-testid` attribute for the main React-DateTime-Picker `
` element.
*
* @example 'datetime-picker'
*/
'data-testid'?: string;
/**
* `aria-label` for the day input.
*
* @example 'Day'
*/
dayAriaLabel?: string;
/**
* `placeholder` for the day input.
*
* @default '--'
* @example 'dd'
*/
dayPlaceholder?: string;
/**
* When set to `true`, will remove the calendar and the button toggling its visibility.
*
* @default false
* @example true
*/
disableCalendar?: boolean;
/**
* When set to `true`, will remove the clock.
*
* @default false
* @example true
*/
disableClock?: boolean;
/**
* Whether the date time picker should be disabled.
*
* @default false
* @example true
*/
disabled?: boolean;
/**
* Input format based on [Unicode Technical Standard #35](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table). Supported values are: `y`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `H`, `HH`, `h`, `hh`, `m`, `mm`, `s`, `ss`, `a`.
*
* **Note**: When using SSR, setting this prop may help resolving hydration errors caused by locale mismatch between server and client.
*
* @example 'y-MM-dd h:mm:ss a'
*/
format?: string;
/**
* `aria-label` for the hour input.
*
* @example 'Hour'
*/
hourAriaLabel?: string;
/**
* `placeholder` for the hour input.
*
* @default '--'
* @example 'hh'
*/
hourPlaceholder?: string;
/**
* `id` attribute for the main React-DateTime-Picker `
` element.
*
* @example 'datetime-picker'
*/
id?: string;
/**
* Whether the calendar should be opened.
*
* @default false
* @example true
*/
isCalendarOpen?: boolean;
/**
* Whether the clock should be opened.
*
* @default false
* @example true
*/
isClockOpen?: boolean;
/**
* Locale that should be used by the datetime picker and the calendar. Can be any [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag).
*
* **Note**: When using SSR, setting this prop may help resolving hydration errors caused by locale mismatch between server and client.
*
* @example 'hu-HU'
*/
locale?: string;
/**
* Maximum date that the user can select. Periods partially overlapped by maxDate will also be selectable, although React-DateTime-Picker will ensure that no later date is selected.
*
* @example new Date()
*/
maxDate?: Date;
/**
* The most detailed calendar view that the user shall see. View defined here also becomes the one on which clicking an item in the calendar will select a date and pass it to onChange. Can be `"hour"`, `"minute"` or `"second"`.
*
* Don't need hour picking? Try [React-Date-Picker](https://github.com/wojtekmaj/react-date-picker)!
*
* @default 'minute'
* @example 'second'
*/
maxDetail?: Detail;
/**
* Minimum date that the user can select. Periods partially overlapped by minDate will also be selectable, although React-DateTimeRange-Picker will ensure that no earlier date is selected.
*
* @example new Date()
*/
minDate?: Date;
/**
* `aria-label` for the minute input.
*
* @example 'Minute'
*/
minuteAriaLabel?: string;
/**
* `placeholder` for the minute input.
*
* @default '--'
* @example 'mm'
*/
minutePlaceholder?: string;
/**
* `aria-label` for the month input.
*
* @example 'Month'
*/
monthAriaLabel?: string;
/**
* `placeholder` for the month input.
*
* @default '--'
* @example 'mm'
*/
monthPlaceholder?: string;
/**
* Input name.
*
* @default 'datetime'
*/
name?: string;
/**
* `aria-label` for the native datetime input.
*
* @example 'Date'
*/
nativeInputAriaLabel?: string;
/**
* Function called when the calendar closes.
*
* @example () => alert('Calendar closed')
*/
onCalendarClose?: () => void;
/**
* Function called when the calendar opens.
*
* @example () => alert('Calendar opened')
*/
onCalendarOpen?: () => void;
/**
* Function called when the user picks a valid datetime. If any of the fields were excluded using custom `format`, `new Date(y, 0, 1, 0, 0, 0)`, where `y` is the current year, is going to serve as a "base".
*
* @example (value) => alert('New date is: ', value)
*/
onChange?: (value: Value) => void;
/**
* Function called when the clock closes.
*
* @example () => alert('Clock closed')
*/
onClockClose?: () => void;
/**
* Function called when the clock opens.
*
* @example () => alert('Clock opened')
*/
onClockOpen?: () => void;
/**
* Function called when the user focuses an input.
*
* @example (event) => alert('Focused input: ', event.target.name)
*/
onFocus?: (event: React.FocusEvent) => void;
/**
* Function called when the user picks an invalid datetime.
*
* @example () => alert('Invalid datetime');
*/
onInvalidChange?: () => void;
/**
* Whether to open the widgets on input focus.
*
* **Note**: It's recommended to use `shouldOpenWidgets` function instead.
*
* @default true
* @example false
*/
openWidgetsOnFocus?: boolean;
/**
* Element to render the widgets in using portal.
*
* @example document.getElementById('my-div')
*/
portalContainer?: HTMLElement | null;
/**
* Whether datetime input should be required.
*
* @default false
* @example true
*/
required?: boolean;
/**
* `aria-label` for the second input.
*
* @example 'Second'
*/
secondAriaLabel?: string;
/**
* `placeholder` for the second input.
*
* @default '--'
* @example 'ss'
*/
secondPlaceholder?: string;
/**
* Function called before the widgets close. `reason` can be `"buttonClick"`, `"escape"`, `"outsideAction"`, or `"select"`. `widget` can be `"calendar"` or `"clock"`. If it returns `false`, the widget will not close.
*
* @example ({ reason, widget }) => reason !== 'outsideAction' && widget === 'calendar'`
*/
shouldCloseWidgets?: (props: { reason: CloseReason; widget: 'calendar' | 'clock' }) => boolean;
/**
* Function called before the widgets open. `reason` can be `"buttonClick"` or `"focus"`. `widget` can be `"calendar"` or `"clock"`. If it returns `false`, the widget will not open.
*
* @example ({ reason, widget }) => reason !== 'focus' && widget === 'calendar'`
*/
shouldOpenWidgets?: (props: { reason: OpenReason; widget: 'calendar' | 'clock' }) => boolean;
/**
* Whether leading zeros should be rendered in datetime inputs.
*
* @default false
* @example true
*/
showLeadingZeros?: boolean;
/**
* Input value. Note that if you pass an array of values, only first value will be fully utilized.
*
* @example new Date(2017, 0, 1, 22, 15)
* @example [new Date(2017, 0, 1, 22, 15), new Date(2017, 0, 1, 23, 45)]
* @example ["2017-01-01T22:15:00", "2017-01-01T23:45:00"]
*/
value?: LooseValue;
/**
* `aria-label` for the year input.
*
* @example 'Year'
*/
yearAriaLabel?: string;
/**
* `placeholder` for the year input.
*
* @default '----'
* @example 'yyyy'
*/
yearPlaceholder?: string;
} & Omit;
export default function DateTimePicker(props: DateTimePickerProps): React.ReactElement {
const {
amPmAriaLabel,
autoFocus,
calendarAriaLabel,
calendarIcon = CalendarIcon,
className,
clearAriaLabel,
clearIcon = ClearIcon,
closeWidgets: shouldCloseWidgetsOnSelect = true,
'data-testid': dataTestid,
dayAriaLabel,
dayPlaceholder,
disableCalendar,
disableClock,
disabled,
format,
hourAriaLabel,
hourPlaceholder,
id,
isCalendarOpen: isCalendarOpenProps = null,
isClockOpen: isClockOpenProps = null,
locale,
maxDate,
maxDetail = 'minute',
minDate,
minuteAriaLabel,
minutePlaceholder,
monthAriaLabel,
monthPlaceholder,
name = 'datetime',
nativeInputAriaLabel,
onCalendarClose,
onCalendarOpen,
onChange: onChangeProps,
onClockClose,
onClockOpen,
onFocus: onFocusProps,
onInvalidChange,
openWidgetsOnFocus = true,
required,
secondAriaLabel,
secondPlaceholder,
shouldCloseWidgets,
shouldOpenWidgets,
showLeadingZeros,
value,
yearAriaLabel,
yearPlaceholder,
...otherProps
} = props;
const [isCalendarOpen, setIsCalendarOpen] = useState(isCalendarOpenProps);
const [isClockOpen, setIsClockOpen] = useState(isClockOpenProps);
const wrapper = useRef(null);
const calendarWrapper = useRef(null);
const clockWrapper = useRef(null);
useEffect(() => {
setIsCalendarOpen(isCalendarOpenProps);
}, [isCalendarOpenProps]);
useEffect(() => {
setIsClockOpen(isClockOpenProps);
}, [isClockOpenProps]);
function openCalendar({ reason }: { reason: OpenReason }) {
if (shouldOpenWidgets) {
if (!shouldOpenWidgets({ reason, widget: 'calendar' })) {
return;
}
}
setIsClockOpen(isClockOpen ? false : isClockOpen);
setIsCalendarOpen(true);
if (onCalendarOpen) {
onCalendarOpen();
}
}
const closeCalendar = useCallback(
({ reason }: { reason: CloseReason }) => {
if (shouldCloseWidgets) {
if (!shouldCloseWidgets({ reason, widget: 'calendar' })) {
return;
}
}
setIsCalendarOpen(false);
if (onCalendarClose) {
onCalendarClose();
}
},
[onCalendarClose, shouldCloseWidgets],
);
function toggleCalendar() {
if (isCalendarOpen) {
closeCalendar({ reason: 'buttonClick' });
} else {
openCalendar({ reason: 'buttonClick' });
}
}
function openClock({ reason }: { reason: OpenReason }) {
if (shouldOpenWidgets) {
if (!shouldOpenWidgets({ reason, widget: 'clock' })) {
return;
}
}
setIsCalendarOpen(isCalendarOpen ? false : isCalendarOpen);
setIsClockOpen(true);
if (onClockOpen) {
onClockOpen();
}
}
const closeClock = useCallback(
({ reason }: { reason: CloseReason }) => {
if (shouldCloseWidgets) {
if (!shouldCloseWidgets({ reason, widget: 'clock' })) {
return;
}
}
setIsClockOpen(false);
if (onClockClose) {
onClockClose();
}
},
[onClockClose, shouldCloseWidgets],
);
const closeWidgets = useCallback(
({ reason }: { reason: CloseReason }) => {
closeCalendar({ reason });
closeClock({ reason });
},
[closeCalendar, closeClock],
);
function onChange(value: Value, shouldCloseWidgets: boolean = shouldCloseWidgetsOnSelect) {
if (shouldCloseWidgets) {
closeWidgets({ reason: 'select' });
}
if (onChangeProps) {
onChangeProps(value);
}
}
type DatePiece = Date | null;
function onDateChange(
nextValue: DatePiece | [DatePiece, DatePiece],
shouldCloseWidgets?: boolean,
) {
// React-Calendar passes an array of values when selectRange is enabled
const [nextValueFrom] = Array.isArray(nextValue) ? nextValue : [nextValue];
const [valueFrom] = Array.isArray(value) ? value : [value];
if (valueFrom && nextValueFrom) {
const valueFromDate = new Date(valueFrom);
const nextValueFromWithHour = new Date(nextValueFrom);
nextValueFromWithHour.setHours(
valueFromDate.getHours(),
valueFromDate.getMinutes(),
valueFromDate.getSeconds(),
valueFromDate.getMilliseconds(),
);
onChange(nextValueFromWithHour, shouldCloseWidgets);
} else {
onChange(nextValueFrom, shouldCloseWidgets);
}
}
function onFocus(event: React.FocusEvent) {
if (onFocusProps) {
onFocusProps(event);
}
if (
// Internet Explorer still fires onFocus on disabled elements
disabled ||
!openWidgetsOnFocus ||
event.target.dataset.select === 'true'
) {
return;
}
switch (event.target.name) {
case 'day':
case 'month':
case 'year': {
if (isCalendarOpen) {
return;
}
openCalendar({ reason: 'focus' });
break;
}
case 'hour12':
case 'hour24':
case 'minute':
case 'second': {
if (isClockOpen) {
return;
}
openClock({ reason: 'focus' });
break;
}
default:
}
}
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeWidgets({ reason: 'escape' });
}
},
[closeWidgets],
);
function clear() {
onChange(null);
}
function stopPropagation(event: React.FocusEvent) {
event.stopPropagation();
}
const onOutsideAction = useCallback(
(event: Event) => {
const { current: wrapperEl } = wrapper;
const { current: calendarWrapperEl } = calendarWrapper;
const { current: clockWrapperEl } = clockWrapper;
// Try event.composedPath first to handle clicks inside a Shadow DOM.
const target = (
'composedPath' in event ? event.composedPath()[0] : (event as Event).target
) as HTMLElement;
if (
target &&
wrapperEl &&
!wrapperEl.contains(target) &&
(!calendarWrapperEl || !calendarWrapperEl.contains(target)) &&
(!clockWrapperEl || !clockWrapperEl.contains(target))
) {
closeWidgets({ reason: 'outsideAction' });
}
},
[closeWidgets],
);
const handleOutsideActionListeners = useCallback(
(shouldListen = isCalendarOpen || isClockOpen) => {
for (const event of outsideActionEvents) {
if (shouldListen) {
document.addEventListener(event, onOutsideAction);
} else {
document.removeEventListener(event, onOutsideAction);
}
}
if (shouldListen) {
document.addEventListener('keydown', onKeyDown);
} else {
document.removeEventListener('keydown', onKeyDown);
}
},
[isCalendarOpen, isClockOpen, onOutsideAction, onKeyDown],
);
useEffect(() => {
handleOutsideActionListeners();
return () => {
handleOutsideActionListeners(false);
};
}, [handleOutsideActionListeners]);
function renderInputs() {
const [valueFrom] = Array.isArray(value) ? value : [value];
const ariaLabelProps = {
amPmAriaLabel,
dayAriaLabel,
hourAriaLabel,
minuteAriaLabel,
monthAriaLabel,
nativeInputAriaLabel,
secondAriaLabel,
yearAriaLabel,
};
const placeholderProps = {
dayPlaceholder,
hourPlaceholder,
minutePlaceholder,
monthPlaceholder,
secondPlaceholder,
yearPlaceholder,
};
return (