453 lines
13 KiB
TypeScript
453 lines
13 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* UnifiedInput
|
|
*
|
|
* 통합 입력 컴포넌트
|
|
* - text: 텍스트 입력
|
|
* - number: 숫자 입력
|
|
* - password: 비밀번호 입력
|
|
* - slider: 슬라이더 입력
|
|
* - color: 색상 선택
|
|
* - button: 버튼 (입력이 아닌 액션)
|
|
*/
|
|
|
|
import React, { forwardRef, useCallback, useMemo, useState } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { Label } from "@/components/ui/label";
|
|
import { cn } from "@/lib/utils";
|
|
import { UnifiedInputProps, UnifiedInputType, UnifiedInputFormat } from "@/types/unified-components";
|
|
|
|
// 형식별 입력 마스크 및 검증 패턴
|
|
const FORMAT_PATTERNS: Record<UnifiedInputFormat, { pattern: RegExp; placeholder: string }> = {
|
|
none: { pattern: /.*/, placeholder: "" },
|
|
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" },
|
|
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" },
|
|
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" },
|
|
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" },
|
|
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" },
|
|
};
|
|
|
|
// 통화 형식 변환
|
|
function formatCurrency(value: string | number): string {
|
|
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
|
|
if (isNaN(num)) return "";
|
|
return num.toLocaleString("ko-KR");
|
|
}
|
|
|
|
// 사업자번호 형식 변환
|
|
function formatBizNo(value: string): string {
|
|
const digits = value.replace(/\D/g, "");
|
|
if (digits.length <= 3) return digits;
|
|
if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
|
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5, 10)}`;
|
|
}
|
|
|
|
// 전화번호 형식 변환
|
|
function formatTel(value: string): string {
|
|
const digits = value.replace(/\D/g, "");
|
|
if (digits.length <= 3) return digits;
|
|
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
|
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
|
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
|
|
}
|
|
|
|
/**
|
|
* 텍스트 입력 컴포넌트
|
|
*/
|
|
const TextInput = forwardRef<HTMLInputElement, {
|
|
value?: string | number;
|
|
onChange?: (value: string) => void;
|
|
format?: UnifiedInputFormat;
|
|
mask?: string;
|
|
placeholder?: string;
|
|
readonly?: boolean;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
|
|
// 형식에 따른 값 포맷팅
|
|
const formatValue = useCallback((val: string): string => {
|
|
switch (format) {
|
|
case "currency":
|
|
return formatCurrency(val);
|
|
case "biz_no":
|
|
return formatBizNo(val);
|
|
case "tel":
|
|
return formatTel(val);
|
|
default:
|
|
return val;
|
|
}
|
|
}, [format]);
|
|
|
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
let newValue = e.target.value;
|
|
|
|
// 형식에 따른 자동 포맷팅
|
|
if (format === "currency") {
|
|
// 숫자와 쉼표만 허용
|
|
newValue = newValue.replace(/[^\d,]/g, "");
|
|
newValue = formatCurrency(newValue);
|
|
} else if (format === "biz_no") {
|
|
newValue = formatBizNo(newValue);
|
|
} else if (format === "tel") {
|
|
newValue = formatTel(newValue);
|
|
}
|
|
|
|
onChange?.(newValue);
|
|
}, [format, onChange]);
|
|
|
|
const displayValue = useMemo(() => {
|
|
if (value === undefined || value === null) return "";
|
|
return formatValue(String(value));
|
|
}, [value, formatValue]);
|
|
|
|
const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder;
|
|
|
|
return (
|
|
<Input
|
|
ref={ref}
|
|
type="text"
|
|
value={displayValue}
|
|
onChange={handleChange}
|
|
placeholder={inputPlaceholder}
|
|
readOnly={readonly}
|
|
disabled={disabled}
|
|
className={cn("h-full w-full", className)}
|
|
/>
|
|
);
|
|
});
|
|
TextInput.displayName = "TextInput";
|
|
|
|
/**
|
|
* 숫자 입력 컴포넌트
|
|
*/
|
|
const NumberInput = forwardRef<HTMLInputElement, {
|
|
value?: number;
|
|
onChange?: (value: number | undefined) => void;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
placeholder?: string;
|
|
readonly?: boolean;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => {
|
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const val = e.target.value;
|
|
if (val === "") {
|
|
onChange?.(undefined);
|
|
return;
|
|
}
|
|
|
|
let num = parseFloat(val);
|
|
|
|
// 범위 제한
|
|
if (min !== undefined && num < min) num = min;
|
|
if (max !== undefined && num > max) num = max;
|
|
|
|
onChange?.(num);
|
|
}, [min, max, onChange]);
|
|
|
|
return (
|
|
<Input
|
|
ref={ref}
|
|
type="number"
|
|
value={value ?? ""}
|
|
onChange={handleChange}
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
placeholder={placeholder || "숫자 입력"}
|
|
readOnly={readonly}
|
|
disabled={disabled}
|
|
className={cn("h-full w-full", className)}
|
|
/>
|
|
);
|
|
});
|
|
NumberInput.displayName = "NumberInput";
|
|
|
|
/**
|
|
* 비밀번호 입력 컴포넌트
|
|
*/
|
|
const PasswordInput = forwardRef<HTMLInputElement, {
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
placeholder?: string;
|
|
readonly?: boolean;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}>(({ value, onChange, placeholder, readonly, disabled, className }, ref) => {
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<Input
|
|
ref={ref}
|
|
type={showPassword ? "text" : "password"}
|
|
value={value ?? ""}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
placeholder={placeholder || "비밀번호 입력"}
|
|
readOnly={readonly}
|
|
disabled={disabled}
|
|
className={cn("h-full w-full pr-10", className)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs"
|
|
>
|
|
{showPassword ? "숨김" : "보기"}
|
|
</button>
|
|
</div>
|
|
);
|
|
});
|
|
PasswordInput.displayName = "PasswordInput";
|
|
|
|
/**
|
|
* 슬라이더 입력 컴포넌트
|
|
*/
|
|
const SliderInput = forwardRef<HTMLDivElement, {
|
|
value?: number;
|
|
onChange?: (value: number) => void;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}>(({ value, onChange, min = 0, max = 100, step = 1, disabled, className }, ref) => {
|
|
return (
|
|
<div ref={ref} className={cn("flex items-center gap-4", className)}>
|
|
<Slider
|
|
value={[value ?? min]}
|
|
onValueChange={(values) => onChange?.(values[0])}
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
disabled={disabled}
|
|
className="flex-1"
|
|
/>
|
|
<span className="text-sm font-medium w-12 text-right">{value ?? min}</span>
|
|
</div>
|
|
);
|
|
});
|
|
SliderInput.displayName = "SliderInput";
|
|
|
|
/**
|
|
* 색상 선택 컴포넌트
|
|
*/
|
|
const ColorInput = forwardRef<HTMLInputElement, {
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}>(({ value, onChange, disabled, className }, ref) => {
|
|
return (
|
|
<div className={cn("flex items-center gap-2", className)}>
|
|
<Input
|
|
ref={ref}
|
|
type="color"
|
|
value={value || "#000000"}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
disabled={disabled}
|
|
className="w-12 h-full p-1 cursor-pointer"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
value={value || "#000000"}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
disabled={disabled}
|
|
className="h-full flex-1 uppercase"
|
|
maxLength={7}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
ColorInput.displayName = "ColorInput";
|
|
|
|
/**
|
|
* 여러 줄 텍스트 입력 컴포넌트
|
|
*/
|
|
const TextareaInput = forwardRef<HTMLTextAreaElement, {
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
placeholder?: string;
|
|
rows?: number;
|
|
readonly?: boolean;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, ref) => {
|
|
return (
|
|
<textarea
|
|
ref={ref}
|
|
value={value}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
readOnly={readonly}
|
|
disabled={disabled}
|
|
className={cn(
|
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
className
|
|
)}
|
|
/>
|
|
);
|
|
});
|
|
TextareaInput.displayName = "TextareaInput";
|
|
|
|
/**
|
|
* 메인 UnifiedInput 컴포넌트
|
|
*/
|
|
export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>(
|
|
(props, ref) => {
|
|
const {
|
|
id,
|
|
label,
|
|
required,
|
|
readonly,
|
|
disabled,
|
|
style,
|
|
size,
|
|
config: configProp,
|
|
value,
|
|
onChange,
|
|
} = props;
|
|
|
|
// config가 없으면 기본값 사용
|
|
const config: UnifiedInputConfig = configProp || { type: "text" };
|
|
|
|
// 조건부 렌더링 체크
|
|
// TODO: conditional 처리 로직 추가
|
|
|
|
// 타입별 입력 컴포넌트 렌더링
|
|
const renderInput = () => {
|
|
const inputType = config.type || "text";
|
|
switch (inputType) {
|
|
case "text":
|
|
return (
|
|
<TextInput
|
|
value={value}
|
|
onChange={(v) => onChange?.(v)}
|
|
format={config.format}
|
|
mask={config.mask}
|
|
placeholder={config.placeholder}
|
|
readonly={readonly}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
|
|
case "number":
|
|
return (
|
|
<NumberInput
|
|
value={typeof value === "number" ? value : undefined}
|
|
onChange={(v) => onChange?.(v ?? 0)}
|
|
min={config.min}
|
|
max={config.max}
|
|
step={config.step}
|
|
placeholder={config.placeholder}
|
|
readonly={readonly}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
|
|
case "password":
|
|
return (
|
|
<PasswordInput
|
|
value={typeof value === "string" ? value : ""}
|
|
onChange={(v) => onChange?.(v)}
|
|
placeholder={config.placeholder}
|
|
readonly={readonly}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
|
|
case "slider":
|
|
return (
|
|
<SliderInput
|
|
value={typeof value === "number" ? value : config.min ?? 0}
|
|
onChange={(v) => onChange?.(v)}
|
|
min={config.min}
|
|
max={config.max}
|
|
step={config.step}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
|
|
case "color":
|
|
return (
|
|
<ColorInput
|
|
value={typeof value === "string" ? value : "#000000"}
|
|
onChange={(v) => onChange?.(v)}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
|
|
case "textarea":
|
|
return (
|
|
<TextareaInput
|
|
value={value}
|
|
onChange={(v) => onChange?.(v)}
|
|
placeholder={config.placeholder}
|
|
rows={config.rows}
|
|
readonly={readonly}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
|
|
default:
|
|
return (
|
|
<TextInput
|
|
value={value}
|
|
onChange={(v) => onChange?.(v)}
|
|
placeholder={config.placeholder}
|
|
readonly={readonly}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
|
|
const showLabel = label && style?.labelDisplay !== false;
|
|
// size에서 우선 가져오고, 없으면 style에서 가져옴
|
|
const componentWidth = size?.width || style?.width;
|
|
const componentHeight = size?.height || style?.height;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className="flex flex-col"
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{showLabel && (
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
fontSize: style?.labelFontSize,
|
|
color: style?.labelColor,
|
|
fontWeight: style?.labelFontWeight,
|
|
marginBottom: style?.labelMarginBottom,
|
|
}}
|
|
className="text-sm font-medium flex-shrink-0"
|
|
>
|
|
{label}
|
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
|
</Label>
|
|
)}
|
|
<div className="flex-1 min-h-0">
|
|
{renderInput()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
UnifiedInput.displayName = "UnifiedInput";
|
|
|
|
export default UnifiedInput;
|
|
|