본문 바로가기
FE & BE

4. React 모달창 구현 + Styled Components [MyHomepage: 개인 홈페이지 만들기]

by zeroiron0 2022. 11. 15.

안녕하세요! 개발자 최영철입니다.

 

이번 포스트에서는 모달창의 구현에 대해 이야기하려고 합니다.

 

목차

1. 모달창

2. stopPropagation()

3. props.children

4. { foo && ( 컴포넌트 ) }

5. Styled-components

6. 구현 코드

7. 마무리

1. 모달창

모달창은 다음 그림과 같이 팝업창 뒤에 있는 요소를 비활성화 하고 팝업창에 집중하도록 만드는 창입니다.

출처: https://sabe.io/tutorials/how-to-create-modal-popup-box

 모달창을 통해 유저 플로우를 제어하고 원하는 방향으로 이끌 수 있습니다.

 

 처음 방명록 구현을 진행할 때 방명록은 프로필, 프로젝트를 설명하는 페이지에 나타내는게 아니라 모달창을 통해 나타내고 싶었습니다. 따라서 모달창을 구현하는 방법을 검색했고 다음과 같은 정보를 알게 됐습니다. 

1. 백그라운드와 상호작용할 수 없도록 한 개의 컴포넌트를 CSS를 통해 화면을 전부 뒤덮는다.
2. 내용을 표시할 컴포넌트로 Body를 만든다. 그리고 이 컴포넌트에는 onClick 이벤트에 stopPropagation()을 설정한다.
3. Body 위에 닫기 버튼과 표시할 컴포넌트를 {props.children}으로 나타낸다.
4. 모달창을 표시하거나 닫는 방법은 useState로 생성된 [isModal, setModal]를 이용해 {isModal && (컴포넌트)}처럼 처리한다. 

stopPropagation()과 props.children, {isModal && (컴포넌트)}의 의미를 몰랐기때문에 이에 대해 학습했습니다.

2. stopPropagation()

 만약 메인페이지에 모달창 컴포넌트를 표시한다면 부모 컴포넌트인 메인 페이지의 컴포넌트가 자식 컴포넌트로 모달창을 만들어서 표시하는 방식입니다. 이때 부모 컴포넌트는 자식 컴포넌트에서 일어나는 이벤트를 전파받습니다. 이것을 마우스 클릭 이벤트로 예시를 들어봅시다.

<div onClick={foo}>
  <button onClick={bar} />
</div>

 위와 같은 경우 버튼을 클릭한다면 bar만 실행될 뿐만 아니라 클릭 이벤트가 똑같이 부모인 <div>한테도 전해져 foo 함수도 같이 실행되게 됩니다. 따라서 모달창의 경우 Body에서 일어나는 클릭 이벤트가 부모 컴포넌트에게 전달되는 것을 막아야하므로 stopPropagation()을 사용함을 알게 됐습니다.

 

3. props.children

props.children에 검색한 결과 해당 태그로 감싸고 있는 모든 컴포넌트, 즉 자식 컴포넌트를 부모 컴포넌트에서 관리할 수 있었습니다. 

무슨 이점이 있을까 생각하다가 props.children을 사용하면 다음과 같은 이점이 있다는 것을 알게 되었습니다:

function Foo() {
  const value = 1
  const value2 = 2
  return (
    <div>
      <Bar>
        <div>
          {value}
          <div>
            {value2}
          <div>
        </div>
      </Bar>
      <Baz value={value} value2={value2} />
    </div>
  )
}

function Bar(props) {
return (
    <div>
      {props.children}
    </div>
  )
}

function Baz(props) {
  const { value, value2 } = props
  return (
    <div>
      {value}
      <Quix value2={value2} />
    </div>
  )
}

function Quix(props) {
  const { value2 } = props
  return (
    <div>
      {value2}
    </div>
  )
}

 위의 코드를 보면 Bar 컴포넌트는 표시할 value, value2를 Foo에서 할당해주므로 Bar는 자식 컴포넌트를 단순히 표시만 하면 됩니다. 하지만 Baz와 Quix의 경우, Baz는 자신에게 불필요한 value2를 Quix에게 전해주기 위해 props로 받아야하고 불필요한 과정이 추가, 코드가 길어지게 됩니다. 실제로 Bar를 사용할 경우 간단한 작업이 Baz, Quix를 사용할 경우, 함수 선언, return 선언, <div> 선언 등 몇 배에 달하는 코드를 작성하는 것을 볼 수 있습니다.

 모달창의 경우에도 Body에 {props.child}를 사용하는 것을 통해 개발자가 표시하고 싶은 모든 컴포넌트를 간단하게 표시할 수 있게 됩니다.

 

