'use client'; import { ChangeEvent, ChangeEventHandler, FocusEventHandler, ReactNode, useEffect, useState, } from 'react'; import { Icon } from '@iconify/react'; import { cn } from '@/lib/helper'; import FieldMessage from '@/components/helper/FieldMessage'; export type MaskType = 'currency' | 'weight' | 'decimal' | 'number'; export interface NumberInputProps { // Basic Props label?: string; bottomLabel?: string; name: string; value?: number | string; placeholder?: string; // Styling Props className?: { wrapper?: string; label?: string; inputWrapper?: string; input?: string; }; // State Props isError?: boolean; isValid?: boolean; errorMessage?: string; disabled?: boolean; readOnly?: boolean; required?: boolean; isLoading?: boolean; // Adornment Props startAdornment?: ReactNode; endAdornment?: ReactNode; // Event Handlers onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; onFocus?: FocusEventHandler; // Masking Options maskType?: MaskType; decimals?: number; thousandSeparator?: string; decimalSeparator?: string; currencyPrefix?: string; weightUnit?: string; // Validation Props min?: number; max?: number; allowNegative?: boolean; // Stepper Options showSteppers?: boolean; step?: number; } // UTILITY FUNCTIONS /** * Core number formatting function * Formats number with thousand separator and decimal separator */ const formatNumber = ( value: number | string, decimals: number = 0, thousandSeparator: string = '.', decimalSeparator: string = ',' ): string => { if (value === '' || value === null || value === undefined) return ''; 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; }; /** * Parse formatted string to number * Converts formatted input back to raw number for processing */ 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; }; /** * Clean and validate numeric input while typing * Ensures only valid characters are allowed */ 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; }; 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(''); // CONFIG & HELPERS const allowDecimal = maskType === 'decimal' || maskType === 'weight' || decimals > 0; const getInputPrefix = (): string => { switch (maskType) { case 'currency': return currencyPrefix; default: return ''; } }; const getInputSuffix = (): string => { switch (maskType) { case 'weight': return weightUnit; default: return ''; } }; const getFormattedValue = (rawValue: number | string): string => { if (rawValue === '' || rawValue === null || rawValue === undefined) return ''; switch (maskType) { case 'currency': case 'weight': case 'decimal': case 'number': return formatNumber( rawValue, decimals, thousandSeparator, decimalSeparator ); default: return String(rawValue); } }; // EFFECTS useEffect(() => { setDisplayValue(getFormattedValue(value || '')); }, [value]); // EVENT HANDLERS const handleInputChange = (e: ChangeEvent) => { const inputValue = e.target.value; // 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) { const syntheticEvent = { ...e, target: { ...e.target, name, value: numericValue.toString(), }, } as ChangeEvent; onChange(syntheticEvent); } }; 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(), }, } as ChangeEvent; onChange(syntheticEvent); } }; 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); } }; // RENDER CALCULATIONS const showErrorMessage = Boolean(isError && errorMessage); const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; const inputPrefix = getInputPrefix(); const inputSuffix = getInputSuffix(); return (
{label && ( )} {/* Input Container */}
{/* Prefix Block */} {inputPrefix && (
{inputPrefix}
)} {/* Input Wrapper */}
{/* Stepper Buttons */} {showSteppers && ( )} {/* Start Adornment */} {startAdornment && startAdornment} {/* Main Input */} {/* End Adornment & Loading */} {(isLoading || endAdornment) && (
{isLoading && } {endAdornment && endAdornment}
)} {/* Increment Button */} {showSteppers && ( )}
{/* Suffix Block */} {inputSuffix && (
{inputSuffix}
)}
{/* Field Message */}
); }; export default NumberInput;