가입 페이지에서 19세 미만의 생년월일을 입력한 경우, 경고 메세지를 담은 모달을 띄우려 한다.

 

무선 모달창의 구성과 애니메이션을 구현하고, 체크박스를 선택해야만 모달을 닫을 수 있도록 만들었다.

아래는 그 결과물이다.

 

 

 

 

나이에 따라 팝업 되는 기능을 추가하기에 앞서, 

모달 컴포넌트를 재사용할 것을 대비해 모달 내 요소들을 props로 바꾸고, 다른 컴포넌트에 적용하는 실험을 해보았다.

import { useState, useEffect, ReactNode } from "react";
import ReactDOM from "react-dom";
import s from "../stores/styling";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  modalTitle: string;
  children: ReactNode;
  showCheckbox?: boolean;
  checkboxText?: string;
}

const Modal: React.FC<ModalProps> = ({ isOpen, onClose, modalTitle, children, showCheckbox, checkboxText }) => {
  
  const [understand, setUnderstand] = useState(false);	
  //체크박스 체크 여부 저장
  const [animationClosing, setAnimationClosing] = useState(false);	
  //닫히는 애니메이션울 클래스 추가하는 방식으로 조건부 렌더링

  if (!isOpen) return null;

  const handleUnderstandChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setUnderstand((prevUnderstand) => !prevUnderstand);	//보통 prev라고 쓰지만, 알아보기 쉽게 바꾸어 썼다.
  };

  const handleModalClose = () => {
    if (!showCheckbox || understand) { // 체크박스가 없는 포맷이거나 understand에 체크가 돼있을 때
      setAnimationClosing(true)	// 모달 닫음으로 처리
      setTimeout(() => {
        setAnimationClosing(false);
        onClose();
      }, 800)	//애니메이션 duration과 같게 쓴다.
    }
  };

  const portalElement = document.getElementById("portal");
  if (!portalElement) return null;


  
  return ReactDOM.createPortal(
    <>
      <s.Modal className={`modal-overlay ${animationClosing ? "closing" : ""}`}>
        <s.Modal className={`modal-wrapper ${animationClosing ? "closing" : ""}`}>
          <s.Modal className="circle">
            <s.WarnIcon />
          </s.Modal>
          <s.Echo className="wrapper">
            <s.Echo className="circle00" />
            <s.Echo className="circle01" />
            <s.Echo className="circle02" />
            <s.Echo className="circle03" />
          </s.Echo>
          <s.Modal className="modal-container">
            <s.StyledH1 className="warning">{modalTitle}</s.StyledH1>
            <s.Modal className="text-box">{children}</s.Modal>
            {showCheckbox && <s.Modal className="checkbox-container">
              <s.Input
                type="checkbox"
                id="understand"
                checked={understand}
                onChange={handleUnderstandChange}
                className="modal-check"
              />
              <s.Label
                htmlFor="understand"
                className="understand"
              >
                {understand ? (
                  <s.CheckboxAfterIcon className="checkbox-icon-checked" />
                ) : (
                  <s.CheckboxBeforeIcon className="checkbox-icon" />
                )}
                {checkboxText}
              </s.Label>
            </s.Modal>}
            <s.Button className="Round" onClick={handleModalClose}>
              닫기
            </s.Button>
          </s.Modal>
        </s.Modal>
      </s.Modal>
    </>,
    portalElement
  );
};

export default Modal;

 

 

재사용 테스트를 거쳐보니,

props는 손쉽게 사용할 수 있지만 매번 이벤트 핸들러와 state를 다시 써줘야하는 번거로움이 있었다.

 


 

이 점을 해결하기 위해 Custom Hook을 사용하여 캡슐화하고, 코드 중복을 더 줄여보자.

 

사족: 기존 파일과 빈 파일 두개를 나란히 두고 한줄 한줄 이식하듯이(?) 작성하니 보다 이해하기 쉬웠다.

 

 

우선 커스텀 훅의 뼈대를 잡는다.

커스텀 훅은 use... 로 시작하며, return 문에 해당 훅이 생성한 값이나 함수를 넣는다.

const useModal = () => {

};


return {

};

export default useModal;

 

 

기존 코드에서 useState문과 관련된 부분을 모두 가져오고,

return문에 재사용할 state와 함수를 객체 형태로 반환한다. (배열도 가능하다)

import { useState } from "react";

const useModal = () => {
  const [understand, setUnderstand] = useState(false);
  const [animationClosing, setAnimationClosing] = useState(false);

  const handleUnderstandChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    setUnderstand((prevUnderstand) => !prevUnderstand);
  };

  const handleModalClose = () => {
    if (understand) {
      setAnimationClosing(true);
      setTimeout(() => {
        setAnimationClosing(false);
        onClose(); // Ensure the onClose callback is called when the modal is closed
      }, 800);
    }
  };

  return {
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  };
};

export default useModal;

 

 

그리고 인자를 넘겨준다.

나는 여기서 많이 헤멨는데, 아무래도 인수의 개념이 탄탄히 잡히지 않아 그랬던 것으로 보인다.

 

 

간단히 말하자면-

 

1. 커스텀 훅에서 처리할 상태와 함수를 정리해서 보내주면

2. Modal 컴포넌트에서 이것을 인수 (여기서는 모달 열림, 닫힘) 에 따라 처리한다.

import { useState } from "react";
import { UseModalProps } from "../types/ModalProps";

