refactor(FE-114): enhance NumberInput component with improved props and validation handling

This commit is contained in:
rstubryan
2025-10-21 09:33:17 +07:00
parent 83d31b7047
commit 41f8067727
+123 -92
View File
@@ -13,7 +13,65 @@ import { Icon } from '@iconify/react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage'; import FieldMessage from '@/components/helper/FieldMessage';
// Utility Functions 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<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
// 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 = ( const formatNumber = (
value: number | string, value: number | string,
decimals: number = 0, decimals: number = 0,
@@ -37,6 +95,10 @@ const formatNumber = (
: integerPart; : integerPart;
}; };
/**
* Parse formatted string to number
* Converts formatted input back to raw number for processing
*/
const parseNumber = ( const parseNumber = (
value: string, value: string,
thousandSeparator: string = '.', thousandSeparator: string = '.',
@@ -53,26 +115,10 @@ const parseNumber = (
return isNaN(parsed) ? 0 : parsed; return isNaN(parsed) ? 0 : parsed;
}; };
const formatCurrency = ( /**
value: number | string, * Clean and validate numeric input while typing
prefix: string = 'Rp ', * Ensures only valid characters are allowed
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 = ( const cleanNumericInput = (
value: string, value: string,
allowDecimal: boolean = false, allowDecimal: boolean = false,
@@ -100,56 +146,6 @@ const cleanNumericInput = (
return cleaned; return cleaned;
}; };
// Types
export type MaskType = 'currency' | 'weight' | 'decimal' | 'number';
export interface NumberInputProps {
label?: string;
bottomLabel?: string;
name: string;
value?: number | string;
placeholder?: string;
className?: {
wrapper?: string;
label?: string;
inputWrapper?: string;
input?: string;
};
isError?: boolean;
isValid?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
isLoading?: boolean;
errorMessage?: string;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
// 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;
}
const NumberInput = ({ const NumberInput = ({
label, label,
bottomLabel, bottomLabel,
@@ -183,20 +179,35 @@ const NumberInput = ({
}: NumberInputProps) => { }: NumberInputProps) => {
const [displayValue, setDisplayValue] = useState<string>(''); const [displayValue, setDisplayValue] = useState<string>('');
// Determine if decimals are allowed based on maskType // CONFIG & HELPERS
const allowDecimal = const allowDecimal =
maskType === 'decimal' || maskType === 'weight' || decimals > 0; maskType === 'decimal' || maskType === 'weight' || decimals > 0;
// Format value for display based on maskType 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 => { const getFormattedValue = (rawValue: number | string): string => {
if (rawValue === '' || rawValue === null || rawValue === undefined) if (rawValue === '' || rawValue === null || rawValue === undefined)
return ''; return '';
switch (maskType) { switch (maskType) {
case 'currency': case 'currency':
return formatCurrency(rawValue, currencyPrefix, decimals);
case 'weight': case 'weight':
return formatWeight(rawValue, weightUnit, decimals);
case 'decimal': case 'decimal':
case 'number': case 'number':
return formatNumber( return formatNumber(
@@ -210,21 +221,14 @@ const NumberInput = ({
} }
}; };
// Initialize display value when value prop changes // EFFECTS
useEffect(() => { useEffect(() => {
setDisplayValue(getFormattedValue(value || '')); setDisplayValue(getFormattedValue(value || ''));
}, [value]); }, [value]);
// EVENT HANDLERS
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
let inputValue = e.target.value; const inputValue = e.target.value;
// 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 // Clean input
const cleaned = cleanNumericInput( const cleaned = cleanNumericInput(
@@ -263,7 +267,6 @@ const NumberInput = ({
// Call onChange with modified event // Call onChange with modified event
if (onChange) { if (onChange) {
// Create a synthetic event with the numeric value
const syntheticEvent = { const syntheticEvent = {
...e, ...e,
target: { target: {
@@ -277,7 +280,6 @@ const NumberInput = ({
} }
}; };
// Handle Increment
const handleIncrement = () => { const handleIncrement = () => {
if (disabled || readOnly) return; if (disabled || readOnly) return;
@@ -315,7 +317,6 @@ const NumberInput = ({
} }
}; };
// Handle Decrement
const handleDecrement = () => { const handleDecrement = () => {
if (disabled || readOnly) return; if (disabled || readOnly) return;
@@ -356,8 +357,11 @@ const NumberInput = ({
} }
}; };
// RENDER CALCULATIONS
const showErrorMessage = Boolean(isError && errorMessage); const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel; const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
const inputPrefix = getInputPrefix();
const inputSuffix = getInputSuffix();
return ( return (
<div <div
@@ -389,17 +393,31 @@ const NumberInput = ({
</label> </label>
)} )}
{/* Input Container */}
<div className='relative flex'>
{/* Prefix Block */}
{inputPrefix && (
<div className='inline-flex items-center px-4 py-2 bg-gray-100 border border-r-0 border-gray-300 rounded-l-md'>
<span className='text-sm font-medium text-gray-600 select-none whitespace-nowrap'>
{inputPrefix}
</span>
</div>
)}
{/* Input Wrapper */}
<div <div
className={cn( className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200', 'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center',
{ {
'border-error': isError, 'border-error': isError,
'border-success!': isValid, 'border-success!': isValid,
'rounded-l-none!': inputPrefix,
'rounded-r-none!': inputSuffix,
}, },
className?.inputWrapper className?.inputWrapper
)} )}
> >
{/* Decrement Button */} {/* Stepper Buttons */}
{showSteppers && ( {showSteppers && (
<button <button
type='button' type='button'
@@ -417,8 +435,10 @@ const NumberInput = ({
</button> </button>
)} )}
{/* Start Adornment */}
{startAdornment && startAdornment} {startAdornment && startAdornment}
{/* Main Input */}
<input <input
type='text' type='text'
id={name} id={name}
@@ -429,15 +449,15 @@ const NumberInput = ({
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
disabled={disabled} disabled={disabled}
className={cn('grow', className?.input)} className={cn('grow bg-transparent outline-none', className?.input)}
readOnly={readOnly} readOnly={readOnly}
inputMode='decimal' inputMode='decimal'
/> />
{/* End Adornment & Loading */}
{(isLoading || endAdornment) && ( {(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />} {isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment} {endAdornment && endAdornment}
</div> </div>
)} )}
@@ -461,6 +481,17 @@ const NumberInput = ({
)} )}
</div> </div>
{/* Suffix Block */}
{inputSuffix && (
<div className='inline-flex items-center px-4 py-2 bg-gray-100 border border-l-0 border-gray-300 rounded-r-md'>
<span className='text-sm font-medium text-gray-600 select-none whitespace-nowrap'>
{inputSuffix}
</span>
</div>
)}
</div>
{/* Field Message */}
<FieldMessage <FieldMessage
message={feedbackMessage} message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'} tone={showErrorMessage ? 'error' : 'info'}