refactor(FE-114): integrate inputmask for enhanced numeric input handling and validation

This commit is contained in:
rstubryan
2025-10-23 16:00:24 +07:00
parent e801ba08ad
commit ae967c5ddb
+234 -289
View File
@@ -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 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;
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 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;
return new Inputmask(options);
};
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,45 +54,43 @@ 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<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;
oncomplete?: () => void;
onincomplete?: () => void;
oncleared?: () => void;
}
const NumberInput = ({
@@ -174,186 +118,124 @@ const NumberInput = ({
decimalSeparator = ',',
currencyPrefix = 'Rp ',
weightUnit = 'kg',
min,
max,
allowNegative = false,
showSteppers = false,
step = 1,
oncomplete,
onincomplete,
oncleared,
}: NumberInputProps) => {
const [displayValue, setDisplayValue] = useState<string>('');
// 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 '';
const inputRef = useRef<HTMLInputElement>(null);
const inputmaskRef = useRef<Inputmask.Instance | null>(null);
const [maskComplete, setMaskComplete] = useState<boolean>(false);
const [maskIncomplete, setMaskIncomplete] = useState<boolean>(false);
const [maskCleared, setMaskCleared] = useState<boolean>(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<HTMLInputElement>) => {
let inputValue = e.target.value;
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
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<HTMLInputElement>;
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<HTMLInputElement>;
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<HTMLInputElement>;
onChange(syntheticEvent);
}
};
const inputPrefix = getInputPrefix();
const inputSuffix = getInputSuffix();
return (
<div
@@ -385,78 +267,140 @@ const NumberInput = ({
</label>
)}
<div className='relative flex'>
{inputPrefix && (
<div
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 bg-white',
'inline-flex items-center px-4 py-2 border border-r-0 rounded-l-md transition-all duration-200',
{
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
>
<span
className={cn(
'text-sm font-medium select-none whitespace-nowrap',
{
'text-gray-600': !disabled,
'text-gray-400': disabled,
}
)}
>
{inputPrefix}
</span>
</div>
)}
<div
className={cn(
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white',
{
'border-error': isError,
'border-success!': isValid,
'rounded-l-none!': inputPrefix,
'rounded-r-none!': inputSuffix,
'input-disabled': disabled,
'cursor-not-allowed': disabled,
'bg-gray-50': disabled,
},
className?.inputWrapper
)}
>
{/* Decrement Button */}
{showSteppers && (
<button
type='button'
onClick={handleDecrement}
disabled={disabled || readOnly}
className={cn(
'btn btn-ghost btn-sm h-8 w-8 min-h-0 p-0 rounded-md',
{
'opacity-50 cursor-not-allowed': disabled || readOnly,
}
)}
tabIndex={-1}
>
<Icon icon='ic:round-minus' width={20} height={20} />
</button>
)}
{startAdornment && startAdornment}
<input
type='text'
id={name}
name={name}
placeholder={placeholder}
value={displayValue}
onChange={handleInputChange}
ref={inputRef}
placeholder={placeholder || '0'}
onKeyUp={handleKeyUp}
onFocus={onFocus}
onBlur={onBlur}
disabled={disabled}
className={cn('grow', className?.input)}
className={cn(
'grow bg-transparent outline-none',
{
'cursor-not-allowed': disabled,
'text-gray-500': disabled,
},
className?.input
)}
readOnly={readOnly}
inputMode='decimal'
inputMode='text'
autoComplete='off'
spellCheck={false}
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
</div>
{/* Increment Button */}
{showSteppers && (
<button
type='button'
onClick={handleIncrement}
disabled={disabled || readOnly}
{inputSuffix && (
<div
className={cn(
'btn btn-ghost btn-sm h-8 w-8 min-h-0 p-0 rounded-md',
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-md transition-all duration-200',
{
'opacity-50 cursor-not-allowed': disabled || readOnly,
'bg-gray-100 border-gray-300': !disabled,
'bg-gray-50 border-gray-200': disabled,
}
)}
tabIndex={-1}
>
<Icon icon='ic:round-plus' width={20} height={20} />
</button>
<span
className={cn(
'text-sm font-medium select-none whitespace-nowrap',
{
'text-gray-600': !disabled,
'text-gray-400': disabled,
}
)}
>
{inputSuffix}
</span>
</div>
)}
</div>
{(maskType === 'text' || (oncomplete || onincomplete || oncleared)) && (
<div className='flex gap-2 text-xs'>
<span
className={cn(
'px-2 py-1 rounded transition-all duration-200',
maskComplete
? 'bg-green-100 text-green-700 border border-green-200'
: 'bg-gray-50 text-gray-400 border border-gray-200'
)}
>
Complete
</span>
<span
className={cn(
'px-2 py-1 rounded transition-all duration-200',
maskIncomplete
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
: 'bg-gray-50 text-gray-400 border border-gray-200'
)}
>
Incomplete
</span>
<span
className={cn(
'px-2 py-1 rounded transition-all duration-200',
maskCleared
? 'bg-blue-100 text-blue-700 border border-blue-200'
: 'bg-gray-50 text-gray-400 border border-gray-200'
)}
>
Cleared
</span>
</div>
)}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
@@ -468,3 +412,4 @@ const NumberInput = ({
};
export default NumberInput;