import { formatAmount } from '@transferwise/formatting'; import { clsx } from 'clsx'; import { AnimatePresence } from 'framer-motion'; import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import { Props as ExpressiveMoneyInputProps } from '../ExpressiveMoneyInput'; import { AnimatedNumber } from '../animatedNumber/AnimatedNumber'; import { useFocus } from '../hooks/useFocus'; import { useInputStyle } from '../hooks/useInputStyle'; import { getDecimalCount, getDecimalSeparator, getEnteredDecimalsCount, getFormattedString, getGroupSeparator, getUnformattedNumber, isAllowedInputKey, isInputPossiblyOverflowing, } from './utils'; type Props = { id: string; describedById?: string; amount?: number | null; currency: string; autoFocus?: boolean; onChange: (amount: number | null) => void; onFocusChange?: (focused: boolean) => void; } & Pick; export const AmountInput = ({ id, describedById, amount, currency, autoFocus, onChange, onFocusChange, loading, }: Props) => { const intl = useIntl(); const { focus, setFocus, visualFocus, setVisualFocus } = useFocus(); const [value, setValue] = useState( amount ? getFormattedString({ value: amount, currency, locale: intl.locale, }) : '', ); const numericValue = useMemo(() => { return getUnformattedNumber({ value, currency, locale: intl.locale, }); }, [value, currency, intl.locale]); const valueWithFullDecimals = useMemo(() => { return getFormattedString({ value: numericValue ?? 0, currency, locale: intl.locale, alwaysShowDecimals: true, }); }, [numericValue, currency, intl.locale]); const ref = useRef(null); useEffect(() => { if (autoFocus) { ref.current?.focus(); } }, []); const placeholder = getPlaceholder(currency, intl.locale); const groupSeparator = getGroupSeparator(currency, intl.locale); const decimalSeparator = getDecimalSeparator(currency, intl.locale); const maxDecimalCount = getDecimalCount(currency, intl.locale); const decimalPart = getDecimalPart(value, decimalSeparator); const decimalMode = decimalSeparator && value.includes(decimalSeparator); useEffect(() => { if (!focus) { setValue( amount ? getFormattedString({ value: amount, currency, locale: intl.locale, }) : '', ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [amount]); useEffect(() => { onFocusChange?.(visualFocus); }, [visualFocus]); const shouldReformatAfterUserInput = (newValue: string) => { // don't reformat if formatting would wipe out user's input if (reformatValue(newValue) === '') { return false; } const endsWithDecimalSeparator = decimalSeparator && newValue.endsWith(decimalSeparator); const endsWithGroupSeparator = groupSeparator && newValue.endsWith(groupSeparator); // if the user has entered a seperator to the end, formatting would delete it if (endsWithDecimalSeparator || endsWithGroupSeparator) { return false; } const containsDecimalSeparator = decimalSeparator && newValue.includes(decimalSeparator); if (containsDecimalSeparator) { const enteredDecimalsCount = getEnteredDecimalsCount(newValue, decimalSeparator); // don't reformat until user has entered all the allowed decimals // for example, we don't want 1.1 to be reformatted to 1.10 immediately if (enteredDecimalsCount < maxDecimalCount) { return false; } } return true; }; const reformatValue = (newValue: string) => { const unformattedValue = getUnformattedNumber({ value: newValue, currency, locale: intl.locale, }); const formattedValue = unformattedValue ? getFormattedString({ value: unformattedValue, currency, locale: intl.locale, }) : ''; return formattedValue; }; const handleChange = (newValue: string) => { const oldCursorPosition = ref.current?.selectionStart ?? 0; const newFormattedString = shouldReformatAfterUserInput(newValue) ? reformatValue(newValue) : newValue; setValue(newFormattedString); const newNumber = getUnformattedNumber({ value: newFormattedString, currency, locale: intl.locale, }); if (newNumber !== numericValue) { if (numericValue || newNumber) { onChange(newNumber); } } const newCursorPosition = oldCursorPosition + (newFormattedString.length - newValue.length); requestAnimationFrame(() => { ref?.current?.setSelectionRange(newCursorPosition, newCursorPosition); }); }; const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); const clipboardData = e.clipboardData?.getData('text/plain'); if (!clipboardData) { return; } // need to sanitise the pasted value otherwise other validation logic will ignore the input entirely const sanitisedValue = reformatValue(clipboardData); handleChange(sanitisedValue); }; const handleBlur = () => { setFocus(false); setValue(reformatValue(value)); }; const handleBackspace = (e: KeyboardEvent) => { const input = e.target as HTMLInputElement; // using the updated selection range after the backspace key has been processed, instead of the current selection range in state const { value: currentValue, selectionStart, selectionEnd } = input; if (selectionStart === selectionEnd && selectionStart && selectionStart > 0) { const charBeforeCursor = currentValue[selectionStart - 1]; // if the user deletes a thousands separator, remove the digit before it as well if (charBeforeCursor === groupSeparator) { e.preventDefault(); const beforeCursor = currentValue.slice(0, selectionStart - 2); const afterCursor = currentValue.slice(selectionStart); const newValue = `${beforeCursor}${afterCursor}`; input.setSelectionRange(beforeCursor.length, beforeCursor.length); handleChange(newValue); } } }; const handleKeyDown = (e: KeyboardEvent) => { setFocus(true); if (!isAllowedInputKey(e)) { e.preventDefault(); } if (e.key === 'Backspace') { handleBackspace(e); } }; const isAllowedInput = (e: ChangeEvent) => { const hasMultipleDecimalSeparators = decimalSeparator && e.target.value.split(decimalSeparator).length > 2; if (hasMultipleDecimalSeparators) { return false; } const newNumericValue = getUnformattedNumber({ value: e.target.value, currency, locale: intl.locale, }); const maxLength = Number.MAX_SAFE_INTEGER.toString().length; if (String(newNumericValue).length > maxLength) { return false; } return true; }; const addonContent = useMemo((): string | null | undefined => { // because we're using a separate "addon" element for the placeholder decimals, there is a possibility that the input itself will become scrollable // and the decimals will appear on top of the input. Safest thing to do is to just hide the addon if there is not enough room if (isInputPossiblyOverflowing({ ref, value })) { return null; } if (!decimalSeparator || !value) { return null; } // if the user has typed a decimal separator, show the full decimal part as a placeholder // this returns a string even if there is no content, typing should replace the placeholder immediately without animation // otherwise there is an ugly animation when going from 1.23 to 1.2 due to AnimatePresence if (focus && decimalMode) { // reuse getDecimalPart const fullDecimalPart = getDecimalPart(valueWithFullDecimals, decimalSeparator); // show only the characters that are not already displayed by the input return fullDecimalPart?.slice(decimalPart?.length); } // in unfocused state, always show the full decimal part unless the user has already entered decimals if (!focus && !decimalMode) { const [_, decimalPlaceholder] = placeholder.split(decimalSeparator); return decimalSeparator + decimalPlaceholder; } return null; }, [ decimalMode, decimalPart?.length, decimalSeparator, focus, placeholder, value, valueWithFullDecimals, ]); const style = useInputStyle({ // whenever decimals are shown, we need to account for the full decimal part for the font size calculation value: addonContent ? valueWithFullDecimals : value, focus: visualFocus, inputElement: ref.current, loading, }); return (
{ if (isAllowedInput(e)) { handleChange(e.target.value); } }} onBlurCapture={() => handleBlur()} onPaste={(e) => handlePaste(e)} onFocus={() => { setFocus(true); }} onBlur={() => { setTimeout(() => setVisualFocus(false), 30); }} onKeyDown={(e) => handleKeyDown(e)} /> {addonContent !== null && ( ref.current?.focus()} > {addonContent} )}
); }; const getPlaceholder = (currency: string, locale: string) => { return formatAmount(0, currency, locale, { alwaysShowDecimals: true }); }; const getDecimalPart = (value: string, decimalSeparator: string | null) => { if (!value || !decimalSeparator) { return undefined; } const [_, decimalPart] = value.split(decimalSeparator); return decimalPart ?? undefined; };