import React, { forwardRef, useRef, useState, useEffect } from 'react'; import cls from 'classnames'; import type { ChangeEvent, KeyboardEvent, MouseEvent, MutableRefObject, RefObject } from 'react'; import type { RootProps } from 'src/engines'; import { useForkRef, useOutsideClick } from 'src/hooks'; import { getSizeValueFromProp } from 'src/utils'; import type { TimePickerGridChangeEvent } from '../TimePickerGrid/TimePickerGrid.types'; import { getMeridiemTextAfter } from '../TimePickerGrid/utils'; import { processTimeInput, processTimeInput12, formatTo12Hour, getMeridiemFromTimeString, delimiter } from './utils'; import { TimePickerProps } from './TimePicker.types'; import { base, LeftHelper, StyledInput, StyledTimePickerGrid } from './TimePicker.styles'; import { classes } from './TimePicker.tokens'; import { FloatingPopover } from './FloatingPopover'; import { base as sizeCSS } from './variations/_size/base'; import { base as viewCSS } from './variations/_view/base'; import { base as hintViewCSS } from './variations/_hint-view/base'; import { base as hintSizeCSS } from './variations/_hint-size/base'; import { base as disabledCSS } from './variations/_disabled/base'; import { base as readonlyCSS } from './variations/_readonly/base'; import { useKeyNavigation } from './hooks/useKeyboardNavigation'; // changes to start tests interface ActiveTime { hours?: number | null; minutes?: number | null; seconds?: number | null; currentColumn?: 'hours' | 'minutes' | 'seconds' | null; } export const timePickerRoot = ( Root: RootProps>, ) => forwardRef( ( { className, // controlled value: outerValue, opened = false, // variations view, size, disabled = false, readonly = false, stretched, appearance, hasClearDivider, valueError, valueSuccess, valueEdited, // layout label, labelPlacement = 'outer', titleCaption, keepPlaceholder, required = false, requiredPlacement = 'right', hasRequiredIndicator = false, placeholder, leftHelper, contentLeft, contentRight, textBefore, textAfter, autoComplete, // hint hintTrigger = 'hover', hintText, hintView = 'default', hintSize = 'm', hintTargetIcon, hintTargetPlacement = 'outer', hintPlacement = 'auto', hintHasArrow, hintOffset = [0, 0], hintWidth, hintContentLeft, // popover-container frame = 'document', usePortal = false, placement = 'bottom-start', closeOnOverlayClick = true, closeOnEsc = true, disableFlip, offset, zIndex = 1000, // dropdown dropdownAlign = 'left', dropdownWidth, dropdownHeight, columnsQuantity = 2, hasTimeFormat = false, min, max, multiplicityMinutes, multiplicitySeconds, onToggle, onFocus, onChange, ...rest }, ref, ) => { const inputRef = useRef(null); const inputForkRef = useForkRef(inputRef, ref); const timeGridRootRef = useRef(null); const floatingPopoverRef = useRef(null); const rootRef = useRef(null); const hoursHideTimeoutRef = useRef(null); const minutesHideTimeoutRef = useRef(null); const secondsHideTimeoutRef = useRef(null); const [isInnerOpen, setIsInnerOpen] = useState(opened); const [innerTime, setInnerTime] = useState(outerValue || ''); /** * Текст поля ввода в 12-часовом режиме. Значение остается в 24-часовом формате. */ const [inputValue, setInputValue] = useState(formatTo12Hour(outerValue || '')); const [rootWidth, setRootWidth] = useState(null); const [, setActiveTime] = useState({ hours: null, minutes: null, seconds: null, currentColumn: null, }); const viewValue = outerValue ?? innerTime; const format = columnsQuantity === 3 ? 'HH:mm:ss' : 'HH:mm'; const getDisplayValue = () => { if (!hasTimeFormat) { return viewValue; } return outerValue === undefined || outerValue === innerTime ? inputValue : formatTo12Hour(outerValue); }; const displayValue = getDisplayValue(); const meridiem = getMeridiemFromTimeString(viewValue); const innerTextAfter = getMeridiemTextAfter(hasTimeFormat && displayValue ? meridiem : null, textAfter); const dropdownWidthValue = dropdownWidth ? getSizeValueFromProp(dropdownWidth, 'rem') : undefined; const dropdownHeightValue = dropdownHeight ? getSizeValueFromProp(dropdownHeight, 'rem') : undefined; useEffect(() => { return () => { [hoursHideTimeoutRef, minutesHideTimeoutRef, secondsHideTimeoutRef].forEach((timeoutRef) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }); }; }, []); useEffect(() => { if (!viewValue) { return; } let timeString = viewValue; if (format === 'HH:mm' && viewValue.length > 5) { timeString = viewValue.substring(0, 5); } setInnerTime(viewValue); if (outerValue !== innerTime) { setInputValue(formatTo12Hour(viewValue)); } const [hh, mm, ss] = timeString.split(delimiter).map(Number); setActiveTime({ hours: !Number.isNaN(hh) ? hh : null, minutes: !Number.isNaN(mm) ? mm : null, seconds: format === 'HH:mm:ss' && !Number.isNaN(ss) ? ss : null, currentColumn: null, }); }, [outerValue, format]); useEffect(() => { if (isInnerOpen && (disabled || readonly)) { setIsInnerOpen(false); } }, [disabled, readonly]); useEffect(() => { if (rootRef.current) { const width = rootRef.current.offsetWidth; setRootWidth(width); } }, [dropdownWidth, isInnerOpen]); const handleToggle = () => { if (disabled || readonly) return; const newState = !isInnerOpen; setIsInnerOpen(newState); if (newState) { setActiveTime((prev) => ({ ...prev, currentColumn: 'hours', })); } onToggle?.(newState); }; useOutsideClick(() => { if (!isInnerOpen || !closeOnOverlayClick) { return; } setIsInnerOpen(false); onToggle?.(false); }, [floatingPopoverRef, timeGridRootRef]); const handleOnChange = (formattedValues: TimePickerGridChangeEvent) => { if (!formattedValues) { return; } const timeString = formattedValues.value; setInnerTime(timeString ?? ''); setInputValue(formatTo12Hour(timeString ?? '')); setActiveTime({ hours: !Number.isNaN(formattedValues.timeValues.hour) ? formattedValues.timeValues.hour : null, minutes: !Number.isNaN(formattedValues.timeValues.minute) ? formattedValues.timeValues.minute : null, seconds: format === 'HH:mm:ss' && !Number.isNaN(formattedValues.timeValues.second) ? formattedValues.timeValues.second : null, currentColumn: null, }); if (onChange) { onChange(formattedValues, formattedValues); } }; const handleInputChange = (event: ChangeEvent) => { let input = event.target.value; let cursorPos = event.target.selectionStart; // Мобильные клавиатуры могут удалять разделитель без события keydown. if ( cursorPos !== null && displayValue[cursorPos] === delimiter && displayValue === `${input.slice(0, cursorPos)}${delimiter}${input.slice(cursorPos)}` ) { const isForwardDeletion = (event.nativeEvent as InputEvent).inputType === 'deleteContentForward'; const deletionIndex = isForwardDeletion ? cursorPos : Math.max(0, cursorPos - 1); input = isForwardDeletion ? `${input.slice(0, cursorPos)}${delimiter}${input.slice(cursorPos + 1)}` : `${input.slice(0, deletionIndex)}${delimiter}${input.slice(cursorPos)}`; cursorPos = isForwardDeletion ? cursorPos + 1 : deletionIndex; } const parsed = hasTimeFormat ? processTimeInput12(input, format, cursorPos, meridiem) : { ...processTimeInput(input, format, cursorPos), displayString: null }; const { innerString, values, newCursorPosition, displayString } = parsed; event.currentTarget.value = outerValue === undefined ? displayString ?? innerString : displayValue; const { hh: hours, mm: minutes, ss: seconds } = values; setInnerTime(innerString); setInputValue(displayString ?? formatTo12Hour(innerString)); setActiveTime((prev) => ({ ...prev, hours, minutes, seconds, })); const formattedValues = { value: innerString, timeValues: { hour: hours, minute: minutes, second: seconds, }, }; if (onChange) { onChange( { ...event, target: { ...event.target, ...formattedValues, }, }, formattedValues, ); } requestAnimationFrame(() => { if (inputRef.current) { inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition); } }); }; const handleKeyDown = (event: KeyboardEvent) => { const input = event.currentTarget; const { selectionStart, selectionEnd } = input; if (selectionStart !== null && selectionStart === selectionEnd) { if (event.key === 'Backspace' && input.value[selectionStart - 1] === delimiter) { input.setSelectionRange(selectionStart - 1, selectionStart - 1); } if (event.key === 'Delete' && input.value[selectionStart] === delimiter) { input.setSelectionRange(selectionStart + 1, selectionStart + 1); } } if (event.key === 'Enter' && !isInnerOpen) { event.preventDefault(); setIsInnerOpen(true); setActiveTime((prev) => ({ ...prev, currentColumn: 'hours' })); onToggle?.(true); } }; const handleOnKeyDown = (e: KeyboardEvent) => { onKeyDownNavigation(e); handleKeyDown(e); }; const handleTimeGridRootClick = (event: MouseEvent) => { if (disabled || readonly) { return; } if (isInnerOpen && event.target === timeGridRootRef?.current) { setIsInnerOpen(false); if (onToggle) { onToggle(false, event); } } }; const { onKeyDown: onKeyDownNavigation } = useKeyNavigation({ isCalendarOpen: isInnerOpen, format, maskWithFormat: true, delimiter, closeOnEsc, onToggle: handleToggle, }); const getActualWidth = () => { if (!rootWidth) { return 'fit-content'; } if (dropdownWidthValue === 'fixed' || dropdownWidthValue === undefined) { return undefined; } if (dropdownWidth === 'fullWidth') { return `${rootWidth}px`; } if (String(dropdownWidth)?.includes('%') && typeof dropdownWidth === 'string') { const widthValue = (rootWidth / 100) * parseFloat(dropdownWidth.replace('%', '')); return `${widthValue}px`; } return String(dropdownWidth || 'fit-content'); }; return ( ) : undefined} zIndex={zIndex} target={(referenceRef) => ( } className={cls({ [classes.timePickerError]: valueError, [classes.timePickerSuccess]: valueSuccess, [classes.timePickerEdited]: valueEdited, })} value={displayValue} size={size} disabled={disabled} readOnly={readonly} appearance={appearance} hasDivider={appearance === 'clear' && hasClearDivider} placeholder={placeholder} contentLeft={contentLeft} contentRight={contentRight} textBefore={textBefore} textAfter={innerTextAfter} autoComplete={autoComplete} onChange={handleInputChange} onFocus={onFocus} onKeyDown={handleOnKeyDown} required={required} requiredPlacement={requiredPlacement} hasRequiredIndicator={hasRequiredIndicator} label={label} labelPlacement={labelPlacement} titleCaption={titleCaption} keepPlaceholder={keepPlaceholder} {...(hintText ? { hintText, hintView, hintSize, hintTrigger, hintTargetIcon, hintPlacement, hintHasArrow, hintOffset, hintWidth, hintContentLeft, hintTargetPlacement, } : { hintText: undefined })} /> )} > {leftHelper && ( {leftHelper} )} ); }, ); export const timePickerConfig = { name: 'TimePicker', tag: 'div', layout: timePickerRoot, base, variations: { view: { css: viewCSS, }, size: { css: sizeCSS, }, hintView: { css: hintViewCSS, }, hintSize: { css: hintSizeCSS, }, disabled: { css: disabledCSS, attrs: true, }, readonly: { css: readonlyCSS, attrs: true, }, }, defaults: { size: 'm', view: 'default', }, };