'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 { useExternRef } from '../../hooks/useExternRef'; import { useGlobalEscKeyDown } from '../../hooks/useGlobalEscKeyDown'; import { convertDateToTimeZone, createDateInTimeZone, dateFormatter, dateTimeFormatter, isMatch, parse, startOfDay, startOfMinute, } from '../../lib/date'; import type { PlacementWithAuto } from '../../lib/floating'; import { cacheDateTimeFormat } from '../../lib/intlCache'; import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect'; import type { HasRootRef } from '../../types'; import { Calendar, type CalendarProps, type CalendarTestsProps } from '../Calendar/Calendar'; 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 { useDateInputValue } from './hooks'; import '../InputLike/InputLike.module.css'; // Reorder css import styles from './DateInput.module.css'; const labelDateTimeFormatOptions = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', } as const; const labelDateTimeFormat = /*#__PURE__*/ cacheDateTimeFormat(); const densityClassNames = { none: styles.densityNone, compact: styles.densityCompact, }; export type DateInputPropsTestsProps = { /** * Передает атрибут `data-testid` для поля ввода дня. */ dayFieldTestId?: string | undefined; /** * Передает атрибут `data-testid` для поля ввода месяца. */ monthFieldTestId?: string | undefined; /** * Передает атрибут `data-testid` для поля ввода года. */ yearFieldTestId?: string | undefined; /** * Передает атрибут `data-testid` для поля ввода часа. */ hourFieldTestId?: string | undefined; /** * Передает атрибут `data-testid` для поля ввода минут. */ minuteFieldTestId?: string | undefined; /** * Передает атрибут `data-testid` для кнопки показа календаря. */ showCalendarButtonTestId?: string | undefined; /** * Передает атрибут `data-testid` для кнопки очистки даты. */ clearButtonTestId?: string | undefined; }; export interface DateInputProps extends Omit< React.InputHTMLAttributes, 'value' | 'defaultValue' | 'onChange' | 'size' >, Pick< CalendarProps, | 'disablePast' | 'disableFuture' | 'enableTime' | 'shouldDisableDate' | 'value' | 'defaultValue' | 'doneButtonText' | 'DoneButton' | 'weekStartsOn' | 'disablePickers' | 'changeHoursLabel' | 'changeMinutesLabel' | 'prevMonthLabel' | 'nextMonthLabel' | 'changeMonthLabel' | 'changeYearLabel' | 'showNeighboringMonth' | 'size' | 'viewDate' | 'onHeaderChange' | 'onNextMonth' | 'onPrevMonth' | 'prevMonthIcon' | 'nextMonthIcon' | 'minDateTime' | 'maxDateTime' | 'renderDayContent' >, HasRootRef, Omit, DateInputPropsTestsProps { /** * Обработчик изменения выбранной даты. */ onChange?: ((value: Date | null) => void) | undefined; /** * Передает атрибуты `data-testid` для интерактивных элементов в календаре. */ calendarTestsProps?: CalendarTestsProps | undefined; /** * Расположение календаря относительно поля ввода. */ calendarPlacement?: PlacementWithAuto | undefined; /** * Автоматически закрывать календарь при изменениях. */ closeOnChange?: boolean | undefined; /** * `aria-label` для календаря. */ calendarLabel?: string | undefined; /** * Label для кнопки очистки. Делает доступным для ассистивных технологий. */ clearFieldLabel?: string | undefined; /** * Label для кнопки открытия календаря. Делает доступным для ассистивных технологий. */ showCalendarLabel?: string | undefined; /** * Отключение открытия календаря. */ disableCalendar?: boolean | undefined; /** * Обработчик изменения состояния открытия календаря. */ onCalendarOpenChanged?: ((opened: boolean) => void) | undefined; /** * `aria-label` для поля изменения дня. */ changeDayLabel?: string | undefined; /** * Обработчик нажатия на кнопку `"Done"`. Используется совместно с флагом `enableTime`. */ onApply?: ((value?: Date) => void) | undefined; /** * Функция для кастомного форматирования отображаемого значения даты. * Позволяет переопределить стандартное отображение даты и вернуть собственное представление. */ renderCustomValue?: ((date: Date | undefined) => React.ReactNode) | undefined; /** * Часовой пояс для отображения даты. */ timezone?: string | undefined; /** * @deprecated Since 8.0.0. Будет удалено в 9.0.0. * * Включает режим в котором DateInput доступен * для ассистивных технологий. * В этом режиме: * - календарь больше не открывает при фокусе на DateInput; * - иконка календаря видна всегда, чтобы пользователи * ассистивных технологий могли открыть календарь по клику на иконку; * - календарь при открытии получает фокус, клавиатурный * фокус зациклен и не выходит за пределы календаря пока календарь не закрыт. */ accessible?: boolean | undefined /* TODO [>=v9] удалить свойство */; /** * Позволяет отключить захват фокуса при появлении календаря. */ disableFocusTrap?: boolean | undefined; /** * Управление поведением возврата фокуса при закрытии всплывающего окна. * @default true */ restoreFocus?: boolean | (() => boolean | HTMLElement) | undefined; } const elementsConfig = (index: number) => { let length = 2; let min = 1; let max = 0; switch (index) { case 0: max = 31; break; case 1: max = 12; break; case 2: max = 2100; min = 1900; length = 4; break; case 3: max = 23; break; case 4: max = 59; break; } return { length, min, max }; }; const getInternalValue = (value: CalendarProps['value']) => { const newValue = ['', '', '', '', '']; if (value) { newValue[0] = String(value.getDate()).padStart(2, '0'); newValue[1] = String(value.getMonth() + 1).padStart(2, '0'); newValue[2] = String(value.getFullYear()).padStart(4, '0'); newValue[3] = String(value.getHours()).padStart(2, '0'); newValue[4] = String(value.getMinutes()).padStart(2, '0'); } return newValue; }; /** * @see https://vkui.io/components/date-input */ export const DateInput = ({ enableTime, shouldDisableDate, disableFuture, disablePast, minDateTime, maxDateTime, 'value': valueProp, defaultValue, onChange, 'calendarPlacement': calendarPlacementProp = 'bottom-start', style, className, doneButtonText, DoneButton, closeOnChange = true, disablePickers, getRootRef, name, autoFocus, disabled, accessible = true, calendarLabel = 'Календарь', prevMonthLabel = 'Предыдущий месяц', nextMonthLabel = 'Следующий месяц', changeDayLabel = 'День', changeMonthLabel = 'Месяц', changeYearLabel = 'Год', changeHoursLabel = 'Час', changeMinutesLabel = 'Минута', clearFieldLabel = 'Очистить поле', showCalendarLabel = 'Показать календарь', showNeighboringMonth, size, viewDate, onHeaderChange, onNextMonth, onPrevMonth, prevMonthIcon, nextMonthIcon, renderDayContent, onCalendarOpenChanged, calendarTestsProps, dayFieldTestId, monthFieldTestId, yearFieldTestId, hourFieldTestId, minuteFieldTestId, showCalendarButtonTestId, clearButtonTestId, id, onApply, renderCustomValue, timezone, restoreFocus, disableFocusTrap, readOnly, 'disableCalendar': disableCalendarProp = false, 'aria-label': ariaLabel = '', before, ...props }: DateInputProps): React.ReactNode => { const daysRef = React.useRef(null); const monthsRef = React.useRef(null); const yearsRef = React.useRef(null); const hoursRef = React.useRef(null); const minutesRef = React.useRef(null); const focusTrapRootRef = React.useRef(null); const disableCalendar = readOnly ? true : disableCalendarProp; const { value, updateValue, setInternalValue, getLastUpdatedValue, clearValue } = useDateInputValue({ value: valueProp, defaultValue, onChange, timezone, }); const maxElement = enableTime ? 4 : 2; const onInternalValueChange = React.useCallback( (internalValue: string[]) => { for (let i = 0; i <= maxElement; i += 1) { if (internalValue[i].length < elementsConfig(i).length) { return; } } let formattedValue = `${internalValue[0]}.${internalValue[1]}.${internalValue[2]}`; let mask = 'dd.MM.yyyy'; if (enableTime) { formattedValue += ` ${internalValue[3]}:${internalValue[4]}`; mask += ' HH:mm'; } if (isMatch(formattedValue, mask)) { const now = new Date(); const referenceDate = value ?? (enableTime ? startOfMinute(now) : startOfDay(now)); const parsed = parse(formattedValue, mask, referenceDate); const toUpdate = createDateInTimeZone(parsed, timezone); updateValue(toUpdate); } }, [enableTime, maxElement, timezone, updateValue, value], ); const refs = React.useMemo( () => [daysRef, monthsRef, yearsRef, hoursRef, minutesRef], [daysRef, monthsRef, yearsRef, hoursRef, minutesRef], ); const { rootRef, calendarRef, open, internalValue, handleKeyDown, setFocusedElement, handleFieldEnter, clear, removeFocusFromField, closeCalendar, toggleCalendar, openCalendar, handleRestoreFocus, } = useDateInput({ maxElement, refs, autoFocus, disabled: disabled || readOnly, elementsConfig, onClear: clearValue, onInternalValueChange, getInternalValue, value, onCalendarOpenChanged, accessible, }); const { density = 'none' } = useAdaptivity(); const handleRootRef = useExternRef(rootRef, getRootRef); useIsomorphicLayoutEffect( function resetValueOnCloseCalendar() { if (!open) { setInternalValue(getLastUpdatedValue()); } }, [open, getLastUpdatedValue], ); const onCalendarChange = React.useCallback( (value: Date) => { if (enableTime) { const valueForDisplay = convertDateToTimeZone(value, timezone) as Date; setInternalValue(valueForDisplay); return; } updateValue(value); if (closeOnChange) { removeFocusFromField(); } }, [enableTime, timezone, updateValue, closeOnChange, setInternalValue, removeFocusFromField], ); const onDoneButtonClick = React.useCallback(() => { if (!value) { return; } const newValue = updateValue(value); onApply?.(newValue); removeFocusFromField(); }, [onApply, removeFocusFromField, updateValue, value]); const customValue = React.useMemo( () => !open && renderCustomValue?.(value || undefined), [open, renderCustomValue, value], ); // при переключении месяцев высота календаря может меняться, // чтобы календарь не прыгал при переключении месяцев каждый раз на // лучшую позицию мы запоминаем последнюю удачную, чтобы календарь оставался // на ней, пока помещается. const [calendarPlacement, setCalendarPlacement] = React.useState(calendarPlacementProp); const { locale } = useConfigProvider(); const currentDateLabel = value ? labelDateTimeFormat(locale, labelDateTimeFormatOptions).format(value) : null; 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} )} нормализация не нужна onClick={showCalendarOnInputAreaClick} > . . {enableTime && ( : )} {customValue && ( {customValue} )}
{open && !disableCalendar && (
)}
); };