import React, { useCallback, useEffect } from 'react'; import { useCombinedRefs, useStyledRef } from '../lib/utilityHooks'; interface IFormattableTextInputProps extends React.InputHTMLAttributes { allowedCharacters: string; separator: string; uppercaseOnly?: boolean; maxLength?: number; groupLength: number; addTrailingSeparator?: boolean; handleChange: (value: string) => void; } function FormattableTextInput( props: IFormattableTextInputProps, forwardedRef: React.Ref, ) { const { addTrailingSeparator, allowedCharacters, groupLength, handleChange, maxLength, separator, uppercaseOnly, value, ...otherProps } = props; const ref = useStyledRef(); const combinedRef = useCombinedRefs(ref, forwardedRef); const unformat = useCallback( (value: string) => { const correctCaseValue = uppercaseOnly ? value.toUpperCase() : value; return correctCaseValue.match(new RegExp(allowedCharacters, 'g'))?.join('') ?? ''; }, [uppercaseOnly, allowedCharacters], ); const format = useCallback( (value: string, addTrailingSeparator?: boolean) => { let formatted = value.match(new RegExp(`.{1,${groupLength}}`, 'g'))?.join(separator) ?? ''; if ( addTrailingSeparator && value.length > 0 && value.length % groupLength === 0 && (!maxLength || maxLength > value.length) ) { formatted += separator; } return formatted; }, [groupLength, separator, maxLength], ); const onBeforeInput = useCallback( (event: Event) => { const { inputType, data, target } = event as InputEvent; if (ref.current) { const inputElement = target as HTMLInputElement; const oldValue = inputElement.value; const selectionStart = inputElement.selectionStart ?? oldValue.length; const selectionEnd = inputElement.selectionEnd ?? selectionStart; const emptySelection = selectionStart === selectionEnd; const beforeSelection = unformat(oldValue.slice(0, selectionStart)); const afterSelection = unformat(oldValue.slice(selectionEnd)); let unformattedData = unformat(data ?? ''); // Only allow adding data that fits into the max length. if (maxLength) { const charactersLeft = maxLength - beforeSelection.length - afterSelection.length; unformattedData = unformattedData.slice(0, charactersLeft); } let newValue: string; let caretPosition: number; if (inputType === 'deleteContentBackward' && emptySelection && beforeSelection.length > 0) { // This is triggered when pressing backspace without a selection newValue = beforeSelection.slice(0, -1) + afterSelection; caretPosition = format(beforeSelection + unformattedData, false).length - 1; } else if (inputType === 'deleteContentForward' && emptySelection) { // This is triggered when pressing delete without a selection newValue = beforeSelection + afterSelection.slice(1); caretPosition = format(beforeSelection + unformattedData, true).length; } else { newValue = beforeSelection + unformattedData + afterSelection; caretPosition = format(beforeSelection + unformattedData, true).length; } const formattedValue = format(newValue, addTrailingSeparator); caretPosition = Math.min(caretPosition, formattedValue.length); // The new value can't be set before the browser has changed the content of the input // element since that would result in the change being made twice. Another alternative would // be to call `event.preventDefault()` but that prevents other side effects such as the // scrolling of the input content when overflowing. ref.current.addEventListener( 'input', () => { inputElement.value = formattedValue; inputElement.selectionStart = inputElement.selectionEnd = caretPosition; handleChange(newValue); }, { once: true }, ); } }, [unformat, format, handleChange, addTrailingSeparator], ); // React doesn't fully support onBeforeInput currently and it's therefore set here. useEffect(() => { ref.current?.addEventListener('beforeinput', onBeforeInput); return () => ref.current?.removeEventListener('beforeinput', onBeforeInput); }, [onBeforeInput]); // Use value provided in props if it differs from current input value. useEffect(() => { if (typeof value === 'string' && ref.current && unformat(ref.current.value) !== value) { ref.current.value = format(value, addTrailingSeparator); } }, [format, value, addTrailingSeparator]); return ; } export default React.memo(React.forwardRef(FormattableTextInput));