[주문앱 3탄] Portal로 모달 만들기

비얌·2023년 4월 26일
9
post-thumbnail
post-custom-banner

🧹 개요

현재까지 만든 그릭요거트 주문앱은 장바구니 버튼을 클릭해도 아래처럼 아무것도 뜨지 않는다.

이번 포스팅에서는 장바구니 버튼을 클릭했을 때 뜨는 장바구니 창(모달)을 만들어보려고 한다. 추가적으로 닫기 버튼이나 배경을 클릭하면 창이 닫히는 기능도 만들 것이다.



✨ 결과 미리보기

열기/닫기 기능이 있는 모달을 만든 결과는 아래와 같다😉



🛫 과정

포탈은 리액트에서 컴포넌트가 렌더링되는 위치를 제어할 수 있게 해주는 기능이다. 이를 이용하면 모달을 최상위 DOM 노드에 위치시키고, 다른 컴포넌트들과 독립적으로 렌더링할 수 있다.

Portal을 쓰는 이유

Portal을 쓰는 이유에 대해 더 자세히 알아보자. input창에 유효하지 않은 값을 입력했을 때 오류 메시지를 보여주는 모달을 Portal을 사용하지 않고/Portal을 사용해서 구현해보았다.

1) 일반적으로 모달을 구현했을 때

아래는 포탈을 사용하지 않았을 때의 예시이다. 포탈을 사용하지 않고 z-index 등의 스타일링을 이용해 모달을 구현했다. backdrop과 ErrorModal이 root 안에서 다른 부분들과 중첩되어있는 것을 알 수 있다.

backdrop: Modal의 뒤에 있는 배경 부분

이렇게 포탈을 쓰지 않고 모달을 구현했을 때의 문제점을 알아보자.

  • 위와 같이 오버레이 내용이 중첩되어 있으면 스크린 리더가 렌더링되는 HTML을 해석할 때 일반적인 오버레이라고 인식하지 못할 수 있다.

  • 상위 요소에 어떤 CSS가 적용되어 있는지에 따라 모달의 동작을 아예 구현할 수 없는 경우가 있다.

  • 모달은 전체 페이지에 대한 오버레이이므로 모든 것들 위에 있다. 따라서 이렇게 하면 기술적으로는 작동할지 몰라도 의미나 구조적인 측면에서 좋은 코드가 아니다.


2) Portal을 써서 모달을 구현했을 때

이번에는 포탈을 써서 모달을 구현했을 때의 예시이다. 개발자 도구를 보면, root 위의 최상위 DOM에 backdrop과 ErrorModal을 위치시킬 수 있었다.

이렇게 Portal을 썼을 때의 장점을 알아보자.

  • 모달을 최상위 DOM 노드에 위치시킬 수 있다. 따라서 모달이 다른 코드와 중첩되지 않게 할 수 있다.

  • 상위 요소의 CSS에 따라서 모달을 구현하지 못하는 경우를 방지할 수 있다.


모달이 렌더링 될 위치 잡기

모달(overlay-root)과 backdrop(backdrop-root)이 렌더링될 위치에 DOM요소를 만들고 id를 부여해준다.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>GreekZik</title>
    <link href="https://fonts.googleapis.com/css?family=Jua:400" rel="stylesheet">
  </head>
  <body>
    <div id="backdrop-root"></div> <!-- 이곳 -->
    <div id="overlay-root"></div> <!-- 이곳 -->
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Modal 컴포넌트를 만들고라이브러리를 임포트한다. 둘 중에 한 방식을 선택해서 하면 된다.

// Modal.jsx
import ReactDOM from "react-dom"; // ReactDOM.createPortal()로 사용
import { createPortal } from 'react-dom'; // createPortal()로 사용

createPortal로 포탈을 만들어준다.

import React from 'react';
import ReactDOM from "react-dom";
import classes from './Modal.module.css';

const Backdrop = props => {
  return <div className={classes.backdrop} />
}

const Overlay = props => {
  return (
    <div className={classes.modal}>
      <div className={classes.content}>{props.children}</div>
    </div>
  );
}

const Modal = props => {
  return (
    <>
      {ReactDOM.createPortal(<Backdrop />, document.getElementById("backdrop-root"))}
      {ReactDOM.createPortal(<Overlay>{props.children}</Overlay>, document.getElementById("overlay-root"))}
    </>
  );
};

export default Modal;

Modal을 사용할 Cart 컴포넌트 만들기

Cart 컴포넌트를 만들고 내부를 Modal 컴포넌트로 감쌀 것이다. Cart 컴포넌트에서는 장바구니 안에 표시될 내용을 넣는다.

// Cart.jsx
import React from 'react';
import Modal from '../UI/Modal';

const Cart = props => {
  return (
    <Modal>
      <div>
        <span>Total Amount</span>
        <span>0</span>
      </div>
      <div>
        <button>Close</button>
        <button>Order</button>
      </div>
    </Modal>
  );
};

export default Cart;

Cart 컴포넌트 import하기

App 컴포넌트에서 Header 위에 Cart를 위치시킨다.

