[React] 모달창 구현하기

Jinny·2024년 6월 23일
0

React

목록 보기
21/23
post-custom-banner

ReactDOM.createPortal를 이용한 모달 구현

ReactDom.createPortal이란?

일부 자식을 DOM의 다른 부분으로 렌더링할 수 있다.

createPortal(children, domNode, key?)
portal을 만들기 위해서는 JSX와 렌더링할 DOM 노드를 전달한다.

포털을 사용하면 컴포넌트가 자식 중 일부를 DOM의 다른 위치에 렌더링할 수 있다. 예를 들어, 모달 대화상자나 툴팁을 페이지의 나머지 부분
위와 외부에 표시할 수 있다.

import { createPortal } from 'react-dom';

function MyComponent() {
  return (
    <div style={{ border: '2px solid black' }}>
      <p>This child is placed in the parent div.</p>
      {createPortal(
        <p>This child is placed in the document body.</p>,
        document.body
      )}
    </div>
  );
}

React는 전달한 JSX에 대한 DOM 노드를 사용자가 제공한 DOM 노드 안에 배치한다.
포털이 없다면, 두 번째 <p>태그는 부모 <div> 태그 안에 배치되지만 포털은 있으면 본문(document.body)으로 이동한다.

import { createPortal } from 'react-dom';

export default function MyComponent() {
  return (
    <div style={{ border: '2px solid black' }}>
      <p>This child is placed in the parent div.</p>
      {createPortal(
        <p>This child is placed in the document body.</p>,
        document.body
      )}
    </div>
  );
}

개발자 도구로 DOM 구조를 확인하면 두 번째 <p>태그가 <body>태그 안에 배치된 모습을 확인할 수 있다.

Portal을 이용한 모달 구현하기

모달을 불러오는 구성요소가 overflow가 있는 컨테이너 안에 있는 경우에도 포털을 사용하여 페이지의 나머지 부분 위에 떠있는 모달을 만들 수 있다.

아래 예제는 2개의 컨테이너가 모달을 방해하는 스타일이 있지만, DOM에서 모달이 부모 JSX 요소에 포함되어 있지 않기 때문에 포털로 렌더링된 모달은 영향을 받지 않는다.

export default function App() {
  return (
    <>
      <div className="clipping-container">
        <NoPortalExample  />
      </div>
      <div className="clipping-container">
        <PortalExample />
      </div>
    </>
  );
}

포탈이 없는 경우, 부모 요소 스타일 영향을 받은 모습이고
포탈이 있는 경우, 부모 요소 스타일 영향을 받지 않은 모습이다.

  1. 프로젝트 구조 살피기
    우선 index.html을 확인해보자.
    React는 <div id="root"></div> 종속하여 구현한다.
    createPortal을 사용하면 id가 root인 div에서 벗어날 수 있다.
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
</html>
  1. 렌더링할 div 넣어주기
    아래 index.html에 <div id="modal"></div>를 추가했다.
    이제 모달을 modal인 div 태그에 렌더링을 할 수 있다.
 <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <div id="modal"></div>
    <script type="module" src="/src/index.tsx"></script>
  </body>
  1. createPortal 사용하기
    모달 컴포넌트에서 createPortal을 사용하면 id="modal"을 가진 DOM 노드 안에 배치한다.
import { createPortal } from 'react-dom';
import { useModalStore } from 'store/modal';

const Modal = ({ children }: { children: React.ReactNode }) => {
  const { isOpen } = useModalStore();
  if (!isOpen) return null;

  return createPortal(
    <div className='w-100 h-100 fixed top-0 left-0 right-0 bottom-0 flex justify-center items-center'>
      <div className='rounded-md min-w-80 absolute top-1/2 left-1/2 p-10 bg-white shadow-modal -translate-x-1/2 -translate-y-1/2 overflow-hidden'>
        <div className='w-80 h-auto flex flex-col items-center'>{children}</div>
      </div>
    </div>,
    document.getElementById('modal')!,
  );
};