interface UseModalProps {	// 인수 타입 선언
  isOpen: boolean;
  onClose: () => void;
}

const useModal = ({ isOpen, onClose }: UseModalProps) => {
  const [understand, setUnderstand] = useState(false);
  const [animationClosing, setAnimationClosing] = useState(false);

  const handleUnderstandChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    setUnderstand((prevUnderstand) => !prevUnderstand);
  };

  const handleModalClose = () => {
    if (understand) {
      setAnimationClosing(true);
      setTimeout(() => {
        setAnimationClosing(false);
        onClose(); 
      }, 800);
    }
  };

  return {
    isOpen,
    onClose,
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  };
};

export default useModal;

 

 

 

여기서 바로 컴포넌트로 넘겨주어도 좋지만, 커스텀 훅에서 써둔 타입 선언부를 컴포넌트에서도 다시 작성해야하는 번거로움을 발견했다.

// 수정 전

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
  modalTitle: string;
  showCheckbox?: boolean;
  checkboxText?: string;
}

const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  children,
  modalTitle,
  showCheckbox,
  checkboxText,
}) => {

  const {
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  } = useModal({ isOpen, onClose });
  
  // 후략

 

 

따라서 이번 기회에 타입을 별도 파일로 분리해보았다.

서로 겹치는 부분은 extends 를 사용하여 가급적 겹치지 않게 했다.

// ModalProps.ts
import { ReactNode } from "react";

interface CommonProps {
  isOpen: boolean;
  onClose: () => void;
}

export interface ModalProps extends CommonProps {
  children?: ReactNode;
  modalTitle?: string;
  showCheckbox?: boolean;
  checkboxText?: string;
}

export interface UseModalProps extends CommonProps {}

 

 

다시 커스텀 훅으로 돌아와 정리해둔 타입을 불러와 선언해준다.

import { useState } from "react";
import { UseModalProps } from "../types/ModalProps";


const useModal = ({ isOpen, onClose }: UseModalProps) => {
  const [understand, setUnderstand] = useState(false);
  const [animationClosing, setAnimationClosing] = useState(false);

  const handleUnderstandChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    setUnderstand((prevUnderstand) => !prevUnderstand);
  };

  const handleModalClose = () => {
    if (understand) {
      setAnimationClosing(true);
      setTimeout(() => {
        setAnimationClosing(false);
        onClose(); 
      }, 800);
    }
  };

  return {
    isOpen,
    onClose,
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  };
};

export default useModal;

 

 

첫단계에서 함께한 Modal 컴포넌트 파일에서도 커스텀 훅으로 넘겨준 모든 state와 함수를 지우고, 타입을 선언해준다.

import ReactDOM from "react-dom";
import s from "../stores/styling";
import useModal from "../hooks/ModalHook";
import { ModalProps } from "../types/ModalProps"; 


const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  children,
  modalTitle,
  showCheckbox,
  checkboxText,
}) => {

  const {
    understand,
    animationClosing,
    handleUnderstandChange,
    handleModalClose,
  } = useModal({ isOpen, onClose });


  if (!isOpen) return null;

  const portalElement = document.getElementById("portal");
  if (!portalElement) return null;

  return ReactDOM.createPortal(
    <>
      <s.Modal className={`modal-overlay ${animationClosing ? "closing" : ""}`}>
        <s.Modal
          className={`modal-wrapper ${animationClosing ? "closing" : ""}`}
        >
          <s.Modal className="circle">
            <s.WarnIcon />
          </s.Modal>
          <s.Modal className="modal-container">
            <s.StyledH1 className="warning">{modalTitle}</s.StyledH1>
            <s.Modal className="text-box">{children}</s.Modal>
            <s.Modal className="checkbox-container">
              <s.Input
                type="checkbox"
                id="understand"
                checked={understand}
                onChange={handleUnderstandChange}
                className="modal-check"
              />
              <s.Label htmlFor="understand" className="understand">
                {understand ? (
                  <s.CheckboxAfterIcon className="checkbox-icon-checked" />
                ) : (
                  <s.CheckboxBeforeIcon className="checkbox-icon" />
                )}
                {checkboxText}
              </s.Label>
            </s.Modal>
            <s.Button className="Round" onClick={handleModalClose}>
              닫기
            </s.Button>
          </s.Modal>
        </s.Modal>
      </s.Modal>
    </>,
    portalElement
  );
};

export default Modal;

 

이제 다른 컴포넌트에 삽입해보면 이전과 동일한 기능과 효과를 보여주지만 보다 간결하고 깔끔한 코드가 된다.

컴포넌트를 이식(?) 해주고 실제로 여닫히는 상황을 state에 저장할 수 있도록 처리해주면 완성된다.

//LoginPage.tsx

import Modal from "../components/Modal";

const LoginPage = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  
  const handleOpenModal = () => {
    setIsModalOpen(true);
  };

  const handleCloseModal = () => {
    setIsModalOpen(false);
  };
  
	//...중략...
}
  return (
  <>
  {/*...중략...*/}
	<Modal
      isOpen={isModalOpen}
      onClose={handleCloseModal}
      modalTitle={"잠깐!"}
      showCheckbox={true}
      checkboxText={"이해했습니다."}
    >
      <s.StyledP className="modal">
        19세 미만 회원의 경우,
        <br />
        예약 및 시술이 제한될 수 있습니다.
      </s.StyledP>
    </Modal>
  </>
 )
}

export default LoginPage;

 

+ Recent posts