mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
ChangeEvent,
|
|
ChangeEventHandler,
|
|
FocusEventHandler,
|
|
ReactNode,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { cn } from '@/lib/helper';
|
|
import Inputmask from 'inputmask';
|
|
|
|
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
|
|
};
|
|
|
|
return new Inputmask(options);
|
|
};
|
|
|
|
export type MaskType = 'currency' | 'weight' | 'decimal' | 'number' | 'text';
|
|
|
|
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;
|
|
errorMessage?: string;
|
|
disabled?: boolean;
|
|
readOnly?: boolean;
|
|
required?: boolean;
|
|
isLoading?: boolean;
|
|
|
|
startAdornment?: ReactNode;
|
|
endAdornment?: ReactNode;
|
|
|
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
|
onFocus?: FocusEventHandler<HTMLInputElement>;
|
|
|
|
maskType?: MaskType;
|
|
decimals?: number;
|
|
thousandSeparator?: string;
|
|
decimalSeparator?: string;
|
|
currencyPrefix?: string;
|
|
weightUnit?: string;
|
|
|
|
min?: number;
|
|
max?: number;
|
|
allowNegative?: boolean;
|
|
|
|
oncomplete?: () => void;
|
|
onincomplete?: () => void;
|
|
oncleared?: () => void;
|
|
}
|
|
|
|
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',
|
|
allowNegative = false,
|
|
oncomplete,
|
|
onincomplete,
|
|
oncleared,
|
|
}: NumberInputProps) => {
|
|
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 currencyPrefix;
|
|
default:
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const getInputSuffix = (): string => {
|
|
switch (maskType) {
|
|
case 'weight':
|
|
return weightUnit;
|
|
default:
|
|
return '';
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
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,
|
|
',',
|
|
'.',
|
|
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 handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
const currentValue = (e.currentTarget as HTMLInputElement).value;
|
|
console.log('✅ After format:', currentValue);
|
|
|
|
if (onChange) {
|
|
const syntheticEvent = {
|
|
target: {
|
|
name,
|
|
value: currentValue,
|
|
},
|
|
} as ChangeEvent<HTMLInputElement>;
|
|
onChange(syntheticEvent);
|
|
}
|
|
};
|
|
|
|
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>
|
|
)}
|
|
|
|
<div className='relative flex'>
|
|
{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>
|
|
)}
|
|
|
|
<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
|
|
)}
|
|
>
|
|
{startAdornment && startAdornment}
|
|
|
|
<input
|
|
type='text'
|
|
id={name}
|
|
name={name}
|
|
ref={inputRef}
|
|
placeholder={placeholder || '0'}
|
|
onKeyUp={handleKeyUp}
|
|
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='text'
|
|
autoComplete='off'
|
|
spellCheck={false}
|
|
/>
|
|
|
|
{(isLoading || endAdornment) && (
|
|
<div className='flex flex-row gap-2'>
|
|
{isLoading && <span className='loading loading-spinner' />}
|
|
{endAdornment && endAdornment}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{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>
|
|
|
|
{(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>
|
|
)}
|
|
{isError && errorMessage && (
|
|
<p className='w-full text-sm text-error'>{errorMessage}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default NumberInput;
|
|
|