export default Modal;

Zustand로 모달 구현하기

장바구니 담기 또는 중복 옵션을 선택했을 때 모달창을 띄우기 위해
전역 상태 관리로 모드를 변경하고 열고 닫을 수 있도록 설정하기로 했다.

// src\store\modal.ts
import { create } from 'zustand';

type ModalStore = {
  isOpen: boolean;
  mode: 'add cart' | 'duplicate option';
  setMode: (mode: 'add cart' | 'duplicate option') => void;
  toggleModal: () => void;
};

export const useModalStore = create<ModalStore>((set) => ({
  isOpen: false,
  mode: 'add cart',
  setMode: (mode) => set({ mode }),
  toggleModal: () => set((state) => ({ isOpen: !state.isOpen })),
}));
// src\components\AddToCartModal.tsx
import ModalCloseSvg from 'assets/svg/ModalCloseSvg';
import { Link } from 'react-router-dom';
import { useModalStore } from 'store/modal';

const AddToCartModal = () => {
  const { toggleModal } = useModalStore();
  const handleClick = () => {
    toggleModal();
  };
  return (
    <div className='flex flex-col gap-5 text-lg text-center leading-6'>
      <p>장바구니에 상품이 담겼습니다.</p>
      <Link
        className='border border-gray-400 p-3'
        to={'/carts'}
        onClick={handleClick}
      >
        장바구니 바로 가기
      </Link>
      <button className='w-10 absolute top-2 right-2 p-2' onClick={toggleModal}>
        <ModalCloseSvg />
      </button>
    </div>
  );
};

export default AddToCartModal;

장바구니 담기 확인 모달창이 나올 때 mode를 확인 후 필요시
변경한 다음 모달창을 연다.

//src\pages\productDetail.tsx

const ProductDetail = () => {
 // ...
  const { isOpen, mode, setMode, toggleModal } = useModalStore();
  
  const handleAdd = () => {
    if (!selected) return;
    if (mode !== 'add cart') {
      setMode('add cart');
    }
    toggleModal();
	// ... 
  };

  return (
    <div className='w-full flex flex-col md:flex-row content-between gap-10 p-10'>
      {isOpen && mode === 'add cart' && (
        <Modal>
          <AddToCartModal />
        </Modal>
      )}
      {isOpen && mode === 'duplicate option' && (
        <Modal>
          <SelectedOptionModal />
        </Modal>
      )}
      <img
        className='w-full basis-1/2 md:w-140 md:h-140'
        src={image}
        alt={'상품 이미지'}
      />
      <div className='w-full basis-1/2 flex flex-col gap-2 pl-10'>
        <div className='flex justify-between'>
          <h3 className='text-xl font-semibold'>{name}</h3>
          <button>
            <IoMdHeartEmpty size={26} />
          </button>
        </div>
        <div className='border-b-2 pb-2'>
          <span className='text-xl font-semibold'>
            {price.toLocaleString()}
          </span>
          <span className='font-bold'></span>
        </div>

        <p>{description}</p>
        <select
          className='h-8 border border-gray-400 outline-none cursor-pointer'
          onChange={handleSelect}
          value={''}
        >
          <option disabled value={''}>
            [사이즈]를 선택하세요.
          </option>
          {size?.map((s, i) => (
            <option key={i} value={s}>
              {s}
            </option>
          ))}
        </select>
        {selected && <SelectedProduct option={option} />}
        <button
          className='h-12 mt-4 bg-primary text-white hover:bg-price-stress shadow-md'
          onClick={handleAdd}
        >
          장바구니 담기
        </button>
      </div>
    </div>
  );
};

export default ProductDetail;

➡️ 상황에 따라 모달 창이 다르게 나오는 것을 확인할 수 있다.

🔗 출처
React-createPortal 공식문서
React-모달창 만들기 참고

post-custom-banner

0개의 댓글