import React, { forwardRef, useEffect, useState, useRef } from 'react'; import styled from 'styled-components'; import cuid from 'cuid'; import isChromatic from 'chromatic/isChromatic'; import AttachMoney from '../../assets/icons/AttachMoney'; import { filterGlobalProps, pxToRem } from '@lendi-ui/commons/utils'; import { InputBaseComponent, ErrorText, HelpText, FieldLabel, useClearAction, ClearActionButton } from '../TextField'; import { cleanNumericValue } from '../NumericField'; import { Spacer } from '../../foundations/Layout'; import type { InputFieldBaseProps, InputProps } from '../TextField'; import formatToCurrency from './utils/formatToCurrency'; import padTrimValue from './utils/padTrimValue'; import { useTheme } from '../../foundations/Theme'; interface DollarFieldProps extends InputFieldBaseProps { onValueChange?: (value?: number, name?: string) => void; } export type DollarFieldComponentProps = InputProps; const Input = styled(InputBaseComponent as any)<{ hasRightItem?: boolean }>` padding-left: ${pxToRem(46)}; padding-right: ${({ hasRightItem }) => (hasRightItem ? pxToRem(46) : pxToRem(16))}; `; const Wrapper = styled.div` position: relative; width: 100%; `; const LeftInputFieldIconWrapper = styled.span` position: absolute; top: 12px; left: 12px; `; function DollarFieldComponent( { inputId, name, helpText, errorText, validationState, placeholder, ariaLabel, ariaLabelledBy, label, labelSuffix, inlineAction, isRequired, isDisabled, value: userValue, defaultValue, onChange, onValueChange, onBlur, width = '100%', minWidth, maxWidth, maxCharCount, minCharCount, ...props }: DollarFieldComponentProps, ref: React.Ref ) { const globalProps = filterGlobalProps(props); const inputElId = isChromatic() ? '1' : inputId ?? cuid(); const helpTextId = isChromatic() ? '2' : cuid(); const errorTextId = isChromatic() ? '3' : cuid(); const hasError = errorText && validationState === 'invalid'; const theme = useTheme(); // This is of course against hook rules, but in order for cursor tracking to work, // we must always have a valid ref to the input available // eslint-disable-next-line react-hooks/rules-of-hooks const inputRef = ref ?? useRef(null); const initialStateValue = defaultValue !== undefined ? formatToCurrency(String(defaultValue)) : userValue !== undefined ? formatToCurrency(String(userValue)) : ''; const [stateValue, setStateValue] = useState(initialStateValue); const [dirty, setDirty] = useState(false); const [cursor, setCursor] = useState(0); const processValue = (value: string, selectionStart?: number | null, name?: string): void => { setDirty(true); const valueOnly = cleanNumericValue({ value, allowDecimal: true }); if (valueOnly === '') { onValueChange?.(undefined, name); setStateValue(''); return; } if (maxCharCount && valueOnly.length > maxCharCount) { return; } if (valueOnly === '.') { onValueChange?.(undefined, name); setStateValue(valueOnly); return; } const formatted = formatToCurrency(valueOnly); if (selectionStart !== undefined && selectionStart !== null) { // Prevent cursor jumping const newCursor = selectionStart + (formatted.length - value.length) || 0; const isNegative = Math.sign(newCursor) < 0; setCursor(isNegative ? 0 : newCursor); } setStateValue(formatted); onValueChange?.(valueOnly, name); }; const handleOnChange = (event: React.ChangeEvent) => { event.persist(); const { target: { value, selectionStart }, } = event; processValue(value, selectionStart); onChange?.(event); }; const handleOnKeyUp = (event: React.KeyboardEvent) => { const { key, currentTarget: { selectionStart }, } = event; if (key !== 'ArrowUp' && key !== 'ArrowDown') { if (selectionStart && selectionStart > stateValue.length) { if (inputRef && typeof inputRef === 'object' && inputRef.current) { const newCursor = stateValue.length; inputRef.current.setSelectionRange(newCursor, newCursor); } } } }; const handleOnFocus = (event: React.FocusEvent): number => { return stateValue ? stateValue.length : 0; }; const handleOnBlur = (event: React.FocusEvent) => { event.persist(); const { target: { value }, } = event; if (value) { const valueOnly = cleanNumericValue({ value, allowDecimal: true }); if (!valueOnly) { setStateValue(''); return; } const paddedDecimalsValue = padTrimValue(valueOnly); onValueChange?.(paddedDecimalsValue, name); const formattedValue = formatToCurrency(paddedDecimalsValue); setStateValue(formattedValue); } onBlur?.(event); }; useEffect(() => { // prevent cursor jumping if editing value if (dirty && inputRef && typeof inputRef === 'object' && inputRef.current) { inputRef.current.setSelectionRange(cursor, cursor); } }, [stateValue, cursor, inputRef, dirty]); const formattedPropsValue = userValue !== undefined ? formatToCurrency(String(userValue)) : undefined; const inputValue = formattedPropsValue !== undefined && stateValue !== '.' ? formattedPropsValue : stateValue; const { isCleared, handleChange, clearValue } = useClearAction(handleOnChange, inputRef, inputValue); const ariaDescribedBy = []; if (helpText) { ariaDescribedBy.push(helpTextId); } if (hasError) { ariaDescribedBy.push(errorTextId); } return ( {label && } 0 ? ariaDescribedBy.join(' ') : undefined} required={isRequired} disabled={isDisabled} maxLength={maxCharCount} minLength={minCharCount} onBlur={handleOnBlur} onKeyUp={handleOnKeyUp} onFocus={handleOnFocus} value={inputValue && isCleared ? '' : inputValue} onChange={handleChange} hasRightItem={!!inlineAction} inputMode="decimal" ref={inputRef} /> {inlineAction === 'clear' && } {helpText && } {hasError && errorText && } ); } export const DollarField = forwardRef(DollarFieldComponent);