mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 21:41:57 +00:00
refactor(FE-114): enhance NumberInput component with improved props and validation handling
This commit is contained in:
@@ -13,7 +13,65 @@ import { Icon } from '@iconify/react';
|
||||
import { cn } from '@/lib/helper';
|
||||
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 = (
|
||||
value: number | string,
|
||||
decimals: number = 0,
|
||||
@@ -37,6 +95,10 @@ const formatNumber = (
|
||||
: integerPart;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse formatted string to number
|
||||
* Converts formatted input back to raw number for processing
|
||||
*/
|
||||
const parseNumber = (
|
||||
value: string,
|
||||
thousandSeparator: string = '.',
|
||||
@@ -53,26 +115,10 @@ const parseNumber = (
|
||||
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}` : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean and validate numeric input while typing
|
||||
* Ensures only valid characters are allowed
|
||||
*/
|
||||
const cleanNumericInput = (
|
||||
value: string,
|
||||
allowDecimal: boolean = false,
|
||||
@@ -100,56 +146,6 @@ const cleanNumericInput = (
|
||||
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 = ({
|
||||
label,
|
||||
bottomLabel,
|
||||
@@ -183,20 +179,35 @@ const NumberInput = ({
|
||||
}: NumberInputProps) => {
|
||||
const [displayValue, setDisplayValue] = useState<string>('');
|
||||
|
||||
// Determine if decimals are allowed based on maskType
|
||||
// CONFIG & HELPERS
|
||||
const allowDecimal =
|
||||
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 => {
|
||||
if (rawValue === '' || rawValue === null || rawValue === undefined)
|
||||
return '';
|
||||
|
||||
switch (maskType) {
|
||||
case 'currency':
|
||||
return formatCurrency(rawValue, currencyPrefix, decimals);
|
||||
case 'weight':
|
||||
return formatWeight(rawValue, weightUnit, decimals);
|
||||
case 'decimal':
|
||||
case 'number':
|
||||
return formatNumber(
|
||||
@@ -210,21 +221,14 @@ const NumberInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize display value when value prop changes
|
||||
// EFFECTS
|
||||
useEffect(() => {
|
||||
setDisplayValue(getFormattedValue(value || ''));
|
||||
}, [value]);
|
||||
|
||||
// EVENT HANDLERS
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
let 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);
|
||||
}
|
||||
const inputValue = e.target.value;
|
||||
|
||||
// Clean input
|
||||
const cleaned = cleanNumericInput(
|
||||
@@ -263,7 +267,6 @@ const NumberInput = ({
|
||||
|
||||
// Call onChange with modified event
|
||||
if (onChange) {
|
||||
// Create a synthetic event with the numeric value
|
||||
const syntheticEvent = {
|
||||
...e,
|
||||
target: {
|
||||
@@ -277,7 +280,6 @@ const NumberInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Increment
|
||||
const handleIncrement = () => {
|
||||
if (disabled || readOnly) return;
|
||||
|
||||
@@ -315,7 +317,6 @@ const NumberInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Decrement
|
||||
const handleDecrement = () => {
|
||||
if (disabled || readOnly) return;
|
||||
|
||||
@@ -356,8 +357,11 @@ const NumberInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
// RENDER CALCULATIONS
|
||||
const showErrorMessage = Boolean(isError && errorMessage);
|
||||
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
|
||||
const inputPrefix = getInputPrefix();
|
||||
const inputSuffix = getInputSuffix();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -389,78 +393,105 @@ const NumberInput = ({
|
||||
</label>
|
||||
)}
|
||||
|
||||
<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',
|
||||
{
|
||||
'border-error': isError,
|
||||
'border-success!': isValid,
|
||||
},
|
||||
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}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
className={cn('grow', className?.input)}
|
||||
readOnly={readOnly}
|
||||
inputMode='decimal'
|
||||
/>
|
||||
|
||||
{(isLoading || endAdornment) && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
{isLoading && <span className='loading loading-spinner' />}
|
||||
|
||||
{endAdornment && endAdornment}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Increment Button */}
|
||||
{showSteppers && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleIncrement}
|
||||
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-plus' width={20} height={20} />
|
||||
</button>
|
||||
{/* Input Wrapper */}
|
||||
<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',
|
||||
{
|
||||
'border-error': isError,
|
||||
'border-success!': isValid,
|
||||
'rounded-l-none!': inputPrefix,
|
||||
'rounded-r-none!': inputSuffix,
|
||||
},
|
||||
className?.inputWrapper
|
||||
)}
|
||||
>
|
||||
{/* Stepper Buttons */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Start Adornment */}
|
||||
{startAdornment && startAdornment}
|
||||
|
||||
{/* Main Input */}
|
||||
<input
|
||||
type='text'
|
||||
id={name}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={displayValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
className={cn('grow bg-transparent outline-none', className?.input)}
|
||||
readOnly={readOnly}
|
||||
inputMode='decimal'
|
||||
/>
|
||||
|
||||
{/* End Adornment & Loading */}
|
||||
{(isLoading || endAdornment) && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
{isLoading && <span className='loading loading-spinner' />}
|
||||
{endAdornment && endAdornment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Increment Button */}
|
||||
{showSteppers && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleIncrement}
|
||||
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-plus' width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
</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
|
||||
message={feedbackMessage}
|
||||
tone={showErrorMessage ? 'error' : 'info'}
|
||||
|
||||
Reference in New Issue
Block a user