소개
Zod + React Hook Form을 이용하여 만든 커스텀 Form
Form.tsx
jsxexport type CheckboxSelectionType = { value: string; label: string; icon?: string; description?: string; } export type RadioSelectionType = { icon?: string; value: string; label: string; } // Form 컨테이너 export const FormTable = ({ title, description, children, onSubmit, isSubmitting, buttonText = "확인", gap = 0 }: any) => { return ( <div className="bg-white rounded-[2.5rem] shadow-xl shadow-gray-100/50 border border-gray-100 overflow-hidden"> {/* 폼 헤더 */} { title && <div className="px-8 md:px-12 pt-10 pb-6 border-b border-gray-50"> <h2 className="text-2xl font-bold tracking-tighter text-[#1a1a1a]">{title}</h2> {description && ( <p className="text-sm text-gray-400 mt-1 font-light">{description}</p> )} </div> } {/* 폼 본문 (테이블 형태의 레이아웃) */} <form onSubmit={onSubmit} className="p-4 md:p-12 space-y-8"> <div className={`space-y-${gap}`}> {children} </div> {/* 하단 버튼 */} <div className="pt-10"> <button type="submit" disabled={isSubmitting} className={`w-full py-4 rounded-2xl font-medium text-sm tracking-[0.2em] uppercase transition-all duration-300 shadow-lg ${isSubmitting ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-[#1a1a1a] text-white hover:bg-slate-600 shadow-gray-200' }`} > {isSubmitting ? "Processing..." : buttonText} </button> </div> </form> </div> ); }; // Input 컴포넌트 export const FormField = ({ label, register, name, error, type = "text", value, placeholder, required = false, disabled = false }: any) => ( <div className="grid grid-cols-1 md:grid-cols-4 gap-2 md:gap-4 items-start pb-6"> <label className="text-sm font-bold text-gray-700 mt-3 md:col-span-1"> {label} {required && <span className="text-slate-600">*</span>} </label> <div className="md:col-span-3 space-y-1"> <input {...register?.(name)} type={type} placeholder={placeholder} defaultValue={value} disabled={disabled} className={`w-full px-5 py-3.5 bg-gray-50/50 border rounded-xl text-sm focus:outline-none focus:ring-2 transition-all placeholder:text-gray-300 ${error ? 'border-red-300 focus:ring-red-100 bg-red-50/10' : 'border-gray-100 focus:ring-slate-100 focus:bg-white' }`} /> {error && ( <p className="text-[11px] text-red-500 font-medium ml-1 flex items-center gap-1"> <span className="w-1 h-1 bg-red-500 rounded-full" /> {error.message} </p> )} </div> </div> ); // Input 마스킹 컴포넌트 export const FormMaskedField = ({ label, control, name, error, value, placeholder, required = false, disabled = false, onFocus = (value: any) => { }, onBlur = (value: any) => { } }: any) => { const [isFocused, setIsFocused] = useState(true); return ( <Controller name={name} control={control} defaultValue={value} render={({ field }) => ( <div className="grid grid-cols-1 md:grid-cols-4 gap-2 md:gap-4 items-start border-b border-gray-50 pb-6 last:border-0"> <label className="text-sm font-bold text-gray-700 mt-3 md:col-span-1"> {label} {required && <span className="text-slate-600">*</span>} </label> <div className="md:col-span-3 space-y-1"> <input {...field} className={`w-full px-5 py-3.5 bg-gray-50/50 border rounded-xl text-sm focus:outline-none focus:ring-2 transition-all placeholder:text-gray-300 ${error ? 'border-red-300 focus:ring-red-100 bg-red-50/10' : 'border-gray-100 focus:ring-slate-100 focus:bg-white' }`} value={ isFocused ? onFocus(field.value || "") : onBlur(field.value || "")} disabled={disabled} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} onChange={(e) => { const raw = e.target.value.replace(/\D/g, "").slice(0, 16); field.onChange(raw); }} placeholder={placeholder} /> {error && ( <p className="text-[11px] text-red-500 font-medium ml-1 flex items-center gap-1"> <span className="w-1 h-1 bg-red-500 rounded-full" /> {error.message} </p> )} </div> </div> )} /> ) }; // Checkbox export const CheckboxSelection = ({ label, list, name, error, register, required = false }: any) => { // 다중 선택을 위해 배열로 상태 관리 const [selected, setSelected] = useState<string[]>([]); const toggle = (id: string) => { setSelected((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id] ); }; return ( <div className="grid grid-cols-1 md:grid-cols-4 gap-2 md:gap-4 items-start pb-6"> <label className="text-sm font-bold text-gray-700 mt-3 md:col-span-1"> {label} {required && <span className="text-slate-600">*</span>} </label> <div className="md:col-span-3 space-y-1"> <div className={`grid grid-cols-1 gap-2`}> {list?.map((item: CheckboxSelectionType) => { const isSelected = selected.includes(item.value); return ( <label key={item.value} className={` relative flex items-center p-3 rounded-xl border-2 cursor-pointer transition-all duration-500 ${isSelected ? 'border-slate-600 bg-white shadow-lg shadow-slate-50' : 'border-gray-100 bg-gray-50/30 hover:border-gray-200'} `} > <input {...register?.(name)} value={item.value} type="checkbox" className="sr-only" checked={isSelected} onChange={() => toggle(item.value)} /> {/* 커스텀 체크 표시 UI */} <div className={` min-w-6 w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all duration-300 ${isSelected ? 'bg-slate-600 border-slate-600' : 'bg-white border-gray-200'} `}> {isSelected && ( <svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7" /> </svg> )} </div> {/* 텍스트 정보 */} <div className="ml-5"> <p className={`text-sm font-bold transition-colors ${isSelected ? 'text-black' : 'text-gray-600'}`}> {item.label} </p> <p className="text-xs text-gray-400 font-light mt-0.5">{item.description}</p> </div> </label> ); })} </div> {error && ( <p className="text-[11px] text-red-500 font-medium ml-1 flex items-center gap-1"> <span className="w-1 h-1 bg-red-500 rounded-full" /> {error.message} </p> )} </div> </div> ); }; // Radiobox export const RadioSelection = ({ label, list, name, error, register, required = false, minColumns, maxColumns, mode }: any) => { const [selected, setSelected] = useState<any>(); const maxSize = useMemo(() => maxColumns || Math.min(list?.length, 4), [list]); const minSize = useMemo(() => minColumns || 2, [list]); return ( <div className="grid grid-cols-1 md:grid-cols-4 gap-2 md:gap-4 items-start pb-6"> <label className="text-sm font-bold text-gray-700 mt-3 md:col-span-1"> {label} {required && <span className="text-slate-600">*</span>} </label> <div className="md:col-span-3 space-y-1"> { mode === "card" ? <div className={`grid grid-cols-${minSize} md:grid-cols-${maxSize} gap-4`}> {/* 카드형 라디오 */} {list?.map((item: RadioSelectionType) => ( <label key={item.value} className={` relative flex flex-col items-center justify-center p-3 rounded-2xl border-2 cursor-pointer transition-all duration-300 ${selected === item.value ? 'border-slate-600 bg-slate-50/30' : 'border-gray-100 bg-white hover:border-gray-200'} `} > <input {...register?.(name)} type="radio" value={item.value} className="sr-only" // 실제 라디오 숨김 onChange={(e) => setSelected(e.target.value)} /> <span className="text-2xl mb-2">{item.icon}</span> <span className={`text-sm font-medium ${selected === item.value ? 'text-slate-600' : 'text-gray-500' }`}> {item.label} </span> {/* 선택 시 나타나는 작은 체크 표시 */} {selected === item.value && ( <div className="absolute top-3 right-3 w-4 h-4 bg-slate-600 rounded-full flex items-center justify-center"> <svg className="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20"> <path d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" /> </svg> </div> )} </label> ))} </div> : <div className="space-y-3"> {/* 기본 원형 라디오 */} {list?.map((item: RadioSelectionType, idx: number) => ( <label key={idx} className="flex items-center gap-3 cursor-pointer group"> <div className="relative flex items-center justify-center"> <input type="radio" {...register?.(name)} value={item.value} className="peer sr-only" onChange={(e) => setSelected(e.target.value)} /> <div className="w-5 h-5 border-2 border-gray-200 rounded-full peer-checked:border-slate-600 transition-all" /> <div className="absolute w-2.5 h-2.5 bg-slate-600 rounded-full scale-0 peer-checked:scale-100 transition-transform" /> </div> <span className="text-sm text-gray-600 group-hover:text-black transition-colors">{item.label}</span> </label> ))} </div> } {error && ( <p className="text-[11px] text-red-500 font-medium ml-1 flex items-center gap-1"> <span className="w-1 h-1 bg-red-500 rounded-full" /> {error.message} </p> )} </div> </div> ); };
코드 예시
jsx"use client"; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import * as z from 'zod'; import { CheckboxSelection, FormField, FormMaskedField, FormTable, RadioSelection } from './form'; // 1. Zod 스키마 정의 (회원가입 예시) const schema = z.object({ username: z.string().min(2, { message: "이름은 2글자 이상이어야 합니다." }), email: z.string().email({ message: "올바른 이메일 형식이 아닙니다." }), password: z.string().min(8, { message: "비밀번호는 최소 8자 이상이어야 합니다." }), phone: z.string().regex(/^010-\d{4}-\d{4}$/, { message: "010-0000-0000 형식으로 입력해주세요." }), genre: z.string().nullable().optional(), cardNumber: z.string().nullable().optional(), payMethod: z.string().nullable().optional() }); type FormData = z.infer<typeof schema>; // 사용 예시 (회원가입 시) export default function RegistrationPage() { const { control, register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<FormData>({ resolver: zodResolver(schema), }); const onSubmit = async (data: FormData) => { // Spring Boot API와 연동하는 부분 console.log("Form Data:", data); await new Promise((resolve) => setTimeout(resolve, 1000)); // 시뮬레이션 alert("회원가입 요청이 전송되었습니다."); }; const formatCardNumber = (value: string) => { return value .replace(/\D/g, "") // 숫자만 남기기 .replace(/(\d{4})(?=\d)/g, "$1 ") // 4자리마다 공백 .trim(); }; const formatCardNumberMasked = (value: string) => { const numbers = value.replace(/\D/g, ''); // 숫자만 추출 let result = ''; for (let i = 0; i < numbers.length; i++) { if (i > 0 && i % 4 === 0) result += ' '; // 4자리마다 공백 if (i >= 4 && i < 12) result += '•'; // 가운데 8자리 마스킹 else result += numbers[i]; } return result; }; return ( <div className="min-h-screen bg-[#ffffff] text-black py-32 px-6"> <div className="max-w-3xl mx-auto"> <RadioSelection mode="card" label="결제방법" list={[{ value: 'card', label: '신용카드', icon: '💳' }, { value: 'transfer', label: '계좌이체', icon: '🏦' }, { value: 'kakao', label: '카카오페이', icon: '🟡' }, { value: 'toss', label: '토스페이', icon: '🔵' },]} name="payMethod" register={register} error={errors.payMethod} required /> <CheckboxSelection label="결제방법" list={[{ value: 'classic', label: '고전극', description: '셰익스피어, 체호프 등' }, { value: 'modern', label: '현대극', description: '창작극 및 실험 연극' }, { value: 'musical', label: '뮤지컬', description: '음악과 무용의 조화' }, { value: 'physical', label: '신체극', description: '언어를 넘어선 몸짓' }]} name="payMethod" register={register} error={errors.genre} required /> <FormTable title="Create Account" description="극단 페르소나의 단원이 되어 더 많은 소식을 받아보세요." buttonText="가입하기" onSubmit={handleSubmit(onSubmit)} isSubmitting={isSubmitting} > <FormField label="이름" placeholder="성함을 입력해주세요" name="username" register={register} error={errors.username} required /> <FormField label="이메일" type="email" placeholder="example@email.com" name="email" register={register} error={errors.email} required /> <FormField label="비밀번호" type="password" placeholder="8자 이상 입력해주세요" name="password" register={register} error={errors.password} required /> <FormField label="연락처" placeholder="010-0000-0000" name="phone" register={register} error={errors.phone} /> <FormMaskedField label="카드 번호" placeholder="1234 •••• •••• 5678" name="cardNumber" control={control} error={errors.cardNumber} onFocus={formatCardNumber} onBlur={formatCardNumberMasked} required /> {/* 셀렉트 박스나 체크박스가 필요할 경우를 위한 커스텀 예시 */} <div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-center border-b border-gray-50 pb-6 last:border-0"> <label className="text-sm font-bold text-gray-700 md:col-span-1">관심 분야</label> <div className="md:col-span-3 flex gap-4"> {['배우', '연출', '스태프'].map(role => ( <label key={role} className="flex items-center gap-2 cursor-pointer group"> <input type="checkbox" className="w-4 h-4 accent-slate-600" /> <span className="text-sm text-gray-500 group-hover:text-black transition-colors">{role}</span> </label> ))} </div> </div> </FormTable> </div> </div> ); }