import { isValidDate } from "@web3r/flowerkit/date"; import { bubble } from "@web3r/flowerkit/evt"; import { wait } from "@web3r/flowerkit/fn"; import { isWithinInterval } from "date-fns"; import type { ReactNode, FC } from "react"; import { memo, useCallback, useEffect, useState, useRef, useMemo, useImperativeHandle, } from "react"; import * as Picker from "react-datepicker"; import type { DatePicker as DatePickerInstance, ReactDatePickerCustomHeaderProps, DatePickerProps } from "react-datepicker"; import type { IComponentBaseProps } from "#package/types"; import { defaultLocales, ruLocale, todayMinus100Years, todayPlus30Years, } from "./config/constants"; import type { IDatePicker, TNativeDatepickerEvent, TStateActions, TValidDateSingleStamp, TValidDateUnionStamp, TValidDateRangeStamp, TDatePickerInputProps, TDatePickerCustomHeaderProps, TDatePickerCallbackArg, TDatePickerCustomHeaderSlotProps, TCustomMaskArg, IDatePickerRef, } from "./config/types"; import { DatePickerContext } from "./context"; import { getDateFormat, getDateInstance, getAsyncFn, getIsValidRange, getClearDate, getDateWithLocalTime, getStrFromStamp, getDefaultMask, } from "./lib"; import { DatePickerCustomHeader } from "./ui/DatePickerCustomHeader"; import { DatePickerCustomInput } from "./ui/DatePickerCustomInput"; import { constants } from "../../constants"; import { getElRefHelper } from "../../utils/react/getElRefHelper"; import { useCN } from "../../utils/react/useCN"; import { useListeners } from "../../utils/react/useListeners"; import { useSSGRender } from "../../utils/react/useSSGRender"; import type { IResetEventParams, IInputRef } from "../input"; import { Input } from "../input"; import "./style.pcss"; const PickerApp = Picker.default; const MemoHeader = memo((props: TDatePickerCustomHeaderProps) => ); MemoHeader.displayName = "DatePickerCustomHeader"; const MemoInput = memo((props: TDatePickerInputProps) => ); MemoInput.displayName = "DatePickerCustomInput"; /** * Компонент всплывающего календаря * @returns {ReactNode} */ const DatePicker: FC = (props): ReactNode => { const { baseClass = "datePicker", extraAttrs = {}, extraClasses = {}, inputExtraClasses = {}, onChange, onFocus, onReset, children, value, name, id, form, placeholder, min = todayMinus100Years, max = todayPlus30Years, isDisabled = false, isRequired = false, isTime = false, dateFormat = "dd.MM.yyyy", timeFormat = "HH:mm", getDateValidation = (value: TValidDateUnionStamp) => true, getTimeValidation = (value: TValidDateSingleStamp) => true, getDateInputValue = (value: TValidDateUnionStamp, isTime: boolean) => getStrFromStamp(value, isTime), timeIntervals = 15, isRange = false, locale = ruLocale, locales = defaultLocales, pickerCustomization = {}, onCtrlChange, slotHeader, isFastNav = false, iconPrev = "prev", iconNext = "next", onMount, onUnmount, getInputMask = (props: TCustomMaskArg) => getDefaultMask(props), isAutoFix = false, onUserInputChange, onValidate, ref, } = props || {}; const { getCN } = useCN(baseClass); // Состояние промежутка дат const [ isRanged, setIsRanged ] = useState(() => (isRange && getIsValidRange(value)) || getIsValidRange(value) || isRange && !value); // Состояние выбранной даты (может быть null) const [ date, setDate ] = useState(() => getIsValidRange(value) ? getDateInstance((value as [ Date, Date ])[0]) : getDateInstance(value)); // Конечная дата (может быть null) const [ endDate, setEndDate ] = useState(() => getIsValidRange(value) ? getDateInstance((value as [ Date, Date ])[1]) : null); // Если календарь со временем, то максимум и минимум без часов const [ minDate, setMinDate ] = useState(() => isTime ? getDateInstance(min) : getClearDate(getDateInstance(min))); const [ maxDate, setMaxDate ] = useState(() => isTime ? getDateInstance(max) : getClearDate(getDateInstance(max))); // Прочие состояния const [ currentLocale, setCurrentLocale ] = useState(locale); const [ isDisabledState, setIsDisabledState ] = useState(isDisabled); // Агрегатор состояний const stateActions = useMemo(() => ({ setInputDisabledState: setIsDisabledState, setValue: setDate, setEndDate, setMinDate, setMaxDate, setCurrentLocale, }), []); // Референсы const inputRef = useRef(null); const pickerRef = useRef(null); const containerRef = useRef(null); // Предыдущие значения const prevMinDate = useRef(minDate); const prevMaxDate = useRef(maxDate); const prevIsDisabled = useRef(isDisabledState); const prevLocale = useRef(locale); const prevIsRanged = useRef(isRanged); const originalEndDateValue = useRef(getIsValidRange(value) ? getDateInstance((value as [ Date, Date ])[1]) : null); const prevDate = useRef(date); const originalDateValue = useRef(getIsValidRange(value) ? getDateInstance((value as [ Date, Date ])[0]) : getDateInstance(value)); const prevEndDate = useRef(endDate); // Формат вывода const currentDateFormat = useMemo(() => getDateFormat(isTime, dateFormat, timeFormat), [ isTime, timeFormat, dateFormat ]); // Значение для поля const dateInputValue = useMemo(() => getDateInputValue(!!endDate ? [ date, endDate ] : date, isTime), [ date, endDate, isTime ]); const prevInputValue = useRef(dateInputValue); const setInputBlur = () => { const container = getElRefHelper(containerRef); const input = getElRefHelper(inputRef); if (container && input) { wait(200).finally(() => { bubble(input, "blur", { container, }); }); } }; /** * Callback для ручного ввода поля */ const handleUserInputChange = useCallback((event?: TNativeDatepickerEvent) => { if (event) { if (typeof onUserInputChange === "function") { onUserInputChange({ event, stateActions, data: { name, value: date, dateInputValue, }, ref: pickerRef, }); } else { event.preventDefault(); } } }, [ onUserInputChange, dateInputValue, date, name ]); const handleChange = useCallback(async (value: TValidDateUnionStamp, e?: TNativeDatepickerEvent) => { // Проверка на предыдущую дату const getComparedDate = (val: TValidDateUnionStamp) => { if (isRanged) { // range return val; } else { if (isValidDate(val)) { return isTime ? (val as Date).getTime() : getClearDate(new Date(val as Date)).getTime(); } else { return val; } } }; const oldDate = getComparedDate(prevDate.current); const newDate = getComparedDate(value); if (oldDate !== newDate) { // Штамп с локальным временем пользователя const stamp = isValidDate(value) && !isTime ? getDateWithLocalTime(value as Date) : value; if (typeof onChange === "function") { const event = new CustomEvent("change", { bubbles: true, cancelable: true, detail: { type: "date" as const, nativeEvent: e, value: stamp, name, target: getElRefHelper(inputRef) as HTMLInputElement, }, }); onChange({ event, data: { name, value: stamp, dateInputValue, }, ref: pickerRef, stateActions, }); } else { const fn = (value: TValidDateSingleStamp) => getAsyncFn(() => getDateValidation(value)); if (isRanged) { const [ start, end ] = stamp as TValidDateRangeStamp; const isStartDateValid = await fn(start); const isEndDateValid = await fn(end); if (isStartDateValid && isEndDateValid) { setDate(start); setEndDate(end); } else { setDate(null); setEndDate(null); } } else { const isValueValid = await fn(stamp as TValidDateSingleStamp); if (isValueValid) { setDate(stamp as TValidDateSingleStamp); } else { setDate(null); } } } } setInputBlur(); }, [ onChange, getDateValidation, isRanged, isTime ]); const handleFocus = useCallback((event?: TNativeDatepickerEvent) => { if (typeof onFocus === "function" && event) { onFocus({ event, data: { name, value: date, dateInputValue, }, ref: pickerRef, stateActions, }); } }, [ onFocus, name, date, dateInputValue ]); const handleLocaleChange = (e: CustomEvent<{ lang: string; }>) => { const lang = e.detail.lang; setCurrentLocale(lang); }; const handleReset = useCallback((event: IResetEventParams) => { if (typeof onReset === "function") { onReset({ event: event.event, data: { name, value: date, dateInputValue, }, ref: pickerRef, stateActions, }); } else { setDate(originalDateValue.current); setEndDate(originalEndDateValue.current); if (pickerRef.current) { pickerRef.current.setOpen(false); } } }, [ onReset, date, dateInputValue ]); const handleValidate = useCallback((e: CustomEvent<{ isValid: boolean; }>) => { if (typeof onValidate === "function") { onValidate({ event: e, stateActions, }); } else { containerRef?.current?.classList.toggle(constants.stateClasses.invalid, !e.detail.isValid); } }, [ onValidate ]); useEffect(() => { const isRanged = getIsValidRange(value); const newDate = isRanged ? getDateInstance((value as TValidDateRangeStamp)[0]) : getDateInstance(value); const newEndDate = isRanged ? getDateInstance((value as TValidDateRangeStamp)[1]) : null; const newMinDate = getDateInstance(min); const newMaxDate = getDateInstance(max); if (newDate !== prevDate.current) { setDate(newDate); } const processedMinDate = isTime ? newMinDate : getClearDate(newMinDate); if (processedMinDate !== prevMinDate.current) { setMinDate(processedMinDate); prevMinDate.current = processedMinDate; } const processedMaxDate = isTime ? newMaxDate : getClearDate(newMaxDate); if (processedMaxDate !== prevMaxDate.current) { setMaxDate(processedMaxDate); prevMaxDate.current = processedMaxDate; } if (isDisabled !== prevIsDisabled.current) { setIsDisabledState(isDisabled); prevIsDisabled.current = isDisabled; } if (locale !== prevLocale.current) { setCurrentLocale(locale); prevLocale.current = locale; } const newIsRanged = isRanged || isRange; if (newIsRanged !== prevIsRanged.current) { setIsRanged(newIsRanged); prevIsRanged.current = newIsRanged; } if (newEndDate !== prevEndDate.current) { setEndDate(newEndDate); prevEndDate.current = newEndDate; } }, [ min, max, value, isDisabled, locale, isRange, isTime ]); useEffect(() => { // add locales (locales || []).forEach(({ name, data }) => { if (name && data) { Picker.registerLocale(name, data); } else { console.error(`[DatePicker] Missing prop "name" or "data" for locale object`); } }); if (typeof onMount === "function") { onMount(); } return () => { if (typeof onUnmount === "function") { onUnmount(); } }; }, []); useEffect(() => { if (dateInputValue !== prevInputValue.current) { const inputNode = getElRefHelper(inputRef); if (inputNode) { const setBubble = () => { bubble(inputNode, "change", { data: { name, value: date, endDate, dateInputValue, }, ref: pickerRef, stateActions, }); }; if (isRanged) { if (endDate) { setBubble(); } } else { setBubble(); } } prevInputValue.current = dateInputValue; } }, [ dateInputValue ]); useEffect(() => { if (date !== prevDate.current) { prevDate.current = date; } }, [ date ]); useListeners({ listeners: { [constants.localisationBubbles.localisationChange]: handleLocaleChange, }, }); useImperativeHandle(ref, (): IDatePickerRef => ({ el: containerRef.current, ref: pickerRef, utils: null, getData: () => ({ rawValue: isRanged ? [ date, endDate ] : date, value: dateInputValue, }), stateActions, }), [ isRanged, date, endDate, dateInputValue ]); const isDateTimeInRange = (currentDate: Date) => { switch (true) { case typeof getTimeValidation === "function": return getTimeValidation(currentDate); case !!(minDate && maxDate): return isWithinInterval(currentDate, { start: minDate, end: maxDate }); default: return true; } }; const renderCustomHeader = useCallback((props: ReactDatePickerCustomHeaderProps) => , [ iconPrev, iconNext, isFastNav, isTime, slotHeader, onCtrlChange, maxDate, minDate, timeFormat, dateFormat, name, dateInputValue ]); const requiredValue = useMemo(() => { if (typeof isRequired === "boolean" && isRequired) { return isRanged ? "dateRange" : "date"; } else { return isRequired; } }, [ isRequired, isRanged ]); const classNames = { isRange: isRanged, isTime, isRequired, ...extraClasses, }; const extraInputAttrs = { "data-js-component-input": "datePicker", "data-js-input-validation-bubble": "true", ...isRanged && { "data-js-datepicker-from": getDateInputValue(date, isTime), "data-js-datepicker-to": getDateInputValue(endDate, isTime), }, }; const context = useMemo(() => ({ getCN, }), [ getCN ]); const safePickerCustomization = pickerCustomization as Omit; const SSGComponent = useSSGRender("data-js-datepicker", props); if (SSGComponent) { return SSGComponent; } return ( {/* @ts-expect-error (some dynamic props) */} {children} )} locale={currentLocale} selected={date} onChange={handleChange} onFocus={handleFocus} startDate={date} endDate={endDate} selectsRange={isRanged} renderCustomHeader={renderCustomHeader} ref={pickerRef} {...safePickerCustomization} /> ); }; export type { IDatePicker, IDatePickerRef, TCustomMaskArg, TDatePickerCallbackArg, TDatePickerCustomHeaderSlotProps, TValidDateRangeStamp, TValidDateSingleStamp, TValidDateUnionStamp, }; export { DatePicker };