import React, { type AnimationEvent, forwardRef, Fragment, type KeyboardEvent, type MouseEvent, type MouseEventHandler, useCallback, useRef, useState, } from 'react'; import mergeRefs from 'react-merge-refs'; import cn from 'classnames'; import { getDataTestId } from '@alfalab/core-components-shared'; import { StatusBadge } from '@alfalab/core-components-status-badge'; import { useFocus, useLayoutEffect_SAFE_FOR_SSR } from '@alfalab/hooks'; import { hasStepperInRightAddon } from '../../helpers/has-stepper-in-right-addon'; import { ClearButton } from '../clear-button'; import { LockIcon } from '../lock-icon'; import { type BaseInputProps } from './types/base-input-props'; import defaultColors from './default.module.css'; import styles from './index.module.css'; import invertedColors from './inverted.module.css'; const colorCommonStyles = { default: defaultColors, inverted: invertedColors, }; const inputTypesForSelectionRange = ['password', 'search', 'tel', 'text', 'url']; export const BaseInput = forwardRef< HTMLInputElement, BaseInputProps & { platformStyles?: Record; } >( // eslint-disable-next-line complexity ( { size = 48, type = 'text', block = false, colors = 'default', bottomAddons, dataTestId, clear = false, disabled, error, success, hint, className, fieldClassName, inputClassName, labelClassName, addonsClassName, rightAddonsProps, leftAddonsProps, focusedClassName, filledClassName, label, labelView = 'inner', leftAddons, onFocus, onBlur, onChange, onClear, onClick, onMouseDown, onMouseUp, onAnimationStart, rightAddons, value, defaultValue, wrapperRef, readOnly: readOnlyProp, FormControlComponent, disableUserInput, platformStyles = {}, bold, fontWeight = bold ? 'bold' : 'normal', ...restProps }, ref, ) => { const { onKeyDown, onMouseEnter, onMouseLeave } = restProps; const uncontrolled = value === undefined; const readOnly = readOnlyProp || disableUserInput; const inputRef = useRef(null); const [focusVisible] = useFocus(inputRef, 'keyboard'); const [focused, setFocused] = useState(restProps.autoFocus); const [hovered, setHovered] = useState(false); const [stateValue, setStateValue] = useState(defaultValue || ''); const filled = Boolean(uncontrolled ? stateValue : value); const [autofilled, setAutofilled] = useState(false); const resolveClearVisible = () => { if (Boolean(clear) && filled && !disabled && !readOnlyProp) { if ([true, 'always'].includes(clear)) { return true; } if (clear === 'auto' && (focused || hovered)) { return true; } return false; } return false; }; const clearButtonVisible = resolveClearVisible(); const handleMouseEnter: MouseEventHandler = useCallback( (e) => { setHovered(true); if (onMouseEnter) { onMouseEnter(e); } }, [onMouseEnter], ); const handleMouseLeave: MouseEventHandler = useCallback( (e) => { setHovered(false); if (onMouseLeave) { onMouseLeave(e); } }, [onMouseLeave], ); const shouldRenderErrorIcon = error && !clearButtonVisible; const shouldRenderSuccessIcon = success && !error && !clearButtonVisible; const hasInnerLabel = label && labelView === 'inner'; useLayoutEffect_SAFE_FOR_SSR(() => { const input = inputRef.current; if ( !restProps.autoFocus || !input || // https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange !inputTypesForSelectionRange.includes(input.type) ) { return; } // https://github.com/facebook/react/issues/14125 input.setSelectionRange(input.value.length, input.value.length); }, []); const handleInputFocus = useCallback( (event: React.FocusEvent) => { if (onFocus) { onFocus(event); } if (readOnlyProp) { return; } if (disableUserInput && hasStepperInRightAddon(rightAddons)) { return; } setFocused(true); }, // eslint-disable-next-line react-hooks/exhaustive-deps [onFocus, readOnlyProp, disableUserInput], ); const handleInputBlur = useCallback( (event: React.FocusEvent) => { setFocused(false); if (onBlur) { onBlur(event); } }, [onBlur], ); const handleInputChange = useCallback( (event: React.ChangeEvent) => { let inputValue = event.target.value; const target = event.target as HTMLInputElement; const isInputTypeNumber = target.getAttribute('type') === 'number'; const pattern = /[eE]/g; if (isInputTypeNumber && pattern.test(inputValue)) { inputValue = inputValue.replace(pattern, ''); } if (onChange) { onChange(event, { value: inputValue }); } if (uncontrolled) { setStateValue(inputValue); } }, [onChange, uncontrolled], ); const handleKeyDown = useCallback( (event: KeyboardEvent) => { /** * По умолчанию в input[type=number] можно вводить числа типа 2e5 (200 000) * Это ломает некоторое поведение, поэтому запрещаем ввод символов [eE] * @see DS-6808 */ const { key, target } = event; const eventTarget = target as HTMLInputElement; const isInputTypeNumber = eventTarget.getAttribute('type') === 'number'; if (isInputTypeNumber && (key === 'e' || key === 'E')) { event.preventDefault(); return; } if (onKeyDown) { onKeyDown(event); } }, [onKeyDown], ); const handleClear = useCallback( (event: MouseEvent) => { if (!clearButtonVisible) return; if (uncontrolled) { setStateValue(''); } if (onClear) { onClear(event); } if (inputRef.current && !focused) { inputRef.current.focus(); } }, [clearButtonVisible, focused, onClear, uncontrolled], ); const handleAnimationStart = useCallback( (event: AnimationEvent) => { if (onAnimationStart) { onAnimationStart(event); } setAutofilled(event.animationName.includes('start')); }, [onAnimationStart], ); /** * Right addons priority [4] or [3] <= [2] <= [1] or [0] * [4] - Status (error, success) * [3] - Clear * [2] - Common (info, e.g.) * [1] - Indicators (eye, calendar, chevron, stepper e.g.) * [0] - Lock */ const renderRightAddons = () => { const statusBadgeSize = size === 40 ? 16 : 20; return ( {clearButtonVisible && ( )} {shouldRenderErrorIcon && (
)} {shouldRenderSuccessIcon && (
)} {rightAddons} {(disabled || readOnlyProp) && }
); }; return FormControlComponent ? ( ) : null; }, );