Files
lti-web-client/src/components/input/NumberInput.tsx
T

547 lines
13 KiB
TypeScript

'use client';
import {
ChangeEvent,
ChangeEventHandler,
FocusEventHandler,
ReactNode,
useEffect,
useState,
} from 'react';
import { Icon } from '@iconify/react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
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,
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;
};
/**
* Parse formatted string to number
* Converts formatted input back to raw number for processing
*/
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;
};
/**
* Clean and validate numeric input while typing
* Ensures only valid characters are allowed
*/
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;
};
const NumberInput = ({
label,
bottomLabel,
name,
value,
placeholder,
className,
isError,
isValid,
errorMessage,
startAdornment,
endAdornment,
disabled = false,
required = false,
onChange,
onBlur,
onFocus,
readOnly = false,
isLoading = false,
maskType = 'number',
decimals = 0,
thousandSeparator = '.',
decimalSeparator = ',',
currencyPrefix = 'Rp ',
weightUnit = 'kg',
min,
max,
allowNegative = false,
showSteppers = false,
step = 1,
}: NumberInputProps) => {
const [displayValue, setDisplayValue] = useState<string>('');
// CONFIG & HELPERS
const allowDecimal =
maskType === 'decimal' || maskType === 'weight' || decimals > 0;
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':
case 'weight':
case 'decimal':
case 'number':
return formatNumber(
rawValue,
decimals,
thousandSeparator,
decimalSeparator
);
default:
return String(rawValue);
}
};
// EFFECTS
useEffect(() => {
setDisplayValue(getFormattedValue(value || ''));
}, [value]);
// EVENT HANDLERS
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// 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) {
const syntheticEvent = {
...e,
target: {
...e.target,
name,
value: numericValue.toString(),
},
} as ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
}
};
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(),
},
} as ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
}
};
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);
}
};
// RENDER CALCULATIONS
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
const inputPrefix = getInputPrefix();
const inputSuffix = getInputSuffix();
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</label>
)}
{/* Input Container */}
<div className='relative flex'>
{/* Prefix Block */}
{inputPrefix && (
<div
className={cn(
'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>
)}
{/* 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 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
)}
>
{/* 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',
{
'cursor-not-allowed': disabled,
'text-gray-500': disabled,
},
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={cn(
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-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,
}
)}
>
{inputSuffix}
</span>
</div>
)}
</div>
{/* Field Message */}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
</div>
);
};
export default NumberInput;