From 6290199074bc8f56db602554850987afd49a3a41 Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 25 Oct 2025 10:49:07 +0700 Subject: [PATCH] feat(FE-Storyless): integrate NumberInput and PatternInput components with react-number-format for enhanced input handling --- package-lock.json | 11 + package.json | 1 + src/components/input/NumberInput.tsx | 426 ++------------------------ src/components/input/PatternInput.tsx | 60 ++++ 4 files changed, 104 insertions(+), 394 deletions(-) create mode 100644 src/components/input/PatternInput.tsx diff --git a/package-lock.json b/package-lock.json index 4a583dbd..6f255a99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 70e5737f..b371e4e7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx index 5b2188ee..8efef51d 100644 --- a/src/components/input/NumberInput.tsx +++ b/src/components/input/NumberInput.tsx @@ -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; - onBlur?: FocusEventHandler; - onFocus?: FocusEventHandler; - - maskType?: MaskType; - decimals?: number; +interface NumberInputProps extends Omit { 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(null); - const inputmaskRef = useRef(null); - const [maskComplete, setMaskComplete] = useState(false); - const [maskIncomplete, setMaskIncomplete] = useState(false); - const [maskCleared, setMaskCleared] = useState(false); + const valueChangeHandler: OnValueChange = ( + numberFormatValues, + sourceInfo + ) => { + const newChangeEvent = sourceInfo.event as + | ChangeEvent + | 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) => { - const currentValue = (e.currentTarget as HTMLInputElement).value; - console.log('✅ After format:', currentValue); - - if (onChange) { - const syntheticEvent = { - target: { - name, - value: currentValue, - }, - } as ChangeEvent; - onChange(syntheticEvent); - } - }; - - const inputPrefix = getInputPrefix(); - const inputSuffix = getInputSuffix(); - return ( -
- {label && ( - - )} - -
- {inputPrefix && ( -
- - {inputPrefix} - -
- )} - -
- {startAdornment && startAdornment} - - - - {(isLoading || endAdornment) && ( -
- {isLoading && } - {endAdornment && endAdornment} -
- )} -
- - {inputSuffix && ( -
- - {inputSuffix} - -
- )} -
- - {(maskType === 'text' || (oncomplete || onincomplete || oncleared)) && ( -
- - Complete - - - Incomplete - - - Cleared - -
- )} - - {!isError && bottomLabel && ( -

{bottomLabel}

- )} - {isError && errorMessage && ( -

{errorMessage}

- )} -
+ ); }; export default NumberInput; - diff --git a/src/components/input/PatternInput.tsx b/src/components/input/PatternInput.tsx new file mode 100644 index 00000000..1905d2e3 --- /dev/null +++ b/src/components/input/PatternInput.tsx @@ -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 { + 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 + | undefined; + + if (newChangeEvent) { + newChangeEvent.target.value = patternFormatValues.value; + + onChange?.(newChangeEvent); + } + }; + + return ( + + ); +}; + +export default PatternInput;