"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; import { formatNumber, removeLeadingZeros } from "@/lib/formatters"; export interface NumberInputProps extends Omit, "onChange" | "value" | "type"> { value: number | string | undefined; onChange: (value: number | undefined) => void; error?: boolean; /** 소수점 허용 (기본: false) */ allowDecimal?: boolean; /** 소수점 자릿수 제한 */ decimalPlaces?: number; /** 음수 허용 (기본: false) */ allowNegative?: boolean; /** 천단위 콤마 표시 (기본: false, 포커스 해제 시 적용) */ useComma?: boolean; /** 접미사 (원, 개, % 등) */ suffix?: string; /** 최소값 */ min?: number; /** 최대값 */ max?: number; /** 빈 값 허용 (기본: true, false면 빈 값 시 0 반환) */ allowEmpty?: boolean; } /** * NumberInput - 개선된 숫자 입력 컴포넌트 * * 특징: * - Leading zero 자동 제거 (01 → 1) * - 소수점 허용/자릿수 제한 옵션 * - 음수 허용 옵션 * - 천단위 콤마 표시 (포커스 해제 시) * - 접미사 지원 (원, 개, % 등) * - min/max 범위 제한 * - onChange에는 항상 number 타입 반환 * * @example * // 정수만 (수량) * * * // 금액 (천단위 콤마) * * * // 소수점 2자리 (비율) * * * // 퍼센트 (0-100 제한) * */ const NumberInput = React.forwardRef( ( { className, value, onChange, error, allowDecimal = false, decimalPlaces, allowNegative = false, useComma = false, suffix, min, max, allowEmpty = true, onFocus, onBlur, ...props }, ref ) => { const [isFocused, setIsFocused] = React.useState(false); const [displayValue, setDisplayValue] = React.useState(""); // 외부 value 변경 시 displayValue 동기화 React.useEffect(() => { if (!isFocused) { if (value === undefined || value === null || value === "") { setDisplayValue(""); } else { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { setDisplayValue( formatNumber(numValue, { useComma, decimalPlaces, }) ); } else { setDisplayValue(""); } } } }, [value, isFocused, useComma, decimalPlaces]); // 입력값 유효성 검사 및 정제 const sanitizeInput = (input: string): string => { let result = input; // 허용 문자만 남기기 if (allowDecimal && allowNegative) { result = result.replace(/[^\d.\-]/g, ""); } else if (allowDecimal) { result = result.replace(/[^\d.]/g, ""); } else if (allowNegative) { result = result.replace(/[^\d\-]/g, ""); } else { result = result.replace(/\D/g, ""); } // 음수 기호는 맨 앞에만 if (allowNegative && result.includes("-")) { const isNegative = result.startsWith("-"); result = result.replace(/-/g, ""); if (isNegative) result = "-" + result; } // 소수점은 하나만 if (allowDecimal && result.includes(".")) { const parts = result.split("."); result = parts[0] + "." + parts.slice(1).join(""); // 소수점 자릿수 제한 if (decimalPlaces !== undefined && parts[1]) { result = parts[0] + "." + parts[1].slice(0, decimalPlaces); } } // Leading zero 제거 (소수점 앞 제외) if (result && !result.startsWith("0.") && !result.startsWith("-0.")) { result = removeLeadingZeros(result); } return result; }; // 값을 숫자로 변환하고 범위 적용 const parseAndClamp = (input: string): number | undefined => { if (!input || input === "-" || input === ".") { return allowEmpty ? undefined : 0; } let num = parseFloat(input); if (isNaN(num)) { return allowEmpty ? undefined : 0; } // 범위 제한 if (min !== undefined && num < min) num = min; if (max !== undefined && num > max) num = max; return num; }; const handleChange = (e: React.ChangeEvent) => { const rawValue = e.target.value; // 콤마 제거 (붙여넣기 대응) const withoutComma = rawValue.replace(/,/g, ""); const sanitized = sanitizeInput(withoutComma); setDisplayValue(sanitized); // 입력 중에는 "-", ".", "-." 같은 중간 상태 허용 if (sanitized === "-" || sanitized === "." || sanitized === "-.") { return; } const numValue = parseAndClamp(sanitized); onChange(numValue); }; const handleFocus = (e: React.FocusEvent) => { setIsFocused(true); // 포커스 시 콤마 제거된 순수 숫자로 표시 if (value !== undefined && value !== null && value !== "") { const numValue = typeof value === "string" ? parseFloat(value) : value; if (!isNaN(numValue)) { setDisplayValue(String(numValue)); } } onFocus?.(e); }; const handleBlur = (e: React.FocusEvent) => { setIsFocused(false); // 블러 시 최종 값 정리 및 포맷팅 const sanitized = sanitizeInput(displayValue); const numValue = parseAndClamp(sanitized); onChange(numValue); // 포맷팅된 값으로 표시 if (numValue !== undefined) { setDisplayValue( formatNumber(numValue, { useComma, decimalPlaces, }) ); } else { setDisplayValue(""); } onBlur?.(e); }; const handleKeyDown = (e: React.KeyboardEvent) => { // 허용되는 키들 const allowedKeys = [ "Backspace", "Delete", "Tab", "Escape", "Enter", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End" ]; if (allowedKeys.includes(e.key)) return; // Ctrl/Cmd + A, C, V, X 허용 if ((e.ctrlKey || e.metaKey) && ["a", "c", "v", "x"].includes(e.key.toLowerCase())) { return; } // 숫자 허용 if (/^\d$/.test(e.key)) return; // 소수점 허용 (한 번만) if (allowDecimal && e.key === "." && !displayValue.includes(".")) return; // 음수 허용 (맨 앞에만) if (allowNegative && e.key === "-" && !displayValue.includes("-")) { const input = e.target as HTMLInputElement; if (input.selectionStart === 0) return; } // 그 외 차단 e.preventDefault(); }; return (
{suffix && !isFocused && displayValue && ( {suffix} )}
); } ); NumberInput.displayName = "NumberInput"; export { NumberInput };