본문 바로가기
🔥React 뽀개기

input 공통 컴포넌트로 만들기 (feat. react-hook-form + yup)

by 짱돌보리 2024. 8. 1.
728x90

리액트 훅 폼+yup을 사용해 input 공통 컴포넌트 만들기

✨초기작업

타입에 따라 input 분기처리하기

import { passwordOffIcon, passwordOnIcon } from '@/libs/utils/Icon';
import Image from 'next/image';
import React, { useState } from 'react';
import { FieldError, UseFormRegisterReturn } from 'react-hook-form';

type InputProps = {
  label: string;
  name: string;
  type?: 'text' | 'password';
  placeholder: string;
  register: UseFormRegisterReturn;
  error?: FieldError;
};

export default function Input({
  label,
  name,
  type,
  placeholder,
  register,
  error,
}: InputProps) {
  const [isVisibilityIcon, setIsVisibilityIcon] = useState(false);

  const togglePasswordVisibility = () => {
    setIsVisibilityIcon(!isVisibilityIcon);
  };

  return (
    <div className="relative flex w-full max-w-[640px] flex-col gap-2">
      <label htmlFor={name}>{label}</label>
      {type === 'text' ? (
        <input
          type="text"
          id={name}
          placeholder={placeholder}
          {...register}
          className={`h-[58px] rounded-[6px] border-2 px-4 outline-none ${error && 'border-red-400'}`}
        />
      ) : (
        <>
          <input
            type={isVisibilityIcon ? 'text' : 'password'}
            id={name}
            placeholder={placeholder}
            {...register}
            className={`h-[58px] rounded-[6px] border-2 px-4 outline-none ${error && 'border-red-400'}`}
          />
          <Image
            src={isVisibilityIcon ? passwordOffIcon : passwordOnIcon}
            width={24}
            height={24}
            alt="password"
            className="absolute right-4 top-12 cursor-pointer"
            onClick={togglePasswordVisibility}
          />
        </>
      )}
      {error && <p className="text-red-400">{error.message}</p>}
    </div>
  );
}

💣register 타입 에러

UseFormRegister - 오류… 자꾸 타입을 쓰라고 나왔다.

UseFormRegisterReturn로 써서 해결!

UseFormRegisterReturn는 register 함수의 반환값을 나타내는 타입이니다. 즉, register 함수를 호출했을 때 반환되는 객체의 타입이다. 이 반환값은 입력 필드에 필요한 이벤트 핸들러(onChange, onBlur)와 ref를 포함하고 있다. 입력 필드에 직접 전달할 때는 이 타입을 사용해야 한다!

🤔input의 크기가 다를 때..?

기본값을 설정하고 필요할 때 프롭스로 내려주자.

import passwordOffIcon from '@/../public/icon/passwordOffIcon.svg';
import passwordOnIcon from '@/../public/icon/passwordOnIcon.svg';
import Image from 'next/image';
import React, { useState } from 'react';
import { FieldError, UseFormRegisterReturn } from 'react-hook-form';

/**
 * 재사용 가능한 입력 컴포넌트로, 텍스트, 비밀번호, 숫자 타입을 지원합니다.
 * 비밀번호 필드에는 비밀번호 표시/숨기기 토글이 포함되어 있습니다.
 *
 * @example
 * ```jsx
 * const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
 *   resolver: yupResolver(schema),
 * });
 *
 * const onSubmit: SubmitHandler<FormData> = (data) => {
 *   console.log('폼 제출', data);
 * };
 *
 * <Input
 *   label="이메일"
 *   name="email"
 *   type="text"
 *   placeholder="이메일을 입력해주세요"
 *   register={register('email')}
 *   error={errors.email}
 *   maxWidth="640px"
 * />
 * ```
 *
 * @typedef {Object} InputProps
 * @property {string} label - 입력 필드의 레이블
 * @property {string} name - 입력 필드의 이름
 * @property {'text' | 'password' | 'number'} [type='text'] - 입력 필드의 타입
 * @property {string} placeholder - 입력 필드의 플레이스홀더 텍스트
 * @property {UseFormRegisterReturn} register - react-hook-form의 register 함수 반환값
 * @property {FieldError} [error] - 입력 필드의 에러 정보
 * @property {string} [maxWidth='640'] - (반응형을 위한)입력 필드 컨테이너의 최대 너비. 기본값 640px
 * @author 김보미
 */

type InputProps = {
  label: string;
  name: string;
  type?: 'text' | 'password' | 'number';
  placeholder: string;
  register: UseFormRegisterReturn;
  error?: FieldError;
  maxWidth?: string;
};

