mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
refactor(FE-114): integrate inputmask for enhanced numeric input handling and validation
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user