feat(FE-Storyless): integrate NumberInput and PatternInput components with react-number-format for enhanced input handling

This commit is contained in:
rstubryan
2025-10-25 10:49:07 +07:00
parent 896a0c6de2
commit 6290199074
4 changed files with 104 additions and 394 deletions
+11
View File
@@ -19,6 +19,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4",
"react-select": "^5.10.2",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
@@ -5834,6 +5835,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-number-format": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
"integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==",
"license": "MIT",
"peerDependencies": {
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-select": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
+1
View File
@@ -21,6 +21,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4",
"react-select": "^5.10.2",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
+32 -394
View File
@@ -1,415 +1,53 @@
'use client';
import {
ChangeEvent,
ChangeEventHandler,
FocusEventHandler,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { ChangeEvent } from 'react';
import { NumericFormat, OnValueChange } from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
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;
interface NumberInputProps extends Omit<TextInputProps, 'type'> {
thousandSeparator?: string;
decimalSeparator?: string;
currencyPrefix?: string;
weightUnit?: string;
min?: number;
max?: number;
decimalScale?: number;
allowNegative?: boolean;
oncomplete?: () => void;
onincomplete?: () => void;
oncleared?: () => void;
prefix?: string;
suffix?: string;
fixedDecimalScale?: boolean;
}
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,
decimalScale = 5,
allowNegative = true,
onChange,
...restProps
}: 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 valueChangeHandler: OnValueChange = (
numberFormatValues,
sourceInfo
) => {
const newChangeEvent = sourceInfo.event as
| ChangeEvent<HTMLInputElement>
| undefined;
const getInputPrefix = (): string => {
switch (maskType) {
case 'currency':
return currencyPrefix;
default:
return '';
if (newChangeEvent) {
newChangeEvent.target.value = numberFormatValues.value;
onChange?.(newChangeEvent);
}
};
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>
<NumericFormat
thousandSeparator={thousandSeparator}
decimalSeparator={decimalSeparator}
customInput={TextInput}
onValueChange={valueChangeHandler}
decimalScale={decimalScale}
allowNegative={allowNegative}
{...restProps}
/>
);
};
export default NumberInput;
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { ChangeEvent } from 'react';
import { PatternFormat, OnValueChange } from 'react-number-format';
import TextInput, { TextInputProps } from '@/components/input/TextInput';
interface PatternInputProps extends Omit<TextInputProps, 'type'> {
type?: 'password' | 'tel' | 'text' | undefined;
/** Format pattern, e.g. "##/##/####", "(###) ###-####", "####-####-####" */
format: string;
/** Mask character for empty slots, e.g. "_" */
mask?: string;
/** Allow showing mask even when value is empty */
allowEmptyFormatting?: boolean;
patternChar?: string;
}
const PatternInput = ({
type = 'text',
format,
mask = '_',
allowEmptyFormatting = false,
patternChar = '#',
onChange,
...restProps
}: PatternInputProps) => {
const valueChangeHandler: OnValueChange = (
patternFormatValues,
sourceInfo
) => {
const newChangeEvent = sourceInfo.event as
| ChangeEvent<HTMLInputElement>
| undefined;
if (newChangeEvent) {
newChangeEvent.target.value = patternFormatValues.value;
onChange?.(newChangeEvent);
}
};
return (
<PatternFormat
type={type}
format={format}
mask={mask}
allowEmptyFormatting={allowEmptyFormatting}
patternChar={patternChar}
customInput={TextInput}
onValueChange={valueChangeHandler}
{...restProps}
/>
);
};
export default PatternInput;