export default function Input({
  label,
  name,
  type = 'text',
  placeholder,
  register,
  error,
  maxWidth = '640px',
}: InputProps) {
  const [isVisibilityIcon, setIsVisibilityIcon] = useState(false);

  const togglePasswordVisibility = () => {
    setIsVisibilityIcon(!isVisibilityIcon);
  };

  return (
    <div
      className={`relative flex w-full flex-col gap-2`}
      style={{ maxWidth: maxWidth }}
    >
      <label htmlFor={name}>{label}</label>
      {type === 'text' || type === 'number' ? (
        <input
          type={type}
          id={name}
          placeholder={placeholder}
          {...register}
          className={`h-58 rounded-md border-2 px-16 outline-none focus:border-custom-green-200 ${error && 'border-red-400'}`}
        />
      ) : type === 'password' ? (
        <>
          <input
            type={isVisibilityIcon ? 'text' : 'password'}
            id={name}
            placeholder={placeholder}
            {...register}
            className={`h-58 rounded-md border-2 px-16 outline-none focus:border-custom-green-200 ${error && 'border-red-400'}`}
          />
          <Image
            src={isVisibilityIcon ? passwordOffIcon : passwordOnIcon}
            width={24}
            height={24}
            alt="비밀번호 가시성 토글"
            className="absolute right-20 top-42 cursor-pointer"
            onClick={togglePasswordVisibility}
          />
        </>
      ) : null}
      {error && (
        <p className="pl-8 text-xs-regular text-custom-red-200">
          {error.message}
        </p>
      )}
    </div>
  );
}
  • 기본 640 설정하고 필요하다면 props로 maxWidth써주기!

✨최종 코드

  1. input 컴포넌트
import React, { useState } from 'react';
import { FieldError, UseFormRegisterReturn } from 'react-hook-form';

import PasswordInput from './PasswordInput';

type InputProps = {
  label: string;
  name: string;
  type?: 'text' | 'password' | 'number' | 'email';
  placeholder: string;
  register: UseFormRegisterReturn;
  error?: FieldError;
  maxWidth?: string;
  onBlur?: () => void;
};

/**
 * @param {string} label - 입력 필드의 레이블
 * @param {string} name - 입력 필드의 이름
 * @param {'text' | 'password' | 'number' | 'email'} [type='text'] - 입력 필드의 타입
 * @param {string} placeholder - 입력 필드의 플레이스홀더 텍스트
 * @param {UseFormRegisterReturn} register - react-hook-form의 register 함수 반환값
 * @param {FieldError} [error] - 입력 필드의 에러 정보
 * @param {string} [maxWidth='640px'] - 반응형을 위한 입력 필드 컨테이너의 최대 너비. 기본값 640px
 * @param {() => void} [onBlur] - 포커스 아웃 시 호출되는 함수
 * @returns {JSX.Element} - 렌더링된 Input 컴포넌트
 * @example
 * const { register, handleSubmit, formState: { errors }, onBlur } = useForm<FormData>({
 *   resolver: yupResolver(schema),
 * });
 *
 * const onSubmit: SubmitHandler<FormData> = (data) => {
 *   console.log('폼 제출', data);
 * };
 *
 * <Input
 *   label="이메일"
 *   name="email"
 *   type="text"
 *   placeholder="이메일을 입력해주세요"
 *   register={register('email')}
 *   error={errors.email}
 *   maxWidth="640px"
 *   onBlur={() => trigger('email')}
 * />
 */

export default function Input({
  label,
  name,
  type,
  placeholder,
  register,
  error,
  maxWidth = '640px',
  onBlur,
}: InputProps) {
  const [isVibrating, setIsVibrating] = useState(false);

  const handleBlur = () => {
    if (onBlur) onBlur();
    if (!error) return;
    setIsVibrating(true);
    setTimeout(() => setIsVibrating(false), 300);
  };

  return (
    <div className={`relative flex w-full flex-col gap-2`} style={{ maxWidth }}>
      <label htmlFor={name}>{label}</label>
      {(type === 'text' || type === 'number' || type === 'email') && (
        <input
          type={type}
          id={name}
          placeholder={placeholder}
          {...register}
          onBlur={handleBlur}
          className={`h-58 rounded-md border-2 px-16 outline-none focus:border-custom-green-200 ${isVibrating && 'animate-vibration'} ${error && 'border-red-400'}`}
        />
      )}
      {type === 'password' && (
        <PasswordInput
          name={name}
          placeholder={placeholder}
          register={register}
          error={error}
          onBlur={handleBlur}
        />
      )}
      {error && (
        <p className="pl-8 text-xs-regular text-custom-red-200">
          {error.message}
        </p>
      )}
    </div>
  );
}

 

2. PasswordInput 컴포넌트

