mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 21:41:57 +00:00
547 lines
13 KiB
TypeScript
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;
|