You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

372 lines
10 KiB

3 years ago
import React, { FC, useState, useEffect, useRef, forwardRef, useMemo } from 'react';
import { CurrencyInputProps, CurrencyInputOnChangeValues } from './CurrencyInputProps';
import {
isNumber,
cleanValue,
fixedDecimalValue,
formatValue,
getLocaleConfig,
padTrimValue,
CleanValueOptions,
getSuffix,
FormatValueOptions,
repositionCursor,
} from './utils';
export const CurrencyInput: FC<CurrencyInputProps> = forwardRef<
HTMLInputElement,
CurrencyInputProps
>(
(
{
allowDecimals = true,
allowNegativeValue = true,
id,
name,
className,
customInput,
decimalsLimit,
defaultValue,
disabled = false,
maxLength: userMaxLength,
value: userValue,
onValueChange,
fixedDecimalLength,
placeholder,
decimalScale,
prefix,
suffix,
intlConfig,
step,
min,
max,
disableGroupSeparators = false,
disableAbbreviations = false,
decimalSeparator: _decimalSeparator,
groupSeparator: _groupSeparator,
onChange,
onFocus,
onBlur,
onKeyDown,
onKeyUp,
transformRawValue,
...props
}: CurrencyInputProps,
ref
) => {
if (_decimalSeparator && isNumber(_decimalSeparator)) {
throw new Error('decimalSeparator cannot be a number');
}
if (_groupSeparator && isNumber(_groupSeparator)) {
throw new Error('groupSeparator cannot be a number');
}
const localeConfig = useMemo(() => getLocaleConfig(intlConfig), [intlConfig]);
const decimalSeparator = _decimalSeparator || localeConfig.decimalSeparator || '';
const groupSeparator = _groupSeparator || localeConfig.groupSeparator || '';
if (
decimalSeparator &&
groupSeparator &&
decimalSeparator === groupSeparator &&
disableGroupSeparators === false
) {
throw new Error('decimalSeparator cannot be the same as groupSeparator');
}
const formatValueOptions: Partial<FormatValueOptions> = {
decimalSeparator,
groupSeparator,
disableGroupSeparators,
intlConfig,
prefix: prefix || localeConfig.prefix,
suffix: suffix,
};
const cleanValueOptions: Partial<CleanValueOptions> = {
decimalSeparator,
groupSeparator,
allowDecimals,
decimalsLimit: decimalsLimit || fixedDecimalLength || 2,
allowNegativeValue,
disableAbbreviations,
prefix: prefix || localeConfig.prefix,
transformRawValue,
};
const formattedStateValue =
defaultValue !== undefined && defaultValue !== null
? formatValue({ ...formatValueOptions, decimalScale, value: String(defaultValue) })
: userValue !== undefined && userValue !== null
? formatValue({ ...formatValueOptions, decimalScale, value: String(userValue) })
: '';
const [stateValue, setStateValue] = useState(formattedStateValue);
const [dirty, setDirty] = useState(false);
const [cursor, setCursor] = useState(0);
const [changeCount, setChangeCount] = useState(0);
const [lastKeyStroke, setLastKeyStroke] = useState<string | null>(null);
const inputRef = ref || useRef<HTMLInputElement>(null);
/**
* Process change in value
*/
const processChange = (value: string, selectionStart?: number | null): void => {
setDirty(true);
const { modifiedValue, cursorPosition } = repositionCursor({
selectionStart,
value,
lastKeyStroke,
stateValue,
groupSeparator,
});
const stringValue = cleanValue({ value: modifiedValue, ...cleanValueOptions });
if (userMaxLength && stringValue.replace(/-/g, '').length > userMaxLength) {
return;
}
if (stringValue === '' || stringValue === '-' || stringValue === decimalSeparator) {
onValueChange && onValueChange(undefined, name, { float: null, formatted: '', value: '' });
setStateValue(stringValue);
return;
}
const stringValueWithoutSeparator = decimalSeparator
? stringValue.replace(decimalSeparator, '.')
: stringValue;
const numberValue = parseFloat(stringValueWithoutSeparator);
const formattedValue = formatValue({
value: stringValue,
...formatValueOptions,
});
if (cursorPosition !== undefined && cursorPosition !== null) {
// Prevent cursor jumping
let newCursor = cursorPosition + (formattedValue.length - value.length);
newCursor = newCursor <= 0 ? (prefix ? prefix.length : 0) : newCursor;
setCursor(newCursor);
setChangeCount(changeCount + 1);
}
setStateValue(formattedValue);
if (onValueChange) {
const values: CurrencyInputOnChangeValues = {
float: numberValue,
formatted: formattedValue,
value: stringValue,
};
onValueChange(stringValue, name, values);
}
};
/**
* Handle change event
*/
const handleOnChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
const {
target: { value, selectionStart },
} = event;
processChange(value, selectionStart);
onChange && onChange(event);
};
/**
* Handle focus event
*/
const handleOnFocus = (event: React.FocusEvent<HTMLInputElement>): number => {
onFocus && onFocus(event);
return stateValue ? stateValue.length : 0;
};
/**
* Handle blur event
*
* Format value by padding/trimming decimals if required by
*/
const handleOnBlur = (event: React.FocusEvent<HTMLInputElement>): void => {
const {
target: { value },
} = event;
const valueOnly = cleanValue({ value, ...cleanValueOptions });
if (valueOnly === '-' || !valueOnly) {
setStateValue('');
onBlur && onBlur(event);
return;
}
const fixedDecimals = fixedDecimalValue(valueOnly, decimalSeparator, fixedDecimalLength);
const newValue = padTrimValue(
fixedDecimals,
decimalSeparator,
decimalScale !== undefined ? decimalScale : fixedDecimalLength
);
const numberValue = parseFloat(newValue.replace(decimalSeparator, '.'));
const formattedValue = formatValue({
...formatValueOptions,
value: newValue,
});
if (onValueChange) {
onValueChange(newValue, name, {
float: numberValue,
formatted: formattedValue,
value: newValue,
});
}
setStateValue(formattedValue);
onBlur && onBlur(event);
};
/**
* Handle key down event
*
* Increase or decrease value by step
*/
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const { key } = event;
setLastKeyStroke(key);
if (step && (key === 'ArrowUp' || key === 'ArrowDown')) {
event.preventDefault();
setCursor(stateValue.length);
const currentValue =
parseFloat(
userValue !== undefined && userValue !== null
? String(userValue).replace(decimalSeparator, '.')
: cleanValue({ value: stateValue, ...cleanValueOptions })
) || 0;
const newValue = key === 'ArrowUp' ? currentValue + step : currentValue - step;
if (min !== undefined && newValue < min) {
return;
}
if (max !== undefined && newValue > max) {
return;
}
const fixedLength = String(step).includes('.')
? Number(String(step).split('.')[1].length)
: undefined;
processChange(
String(fixedLength ? newValue.toFixed(fixedLength) : newValue).replace(
'.',
decimalSeparator
)
);
}
onKeyDown && onKeyDown(event);
};
/**
* Handle key up event
*
* Move cursor if there is a suffix to prevent user typing past suffix
*/
const handleOnKeyUp = (event: React.KeyboardEvent<HTMLInputElement>) => {
const {
key,
currentTarget: { selectionStart },
} = event;
if (key !== 'ArrowUp' && key !== 'ArrowDown' && stateValue !== '-') {
const suffix = getSuffix(stateValue, { groupSeparator, decimalSeparator });
if (suffix && selectionStart && selectionStart > stateValue.length - suffix.length) {
/* istanbul ignore else */
if (inputRef && typeof inputRef === 'object' && inputRef.current) {
const newCursor = stateValue.length - suffix.length;
inputRef.current.setSelectionRange(newCursor, newCursor);
}
}
}
onKeyUp && onKeyUp(event);
};
useEffect(() => {
// prevent cursor jumping if editing value
if (
dirty &&
stateValue !== '-' &&
inputRef &&
typeof inputRef === 'object' &&
inputRef.current &&
document.activeElement === inputRef.current
) {
inputRef.current.setSelectionRange(cursor, cursor);
}
}, [stateValue, cursor, inputRef, dirty, changeCount]);
/**
* If user has only entered "-" or decimal separator,
* keep the char to allow them to enter next value
*/
const getRenderValue = () => {
if (
userValue !== undefined &&
userValue !== null &&
stateValue !== '-' &&
(!decimalSeparator || stateValue !== decimalSeparator)
) {
return formatValue({
...formatValueOptions,
decimalScale: dirty ? undefined : decimalScale,
value: String(userValue),
});
}
return stateValue;
};
const inputProps: React.ComponentPropsWithRef<'input'> = {
type: 'text',
inputMode: 'decimal',
id,
name,
className,
onChange: handleOnChange,
onBlur: handleOnBlur,
onFocus: handleOnFocus,
onKeyDown: handleOnKeyDown,
onKeyUp: handleOnKeyUp,
placeholder,
disabled,
value: getRenderValue(),
ref: inputRef,
...props,
};
if (customInput) {
const CustomInput = customInput;
return <CustomInput {...inputProps} />;
}
return <input {...inputProps} />;
}
);
CurrencyInput.displayName = 'CurrencyInput';
export default CurrencyInput;