// App.jsx
import Cart from './components/Cart/Cart';
import React from 'react';
import Header from './components/Layout/Header';
import Toppings from './components/Toppings/Toppings';
import './App.css';

function App() {
  return (
    <>
      <Cart />
      <Header />
      <main>
        <Toppings />
      </main>
    </>
  );
}

export default App;

CSS 작성하기

css는 강의에서 사용한 css를 가져와 일부만 수정했다.
여기까지 하면 아래처럼 만들 수 있다. 하지만 문제는 장바구니 버튼을 누르지 않았는데도 새로고침하면 무조건 모달이 뜨고, 닫기 버튼을 눌러도 닫히지 않는다는 것이다.

왜냐하면 App 컴포넌트에서 Cart 컴포넌트를 아무 조건 없이 렌더링하고 있기 때문이다.

// App.jsx
import Cart from './components/Cart/Cart';
import React from 'react';
import Header from './components/Layout/Header';
import Toppings from './components/Toppings/Toppings';
import './App.css';

function App() {
  return (
    <>
      <Cart />
      <Header />
      <main>
        <Toppings />
      </main>
    </>
  );
}

export default App;

장바구니 버튼을 눌렀을 때에만 모달창이 보이게 해보자.


장바구니 버튼이 눌릴 때만 모달 열기

평소에는 이 모달이 보이지 않다가 장바구니 버튼을 클릭했을 때만 모달을 열려고 한다.

이를 위해 cartIsShown이라는 State를 만들었다. cartIsShown이 true면 모달을 띄우고, false면 모달을 띄우지 않을 것이다.

cartIsShown은 Cart와 HeaderCartButton의 공통된 상위 컴포넌트인 App에서 선언할 것이다. HeaderCartButton에서 버튼이 눌리면 그 정보가 App까지 올라와서 다시 Cart로 내려간다. 그 과정을 그려보면 아래와 같다. 각 번호당 어떤 일이 일어나는지 살펴보자.

  1. App에서 cartIsShown이라는 State를 선언하고 초기값을 false로 설정한다.

    // App.jsx
    const [cartIsShown, setCartIsShown] = useState(false);=
  2. HeaderCartButton이 클릭된다.

  3. 그러면 prop으로 받은 onClick 함수가 실행된다. 그렇게 Header 컴포넌트로 데이터가 올라가게 된다.

    // HeaderCartButton.jsx
    const HeaderCartButton = props => {
    
      return (
        <button className={classes.button} onClick={props.onClick}>
          <span className={classes.icon}>
            <CartIcon />
          </span>
          <span>장바구니</span>
          <span className={classes.badge}>0</span>
        </button>
      );
    };
    
    export default HeaderCartButton;
  4. 그 onClick 함수는 Header에서 prop으로 받은 showCartHandler를 실행한다. 그렇게 App 컴포넌트로 데이터를 올라간다.

    // Header.jsx
    const Header = props => {
    
      return (
        <>
          <header className={classes.header} >
            <h1>GreekZik</h1>
            <HeaderCartButton onClick={props.showCartHandler} />
          </header>
        </>
      );
    };
    
    export default Header;
  5. App에서 showCartHandler가 실행된다. 이 함수는 setCartIsShown으로 cartIsShown을 true로 설정한다.

    // App.jsx
    const showCartHandler = () => {
        setCartIsShown(true);
      }
  6. 조건부 렌더링으로 Cart 컴포넌트가 렌더링되며 Modal이 보인다.

      // App.jsx
      const [cartIsShown, setCartIsShown] = useState(false);
    
      const showCartHandler = () => {
        setCartIsShown(true);
      }
    
      return (
        <>
          {cartIsShown && <Cart />}
          <Header showCartHandler={showCartHandler} />
          <main>
            <Toppings />
          </main>
        </>
      );
    }
    
    export default App;

모달 닫기 기능 (1) - 닫기 버튼 클릭시

지금까지는 모달 열기 기능을 만들어봤다. 하지만 아래에서 볼 수 있듯이 모달이 뭘 눌러도 닫히지 않는다. 닫기 버튼을 눌렀을 때 모달이 닫히는 기능부터 만들어보자.

먼저, App 컴포넌트에 hideCartHandler 함수를 만든다. showCartHandler 함수와 비슷하지만 반대의 기능을 하는데, cartIsShown을 false로 설정해준다.

그리고 이 함수를 Cart 컴포넌트에 prop으로 전달한다.

// App.jsx
function App() {
  const [cartIsShown, setCartIsShown] = useState(false);

  const showCartHandler = () => {
    setCartIsShown(true);
  }

  const hideCartHandler = () => {
    setCartIsShown(false);
  }

  return (
    <>
      {cartIsShown && <Cart hideCartHandler={hideCartHandler} />}
      <Header showCartHandler={showCartHandler} />
      <main>
        <Toppings />
      </main>
    </>
  );
}

export default App;

Cart 컴포넌트에서 닫기 버튼을 누르면 prop으로 받은 hideCartHandler 함수를 실행하게 한다.(onClick={props.hideCartHandler}로 인해) 그러면 App 컴포넌트에 있는 hideCartHandler 함수가 실행되며 cartIsShown가 false가 되고, 모달이 닫히게 된다.

