import React, { type CSSProperties, type ForwardedRef, type Ref, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import Button from '../Button/Button' import { hasValue, notEmpty, parseSymbology, } from '../../services/HelperServiceTyped' import Icon from '../Icons/Icon' import type Tooltip from '../Tooltip/Tooltip' import FormLabel from '../FormLabel/FormLabel' import styles from './_text-input.module.scss' import { adjustDatePart, dateOverlayPlaceholder, getMaxLength, getPlaceholder, handleDateArrowKey, handleDateInput, showDateOverlay, } from './dateInputHelper' const ARROW_KEY_VALUES = ['arrowup', 'arrowdown'] // Utility functions for number formatting const formatNumberWithCommas = ( value: string | number, locale?: string, ): string => { if (!notEmpty(value)) return '' const stringValue = String(value) const num = typeof value === 'string' ? parseFloat(value) : value // Check if number is too large for safe conversion (beyond MAX_SAFE_INTEGER) // or if converting to number would lose precision if (stringValue.length > 15 || isNaN(num)) { // For very large numbers, manually add commas without converting to number // This preserves precision for numbers beyond JavaScript's safe integer range const parts = stringValue.split('.') const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') return parts.length > 1 ? `${integerPart}.${parts[1]}` : integerPart } // For normal-sized numbers, use toLocaleString for proper locale formatting return num.toLocaleString(locale || navigator.language || 'en-US') } type CalloutType = (stateName: StateNameType, value: string | number) => void type StateNameType = string | number | undefined type TooltipProps = React.ComponentProps type TextInputBase = { /** The value to display in the input */ value: string | number /** Optional callback when focus on input is lost */ onBlurCallout?: ( stateName: StateNameType, value: React.SyntheticEvent | string, ) => void /** Optional number prop used to delay the execution used in a setTimeout's second param as milliseconds */ debounce?: number /** Name of the state for the input */ stateName?: string | number /** Optional callback function to specify when user releases a key on the keyboard */ keyUp?: (stateName: StateNameType, value: unknown) => void /** Optional label above the input */ labelText?: React.ReactNode /** Optional label to the top right of the input */ rightLabel?: React.ReactNode /** Optional placeholder to display inside input */ placeholder?: string /** Optional identifier for the input */ id?: string /** Optional className for the input */ classType?: string /** This gives the input the required attribute and will also display a red asterisk next to the label */ required?: boolean /** Optional boolean to display the "Clear" text in the input */ clear?: boolean /** Optional callback when the Clear button is clicked */ clearCallout?: () => void /** Optional boolean to inidicate if the input should be initialized to the focused state */ autoFocus?: boolean /** Optional show the browser's autocomplete popover */ autoComplete?: 'on' | 'off' /** Optional label that appears inside the input and aligned to the right */ endText?: string /** Boolean to know if an error has occured */ hasError?: boolean /** Optional boolean to specify that an input field is read-only */ readOnly?: boolean /** Optional boolean used to display full width of the input */ fullWidth?: boolean /** Optional boolean to specify the maximum number of characters allowed in the input */ maxLength?: number /** Optional function to specify when the input receives focus */ setIsOnFocus?: (isOnFocus: boolean) => void /** Optional function to run when the input receives focus */ onFocus?: () => void /** Optional function to run when pressing the return / enter key */ onReturnCallout?: () => void /** Optional boolean to not allow the input to remain an empty field */ noEmpty?: boolean /** The right aligned label in the input e.g. '%', 'in', 'lbs' etc */ inputLabel?: string /** Optional class to add to the parent div of the input */ containerClassName?: string /** Optional prop used to display a tooltip for the label */ labelTooltip?: Omit /** Optional prop used to display an error message below the input */ errorText?: string /** Optional boolean to enable dynamic height for textarea */ autoResize?: boolean /** Optional prop to add a test id to the TextInput for QA testing */ qaTestId?: string /** When true, strips scanner symbology prefixes (e.g. `~A`, `~P12`, `]C1`) from the displayed value and callout */ shouldStripSymbologyPrefix?: boolean } type TextInputNotNumber = TextInputBase & { /** Optional prop to indicate the type of the input */ type?: | 'email' | 'password' | 'tel' | 'text' | 'textarea' | 'date-single' | 'date-range' min?: never max?: never step?: never onlyWholeNumbers?: never onlyPositiveNumbers?: never noScrollForNumbers?: never noArrowKeyNumberChange?: never maxDecimalPlaces?: never numberFormat?: never locale?: never } export type TextInputNumber = TextInputBase & { /** Required prop to indicate the input is of type `number` */ type: 'number' /** Optional boolean to specify the minimum value for the input */ min?: number /** Optional boolean to specify the maximum value for the input */ max?: number /** Optional value to specify the interval between legal numbers in the input */ step?: number /** Optional prop used to round a number to the nearest integer */ onlyWholeNumbers?: boolean /** Optional prop used to only accept positive numbers as a value */ onlyPositiveNumbers?: boolean /** Disable incrementing / decrementing number value with scroll wheel */ noScrollForNumbers?: boolean /** Disable incrementing / decrementing number value with keyboard arrows (default: false) */ noArrowKeyNumberChange?: boolean /** Used if we want to restrict a certain number of decimal places. */ maxDecimalPlaces?: number /** Optional prop used to display currency or percentage input */ numberFormat?: { /** To indicate the type of numberFormat */ type: 'currency' | 'percentage' /** Currency symbol to display in a gray background (e.g: $) */ currencySymbol?: string } /** Optional locale for number formatting (defaults to browser locale) */ locale?: string } type TextInputTypes = TextInputNotNumber | TextInputNumber // if disabled===true, then callout is optional // if disabled===false or undefined, then callout is required type DisabledOrNot = | { disabled: true; callout?: CalloutType } | { disabled?: false; callout: CalloutType } export type TextInputProps = TextInputTypes & DisabledOrNot const TextInput = ({ value, type, onBlurCallout, debounce, callout, stateName, keyUp, labelText, rightLabel, placeholder, id, classType, required, clear, clearCallout, min, max, step, disabled, autoFocus, inputLabel, autoComplete, onlyWholeNumbers, onlyPositiveNumbers, endText, readOnly = false, fullWidth = false, noEmpty = false, maxLength, setIsOnFocus, onFocus, onReturnCallout, hasError = false, containerClassName = '', maxDecimalPlaces, labelTooltip, noScrollForNumbers, noArrowKeyNumberChange, numberFormat, autoResize, errorText, ref, qaTestId = 'text-input', locale, shouldStripSymbologyPrefix = false, }: TextInputProps & { ref?: Ref }): React.JSX.Element => { const shouldShowDateOverlay = showDateOverlay({ type }) const isNumberInput = type === 'number' const [customClass, setCustomClass] = useState('') const classes = [customClass, classType].filter(Boolean).join(' ') const [inputValue, setInputValue] = useState(value ? value : '') const timeoutRef = useRef>(undefined) const [inputType, setInputType] = useState( type === 'date-single' || type === 'date-range' ? 'text' : type, ) const inputLabelRef = useRef(null) const textAreaRef = useRef(null) const previousDateValueRef = useRef(value ? String(value) : '') const [isNumberInputFocused, setIsNumberInputFocused] = useState(false) const onBlur = ( value: React.SyntheticEvent | string, ): void => { // TODO: This state name only uses "focus". It should be refactored to use a more descriptive name. // Clearing the focus class. setCustomClass('') // Handle number formatting on blur - just set focus state if (type === 'number') { setIsNumberInputFocused(false) } if (onBlurCallout) { onBlurCallout(stateName ?? '', value) } if (setIsOnFocus) { setIsOnFocus(false) } } useEffect(() => { if (autoResize && type === 'textarea' && textAreaRef) { const textarea = textAreaRef?.current // if element is not present, return if (!textarea) return // function to adjust the height of the textarea const adjustHeight = () => { // Reset height to auto textarea.style.height = 'auto' // Set the height to the scrollHeight textarea.style.height = `${textarea.scrollHeight}px` } // Adjust height initially if there is pre-populated content adjustHeight() // Add event listener for input changes textarea?.addEventListener('input', adjustHeight) // Clean up event listener on component unmount return () => { // removing the event listener textarea?.removeEventListener('input', adjustHeight) } } }, [autoResize, type]) const checkDebounce = useCallback( (value: string | number) => { clearTimeout(timeoutRef.current) if (debounce) { timeoutRef.current = setTimeout(() => { callout?.(stateName, value) }, debounce) } else { callout?.(stateName, value) } }, [debounce, callout, stateName], ) const setValue = useCallback( (rawValue: string): void => { const value = shouldStripSymbologyPrefix ? (parseSymbology(rawValue)?.value ?? rawValue) : rawValue if (type === 'date-single' || type === 'date-range') { const result = handleDateInput( value, type as 'date-single' | 'date-range', String(inputValue), ref as React.RefObject, setInputValue, checkDebounce, ) if (result.shouldReturn) { return } return } let val: string | number = value if (inputType === 'number') { val = onlyWholeNumbers ? val ? Math.round(val as unknown as number) : '' : val val = onlyPositiveNumbers && (val as unknown as number) < 0 ? 0 : val if ( hasValue(min) && (val as unknown as number) < (min as number) && value !== '' ) val = min as number if ( hasValue(max) && (val as unknown as number) > (max as number) && value !== '' ) val = max as number if (value === '' && noEmpty) { val = min ?? 0 } if (maxDecimalPlaces) { const arr = value.split('.') if (arr.length > 1 && arr[1].length > maxDecimalPlaces) return } } setInputValue(val) checkDebounce(val) }, [ type, inputType, checkDebounce, ref, inputValue, onlyWholeNumbers, onlyPositiveNumbers, min, max, noEmpty, maxDecimalPlaces, shouldStripSymbologyPrefix, ], ) const clearValue = () => { setInputValue('') if (type === 'date-single' || type === 'date-range') { previousDateValueRef.current = '' } if (clearCallout) { clearCallout() } // Focus the input after the clear button is clicked setTimeout(() => { if (ref && 'current' in ref) { ref.current?.focus() } }, 100) } const onInputFocus = () => { setCustomClass('focus') // Handle number formatting on focus - just set focus state if (type === 'number') { setIsNumberInputFocused(true) } setIsOnFocus?.(true) onFocus?.() } const togglePassword = () => { setInputType(inputType === 'password' ? 'text' : 'password') } const handleKeyUp = (event: React.KeyboardEvent) => { switch (event.keyCode) { case 13: keyUp && keyUp(stateName, inputValue) break case 27: if (inputValue && (inputValue as string).length > 0) { clearValue() } else if (!inputValue || (inputValue as string).length === 0) { const target = event.target as HTMLInputElement | HTMLTextAreaElement target.blur() } break default: break } } useEffect( () => () => { timeoutRef.current && clearTimeout(timeoutRef.current) }, [], ) useEffect(() => { setInputType( type === 'date-single' || type === 'date-range' ? 'text' : type, ) }, [type]) useEffect(() => { setInputValue(value) if (type === 'date-single' || type === 'date-range') { previousDateValueRef.current = String(value) } }, [value, type]) const inputRefOffsetWidth = inputLabelRef?.current?.offsetWidth const inlineStyles = { '--input-label-width': (inputRefOffsetWidth ?? 0) + 12 + 'px', } as CSSProperties const handleDateArrowKeys = useMemo(() => { if (!showDateOverlay({ type })) return null return (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() const input = e.target as HTMLInputElement const cursorPosition = input.selectionStart || 0 const newValue = adjustDatePart( String(inputValue), cursorPosition, e.key === 'ArrowUp', type, ) // Use specialized arrow key handler that preserves cursor position handleDateArrowKey( newValue, type as 'date-single' | 'date-range', String(inputValue), ref as React.RefObject, setInputValue, checkDebounce, ) } } }, [type, inputValue, ref, checkDebounce]) // Overlay logic extracted for reuse const shouldShowOverlay = isNumberInput && inputValue // Always apply wrapper class when wrapper div is present const overlayWrapperClass = isNumberInput ? styles.numberInputWrapper : '' const overlayInputClass = shouldShowOverlay && !isNumberInputFocused ? styles.numberInputWithOverlay : '' const renderNumberOverlay = () => { if (!shouldShowOverlay || isNumberInputFocused) return null return (
{formatNumberWithCommas(String(inputValue), locale)}
) } const renderInputWithOverlay = (inputProps: Record = {}) => { const inputElement = ( ) // Wrap in div when number input if (isNumberInput) { return (
{inputElement} {renderNumberOverlay()}
) } return inputElement } // Common input props to avoid duplication const commonInputProps = { 'data-testid': 'text-input-field', id, ref: ref as ForwardedRef, type: inputType ? inputType : 'text', value: inputValue, onBlur: (e: React.FocusEvent) => onBlur(e.target.value), onFocus: onInputFocus, onMouseDown: (e: React.MouseEvent) => { e.stopPropagation() ;(e.target as HTMLInputElement).focus() }, onTouchEnd: (e: React.TouchEvent) => { e.stopPropagation() ;(e.target as HTMLInputElement).focus() }, required, onKeyUp: (e: React.KeyboardEvent) => handleKeyUp(e), onChange: (e: React.ChangeEvent) => setValue(e.target.value), min, max, onWheel: (e: React.WheelEvent) => noScrollForNumbers && e.currentTarget.blur(), disabled, autoFocus, autoComplete: autoComplete || 'on', readOnly, step, onKeyDown: (e: React.KeyboardEvent) => { if ( noArrowKeyNumberChange && inputType === 'number' && ARROW_KEY_VALUES.includes(e.key.toLowerCase()) ) { e.preventDefault() } if (e.key === 'Enter') { onReturnCallout?.() } // Handle arrow keys for date inputs if (handleDateArrowKeys) { handleDateArrowKeys(e) } }, } return (
{labelText && (
)} {type === 'textarea' ? (