@syncfusion/react-inputs
Version:
Syncfusion React Input package is a feature-rich collection of UI components, including Textbox, Textarea, Numeric-textbox and Form, designed to capture user input in React applications.
485 lines (484 loc) • 22.6 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle, useMemo, useId } from 'react';
import { InputBase, renderFloatLabelElement, renderClearButton, CLASS_NAMES } from '../common/inputbase';
import { isNullOrUndefined, L10n, preRender, SvgIcon, useProviderContext, useRippleEffect } from '@syncfusion/react-base';
import { formatUnit } from '@syncfusion/react-base';
import { getNumberFormat, getNumberParser } from '@syncfusion/react-base';
import { getValue, getNumericObject, Variant, Size } from '@syncfusion/react-base';
export { Variant, Size };
const ROOT = 'sf-numeric';
const SPINICON = 'sf-input-icon sf-spin-icon';
const SPINUP = 'sf-spin-up';
const SPINDOWN = 'sf-spin-down';
const SPINUP_PATH = 'M20.7929 17H3.20712C2.76167 17 2.53858 16.4615 2.85356 16.1465L11.6465 7.3536C11.8417 7.15834 12.1583 7.15834 12.3536 7.3536L21.1465 16.1465C21.4614 16.4615 21.2384 17 20.7929 17Z';
const SPINDOWN_PATH = 'M20.7929 7H3.20712C2.76167 7 2.53858 7.53857 2.85356 7.85355L11.6465 16.6464C11.8417 16.8417 12.1583 16.8417 12.3536 16.6464L21.1465 7.85355C21.4614 7.53857 21.2384 7 20.7929 7Z';
const classNames = (...classes) => {
return classes.filter(Boolean).join(' ');
};
/**
* NumericTextBox component that provides a specialized input for numeric values with validation,
* formatting, and increment/decrement capabilities. Supports both controlled and uncontrolled modes.
*
* ```typescript
* import { NumericTextBox } from "@syncfusion/react-inputs";
*
* <NumericTextBox defaultValue={100} min={0} max={1000} />
* ```
*/
export const NumericTextBox = forwardRef((props, ref) => {
const { min = -(Number.MAX_VALUE), max = Number.MAX_VALUE, step = 1, value, defaultValue = null, id = `numeric_${useId()}`, placeholder = '', spinButton = true, clearButton = false, format, decimals = null, strictMode = true, validateOnType = false, labelMode = 'Never', disabled = false, readOnly = false, currency = null, width = null, className = '', autoComplete = 'off', size = Size.Medium, variant, onChange, onFocus, onBlur, onKeyDown, ...otherProps } = props;
const isControlled = value !== undefined;
const uniqueId = useRef(id).current;
const currentValueRef = useRef(defaultValue);
const [isFocused, setIsFocused] = useState(false);
const [inputString, setInputString] = useState('');
const [arrowKeyPressed, setIsArrowKeyPressed] = useState(false);
const { locale, dir, ripple } = useProviderContext();
const rippleRef1 = useRippleEffect(ripple, { duration: 500, isCenterRipple: true });
const rippleRef2 = useRippleEffect(ripple, { duration: 500, isCenterRipple: true });
const inputRef = useRef(null);
const decimalSeparator = useMemo(() => getValue('decimal', getNumericObject(locale)), [locale]);
const publicAPI = {
min,
max,
step,
clearButton,
spinButton,
format,
strictMode,
validateOnType,
labelMode,
disabled,
readOnly
};
const { effectiveMin, effectiveMax } = useMemo(() => {
const low = Math.min(min, max);
const high = Math.max(min, max);
return { effectiveMin: low, effectiveMax: high };
}, [min, max]);
const getInitialValue = useCallback((initialValue) => {
if (initialValue === null || initialValue === undefined) {
return null;
}
return strictMode ? Math.min(Math.max(initialValue, effectiveMin), effectiveMax) : initialValue;
}, [strictMode, effectiveMin, effectiveMax]);
const [inputValue, setInputValue] = useState(() => {
const initial = isControlled ? value : defaultValue;
const clampedValue = getInitialValue(initial);
currentValueRef.current = clampedValue;
return clampedValue;
});
const containerClassNames = useMemo(() => {
return classNames(ROOT, CLASS_NAMES.INPUTGROUP, CLASS_NAMES.WRAPPER, labelMode !== 'Never' ? CLASS_NAMES.FLOATINPUT : '', className, (dir === 'rtl') ? CLASS_NAMES.RTL : '', disabled ? CLASS_NAMES.DISABLE : '', isFocused ? CLASS_NAMES.TEXTBOX_FOCUS : '', (!isNullOrUndefined(currentValueRef.current) && labelMode !== 'Always') ? CLASS_NAMES.VALIDINPUT : '', size && size.toLowerCase() !== 'small' ? `sf-${size.toLowerCase()}` : '', 'sf-control', variant && variant.toLowerCase() !== 'standard' ? variant.toLowerCase() === 'outlined' ? 'sf-outline' : `sf-${variant.toLowerCase()}` : '');
}, [
labelMode,
className,
dir,
disabled,
isFocused,
currentValueRef.current,
size
]);
const { incrementText, decrementText } = useMemo(() => {
const l10n = L10n('numericTextbox', {
increment: 'Increment value',
decrement: 'Decrement value'
}, locale);
return {
incrementText: l10n.getConstant('increment'),
decrementText: l10n.getConstant('decrement')
};
}, [locale]);
const formatValue = (value) => {
const numberOfDecimals = getNumberOfDecimals(value);
const formattedValue = getNumberFormat({
locale,
format,
maximumFractionDigits: numberOfDecimals,
minimumFractionDigits: numberOfDecimals,
useGrouping: format?.toLowerCase().includes('n'),
currency: currency
})(value);
return formattedValue;
};
useEffect(() => {
preRender('numerictextbox');
}, []);
useEffect(() => {
if (isControlled) {
const clampedValue = getInitialValue(value);
setInputValue(value);
currentValueRef.current = clampedValue;
if (!isFocused) {
if (clampedValue) {
const formattedValue = formatValue(clampedValue);
setInputString(formattedValue);
}
else {
setInputString('');
}
}
}
}, [value, isControlled, isFocused, getInitialValue]);
useEffect(() => {
if (strictMode && currentValueRef.current !== null) {
const clampedValue = trimValue(currentValueRef.current);
if (clampedValue !== currentValueRef.current) {
updateValue(clampedValue);
}
}
}, [effectiveMin, effectiveMax, strictMode]);
useEffect(() => {
if (!isControlled && defaultValue !== null) {
currentValueRef.current = defaultValue;
}
}, [isControlled, defaultValue]);
useImperativeHandle(ref, () => ({
...publicAPI,
element: inputRef.current
}), [publicAPI]);
const trimValue = useCallback((value) => {
return Math.min(Math.max(value, effectiveMin), effectiveMax);
}, [effectiveMin, effectiveMax]);
const roundNumber = useCallback((value, precision) => {
if (precision < 0) {
return value;
}
const multiplier = Math.pow(10, precision || 0);
return Math.round(value * multiplier) / multiplier;
}, []);
const getNumberOfDecimals = useCallback((value) => {
if (decimals !== null) {
return decimals > 0 ? decimals : 0;
}
if (format) {
const match = format && typeof format === 'string' ? format.match(/\D(\d+)/) : null;
const formatDecimals = match ? Number(match[1]) : null;
if (formatDecimals !== null) {
return formatDecimals;
}
}
const valueString = value.toString();
const decimalPart = valueString.split('.')[1];
return decimalPart ? Math.min(decimalPart.length, 20) : 0;
}, [decimals, format]);
const formatNumber = useCallback((value) => {
if (value === null || value === undefined) {
return isFocused ? inputString || '' : '';
}
if (inputString.endsWith(decimalSeparator)) {
return inputString;
}
try {
if (isFocused) {
if (format && format.toLowerCase().includes('p')) {
return inputString.replace('%', '');
}
else {
const numberOfDecimals = getNumberOfDecimals(value);
const roundedValue = roundNumber(value, numberOfDecimals);
const rounded = numberOfDecimals > 0 ? roundedValue.toFixed(numberOfDecimals) : roundedValue.toString();
if (arrowKeyPressed) {
if (rounded !== inputString) {
setInputString(rounded);
}
return rounded;
}
else {
const hasDecimal = inputString.includes(decimalSeparator);
if (hasDecimal && validateOnType) {
const parts = inputString.split(decimalSeparator);
if (parts[1].length > numberOfDecimals) {
const validInput = inputString.slice(0, inputString.indexOf(decimalSeparator) + numberOfDecimals + 1);
setInputString(validInput);
return validInput;
}
}
return inputString === '' || inputString === 'NaN' ? rounded : inputString;
}
}
}
const formattedValue = formatValue(value);
if (inputString === '' && !isFocused) {
setInputString(formattedValue);
}
return formattedValue;
}
catch (error) {
return value.toFixed(2);
}
}, [format, currency, isFocused, inputString, arrowKeyPressed, getNumberOfDecimals, locale, decimalSeparator]);
const updateValue = useCallback((newValue, e) => {
if (newValue === null || isNaN(newValue)) {
currentValueRef.current = null;
newValue = null;
}
else {
currentValueRef.current = newValue;
}
if (!isControlled) {
setInputValue(newValue);
}
if (onChange) {
onChange({ event: e, value: newValue });
}
}, [inputValue, onChange, isControlled, formatNumber]);
const parseNumericInput = useCallback((text) => {
let str = text;
if (format && format.toLowerCase().includes('p') && text && !text.includes('%')) {
str = `${text}%`;
}
return getNumberParser({ locale: locale, format: format })(str);
}, [locale, format]);
const handleChange = useCallback((e) => {
let rawStringValue = e.target.value;
if (rawStringValue !== null) {
if (rawStringValue.includes('e') || rawStringValue.includes('E')) {
const parsedValue = parseNumericInput(rawStringValue);
updateValue(parsedValue, e);
setInputString(parsedValue.toString());
return;
}
const minusCount = (rawStringValue.match(/-/g) || []).length;
if (minusCount > 1) {
rawStringValue = rawStringValue.replace(/-/g, '');
}
else if (minusCount === 1) {
rawStringValue = '-' + rawStringValue.replace(/-/g, '');
}
if (validateOnType && decimals !== null) {
const decimalIndex = rawStringValue.indexOf(decimalSeparator);
if (decimalIndex !== -1) {
const decimalPart = rawStringValue.substring(decimalIndex + 1);
if (decimalPart.length > decimals) {
rawStringValue = rawStringValue.substring(0, decimalIndex + 1 + decimals);
}
}
}
setInputString(rawStringValue);
}
if (rawStringValue === '') {
updateValue(null, e);
return;
}
if (rawStringValue.startsWith(decimalSeparator) && rawStringValue.length > 1) {
rawStringValue = '0' + rawStringValue;
setInputString(rawStringValue);
}
if (rawStringValue.startsWith(`-${decimalSeparator}`) && rawStringValue.length > 2) {
rawStringValue = `-0${decimalSeparator}` + rawStringValue.substring(2);
setInputString(rawStringValue);
}
if (rawStringValue.endsWith(decimalSeparator) || rawStringValue === '-') {
return;
}
let newValue = null;
if (rawStringValue !== '' && rawStringValue.trim() !== '') {
newValue = parseNumericInput(rawStringValue);
if (newValue !== null && isFinite(newValue)) {
if (strictMode) {
newValue = trimValue(newValue);
}
if (validateOnType && decimals !== null) {
newValue = roundNumber(newValue, decimals);
}
}
setInputString(rawStringValue);
}
updateValue(newValue, e);
}, [strictMode, validateOnType, decimals, format, trimValue, roundNumber, inputValue, updateValue, decimalSeparator,
parseNumericInput]);
const handleSpinClick = (increments) => {
if (disabled || readOnly) {
return;
}
setIsArrowKeyPressed(true);
if (increments) {
increment();
}
else {
decrement();
}
};
const handleFocus = useCallback((e) => {
setIsFocused(true);
if (onFocus) {
onFocus(e);
}
}, [onFocus, formatNumber]);
const handleBlur = useCallback((e) => {
setIsFocused(false);
setIsArrowKeyPressed(false);
let newValue;
if (e.currentTarget.value === '') {
newValue = null;
}
else {
newValue = parseNumericInput(e.currentTarget.value);
if (isNaN(newValue)) {
newValue = currentValueRef.current;
}
if (validateOnType && decimals !== null && newValue !== null) {
newValue = roundNumber(newValue, decimals);
}
if (strictMode && newValue !== null) {
newValue = trimValue(newValue);
}
}
const updatedValue = isControlled ? value : newValue;
if (updatedValue) {
const formattedValue = formatValue(updatedValue);
setInputString(formattedValue);
}
else {
setInputString('');
}
updateValue(updatedValue, e);
if (onBlur) {
onBlur(e);
}
}, [format, decimals, validateOnType, strictMode, roundNumber, updateValue, onBlur, parseNumericInput]);
const adjustValue = useCallback((isIncrement) => {
const adjustment = isIncrement ? step : -step;
let newValue = ((currentValueRef.current === null ||
currentValueRef.current === undefined) ? 0 : currentValueRef.current) + adjustment;
let precision = 10;
if (format && format.toLowerCase().includes('p')) {
const match = format.match(/p(\d+)/i);
if (match && match[1]) {
precision = parseInt(match[1], 10) + 2;
}
}
else {
const stepStr = step.toString();
const decimalIndex = stepStr.indexOf('.');
if (decimalIndex !== -1) {
precision = stepStr.length - decimalIndex - 1;
}
}
newValue = parseFloat(newValue.toFixed(precision));
if (strictMode) {
newValue = trimValue(newValue);
}
if (newValue) {
const formattedValue = formatValue(newValue);
setInputString(formattedValue);
}
if (currentValueRef.current !== newValue) {
updateValue(newValue);
}
}, [step, effectiveMax, effectiveMin, strictMode, updateValue, trimValue, format]);
const increment = useCallback(() => {
adjustValue(true);
}, [adjustValue]);
const decrement = useCallback(() => {
adjustValue(false);
}, [adjustValue]);
const handleKeyDown = useCallback((e) => {
if (!readOnly) {
const hasModifierKey = e.ctrlKey || e.altKey || e.metaKey;
if (hasModifierKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
setIsArrowKeyPressed(false);
if (onKeyDown) {
onKeyDown(e);
}
return;
}
switch (e.key) {
case 'ArrowUp':
setIsArrowKeyPressed(true);
e.preventDefault();
increment();
break;
case 'ArrowDown':
setIsArrowKeyPressed(true);
e.preventDefault();
decrement();
break;
case 'Enter':
{
e.preventDefault();
const parsedValue = parseNumericInput(e.currentTarget.value);
let newValue = Number.isNaN(parsedValue) ? currentValueRef.current : parsedValue;
if (strictMode && newValue !== null) {
newValue = trimValue(newValue);
}
updateValue(newValue);
}
break;
default:
{
const isNavigationKey = [
'Backspace', 'Delete', 'Tab', 'Escape', 'Enter', 'Home',
'End', 'ArrowLeft', 'ArrowRight'
].includes(e.key);
if (hasModifierKey || isNavigationKey) {
setIsArrowKeyPressed(false);
return;
}
if (validateOnType && decimals !== null && /^\d$/.test(e.key)) {
const currentValue = e.currentTarget.value;
const selectionStart = e.currentTarget.selectionStart || 0;
const decimalIndex = currentValue.indexOf(decimalSeparator);
if (decimalIndex !== -1 && selectionStart > decimalIndex) {
const currentDecimalPlaces = currentValue.length - decimalIndex - 1;
if (currentDecimalPlaces >= decimals) {
e.preventDefault();
return;
}
}
}
const allowedChars = /^[0-9.\-+eE]$/;
if (!allowedChars.test(e.key) && e.key !== decimalSeparator) {
e.preventDefault();
return;
}
setIsArrowKeyPressed(false);
let currentChar = e.currentTarget.value;
const isAlterNumPadDecimalChar = e.code === 'NumpadDecimal' && e.key !== decimalSeparator;
if (isAlterNumPadDecimalChar) {
currentChar = decimalSeparator;
}
if (e.key === decimalSeparator && currentChar.split(decimalSeparator).length > 1) {
e.preventDefault();
return;
}
if (e.key === '-') {
const selectionStart = e.currentTarget.selectionStart || 0;
if (selectionStart !== 0 || currentChar.includes('-')) {
e.preventDefault();
return;
}
}
}
break;
}
}
if (onKeyDown) {
onKeyDown(e);
}
}, [increment, decrement, strictMode, trimValue, updateValue, readOnly, format, onKeyDown, parseNumericInput]);
const clearValue = useCallback(() => {
updateValue(null);
setInputString('');
}, [updateValue]);
const displayValue = useMemo(() => {
return formatNumber(isControlled ? value : inputValue);
}, [
isControlled,
value,
inputValue,
formatNumber,
isFocused,
inputString,
arrowKeyPressed
]);
return (_jsxs("span", { className: containerClassNames, style: { width: width ? formatUnit(width) : undefined }, children: [_jsx(InputBase, { id: uniqueId, type: "text", ref: inputRef, className: 'sf-numerictextbox sf-lib sf-input', onChange: handleChange, onFocus: handleFocus, onBlur: handleBlur, ...otherProps, role: "spinbutton", onKeyDown: handleKeyDown, floatLabelType: labelMode, placeholder: placeholder, "aria-valuemin": effectiveMin, "aria-valuemax": effectiveMax, value: displayValue, "aria-valuenow": currentValueRef.current || undefined, autoComplete: autoComplete, tabIndex: 0, disabled: disabled, readOnly: readOnly }), renderFloatLabelElement(labelMode, isFocused, displayValue || '', placeholder, uniqueId), clearButton && renderClearButton(currentValueRef.current && isFocused ? currentValueRef.current.toString() : '', clearValue, clearButton, 'numericTextbox', locale), spinButton && (_jsxs(_Fragment, { children: [_jsxs("button", { className: `${SPINICON} ${SPINDOWN}`, onMouseDown: (e) => {
rippleRef1.rippleMouseDown(e);
e.preventDefault();
}, type: 'button', "aria-label": decrementText, onClick: () => handleSpinClick(false), title: decrementText, tabIndex: -1, children: [_jsx(SvgIcon, { d: SPINDOWN_PATH }), ripple && _jsx(rippleRef1.Ripple, {})] }), _jsxs("button", { className: `${SPINICON} ${SPINUP}`, onMouseDown: (e) => {
rippleRef2.rippleMouseDown(e);
e.preventDefault();
}, type: 'button', "aria-label": incrementText, onClick: () => handleSpinClick(true), title: incrementText, tabIndex: -1, children: [_jsx(SvgIcon, { d: SPINUP_PATH }), ripple && _jsx(rippleRef2.Ripple, {})] })] }))] }));
});
export default NumericTextBox;