From ae967c5ddb863582c6ce7db8aa5b271c4df2050e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Thu, 23 Oct 2025 16:00:24 +0700 Subject: [PATCH] refactor(FE-114): integrate inputmask for enhanced numeric input handling and validation --- src/components/input/NumberInput.tsx | 617 ++++++++++++--------------- 1 file changed, 281 insertions(+), 336 deletions(-) diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx index 2afe7e55..96c7f446 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -6,101 +6,47 @@ import { FocusEventHandler, ReactNode, useEffect, + useRef, useState, } from 'react'; -import { Icon } from '@iconify/react'; import { cn } from '@/lib/helper'; +import Inputmask from 'inputmask'; -// Utility Functions -const formatNumber = ( - value: number | string, - decimals: number = 0, - thousandSeparator: string = '.', - decimalSeparator: string = ',' -): string => { - if (value === '' || value === null || value === undefined) return ''; +const createInputMask = ( + maskType: MaskType, + decimals: number, + thousandSeparator: string, + decimalSeparator: string, + allowNegative: boolean, + oncomplete?: () => void, + onincomplete?: () => void, + oncleared?: () => void +): Inputmask.Instance => { + const options: Inputmask.Options = { + alias: 'numeric', + groupSeparator: thousandSeparator, + radixPoint: decimalSeparator, + digits: decimals, + allowMinus: allowNegative, + rightAlign: false, + insertMode: true, + autoUnmask: false, + clearMaskOnLostFocus: false, + digitsOptional: decimals > 0, + placeholder: '0', + numericInput: false, + positionCaretOnClick: 'radixFocus', + greedy: true, + oncomplete, + onincomplete, + oncleared + }; - const numValue = typeof value === 'string' ? parseFloat(value) : value; - if (isNaN(numValue)) return ''; - - const parts = numValue.toFixed(decimals).split('.'); - const integerPart = parts[0].replace( - /\B(?=(\d{3})+(?!\d))/g, - thousandSeparator - ); - const decimalPart = parts[1]; - - return decimals > 0 && decimalPart - ? `${integerPart}${decimalSeparator}${decimalPart}` - : integerPart; + return new Inputmask(options); }; -const parseNumber = ( - value: string, - thousandSeparator: string = '.', - decimalSeparator: string = ',' -): number => { - if (!value) return 0; - - // Remove thousand separators and replace decimal separator with dot - const cleaned = value - .replace(new RegExp(`\\${thousandSeparator}`, 'g'), '') - .replace(decimalSeparator, '.'); - - const parsed = parseFloat(cleaned); - return isNaN(parsed) ? 0 : parsed; -}; - -const formatCurrency = ( - value: number | string, - prefix: string = 'Rp ', - decimals: number = 0 -): string => { - if (value === '' || value === null || value === undefined) return ''; - const formatted = formatNumber(value, decimals); - return formatted ? `${prefix}${formatted}` : ''; -}; - -const formatWeight = ( - value: number | string, - unit: string = 'kg', - decimals: number = 2 -): string => { - if (value === '' || value === null || value === undefined) return ''; - const formatted = formatNumber(value, decimals); - return formatted ? `${formatted} ${unit}` : ''; -}; - -const cleanNumericInput = ( - value: string, - allowDecimal: boolean = false, - decimalSeparator: string = ',' -): string => { - // Only allow numbers, decimal separator (if allowed), and minus sign at the start - let cleaned = value.replace(/[^\d,.-]/g, ''); - - // Handle decimal separator - if (allowDecimal) { - const parts = cleaned.split(decimalSeparator); - if (parts.length > 2) { - // Keep only first decimal separator - cleaned = parts[0] + decimalSeparator + parts.slice(1).join(''); - } - } else { - cleaned = cleaned.replace(new RegExp(decimalSeparator, 'g'), ''); - } - - // Handle minus sign (only at start) - const hasMinusAtStart = cleaned.startsWith('-'); - cleaned = cleaned.replace(/-/g, ''); - if (hasMinusAtStart) cleaned = '-' + cleaned; - - return cleaned; -}; - -// Types -export type MaskType = 'currency' | 'weight' | 'decimal' | 'number'; +export type MaskType = 'currency' | 'weight' | 'decimal' | 'number' | 'text'; export interface NumberInputProps { label?: string; @@ -108,252 +54,188 @@ export interface NumberInputProps { name: string; value?: number | string; placeholder?: string; + className?: { wrapper?: string; label?: string; inputWrapper?: string; input?: string; }; + isError?: boolean; isValid?: boolean; + errorMessage?: string; disabled?: boolean; readOnly?: boolean; required?: boolean; isLoading?: boolean; - errorMessage?: string; + startAdornment?: ReactNode; endAdornment?: ReactNode; + onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; onFocus?: FocusEventHandler; - // Masking Options maskType?: MaskType; decimals?: number; thousandSeparator?: string; decimalSeparator?: string; - - // Currency specific currencyPrefix?: string; - - // Weight specific weightUnit?: string; - // Validation min?: number; max?: number; allowNegative?: boolean; - // Stepper (Increment/Decrement buttons) - showSteppers?: boolean; - step?: number; + oncomplete?: () => void; + onincomplete?: () => void; + oncleared?: () => void; } const NumberInput = ({ - label, - bottomLabel, - name, - value, - placeholder, - className, - isError, - isValid, - errorMessage, - startAdornment, - endAdornment, - disabled = false, - required = false, - onChange, - onBlur, - onFocus, - readOnly = false, - isLoading = false, - maskType = 'number', - decimals = 0, - thousandSeparator = '.', - decimalSeparator = ',', - currencyPrefix = 'Rp ', - weightUnit = 'kg', - min, - max, - allowNegative = false, - showSteppers = false, - step = 1, -}: NumberInputProps) => { - const [displayValue, setDisplayValue] = useState(''); - - // Determine if decimals are allowed based on maskType - const allowDecimal = - maskType === 'decimal' || maskType === 'weight' || decimals > 0; - - // Format value for display based on maskType - const getFormattedValue = (rawValue: number | string): string => { - if (rawValue === '' || rawValue === null || rawValue === undefined) - return ''; + label, + bottomLabel, + name, + value, + placeholder, + className, + isError, + isValid, + errorMessage, + startAdornment, + endAdornment, + disabled = false, + required = false, + onChange, + onBlur, + onFocus, + readOnly = false, + isLoading = false, + maskType = 'number', + decimals = 0, + thousandSeparator = '.', + decimalSeparator = ',', + currencyPrefix = 'Rp ', + weightUnit = 'kg', + allowNegative = false, + oncomplete, + onincomplete, + oncleared, + }: NumberInputProps) => { + const inputRef = useRef(null); + const inputmaskRef = useRef(null); + const [maskComplete, setMaskComplete] = useState(false); + const [maskIncomplete, setMaskIncomplete] = useState(false); + const [maskCleared, setMaskCleared] = useState(false); + const getInputPrefix = (): string => { switch (maskType) { case 'currency': - return formatCurrency(rawValue, currencyPrefix, decimals); - case 'weight': - return formatWeight(rawValue, weightUnit, decimals); - case 'decimal': - case 'number': - return formatNumber( - rawValue, - decimals, - thousandSeparator, - decimalSeparator - ); + return currencyPrefix; default: - return String(rawValue); + return ''; + } + }; + + const getInputSuffix = (): string => { + switch (maskType) { + case 'weight': + return weightUnit; + default: + return ''; } }; - // Initialize display value when value prop changes useEffect(() => { - setDisplayValue(getFormattedValue(value || '')); + if (inputRef.current && !readOnly && !disabled) { + if (inputmaskRef.current) { + try { + inputmaskRef.current.remove(); + } catch (error) { + console.warn('Error removing Inputmask:', error); + } + } + + const handleComplete = () => { + setMaskComplete(true); + setMaskIncomplete(false); + setMaskCleared(false); + if (oncomplete) oncomplete(); + }; + + const handleIncomplete = () => { + setMaskIncomplete(true); + setMaskComplete(false); + setMaskCleared(false); + if (onincomplete) onincomplete(); + }; + + const handleCleared = () => { + setMaskCleared(true); + setMaskComplete(false); + setMaskIncomplete(false); + if (oncleared) oncleared(); + }; + + const im = createInputMask( + maskType, + decimals, + thousandSeparator, + decimalSeparator, + allowNegative, + handleComplete, + handleIncomplete, + handleCleared + ); + + try { + im.mask(inputRef.current); + inputmaskRef.current = im; + } catch (error) { + console.warn('Error applying Inputmask:', error); + inputmaskRef.current = null; + } + } + + return () => { + if (inputmaskRef.current) { + try { + inputmaskRef.current.remove(); + } catch (error) { + console.warn('Error removing Inputmask on cleanup:', error); + } + } + }; + }, [maskType, decimals, thousandSeparator, decimalSeparator, allowNegative, readOnly, disabled, oncomplete, onincomplete, oncleared]); + + useEffect(() => { + if (inputRef.current && value !== undefined) { + if (value === null || value === '') { + inputRef.current.value = ''; + } else { + inputRef.current.value = String(value); + } + } }, [value]); - const handleInputChange = (e: ChangeEvent) => { - let inputValue = e.target.value; + const handleKeyUp = (e: React.KeyboardEvent) => { + const currentValue = (e.currentTarget as HTMLInputElement).value; + console.log('✅ After format:', currentValue); - // Remove prefix/suffix for editing - if (maskType === 'currency' && inputValue.startsWith(currencyPrefix)) { - inputValue = inputValue.slice(currencyPrefix.length); - } - if (maskType === 'weight' && inputValue.endsWith(` ${weightUnit}`)) { - inputValue = inputValue.slice(0, -weightUnit.length - 1); - } - - // Clean input - const cleaned = cleanNumericInput( - inputValue, - allowDecimal, - decimalSeparator - ); - - // Parse to number - let numericValue = parseNumber( - cleaned, - thousandSeparator, - decimalSeparator - ); - - // Apply validation - if (!allowNegative && numericValue < 0) { - numericValue = 0; - } - if (min !== undefined && numericValue < min) { - numericValue = min; - } - if (max !== undefined && numericValue > max) { - numericValue = max; - } - - // Update display value - const formattedForDisplay = formatNumber( - numericValue, - decimals, - thousandSeparator, - decimalSeparator - ); - - setDisplayValue(formattedForDisplay); - - // Call onChange with modified event - if (onChange) { - // Create a synthetic event with the numeric value - const syntheticEvent = { - ...e, - target: { - ...e.target, - name, - value: numericValue.toString(), - }, - } as ChangeEvent; - - onChange(syntheticEvent); - } - }; - - // Handle Increment - const handleIncrement = () => { - if (disabled || readOnly) return; - - const currentValue = parseNumber( - displayValue, - thousandSeparator, - decimalSeparator - ); - let newValue = currentValue + step; - - // Apply max validation - if (max !== undefined && newValue > max) { - newValue = max; - } - - // Update display - const formattedForDisplay = formatNumber( - newValue, - decimals, - thousandSeparator, - decimalSeparator - ); - setDisplayValue(formattedForDisplay); - - // Call onChange with synthetic event if (onChange) { const syntheticEvent = { target: { name, - value: newValue.toString(), + value: currentValue, }, } as ChangeEvent; - onChange(syntheticEvent); } }; - // Handle Decrement - const handleDecrement = () => { - if (disabled || readOnly) return; - - const currentValue = parseNumber( - displayValue, - thousandSeparator, - decimalSeparator - ); - let newValue = currentValue - step; - - // Apply min validation (prevent negative if not allowed) - if (!allowNegative && newValue < 0) { - newValue = 0; - } - if (min !== undefined && newValue < min) { - newValue = min; - } - - // Update display - const formattedForDisplay = formatNumber( - newValue, - decimals, - thousandSeparator, - decimalSeparator - ); - setDisplayValue(formattedForDisplay); - - // Call onChange with synthetic event - if (onChange) { - const syntheticEvent = { - target: { - name, - value: newValue.toString(), - }, - } as ChangeEvent; - - onChange(syntheticEvent); - } - }; + const inputPrefix = getInputPrefix(); + const inputSuffix = getInputSuffix(); return (
)} -
- {/* Decrement Button */} - {showSteppers && ( - - )} - - {startAdornment && startAdornment} - - - - {(isLoading || endAdornment) && ( -
- {isLoading && } - - {endAdornment && endAdornment} + + {inputPrefix} +
)} - {/* Increment Button */} - {showSteppers && ( -
+ + {inputSuffix && ( +
- - + + {inputSuffix} + +
)}
+ {(maskType === 'text' || (oncomplete || onincomplete || oncleared)) && ( +
+ + Complete + + + Incomplete + + + Cleared + +
+ )} + {!isError && bottomLabel && (

{bottomLabel}

)} @@ -468,3 +412,4 @@ const NumberInput = ({ }; export default NumberInput; +