// Cart.jsx
const Cart = props => {

  return (
    <Modal>
      <div className={classes.total}>
        <span>Total Amount</span>
        <span>0</span>
      </div>
      <div className={classes.actions}>
        <button 
          className={classes['button--alt']} 
          onClick={props.hideCartHandler}
        >
          닫기
        </button>
        <button className={classes.button}>주문</button>
      </div>
    </Modal>
  );
};

export default Cart;

닫기 버튼을 눌렀을 때 잘 닫힌다!


모달 닫기 기능 (2) - backdrop 클릭시

추가적으로, 닫기 버튼 외에 모달 밖의 공간(backdrop)을 클릭했을 때에도 모달이 닫히게 하고 싶다. 현재까지는 모달 밖의 공간을 클릭해도 아무런 변화가 없다.

위에서 살펴보았듯이 App 컴포넌트의 hideCartHandler 함수가 실행되면 cartIsShown이 false가 되어 모달이 닫힌다. 따라서 모달을 닫으려면 App에 있는 hideCartHandler 함수를 실행하면 된다. 그러기 위해 먼저 Cart 컴포넌트에서 Modal 컴포넌트에 hideCartHandler를 prop으로 넘긴다.

// Cart.jsx
<Modal hideCartHandler={props.hideCartHandler}>

Modal 컴포넌트에는 Backdrop이 있다. 이 컴포넌트에 onClick={props.hideCartHandler}속성을 준다.

// Modal.jsx
import React from 'react';
import ReactDOM from "react-dom";
import classes from './Modal.module.css';

const Backdrop = props => {
  return <div className={classes.backdrop} onClick={props.onClick} />
}

const Overlay = props => {
  return (
    <div className={classes.modal}>
      <div className={classes.content}>{props.children}</div>
    </div>
  );
}

const Modal = props => {
 
  return (
    <>
      {ReactDOM.createPortal(<Backdrop onClick={props.hideCartHandler} />, document.getElementById("backdrop-root"))}
      {ReactDOM.createPortal(<Overlay>{props.children}</Overlay>, document.getElementById("overlay-root"))}
    </>
  );
};

export default Modal;

주의할 점은, 여기까지만 하면 아무 일도 일어나지 않는다는 것이다.

Backdrop 컴포넌트에 가서 래퍼인 div에 속성으로 onClick={props.onClick}을 줘야 제대로 동작하게 된다. 그 이유는, Backdrop은 내장된 컴포넌트가 아니라 임의로 만든 것이라서 onClick 속성이 뭔지 모른다. 그래서 onClick를 읽을 수 있는 div에 onClick 속성을 줘야 한다. 그것이 onClick={props.onClick}이다.

onClick이라는 동일한 속성 이름 때문에 헷갈리기 쉬운 것 같다. 그래서 같은 내용 같은 동작인 코드를 가져와보았다. 위의 코드는 아래와 똑같다.

Backdrop에 주는 속성이 onClick이 아니라 hamster면 Backdrop 안의 래퍼인 div의 속성으로 onClick={props.hamster}를 주면 된다.

// Modal.jsx
import React from 'react';
import ReactDOM from "react-dom";
import classes from './Modal.module.css';

const Backdrop = props => {
  return <div className={classes.backdrop} onClick={props.hamster} />
}

const Overlay = props => {
  // 생략
}

const Modal = props => {
  return (
    <>
      {ReactDOM.createPortal(<Backdrop hamster={props.hideCartHandler} />, document.getElementById("backdrop-root"))}
      {/* 생략 */}
    </>
  );
};

export default Modal;

backdrop을 눌렀을 때 잘 닫히는 걸 확인할 수 있다!



✨ 결과

모달이 완성되었다!

  1. 장바구니 버튼을 클릭하면 모달이 열린다
  2. 닫기 버튼을 클릭하면 모달이 닫힌다
  3. 모달 뒤의 배경을 클릭하면 모달이 닫힌다

기존에 구현하려고 했던 걸 모두 구현했다✨



🐹 회고

드디어.. 모달까지 완성했다!! 점점 기능이 추가되는 것이 기쁘다. 사실 모달까지는 연습을 한 경험이 있어서 거의 헤매지 않았는데, 앞으로 만들 장바구니 담기 기능은 처음 해봐서 떨린다🥺

그리고 모달을 만들기 전에 Node Backend + React Frontend라는 유튜브 영상을 보고 node.js로 서버를 만들어보았다!

Toppings 컴포넌트에 있었던 더미데이터를 그곳에 넣고, 이를 클라이언트에 가져와서 기존과 같이 화면에 보여줬다. 배경지식 없이 무작정 따라한 거라서 오류가 많이 났지만 3시간만에 끝내서 뿌듯했다!!

다음 장바구니 담기 기능도 바로 만들어보자.

profile
🐹강화하고 싶은 기억을 기록하고 공유하자🐹
post-custom-banner

0개의 댓글