import React, { useCallback, useMemo, 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 { formatDateForInput, isValidDate, parseDate } from "../../date-utils"; import { MonthPickerProps } from "../MonthPicker.types"; export interface UseMonthPickerOptions extends Pick< MonthPickerProps, "locale" | "fromDate" | "toDate" | "disabled" | "defaultSelected" > { /** * Make Date-selection required */ required?: boolean; /** * Callback for month-change */ onMonthChange?: (date?: Date) => void; /** * Input-format * @default "MMMM yyyy" */ inputFormat?: string; /** * validation-callback */ onValidate?: (val: MonthValidationT) => void; /** * Default shown year */ defaultYear?: Date; /** * Allows input of with `yy` year format. * * Decision between 20th and 21st century is based on before(todays year - 80) ? 21st : 20th. * In 2024 this equals to 1944 - 2043 * @default true */ allowTwoDigitYear?: boolean; } interface UseMonthPickerValue { /** * Use: */ monthpickerProps: MonthPickerProps; /** * Use: */ inputProps: Pick & { /** * @private */ setAnchorRef: React.Dispatch< React.SetStateAction >; }; /** * Currently selected Date * Up to user to validate value and extract month */ selectedMonth?: Date; /** * Manually set selected month if needed */ setSelected: (date?: Date) => void; /** * Resets all states */ reset: () => void; } export type MonthValidationT = { /** * 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`. */ isValidMonth: boolean; /** Whether the month is a disabled month */ isDisabled: boolean; /** Whether the input field is empty */ isEmpty: boolean; /** Whether the entered value cannot be parsed as a month (i.e. wrong format) */ isInvalid: boolean; /** Whether the month is before `fromDate` */ isBefore: boolean; /** Whether the month is after `toDate` */ isAfter: boolean; }; const getValidationMessage = (val = {}): MonthValidationT => ({ isDisabled: false, isEmpty: false, isInvalid: false, isBefore: false, isAfter: false, isValidMonth: true, ...val, }); const getIsBefore = (opt: { fromDate?: Date; date?: Date }) => opt.fromDate && opt.date && (opt.fromDate.getFullYear() > opt.date.getFullYear() || (opt.fromDate.getFullYear() === opt.date.getFullYear() && opt.fromDate.getMonth() > opt.date.getMonth())); const getIsAfter = (opt: { toDate?: Date; date?: Date }) => opt.toDate && opt.date && (opt.toDate.getFullYear() < opt.date.getFullYear() || (opt.toDate.getFullYear() === opt.date.getFullYear() && opt.toDate.getMonth() < opt.date.getMonth())); /** * * @see 🏷️ {@link UseMonthPickerOptions} * @see 🏷️ {@link UseMonthPickerValue} * @see 🏷️ {@link MonthValidationT} * @example * const { monthpickerProps, inputProps } = useMonthpicker({ * fromDate: new Date("Aug 23 2019"), * toDate: new Date("Feb 23 2024"), * onMonthChange: console.log, * onValidate: console.log, * }); */ export const useMonthpicker = ( opt: UseMonthPickerOptions = {}, ): UseMonthPickerValue => { const { locale: _locale, defaultSelected: _defaultSelected, fromDate, toDate, disabled, required, onMonthChange, inputFormat, onValidate, defaultYear, allowTwoDigitYear = true, } = opt; const [anchorRef, setAnchorRef] = useState(null); const [defaultSelected, setDefaultSelected] = useState(_defaultSelected); const today = useMemo(() => new Date(), []); const localeFromProvider = useDateLocale(); const locale = _locale ? getLocaleFromString(_locale) : localeFromProvider; // Initialize states const [year, setYear] = useState(defaultSelected ?? defaultYear ?? today); const [selectedMonth, setSelectedMonth] = useState(defaultSelected); const [open, setOpen] = useState(false); const defaultInputValue = defaultSelected ? formatDateForInput(defaultSelected, locale, "month", inputFormat) : ""; const [inputValue, setInputValue] = useState(defaultInputValue); const handleOpen = useCallback( (newOpen: boolean) => { setOpen(newOpen); newOpen && setYear(selectedMonth ?? defaultSelected ?? defaultYear ?? today); }, [defaultSelected, defaultYear, selectedMonth, today], ); const updateMonth = (date?: Date) => { onMonthChange?.(date); setSelectedMonth(date); }; const updateValidation = (val: Partial = {}) => onValidate?.(getValidationMessage(val)); const reset = () => { updateMonth(defaultSelected); setYear(defaultSelected ?? defaultYear ?? today); setInputValue(defaultInputValue ?? ""); setDefaultSelected(_defaultSelected); }; const setSelected = (date: Date | undefined) => { updateMonth(date); setYear(date ?? defaultYear ?? today); setInputValue( date ? formatDateForInput(date, locale, "month", inputFormat) : "", ); }; const handleFocus: React.FocusEventHandler = (e) => { if (e.target.readOnly) { return; } const day = parseDate( e.target.value, today, locale, "month", allowTwoDigitYear, ); const isBefore = getIsBefore({ fromDate, date: day }); const isAfter = getIsAfter({ toDate, date: day }); if (isValidDate(day)) { !isBefore && !isAfter && setYear(day); setInputValue(formatDateForInput(day, locale, "month", inputFormat)); } else { setYear(defaultSelected ?? defaultYear ?? today); } }; const handleBlur: React.FocusEventHandler = (e) => { const day = parseDate( e.target.value, today, locale, "month", allowTwoDigitYear, ); isValidDate(day) && setInputValue(formatDateForInput(day, locale, "month", inputFormat)); }; /* Only allow de-selecting if not required */ const handleMonthClick = (month?: Date) => { if (!month && required) { return; } if (month) { handleOpen(false); setYear(month); anchorRef?.focus(); } if (!month) { updateMonth(undefined); updateValidation({ isValidMonth: false, isEmpty: true }); setInputValue(""); setYear(defaultYear ?? today); return; } updateMonth(month); updateValidation(); setInputValue( month ? formatDateForInput(month, locale, "month", 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 month = parseDate( e.target.value, today, locale, "month", allowTwoDigitYear, ); const isBefore = getIsBefore({ fromDate, date: month }); const isAfter = getIsAfter({ toDate, date: month }); if ( !isValidDate(month) || (disabled && dateMatchModifiers(month, disabled)) ) { updateMonth(undefined); updateValidation({ isInvalid: isValidDate(month), isDisabled: disabled && dateMatchModifiers(month, disabled), isValidMonth: false, isEmpty: !e.target.value, isBefore: isBefore ?? false, isAfter: isAfter ?? false, }); return; } if ( isAfter || isBefore || (fromDate && toDate && !dateMatchModifiers(month, [{ from: fromDate, to: toDate }])) ) { updateMonth(undefined); updateValidation({ isValidMonth: false, isBefore: isBefore ?? false, isAfter: isAfter ?? false, }); return; } updateMonth(month); updateValidation(); setYear(month); }; const monthpickerProps = { year, onYearChange: (y?: Date) => setYear(y ?? today), onMonthSelect: handleMonthClick, selected: selectedMonth, locale: _locale, fromDate, toDate, open, onOpenToggle: () => handleOpen(!open), onClose: () => { 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 }); }, disabled, }; const inputProps = { onChange: handleChange, onFocus: handleFocus, onBlur: handleBlur, value: inputValue, setAnchorRef, }; return { monthpickerProps, inputProps, reset, selectedMonth, setSelected }; };