UNPKG

@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
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;