import { differenceInCalendarDays, isSameDay, isWeekend } from "date-fns";
import React, { useState } from "react";
import { 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 { DateRange } from "../../Date.typeutils";
import { formatDateForInput, isValidDate, parseDate } from "../../date-utils";
import { DatePickerProps } from "../DatePicker";
import { DateValidationT, UseDatepickerOptions } from "./useDatepicker";
export type RangeValidationT = {
from: DateValidationT;
to: DateValidationT & { isBeforeFrom?: boolean };
};
const getValidationMessage = (from = {}, to = {}): RangeValidationT => ({
from: {
isDisabled: false,
isWeekend: false,
isEmpty: false,
isInvalid: false,
isBefore: false,
isAfter: false,
isValidDate: true,
...from,
},
to: {
isDisabled: false,
isWeekend: false,
isEmpty: false,
isInvalid: false,
isBefore: false,
isAfter: false,
isBeforeFrom: false,
isValidDate: true,
...to,
},
});
export interface UseRangeDatepickerOptions extends Omit<
UseDatepickerOptions,
"defaultSelected" | "onDateChange" | "onValidate"
> {
/**
* The initially selected DateRange
*/
defaultSelected?: DateRange;
/**
* Callback for changed state
*/
onRangeChange?: (val?: DateRange) => void;
/**
* validation-callback
*/
onValidate?: (val: RangeValidationT) => void;
}
interface UseRangeDatepickerValue {
/**
* Use:
*/
datepickerProps: DatePickerProps;
/**
* Use:
*/
fromInputProps: Pick<
DateInputProps,
"onChange" | "onFocus" | "onBlur" | "value"
> & {
/**
* @private
*/
setAnchorRef: React.Dispatch<
React.SetStateAction
>;
};
/**
* Use:
*/
toInputProps: Pick<
DateInputProps,
"onChange" | "onFocus" | "onBlur" | "value"
> & {
/**
* @private
*/
setAnchorRef?: React.Dispatch<
React.SetStateAction
>;
};
/**
* Resets all states (callback)
*/
reset: () => void;
/**
* Currently selected DateRange
* Up to user to validate values
*/
selectedRange?: DateRange;
/**
* Manually override currently selected day
*/
setSelected: (date?: DateRange) => void;
}
const RANGE = {
FROM: "FROM",
TO: "TO",
} as const;
type RangeT = (typeof RANGE)[keyof typeof RANGE];
const fromValidation = (day: Date, opt?: UseRangeDatepickerOptions) => {
const isBefore =
opt?.fromDate && day && differenceInCalendarDays(opt?.fromDate, day) > 0;
const isAfter =
opt?.toDate && day && differenceInCalendarDays(day, opt?.toDate) > 0;
if (
isValidDate(day) &&
!(opt?.disableWeekends && isWeekend(day)) &&
!(opt?.disabled && dateMatchModifiers(day, opt.disabled))
) {
return {
isValidDate: false,
isInvalid: !isValidDate(day),
isWeekend: opt?.disableWeekends && isWeekend(day),
isDisabled: opt?.disabled && dateMatchModifiers(day, opt.disabled),
isBefore,
isAfter,
};
}
if (isBefore || isAfter) {
return {
isValidDate: false,
isBefore,
isAfter,
};
}
};
const toValidation = (
day: Date,
from: Date,
opt?: UseRangeDatepickerOptions,
) => {
const isBefore =
opt?.fromDate && day && differenceInCalendarDays(opt?.fromDate, day) > 0;
const isAfter =
opt?.toDate && day && differenceInCalendarDays(day, opt?.toDate) > 0;
const isBeforeFrom =
(from && differenceInCalendarDays(from, day) > 0) ?? false;
if (
isValidDate(day) &&
!(opt?.disableWeekends && isWeekend(day)) &&
!(opt?.disabled && dateMatchModifiers(day, opt.disabled))
) {
return {
isValidDate: false,
isInvalid: !isValidDate(day),
isWeekend: opt?.disableWeekends && isWeekend(day),
isDisabled: opt?.disabled && dateMatchModifiers(day, opt.disabled),
isBefore,
isAfter,
isBeforeFrom,
};
}
if (isBefore || isAfter || isBeforeFrom) {
return {
isValidDate: false,
isBefore,
isAfter,
isBeforeFrom,
};
}
};
const initialValidation = (
range?: DateRange,
opt?: UseRangeDatepickerOptions,
): RangeValidationT => {
if (!range || !range?.from) {
return getValidationMessage(
{ isEmpty: true, isValidDate: false },
{ isEmpty: true, isValidDate: false },
);
}
const fromVal = fromValidation(range.from, opt);
const toVal = range.to
? toValidation(range.to, range.from, opt)
: { isEmpty: true, isValidDate: false };
return getValidationMessage({ ...fromVal }, { ...toVal });
};
/**
*
* @see 🏷️ {@link UseRangeDatepickerOptions}
* @see 🏷️ {@link UseRangeDatepickerValue}
* @see 🏷️ {@link RangeValidationT}
* @example
* const { datepickerProps, fromInputProps, toInputProps } = useRangeDatepicker({
* fromDate: new Date("Aug 23 2019"),
* onRangeChange: console.log,
* onValidate: console.log,
* });
*/
export const useRangeDatepicker = (
opt: UseRangeDatepickerOptions = {},
): UseRangeDatepickerValue => {
const {
locale: _locale,
defaultSelected: _defaultSelected,
today = new Date(),
fromDate,
toDate,
disabled,
disableWeekends,
onRangeChange,
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?.from || defaultSelected?.to || defaultMonth || today,
);
const [selectedRange, setSelectedRange] = useState(
defaultSelected ?? { from: undefined, to: undefined },
);
const [fromInputValue, setFromInputValue] = useState(
defaultSelected?.from
? formatDateForInput(defaultSelected.from, locale, "date", inputFormat)
: "",
);
const [toInputValue, setToInputValue] = useState(
defaultSelected?.to
? formatDateForInput(defaultSelected.to, locale, "date", inputFormat)
: "",
);
const [validation, setValidation] = useState(
initialValidation(selectedRange, opt),
);
const [open, setOpen] = useState(false);
const updateRange = (range?: DateRange) => {
onRangeChange?.(range);
setSelectedRange(range);
};
const updateValidation = (
from: Partial = {},
to: Partial = {},
) => {
const msg = getValidationMessage(from, to);
setValidation(msg);
onValidate?.(msg);
};
const reset = () => {
updateRange(defaultSelected ?? { from: undefined, to: undefined });
setMonth(
defaultSelected?.from || defaultSelected?.to || defaultMonth || today,
);
setValidation(
initialValidation(
defaultSelected ?? { from: undefined, to: undefined },
opt,
),
);
setFromInputValue(
defaultSelected?.from
? formatDateForInput(defaultSelected.from, locale, "date", inputFormat)
: "",
);
setToInputValue(
defaultSelected?.to
? formatDateForInput(defaultSelected.to, locale, "date", inputFormat)
: "",
);
setDefaultSelected(_defaultSelected);
};
const setSelected = (range?: DateRange) => {
updateRange(range);
setFromInputValue(
range?.from
? formatDateForInput(range.from, locale, "date", inputFormat)
: "",
);
setToInputValue(
range?.to
? formatDateForInput(range?.to, locale, "date", inputFormat)
: "",
);
setValidation(initialValidation(range, opt));
};
const handleFocus = (e, src: RangeT) => {
if (e.target.readOnly) {
return;
}
const day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear,
);
if (isValidDate(day)) {
src === RANGE.FROM
? setFromInputValue(
formatDateForInput(day, locale, "date", inputFormat),
)
: setToInputValue(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 = (e, src: RangeT) => {
const day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear,
);
if (!isValidDate(day)) {
return;
}
if (src === RANGE.FROM) {
setFromInputValue(formatDateForInput(day, locale, "date", inputFormat));
} else if (src === RANGE.TO) {
setToInputValue(formatDateForInput(day, locale, "date", inputFormat));
}
};
const validateDay = (day: any) => {
return (
isValidDate(day) &&
!(disableWeekends && isWeekend(day)) &&
!(disabled && dateMatchModifiers(day, disabled))
);
};
const handleSelect = (range) => {
if (range?.from && range?.to) {
if (!isSameDay(range.from, range.to)) {
setOpen(false);
anchorRef?.focus();
}
}
setFromInputValue(
range?.from
? formatDateForInput(range.from, locale, "date", inputFormat)
: "",
);
setToInputValue(
range?.to
? formatDateForInput(range.to, locale, "date", inputFormat)
: "",
);
updateRange({ from: range?.from, to: range?.to });
updateValidation(
{ isValidDate: !!range?.from, isEmpty: !range?.from },
{ isValidDate: !!range?.to, isEmpty: !range?.to },
);
};
const fromChange = (
val: string = "",
day: Date,
isBefore = false,
isAfter = false,
) => {
setFromInputValue(val);
if (!validateDay(day)) {
updateRange({ ...selectedRange, from: undefined });
updateValidation(
{
isEmpty: !val,
isValidDate: false,
isInvalid: !isValidDate(day),
isWeekend: disableWeekends && isWeekend(day),
isDisabled: disabled && dateMatchModifiers(day, disabled),
isBefore,
isAfter,
},
validation.to,
);
return;
}
if (isBefore || isAfter) {
updateRange({ ...selectedRange, from: undefined });
updateValidation(
{
isValidDate: false,
isBefore,
isAfter,
},
validation.to,
);
return;
}
if (
selectedRange?.to &&
differenceInCalendarDays(day, selectedRange?.to) > 0
) {
updateRange({ to: day, from: day });
setToInputValue(formatDateForInput(day, locale, "date", inputFormat));
setMonth(day);
updateValidation();
return;
}
if (toInputValue && !selectedRange?.to) {
const toDay = parseDate(
toInputValue,
today,
locale,
"date",
allowTwoDigitYear,
);
if (validateDay(toDay)) {
updateRange({ from: day, to: toDay });
setMonth(day);
updateValidation();
return;
}
}
updateRange({ ...selectedRange, from: day });
updateValidation({}, validation.to);
setMonth(day);
};
const toChange = (
val: string = "",
day: Date,
isBefore = false,
isAfter = false,
) => {
setToInputValue(val);
if (!validateDay(day)) {
updateRange({ from: selectedRange?.from, to: undefined });
updateValidation(validation.from, {
isEmpty: !val,
isValidDate: false,
isInvalid: !isValidDate(day),
isWeekend: disableWeekends && isWeekend(day),
isDisabled: disabled && dateMatchModifiers(day, disabled),
isBefore,
isAfter,
});
return;
}
if (isBefore || isAfter) {
updateRange({ from: selectedRange?.from, to: undefined });
updateValidation(validation.from, {
isValidDate: false,
isBefore,
isAfter,
});
return;
}
/* If to-value < from-value */
if (
selectedRange?.from &&
differenceInCalendarDays(selectedRange?.from, day) > 0
) {
updateRange({ from: selectedRange?.from, to: undefined });
updateValidation(validation.from, {
isValidDate: false,
isBeforeFrom: true,
});
return;
}
updateRange({ from: selectedRange?.from, to: day });
updateValidation(validation.from, {});
setMonth(day);
};
/* live-update datepicker based on changes in inputfields */
const handleChange = (e, src: RangeT) => {
const day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear,
);
const isBefore = fromDate && differenceInCalendarDays(fromDate, day) > 0;
const isAfter = toDate && differenceInCalendarDays(day, toDate) > 0;
return src === RANGE.FROM
? fromChange(e.target.value, day, isBefore, isAfter)
: toChange(e.target.value, day, isBefore, isAfter);
};
const datepickerProps = {
month,
onMonthChange: setMonth,
onSelect: handleSelect,
selected: selectedRange,
locale: _locale,
fromDate,
toDate,
today,
mode: "range" as const,
open,
onOpenToggle: () => setOpen((x) => !x),
onClose: () => {
setOpen(false);
/* Delay focus to allow "open"-button to update title before focus */
queueMicrotask(() =>
focusElement(anchorRef, { sync: false, preventScroll: true }),
);
},
disabled,
disableWeekends,
};
const fromInputProps = {
onChange: (e) => handleChange(e, RANGE.FROM),
onFocus: (e) => handleFocus(e, RANGE.FROM),
onBlur: (e) => handleBlur(e, RANGE.FROM),
value: fromInputValue,
setAnchorRef,
};
const toInputProps = {
onChange: (e) => handleChange(e, RANGE.TO),
onFocus: (e) => handleFocus(e, RANGE.TO),
onBlur: (e) => handleBlur(e, RANGE.TO),
value: toInputValue,
setAnchorRef,
};
return {
datepickerProps,
fromInputProps,
toInputProps,
reset,
selectedRange,
setSelected,
};
};