4. { foo && ( 컴포넌트 ) }

 이것의 경우, 처음 알게 됐을 때 신기함을 느꼈습니다.

기존의 a && b() 에서 a가 null, false 등 조건에 만족하지 않는다면 b()가 실행되지 않는다는 지식은 알고 있었지만 이를 React의 useState와 함께 활용하면 그 값에 따라 ()에 쌓여있는 컴포넌트를 표시할 지 정할 수 있습니다.

 

 이렇게 많은 교훈과 정보를 알게되었고, 본격적으로 모달창을 구현하기 시작했습니다.

 

처음에는 단순히 아래와 같은 모달창을 다음과 같은 코드로 구현했습니다.

//modal.css
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.4);
  display: flex;
  justify-content: center;
  align-items: center;
}

.modalBody {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  position: absolute;
  gap: 10px;
  width: 300px;
  height: 500px;
  padding: 40px;
  text-align: center;
  background-color: rgb(255, 255, 255);
  border-radius: 10px;
  box-shadow: 0 2px 3px 0 rgba(34, 36, 38, 0.15);
}
 
#modalCloseBtn {
  position: absolute;
  top: 15px;
  right: 15px;
  border: none;
  color: rgba(0, 0, 0, 0.7);
  background-color: transparent;
  font-size: 20px;
}
 
#modalCloseBtn:hover {
  cursor: pointer;
}

//modal.js
import React from "react";
import './modal.css';
 
function Modal(props) {
  const { closeModal, zIndex } = props
  const z =  zIndex ? zIndex : 100

  return (
    <div className="modal" style= {{ zIndex: z }} onClick={closeModal}>
      <div className="modalBody" style = {{ ... props.style, zIndex: z }} onClick={(e) => e.stopPropagation()}>
        <button id="modalCloseBtn" style= {{ zIndex: z }} onClick={closeModal}>
          ✖
        </button>
        {props.children}
      </div>
    </div>
  );
}

export default Modal;

 그렇게 홈페이지 구현 작업을 진행하던 중, 특정 프로젝트나 방명록 삭제를 위해 JS의 confirm을 표시해야하고 API 전송 결과를 alert로 알려줘야했습니다. 여기에서 기존의 구현한 Modal 컴포넌트를 확장하여 props로 버튼을 받는다면 버튼형 모달창으로 표시되면 좋겠다는 생각이 들었습니다. 또한, 이렇게 되면 모달창을 호출할 때 해당 모달창의 타이틀을 정해주고 표시하면 좋겠다는 생각도 같이 들었습니다. 그래서 아래와 같은 레이아웃을 생각했습니다.

 만약 첫번째 버튼만 받는다면 왼쪽의 형태의 모달창이 반환 되고, 두번째 버튼도 같이 받는다면 오른쪽 형태의 모달창이 반환되는 것입니다. 처음에 이렇게 기획하고 CSS를 작성하기 시작했지만 한 눈에 들어오지 않고 복잡해졌습니다. 그렇게 CSS를 쉽게 작성하는 법을 찾던 중 Styled-components의 존재를 알게 됩니다.

 

5. Styled-components

 Styled-components는 독립된 CSS 파일을 작성하던 것을 컴포넌트 안에서 관리해줄 수 있게 만들어줍니다. 또한, 기존의 CSS 파일처럼 전역으로 선언된 것이 아닌 styled로 선언한 CSS 양식에 로컬로 선언되기 때문에 같은 className이나 id라도 따로 CSS를 적용할 수 있고, CSS에 JS의 함수나 변수를 사용할 수 있다는 이점이 있었습니다.

const Red = "red";

const NewDiv = styled.div`
     .btn {
    	.red {
        	background-color: ${Red};
        }
        .blue {
        	background-color: blue;
        }
    }
 `
 
function Foo() {
     return (
        <NewDiv>
            <div className="btn">
            	<div className="red" />
                <div className="blue" />
            </div>
        </NewDiv>
    )
 }

또한, Styled-components를 공부하면서 red와 blue처럼 btn에 CSS를 nested하게 작성해도 적용되는 것을 알게 됐습니다. 이렇게 얻은 지식을 통해 원했던 버튼형 모달창을 구현할 수 있게 됐습니다.

 

 

6. 구현 코드

스스로 공부하면서 배운 styled와 { foo && ( 컴포넌트 ) }를 활용해 기존에 작성한 CSS는 그대로 사용하면서 다음과 같은 코드를 작성했습니다.

