mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-24 23:35:45 +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 { 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,78 +393,105 @@ const NumberInput = ({
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{/* Input Container */}
|
||||||
className={cn(
|
<div className='relative flex'>
|
||||||
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200',
|
{/* Prefix Block */}
|
||||||
{
|
{inputPrefix && (
|
||||||
'border-error': isError,
|
<div className='inline-flex items-center px-4 py-2 bg-gray-100 border border-r-0 border-gray-300 rounded-l-md'>
|
||||||
'border-success!': isValid,
|
<span className='text-sm font-medium text-gray-600 select-none whitespace-nowrap'>
|
||||||
},
|
{inputPrefix}
|
||||||
className?.inputWrapper
|
</span>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 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}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Increment Button */}
|
{/* Input Wrapper */}
|
||||||
{showSteppers && (
|
<div
|
||||||
<button
|
className={cn(
|
||||||
type='button'
|
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center',
|
||||||
onClick={handleIncrement}
|
{
|
||||||
disabled={disabled || readOnly}
|
'border-error': isError,
|
||||||
className={cn(
|
'border-success!': isValid,
|
||||||
'btn btn-ghost btn-sm h-8 w-8 min-h-0 p-0 rounded-md',
|
'rounded-l-none!': inputPrefix,
|
||||||
{
|
'rounded-r-none!': inputSuffix,
|
||||||
'opacity-50 cursor-not-allowed': disabled || readOnly,
|
},
|
||||||
}
|
className?.inputWrapper
|
||||||
)}
|
)}
|
||||||
tabIndex={-1}
|
>
|
||||||
>
|
{/* Stepper Buttons */}
|
||||||
<Icon icon='ic:round-plus' width={20} height={20} />
|
{showSteppers && (
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Field Message */}
|
||||||
<FieldMessage
|
<FieldMessage
|
||||||
message={feedbackMessage}
|
message={feedbackMessage}
|
||||||
tone={showErrorMessage ? 'error' : 'info'}
|
tone={showErrorMessage ? 'error' : 'info'}
|
||||||
|
|||||||
Reference in New Issue
Block a user