'use client'; import * as React from 'react'; import { Icon16Clear, Icon20CalendarOutline } from '@vkontakte/icons'; import { classNames } from '@vkontakte/vkjs'; import { useAdaptivity } from '../../hooks/useAdaptivity'; import { useDateInput } from '../../hooks/useDateInput'; import { useCustomEnsuredControl } from '../../hooks/useEnsuredControl'; import { useExternRef } from '../../hooks/useExternRef'; import { useGlobalEscKeyDown } from '../../hooks/useGlobalEscKeyDown'; import { dateFormatter, isMatch, parse } from '../../lib/date'; import type { PlacementWithAuto } from '../../lib/floating'; import { cacheDateTimeFormat } from '../../lib/intlCache'; import type { HasRootRef } from '../../types'; import { CalendarRange, type CalendarRangeProps, type CalendarRangeTestsProps, type DateRangeType, } from '../CalendarRange/CalendarRange'; import { useConfigProvider } from '../ConfigProvider/ConfigProviderContext'; import { FocusTrap } from '../FocusTrap/FocusTrap'; import { FormField, type FormFieldProps } from '../FormField/FormField'; import { IconButton } from '../IconButton/IconButton'; import { InputLikeDivider } from '../InputLike/InputLikeDivider'; import { NumberInputLike } from '../NumberInputLike/NumberInputLike'; import { Popper } from '../Popper/Popper'; import { Text } from '../Typography/Text/Text'; import { VisuallyHidden } from '../VisuallyHidden/VisuallyHidden'; import dateInputStyles from '../DateInput/DateInput.module.css'; const labelDateTimeFormatOptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', } as const; const labelDateTimeFormat = /*#__PURE__*/ cacheDateTimeFormat(); const densityClassNames = { none: dateInputStyles.densityNone, compact: dateInputStyles.densityCompact, }; type DateTestsProps = { /** * Передает атрибут `data-testid` для поля ввода дня. */ day?: string | undefined; /** * Передает атрибут `data-testid` для поля ввода месяца. */ month?: string | undefined; /** * Передает атрибут `data-testid` для поля ввода года. */ year?: string | undefined; }; export type DateRangeInputTestsProps = { /** * Передает атрибуты `data-testid` для полей ввода начальной даты. */ startDateTestsProps?: DateTestsProps | undefined; /** * Передает атрибуты `data-testid` для полей ввода конечной даты. */ endDateTestsProps?: DateTestsProps | undefined; /** * Передает атрибут `data-testid` для кнопки показа календаря. */ showCalendarButtonTestId?: string | undefined; /** * Передает атрибут `data-testid` для кнопки очистки даты. */ clearButtonTestId?: string | undefined; }; export interface DateRangeInputProps extends Omit, 'value' | 'defaultValue' | 'onChange'>, Pick< CalendarRangeProps, | 'disablePast' | 'disableFuture' | 'shouldDisableDate' | 'value' | 'defaultValue' | 'weekStartsOn' | 'disablePickers' | 'prevMonthLabel' | 'nextMonthLabel' | 'changeMonthLabel' | 'changeYearLabel' | 'changeDayLabel' | 'prevMonthIcon' | 'nextMonthIcon' | 'renderDayContent' >, HasRootRef, Omit, DateRangeInputTestsProps { /** * Обработчик изменения выбранного промежутка. */ onChange?: ((value: DateRangeType | null) => void) | undefined; /** * Передает атрибуты `data-testid` для интерактивных элементов в календаре. */ calendarTestsProps?: CalendarRangeTestsProps | undefined; /** * Расположение календаря относительно поля ввода. */ calendarPlacement?: PlacementWithAuto | undefined; /** * Автоматически закрывать календарь при изменениях. */ closeOnChange?: boolean | undefined; /** * Обработчик изменения состояния открытия календаря. */ onCalendarOpenChanged?: ((opened: boolean) => void) | undefined; /** * Label для календаря. */ calendarLabel?: string | undefined; /** * Label для кнопки очистки. Делает доступным для ассистивных технологий. */ clearFieldLabel?: string | undefined; /** * Label для кнопки открытия календаря. Делает доступным для ассистивных технологий. */ showCalendarLabel?: string | undefined; /** * Label для ввода дня начальной даты. Делает доступным для ассистивных технологий. */ changeStartDayLabel?: string | undefined; /** * Label для ввода месяца начальной даты. Делает доступным для ассистивных технологий. */ changeStartMonthLabel?: string | undefined; /** * Label для ввода года начальной даты. Делает доступным для ассистивных технологий. */ changeStartYearLabel?: string | undefined; /** * Label для ввода дня конечной даты. Делает доступным для ассистивных технологий. */ changeEndDayLabel?: string | undefined; /** * Label для ввода месяца конечной даты. Делает доступным для ассистивных технологий. */ changeEndMonthLabel?: string | undefined; /** * Label для ввода года конечной даты. Делает доступным для ассистивных технологий. */ changeEndYearLabel?: string | undefined; /** * Отключение открытия календаря. */ disableCalendar?: boolean | undefined; /** * Позволяет отключить захват фокуса при появлении календаря. */ disableFocusTrap?: boolean | undefined; /** * Управление поведением возврата фокуса при закрытии всплывающего окна. * @default true */ restoreFocus?: boolean | (() => boolean | HTMLElement) | undefined; /** * @deprecated Since 8.0.0. Будет удалено в 9.0.0. * * Включает режим в котором DateRangeInput доступен * для ассистивных технологий. * В этом режиме: * - календарь больше не открывает при фокусе на DateRangeInput; * - иконка календаря видна всегда, чтобы пользователи * ассистивных технологий могли открыть календарь по клику на иконку; * - календарь при открытии получает фокус, клавиатурный * фокус зациклен и не выходит за пределы календаря пока календарь не закрыт. */ accessible?: boolean /* TODO [>=v9] удалить свойство */ | undefined; } const elementsConfig = (index: number) => { let length = 2; let min = 1; let max = 0; switch (index) { case 0: case 3: max = 31; break; case 1: case 4: max = 12; break; case 2: case 5: max = 2100; min = 1900; length = 4; break; } return { length, min, max }; }; const getInternalValue = (value: CalendarRangeProps['value']) => { const newValue = ['', '', '', '', '', '']; if (value?.[0]) { newValue[0] = String(value[0].getDate()).padStart(2, '0'); newValue[1] = String(value[0].getMonth() + 1).padStart(2, '0'); newValue[2] = String(value[0].getFullYear()).padStart(4, '0'); } if (value?.[1]) { newValue[3] = String(value[1].getDate()).padStart(2, '0'); newValue[4] = String(value[1].getMonth() + 1).padStart(2, '0'); newValue[5] = String(value[1].getFullYear()).padStart(4, '0'); } return newValue; }; /** * @see https://vkui.io/components/date-range-input */ export const DateRangeInput = ({ shouldDisableDate, disableFuture, disablePast, 'value': valueProp, defaultValue, onChange, 'calendarPlacement': calendarPlacementProp = 'bottom-start', style, className, closeOnChange = true, disablePickers, getRootRef, name, autoFocus, disabled, disableFocusTrap, restoreFocus, calendarLabel = 'Календарь', prevMonthLabel = 'Предыдущий месяц', nextMonthLabel = 'Следующий месяц', changeMonthLabel = 'Месяц', changeYearLabel = 'Год', changeStartDayLabel = 'День начала', changeStartMonthLabel = 'Месяц начала', changeStartYearLabel = 'Год начала', changeEndDayLabel = 'День окончания', changeEndMonthLabel = 'Месяц окончания', changeEndYearLabel = 'Год окончания', clearFieldLabel = 'Очистить поле', showCalendarLabel = 'Показать календарь', 'aria-label': ariaLabel = '', prevMonthIcon, nextMonthIcon, onCalendarOpenChanged, renderDayContent, calendarTestsProps, startDateTestsProps, endDateTestsProps, clearButtonTestId, showCalendarButtonTestId, id, accessible = true, readOnly, 'disableCalendar': disableCalendarProp = false, before, ...props }: DateRangeInputProps): React.ReactNode => { const daysStartRef = React.useRef(null); const monthsStartRef = React.useRef(null); const yearsStartRef = React.useRef(null); const daysEndRef = React.useRef(null); const monthsEndRef = React.useRef(null); const yearsEndRef = React.useRef(null); const focusTrapRootRef = React.useRef(null); const disableCalendar = readOnly ? true : disableCalendarProp; const [value, updateValue] = useCustomEnsuredControl({ value: valueProp, defaultValue: defaultValue as DateRangeType | null, onChange, }); const onInternalValueChange = React.useCallback( (internalValue: string[]) => { let isStartValid = true; let isEndValid = true; for (let i = 0; i <= 2; i += 1) { if (internalValue[i].length < elementsConfig(i).length) { isStartValid = false; } } for (let i = 3; i <= 5; i += 1) { if (internalValue[i].length < elementsConfig(i).length) { isEndValid = false; } } const formattedStartValue = `${internalValue[0]}.${internalValue[1]}.${internalValue[2]}`; const formattedEndValue = `${internalValue[3]}.${internalValue[4]}.${internalValue[5]}`; const mask = 'dd.MM.yyyy'; if (!isMatch(formattedStartValue, mask)) { isStartValid = false; } if (!isMatch(formattedEndValue, mask)) { isEndValid = false; } if (!isStartValid && !isEndValid) { return; } const valueExists = Array.isArray(value); const now = new Date(); const start = isStartValid ? parse(formattedStartValue, mask, (valueExists && value?.[0]) || now) : null; const end = isEndValid ? parse(formattedEndValue, mask, (valueExists && value?.[1]) || now) : null; if (start && end && end > start) { updateValue([start, end]); } }, [updateValue, value], ); const refs = React.useMemo( () => [daysStartRef, monthsStartRef, yearsStartRef, daysEndRef, monthsEndRef, yearsEndRef], [daysStartRef, monthsStartRef, yearsStartRef, daysEndRef, monthsEndRef, yearsEndRef], ); const onClear = React.useCallback(() => updateValue(null), [updateValue]); const { rootRef, calendarRef, open, openCalendar, closeCalendar, toggleCalendar, internalValue, handleKeyDown, setFocusedElement, handleFieldEnter, clear, removeFocusFromField, } = useDateInput({ maxElement: 5, refs, autoFocus, disabled: disabled || readOnly, elementsConfig, onClear, onInternalValueChange, getInternalValue, value, onCalendarOpenChanged, accessible, }); const { density = 'none' } = useAdaptivity(); const handleRootRef = useExternRef(rootRef, getRootRef); const onCalendarChange = React.useCallback( (newValue: DateRangeType) => { updateValue(newValue); if (closeOnChange && newValue?.[1] && newValue[1] !== value?.[1]) { removeFocusFromField(); } }, [updateValue, closeOnChange, value, removeFocusFromField], ); // при переключении месяцев высота календаря может меняться, // чтобы календарь не прыгал при переключении месяцев каждый раз на // лучшую позицию мы запоминаем последнюю удачную, чтобы календарь оставался // на ней, пока помещается. const [calendarPlacement, setCalendarPlacement] = React.useState(calendarPlacementProp); const { locale } = useConfigProvider(); const currentDateLabel = React.useMemo(() => { if (!value) { return null; } const [startDate, endDate] = value; if (!startDate || !endDate) { return null; } return [ labelDateTimeFormat(locale, labelDateTimeFormatOptions).format(startDate), labelDateTimeFormat(locale, labelDateTimeFormatOptions).format(endDate), ].join(' - '); }, [locale, value]); const currentDateLabelId = React.useId(); const ariaLabelId = React.useId(); const showCalendarOnInputAreaClick = React.useCallback(() => { handleFieldEnter(); if (accessible) { openCalendar(); } }, [handleFieldEnter, openCalendar, accessible]); const showCalendarButton = !disableCalendar && (accessible || (!accessible && !value)); const showClearButton = value && !readOnly; useGlobalEscKeyDown(open && !disableCalendar, closeCalendar, { capture: false, }); return ( {showCalendarButton ? ( ) : null} {showClearButton ? ( ) : null} } disabled={disabled} {...props} >
{ariaLabel && {ariaLabel}} {currentDateLabel && ( {currentDateLabel} )} . . {' — '} . .
{open && !disableCalendar && (
)}
); };