mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-25 15:55:48 +00:00
feat(FE-Storyless): integrate NumberInput and PatternInput components with react-number-format for enhanced input handling
This commit is contained in:
Generated
+11
@@ -19,6 +19,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-number-format": "^5.4.4",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
"swr": "^2.3.6",
|
"swr": "^2.3.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -5834,6 +5835,16 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-select": {
|
||||||
"version": "5.10.2",
|
"version": "5.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-number-format": "^5.4.4",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
"swr": "^2.3.6",
|
"swr": "^2.3.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
@@ -1,415 +1,53 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import { ChangeEvent } from 'react';
|
||||||
ChangeEvent,
|
import { NumericFormat, OnValueChange } from 'react-number-format';
|
||||||
ChangeEventHandler,
|
import TextInput, { TextInputProps } from '@/components/input/TextInput';
|
||||||
FocusEventHandler,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/helper';
|
interface NumberInputProps extends Omit<TextInputProps, 'type'> {
|
||||||
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;
|
thousandSeparator?: string;
|
||||||
decimalSeparator?: string;
|
decimalSeparator?: string;
|
||||||
currencyPrefix?: string;
|
decimalScale?: number;
|
||||||
weightUnit?: string;
|
|
||||||
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
allowNegative?: boolean;
|
allowNegative?: boolean;
|
||||||
|
prefix?: string;
|
||||||
oncomplete?: () => void;
|
suffix?: string;
|
||||||
onincomplete?: () => void;
|
fixedDecimalScale?: boolean;
|
||||||
oncleared?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NumberInput = ({
|
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 = ',',
|
thousandSeparator = ',',
|
||||||
decimalSeparator = '.',
|
decimalSeparator = '.',
|
||||||
currencyPrefix = 'Rp ',
|
decimalScale = 5,
|
||||||
weightUnit = 'kg',
|
allowNegative = true,
|
||||||
allowNegative = false,
|
onChange,
|
||||||
oncomplete,
|
...restProps
|
||||||
onincomplete,
|
|
||||||
oncleared,
|
|
||||||
}: NumberInputProps) => {
|
}: NumberInputProps) => {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const valueChangeHandler: OnValueChange = (
|
||||||
const inputmaskRef = useRef<Inputmask.Instance | null>(null);
|
numberFormatValues,
|
||||||
const [maskComplete, setMaskComplete] = useState<boolean>(false);
|
sourceInfo
|
||||||
const [maskIncomplete, setMaskIncomplete] = useState<boolean>(false);
|
) => {
|
||||||
const [maskCleared, setMaskCleared] = useState<boolean>(false);
|
const newChangeEvent = sourceInfo.event as
|
||||||
|
| ChangeEvent<HTMLInputElement>
|
||||||
|
| undefined;
|
||||||
|
|
||||||
const getInputPrefix = (): string => {
|
if (newChangeEvent) {
|
||||||
switch (maskType) {
|
newChangeEvent.target.value = numberFormatValues.value;
|
||||||
case 'currency':
|
|
||||||
return currencyPrefix;
|
onChange?.(newChangeEvent);
|
||||||
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 (
|
return (
|
||||||
<div
|
<NumericFormat
|
||||||
className={cn(
|
thousandSeparator={thousandSeparator}
|
||||||
'w-full flex flex-col gap-2 text-start',
|
decimalSeparator={decimalSeparator}
|
||||||
className?.wrapper
|
customInput={TextInput}
|
||||||
)}
|
onValueChange={valueChangeHandler}
|
||||||
>
|
decimalScale={decimalScale}
|
||||||
{label && (
|
allowNegative={allowNegative}
|
||||||
<label
|
{...restProps}
|
||||||
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;
|
export default NumberInput;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user