import { differenceInCalendarDays, isWeekend } from "date-fns";
import React, { useCallback, useState } from "react";
import { DayEventHandler, dateMatchModifiers } from "react-day-picker";
import { focusElement } from "../../../utils/helpers/focus";
import { useDateLocale } from "../../../utils/i18n/i18n.hooks";
import { DateInputProps } from "../../Date.Input";
import { getLocaleFromString } from "../../Date.locale";
import { formatDateForInput, isValidDate, parseDate } from "../../date-utils";
import { DatePickerProps } from "../DatePicker";
export interface UseDatepickerOptions extends Pick<
DatePickerProps,
| "locale"
| "fromDate"
| "toDate"
| "today"
| "toDate"
| "fromDate"
| "toDate"
| "disabled"
| "disableWeekends"
> {
/**
* The initially selected Date
*/
defaultSelected?: Date;
/**
* Default shown month
*/
defaultMonth?: Date;
/**
* Make selection of Date required
*/
required?: boolean;
/**
* Callback for changed state
*/
onDateChange?: (val?: Date) => void;
/**
* Input-format
* @default "dd.MM.yyyy"
*/
inputFormat?: string;
/**
* validation-callback
*/
onValidate?: (val: DateValidationT) => void;
/**
* Allows input of with `yy` year format.
*
* Decision between 20th and 21st century is based on before(todays year - 80) ? 21st : 20th.
* e.g. In 2024 this equals to 1944 - 2043.
* @default true
*/
allowTwoDigitYear?: boolean;
}
interface UseDatepickerValue {
/**
* Use:
*/
datepickerProps: DatePickerProps;
/**
* Use:
*/
inputProps: Pick<
DateInputProps,
"onChange" | "onFocus" | "onBlur" | "value"
> & {
/**
* @private
*/
setAnchorRef: React.Dispatch<
React.SetStateAction
>;
};
/**
* Resets all states (callback)
*/
reset: () => void;
/**
* Currently selected date
* Up to user to validate date
*/
selectedDay?: Date;
/**
* Manually override currently selected day
*/
setSelected: (date?: Date) => void;
}
export type DateValidationT = {
/**
* Whether there are any validation errors.
* - When `true`, all the other properties will be `false`.
* - When `false`, at least one of the other properties will be `true`.
*/
isValidDate: boolean;
/** Whether the date is a disabled date */
isDisabled: boolean;
/** Whether the date falls on a weekend and `disableWeekends` is true */
isWeekend: boolean;
/** Whether the input field is empty */
isEmpty: boolean;
/** Whether the entered value cannot be parsed as a date (i.e. wrong format or non-existing date) */
isInvalid: boolean;
/** Whether the date is before `fromDate` */
isBefore: boolean;
/** Whether the date is after `toDate` */
isAfter: boolean;
};
const getValidationMessage = (val = {}): DateValidationT => ({
isDisabled: false,
isWeekend: false,
isEmpty: false,
isInvalid: false,
isBefore: false,
isAfter: false,
isValidDate: true,
...val,
});
/**
*
* @see 🏷️ {@link UseDatepickerOptions}
* @see 🏷️ {@link UseDatepickerValue}
* @see 🏷️ {@link DateValidationT}
* @example
* const { datepickerProps, inputProps } = useDatepicker({
* fromDate: new Date("Aug 23 2019"),
* toDate: new Date("Feb 23 2024"),
* onDateChange: console.log,
* onValidate: console.log,
* });
*/
export const useDatepicker = (
opt: UseDatepickerOptions = {},
): UseDatepickerValue => {
const {
locale: _locale,
required,
defaultSelected: _defaultSelected,
today = new Date(),
fromDate,
toDate,
disabled,
disableWeekends,
onDateChange,
inputFormat,
onValidate,
defaultMonth,
allowTwoDigitYear = true,
} = opt;
const [anchorRef, setAnchorRef] = useState(null);
const localeFromProvider = useDateLocale();
const locale = _locale ? getLocaleFromString(_locale) : localeFromProvider;
const [defaultSelected, setDefaultSelected] = useState(_defaultSelected);
// Initialize states
const [month, setMonth] = useState(defaultSelected ?? defaultMonth ?? today);
const [selectedDay, setSelectedDay] = useState(defaultSelected);
const [open, setOpen] = useState(false);
const defaultInputValue = defaultSelected
? formatDateForInput(defaultSelected, locale, "date", inputFormat)
: "";
const [inputValue, setInputValue] = useState(defaultInputValue);
const handleOpen = useCallback(
(newOpen: boolean) => {
setOpen(newOpen);
if (newOpen) {
setMonth(selectedDay ?? defaultSelected ?? defaultMonth ?? today);
}
},
[defaultMonth, defaultSelected, selectedDay, today],
);
const updateDate = (date?: Date) => {
onDateChange?.(date);
setSelectedDay(date);
};
const updateValidation = (val: Partial = {}) =>
onValidate?.(getValidationMessage(val));
const reset = () => {
updateDate(defaultSelected);
setMonth(defaultSelected ?? defaultMonth ?? today);
setInputValue(defaultInputValue ?? "");
setDefaultSelected(_defaultSelected);
};
const setSelected = (date: Date | undefined) => {
updateDate(date);
setMonth(date ?? defaultMonth ?? today);
setInputValue(
date ? formatDateForInput(date, locale, "date", inputFormat) : "",
);
};
const handleFocus: React.FocusEventHandler = (e) => {
if (e.target.readOnly) {
return;
}
const day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear,
);
if (isValidDate(day)) {
setInputValue(formatDateForInput(day, locale, "date", inputFormat));
const isBefore =
fromDate && day && differenceInCalendarDays(fromDate, day) > 0;
const isAfter =
toDate && day && differenceInCalendarDays(day, toDate) > 0;
!isBefore && !isAfter && setMonth(day);
}
};
const handleBlur: React.FocusEventHandler = (e) => {
const day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear,
);
isValidDate(day) &&
setInputValue(formatDateForInput(day, locale, "date", inputFormat));
};
/* Only allow de-selecting if not required */
const handleDayClick: DayEventHandler = (
day,
{ selected },
) => {
if (selected && required) {
return;
}
if (day && !selected) {
handleOpen(false);
// We use sync:false so that when Modal is used (see Date.Dialog.tsx), it is closed before
// we try to focus the open button (since native modal dialogs don't allow focus outside).
focusElement(anchorRef, { sync: false, preventScroll: true });
}
if (selected) {
updateDate(undefined);
setInputValue("");
updateValidation({ isValidDate: false, isEmpty: true });
return;
}
updateDate(day);
updateValidation();
setMonth(day);
setInputValue(
day ? formatDateForInput(day, locale, "date", inputFormat) : "",
);
};
// When changing the input field, save its value in state and check if the
// string is a valid date. If it is a valid day, set it as selected and update
// the calendar’s month.
const handleChange: React.ChangeEventHandler = (e) => {
setInputValue(e.target.value);
const day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear,
);
const isBefore =
fromDate && day && differenceInCalendarDays(fromDate, day) > 0;
const isAfter = toDate && day && differenceInCalendarDays(day, toDate) > 0;
if (
!isValidDate(day) ||
(disableWeekends && isWeekend(day)) ||
(disabled && dateMatchModifiers(day, disabled))
) {
updateDate(undefined);
updateValidation({
isInvalid: !isValidDate(day),
isWeekend: disableWeekends && isWeekend(day),
isDisabled: disabled && dateMatchModifiers(day, disabled),
isValidDate: false,
isEmpty: !e.target.value,
isBefore: isBefore ?? false,
isAfter: isAfter ?? false,
});
return;
}
if (isBefore || isAfter) {
updateDate(undefined);
updateValidation({
isValidDate: false,
isBefore: isBefore ?? false,
isAfter: isAfter ?? false,
});
return;
}
updateDate(day);
updateValidation();
setMonth(defaultMonth ?? day);
};
const datepickerProps = {
month,
onMonthChange: setMonth,
onDayClick: handleDayClick,
selected: selectedDay ?? new Date("Invalid date"),
locale: _locale,
fromDate,
toDate,
today,
open,
onClose: () => {
handleOpen(false);
focusElement(anchorRef, { sync: false, preventScroll: true });
},
onOpenToggle: () => handleOpen(!open),
disabled,
disableWeekends,
};
const inputProps = {
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
value: inputValue,
setAnchorRef,
};
return { datepickerProps, inputProps, reset, selectedDay, setSelected };
};