'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 { type UseFocusTrapProps } from '../../hooks/useFocusTrap'; import { dateFormatter, isMatch, parse } from '../../lib/date'; import type { PlacementWithAuto } from '../../lib/floating'; 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 sizeYClassNames = { none: dateInputStyles.sizeYNone, compact: dateInputStyles.sizeYCompact, }; type DateTestsProps = { /** * Передает атрибут `data-testid` для поля ввода дня. */ day?: string; /** * Передает атрибут `data-testid` для поля ввода месяца. */ month?: string; /** * Передает атрибут `data-testid` для поля ввода года. */ year?: string; }; export type DateRangeInputTestsProps = { /** * Передает атрибуты `data-testid` для полей ввода начальной даты. */ startDateTestsProps?: DateTestsProps; /** * Передает атрибуты `data-testid` для полей ввода конечной даты. */ endDateTestsProps?: DateTestsProps; /** * Передает атрибут `data-testid` для кнопки показа календаря. */ showCalendarButtonTestId?: string; /** * Передает атрибут `data-testid` для кнопки очистки даты. */ clearButtonTestId?: string; }; export interface DateRangeInputProps extends Omit, 'value' | 'defaultValue' | 'onChange'>, Pick< CalendarRangeProps, | 'disablePast' | 'disableFuture' | 'shouldDisableDate' | 'onChange' | 'value' | 'defaultValue' | 'weekStartsOn' | 'disablePickers' | 'prevMonthLabel' | 'nextMonthLabel' | 'changeMonthLabel' | 'changeYearLabel' | 'changeDayLabel' | 'prevMonthIcon' | 'nextMonthIcon' | 'renderDayContent' >, Pick, HasRootRef, Omit, DateRangeInputTestsProps { /** * Передает атрибуты `data-testid` для интерактивных элементов в календаре. */ calendarTestsProps?: CalendarRangeTestsProps; /** * Расположение календаря относительно поля ввода. */ calendarPlacement?: PlacementWithAuto; /** * Автоматически закрывать календарь при изменениях. */ closeOnChange?: boolean; /** * Обработчик изменения состояния открытия календаря. */ onCalendarOpenChanged?: (opened: boolean) => void; /** * Label для календаря. */ calendarLabel?: string; /** * Label для кнопки очистки. Делает доступным для ассистивных технологий. */ clearFieldLabel?: string; /** * Label для кнопки открытия календаря. Делает доступным для ассистивных технологий. */ showCalendarLabel?: string; /** * Label для ввода дня начальной даты. Делает доступным для ассистивных технологий. */ changeStartDayLabel?: string; /** * Label для ввода месяца начальной даты. Делает доступным для ассистивных технологий. */ changeStartMonthLabel?: string; /** * Label для ввода года начальной даты. Делает доступным для ассистивных технологий. */ changeStartYearLabel?: string; /** * Label для ввода дня конечной даты. Делает доступным для ассистивных технологий. */ changeEndDayLabel?: string; /** * Label для ввода месяца конечной даты. Делает доступным для ассистивных технологий. */ changeEndMonthLabel?: string; /** * Label для ввода года конечной даты. Делает доступным для ассистивных технологий. */ changeEndYearLabel?: string; /** * Отключение открытия календаря. */ disableCalendar?: boolean; /** * Позволяет отключить захват фокуса при появлении календаря. */ disableFocusTrap?: UseFocusTrapProps['disabled']; /** * Включает режим в котором DateRangeInput доступен * для ассистивных технологий. * В этом режиме: * - календарь больше не открывает при фокусе на DateRangeInput; * - иконка календаря видна всегда, чтобы пользователи * ассистивных технологий могли открыть календарь по клику на иконку; * - календарь при открытии получает фокус, клавиатурный * фокус зациклен и не выходит за пределы календаря пока календарь не закрыт. */ accessible?: boolean; // TODO [>=8]: включить по умолчанию. } 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; }; const CALENDAR_MUTATION_OBSERVER_OPTIONS: MutationObserverInit = { childList: true, subtree: true, attributes: true, attributeFilter: ['tabindex'], }; /** * @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, 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 disableCalendar = readOnly ? true : disableCalendarProp; const _onChange = React.useCallback( (newValue: DateRangeType | null | undefined) => onChange?.(newValue || undefined), [onChange], ); const [value, updateValue] = useCustomEnsuredControl({ value: valueProp, defaultValue, onChange: _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(undefined), [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 { sizeY = 'none' } = useAdaptivity(); const handleRootRef = useExternRef(rootRef, getRootRef); const onCalendarChange = React.useCallback( (newValue: DateRangeType | undefined) => { 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 [ new Intl.DateTimeFormat(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }).format(startDate), new Intl.DateTimeFormat(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }).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; return ( {showCalendarButton ? ( ) : null} {showClearButton ? ( ) : null} } disabled={disabled} {...props} >
{ariaLabel && {ariaLabel}} {currentDateLabel && ( {currentDateLabel} )} . . {' — '} . .
{open && !disableCalendar && ( )}
); };