import React from "react";
import styled from "styled-components";
import "./modal.css";

function Modal(props) {
  const { closeModal, zIndex, firstBtn, secondBtn } = props;
  const z = zIndex ? zIndex : 130;
  return (
    <div className="modal" style={{ zIndex: z }} onClick={closeModal}>
      <div
        className="modalBody"
        style={{ ...props.style, zIndex: z }}
        onClick={(e) => e.stopPropagation()}
      >
        <ModalGrid>
          <div id="title">{props.title}</div>
          <div id="closeBtn" style={{ zIndex: z }} onClick={closeModal}>
            ✖
          </div>
          {/* 첫번째 버튼과 두번째 버튼 모두 없을 경우 */}
          {!firstBtn && !secondBtn && <div id="content">{props.children}</div>}
          {/* 첫번째 버튼만 있고 두번째 버튼이 없을 경우 */}
          {firstBtn && !secondBtn && (
            <>
              <div id="contentBtn">{props.children}</div>
              <div id="btn">
                <div
                  id="oneBtn"
                  style={firstBtn.style}
                  onClick={firstBtn.event}
                >
                  {firstBtn.name}
                </div>
              </div>
            </>
          )}
          {/* 모든 버튼이 있을 경우 */}
          {firstBtn && secondBtn && (
            <>
              <div id="contentBtn">{props.children}</div>
              <div id="btn">
                <div
                  id="firstBtn"
                  style={firstBtn.style}
                  onClick={firstBtn.event}
                >
                  {firstBtn.name}
                </div>
                <div
                  id="secondBtn"
                  style={secondBtn.style}
                  onClick={secondBtn.event ? secondBtn.event : closeModal}
                >
                  {secondBtn.name}
                </div>
              </div>
            </>
          )}
        </ModalGrid>
      </div>
    </div>
  );
}

const ModalGrid = styled.div`
  display: grid;
  width: 100%;
  height: 100%;
  background-color: white;
  grid-template-columns: 9fr 1fr;
  grid-template-rows: 30px 9fr 35px;
  border-radius: 15px;
  user-select: none;
  #title {
    grid-column: 1 / 3;
    grid-row: 1 / 2;
    font-size: 20px;
    color: rgba(0, 0, 0, 0.7);
    border-bottom: 1px solid rgba(50, 50, 50, 0.5);
  }
  #closeBtn {
    color: rgba(0, 0, 0, 0.7);
    cursor: pointer;
    font-size: 20px;
    grid-column: 2 / 3;
    grid-row: 1 / 2;
  }
  #content {
    color: rgba(0, 0, 0, 0.9);
    padding: 20px;
    grid-column: 1 / 3;
    grid-row: 2 / 4;
  }

  #contentBtn {
    color: rgba(0, 0, 0, 0.9);
    padding: 20px;
    grid-column: 1 / 3;
    grid-row: 2 / 3;
  }

  #btn {
    display: grid;
    grid-column: 1 / 3;
    grid-row: 3 / 4;
    grid-template-columns: 1fr 3fr 3fr 1fr;
    height: 100%;
    color: rgba(0, 0, 0, 0.9);
    border-top: 1px solid rgba(50, 50, 50, 0.5);
    gap: 5px;
    #firstBtn {
      cursor: pointer;
      display: flex;
      margin: auto;
      justify-content: center;
      align-items: center;
      width: 80%;
      height: 80%;
      font-size: 15px;
      grid-column: 2 / 3;
      border-radius: 5px;
    }
    #secondBtn {
      cursor: pointer;
      display: flex;
      margin: auto;
      justify-content: center;
      align-items: center;
      width: 80%;
      height: 80%;
      font-size: 15px;
      grid-column: 3 / 4;
      border-radius: 5px;
    }
    #oneBtn {
      cursor: pointer;
      display: flex;
      margin: auto;
      justify-content: center;
      align-items: center;
      width: 80%;
      height: 80%;
      font-size: 15px;
      grid-column: 2 / 4;
      border-radius: 5px;
    }
  }
`;

export default Modal;

 

7. 마무리

이번 포스트는 모달창과 이에 관련된 지식들, CSS-in-JS인 Styled-components에 대해 알아봤습니다.

 

다음 포스트는 이번에 구현한 모달창과 React의 useState, useEffect 훅을 이용해 프론트엔드에서 방명록 기능을 만들고 이에 대한 백엔드 API를 구현해 연동하는 과정을 이야기 해드리겠습니다.

댓글