import { PasswordOffIcon, PasswordOnIcon } from '@/libs/utils/Icon';
import React, { useState } from 'react';
import { FieldError, UseFormRegisterReturn } from 'react-hook-form';

type PasswordInputProps = {
  name: string;
  placeholder: string;
  register: UseFormRegisterReturn;
  error?: FieldError;
  onBlur?: () => void;
};

/**
 * 비밀번호 입력 필드 컴포넌트로, 비밀번호 표시/숨기기 기능을 포함합니다.
 *
 * @param {string} name - 입력 필드의 이름
 * @param {string} placeholder - 입력 필드의 플레이스홀더 텍스트
 * @param {UseFormRegisterReturn} register - react-hook-form의 register 함수 반환값
 * @param {FieldError} [error] - 입력 필드의 에러 정보 (선택적)
 * @param {() => void} [onBlur] - 포커스 아웃 시 호출되는 함수
 * @returns {JSX.Element} - 렌더링된 PasswordInput 컴포넌트
 *
 * @author 김보미
 */

export default function PasswordInput({
  name,
  placeholder,
  register,
  error,
  onBlur,
}: PasswordInputProps) {
  const [isVisibilityIcon, setIsVisibilityIcon] = useState(false);
  const [isVibrating, setIsVibrating] = useState(false);

  const togglePasswordVisibility = () => {
    setIsVisibilityIcon(!isVisibilityIcon);
  };

  const handleBlur = () => {
    if (onBlur) onBlur();
    if (!error) return;
    setIsVibrating(true);
    setTimeout(() => setIsVibrating(false), 300);
  };

  return (
    <>
      <input
        type={isVisibilityIcon ? 'text' : 'password'}
        id={name}
        placeholder={placeholder}
        {...register}
        onBlur={handleBlur}
        className={`h-58 rounded-md border-2 px-16 outline-none focus:border-custom-green-200 ${isVibrating && 'animate-vibration'} ${error && 'border-red-400'}`}
      />
      {isVisibilityIcon ? (
        <PasswordOnIcon
          width={24}
          height={24}
          className="absolute right-20 top-42 cursor-pointer"
          onClick={togglePasswordVisibility}
        />
      ) : (
        <PasswordOffIcon
          width={24}
          height={24}
          className="absolute right-20 top-42 cursor-pointer"
          onClick={togglePasswordVisibility}
        />
      )}
    </>
  );
}
  1. Textarea 컴포넌트
import React, { TextareaHTMLAttributes } from 'react';
import { FieldError, UseFormRegisterReturn } from 'react-hook-form';

type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
  label: string;
  register: UseFormRegisterReturn;
  error?: FieldError;
  maxWidth?: string;
  height?: string;
  onBlur?: () => void;
};

/**
 * Textarea 컴포넌트
 * @param {string} label - 텍스트영역의 레이블
 * @param {UseFormRegisterReturn} register - react-hook-form의 register 함수 반환값
 * @param {FieldError} [error] - 텍스트영역의 에러 정보 (선택적)
 * @param {string} [maxWidth='640px'] - (반응형을 위한)입력 필드 컨테이너의 최대 너비. 기본값 640px
 * @param {string} [height='346px'] - (반응형을 위한)입력 필드의 높이. 기본값 346px
 * @returns {JSX.Element} - 렌더링된 Textarea 컴포넌트
 * @example
 * const {
 *   register,
 *   handleSubmit,
 *   formState: { errors },
 * } = useForm<FormData>({
 *   resolver: yupResolver(schema),
 * });
 *
 * const onSubmit: SubmitHandler<FormData> = (data) => {
 *   console.log('폼 제출', data);
 * };
 *
 * <Textarea
 *   label="내용"
 *   name="content"
 *   placeholder="내용을 입력해주세요"
 *   register={register('content')}
 *   error={errors.content}
 *  onBlur={() => trigger('content')}
 * />
 *
 * @author 김보미
 */

export default function Textarea({
  label,
  register,
  error,
  maxWidth = '640px',
  height = '346px',
  onBlur,
  ...props
}: TextareaProps) {
  const { name, placeholder } = props;

  return (
    <div className="relative flex w-full flex-col gap-2" style={{ maxWidth }}>
      <label htmlFor={name}>{label}</label>
      <textarea
        id={name}
        placeholder={placeholder}
        {...register}
        onBlur={onBlur}
        className={`resize-none rounded-md border-2 p-16 outline-none focus:border-custom-green-200 ${error ? 'border-red-400' : ''}`}
        style={{ height }}
        {...props}
      />
      {error && (
        <p className="pl-8 text-xs-regular text-custom-red-200">
          {error.message}
        </p>
      )}
    </div>
  );
}

 

✨결과