[DDD-Presen] 전역 모달 직접 구현하기

조민호·2024년 3월 18일
1
post-custom-banner

필요한 기능

  • 클릭했을 때 모달이 띄워져야 한다.

  • 주변 배경을 클릭할 수 없다

  • 모달을 닫는 close 버튼과 이후 작업을 수행할 submit 버튼이 있다

  • 재사용이 가능해야 한다


구현

모달창은 전역 상태로 구현한다.

context를 사용해도 되지만 나의 경우 Zustand를 사용했다

  • 하나의 모달창은 여러 곳에서 재활용이 될 것이므로 중앙 집중화된 상태를 통해 편리하고 일관된 모달을 관리할 수 있게 하기 위함이다.
  • 게시물과 모달을 응집시킴으로써 불필요한 인터페이스와 모달 state를 숨길수 있기 때문이다. (상태를 props 등으로 직접적으로 전달하지 않고도 컴포넌트들이 해당 상태에 접근할 수 있게 한다는 의미이다.)

전역 모달에 사용되는 타입은 다음과 같다

interface ModalStore {

  // 모달의 렌더링 유무 플래그
  isOpen: boolean; 

  // 모달을 렌더링 하는 콜백
  openModal: (modalData: ModalData) => unknown; 

  // 모달을 닫는 콜백
  closeModal: () => unknown; 

 // 모달에 사용될 로직과 컨텐츠들
  modalData: { 
    children?: ReactNode; // 모달에 사용될 컨텐츠
    onCancel?: () => unknown; // 모달을 닫을 때 트리거 할 로직
    onSubmit?: () => unknown; // 모달을 submit할 때 트리거 할 로직
  };

}

출처 : https://velog.io/@dev-redo/React-전역상태관리를-통해-모달을-띄우는-게시물을-만들어보자


store 생성

위 타입을 바탕으로 전역 모달에 대한 상태를 다루는 store를 생성한다

// modal.ts

import { ReactNode } from 'react';
import { create } from 'zustand';

// 모달에 사용될 로직과 컨텐츠 타입을 따로 분리
type ModalData = {
  children?: ReactNode; 
  onCancel?: () => unknown;
  onSubmit?: () => unknown;
};

interface ModalStore {
  isOpen: boolean;
  openModal: (modalData: ModalData) => unknown; 
  closeModal: () => unknown; 
  modalData: ModalData; 
}
export const useModalStore = create<ModalStore>((set) => ({
  isOpen: false,
  modalData: {} as ModalData,

  // 모달을 열면 isOpen를 true, 인자로 받은 ModalData를 modalData에 할당
  openModal: (modalData: ModalData) => {
    set((state) => ({ isOpen: true, modalData: { ...modalData } }));
  },

  // 모달을 닫으면 isOpen를 false, modalData초기화
  closeModal: () => {
    set((state) => ({ isOpen: false, modalData: {} }));
  },
}));
  • 게시물을 클릭했을 때 모달이 띄워져야 한다.
    • 모달의 렌더링 여부를 결정할 isOpen 상태가 필요하다.
  • 모달에는 모달을 닫는 close 버튼과 이후 작업을 수행할 submit 버튼이 있어야 한다.
    • 모달의 close 버튼, submit 버튼을 클릭 시 수행할 작업을 정의해야 한다.
    • 예컨대 close 버튼일 경우 사용자가 모달을 닫으려고 할 때 진짜로 닫을지에 대한 문구를 띄울 수 있다.
  • 모달이 열렸는지, 닫혔는지, 그리고 모달 내부 내용은 어떤 것이 들어가는지에 대해 숨긴다.
    • state를 숨기기 위해 전역 상태를 이용한다.
    • 게시물과 모달을 응집시킴으로써 불필요한 인터페이스와 모달 state를 숨긴다.

모달 컴포넌트 생성

import { useModalStore } from './store/modal';
import styles from './Modal.module.scss';

const Modal = () => {
  const { isOpen, modalData, openModal, closeModal } = useModalStore();

  const { children, onCancel, onSubmit } = modalData;

  if (!isOpen) {
    return <></>;
  }

  const onCancelInternal = () => {
    onCancel?.(); // onCancel이 있을때만 진행
    closeModal();
  };

  const onSubmitInternal = () => {
    onSubmit?.(); // onSubmit이 있을때만 진행
    closeModal();
  };

  // openModal()로 받은 ModalData타입의 내용을 여기서 렌더링
  // 코드를 보니 모든 모달을 전부 커스텀해서 사용하기보다
  // 여기서 기본 UI를 고정하기 때문에 같은 종류(UI적으로)의 모달들을 띄우는듯
  return (
    <div className={styles.modalContainer}> **// 스타일링은 아직 안함**
      <div className={styles.modalContent}> **// 스타일링은 아직 안함**
        <div style={{ color: 'red', cursor: 'pointer' }} onClick={closeModal}>
          x
        </div>
        <div>
          <div>{children}</div>
          <div>
            <button onClick={onCancelInternal}>cancel</button>
            <button onClick={onSubmitInternal}>submit</button>
          </div>
        </div>
      </div>
    </div>
  );
};

export default Modal;
  • onCancelInternal

    • 모달을 닫는 이벤트 핸들러로 background 또는 cancel 버튼에 걸어준다
    • onCancel은 모달을 닫을 때 문구 띄워주기 등 추가로 처리하는 콜백함수이다. 모달을 사용하는 컴포넌트에서 이를 전달받는다.
    • useModalStore에 정의한 closeModal 콜백함수(isOpen를 false로)
      를 이용해 모달을 닫는다.
  • onSubmitInternal

    • 모달을 submit하는 이벤트 핸들러로, submit 버튼에 걸어준다.
    • 대부분의 모달의 경우 Submit시 자동으로 닫히게 되므로 closeModal 콜백함수를 이용해 submit후 닫히게끔 구현하였다.

모달을 사용할 컴포넌트를 생성

import React, { memo } from 'react';
import { useModalStore } from './store/modal';
import Modal from './Modal';
import styles from './FirstCardList.module.scss';

// 모달을 사용하는 컴포넌트
const FirstCardList = memo(() => {
  const { openModal } = **useModalStore**();

  const openModalWithData = (data: number) =>
    // ModalData 생성
    openModal({
      children: <p>{data}</p>,
      onSubmit: () => console.log('submit'),
      onCancel: () => console.log('cancel'),
    });

  return (
    <div className={styles.container}>
      <div className={styles.cards}>
        {[1, 2, 3].map((data) => (
          <div key={data} className={styles.singleCard}>
            <p onClick={() => **openModalWithData(data)**}>{data}</p>
          </div>
        ))}
      </div>
    </div>
  );
});

export default FirstCardList;
// FirstCardList.module.scss
.container {
  height: calc(100dvh - (200px * 2)); **// 헤더와 푸터 제외한 높이**
  background-color: #68a9f3;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
}

.cards {
  display: flex;
  flex-direction: column;
  cursor: pointer;
}
  • 각 게시물을 클릭 시 모달을 렌더링해야 하므로 useModalStore에서 정의한 openModal 콜백함수를 이용한다.
  • openModalWithData
    • children은 모달에서 렌더링하고자 하는 컴포넌트를 전달한다.
    • onSubmit은 모달을 submit할 때 트리거 할 콜백을 넘겨준다
    • onCancel은 모달을 닫을 때 트리거 할 콜백을 넘겨준다

전체 순서도

  1. openModal 함수 호출

  2. 전역 모달 상태 변경

  3. 해당 상태를 구독하고 있는 모달 컴포넌트 리렌더링 및

    리렌더링 된 값을 기반으로 모달 생성




사용 및 개선하기

실습에 사용할 라우터 설정 및 모달 컴포넌트 선언

import App from './App';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import FirstCardList from './FirstCardList';
import SecondCardList from './SecondCardList';

const AppRouter = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />} />
        <Route path="/FirstCardList" element={<FirstCardList />} />
      </Routes>
    </BrowserRouter>
  );
};

export default AppRouter;
import styles from './App.module.scss';
import AppRouter from './AppRouter';
import Modal from './Modal';

const App = () => {
  return (
    <div className={styles.container}>
      <header className={styles.header}>나 헤더인데</header>
      <AppRouter />
      <footer className={styles.footer}>나 푸터인데</footer>
      **<Modal /> // 모달을 최상단에 두고 사용**
    </div>
  );
};

export default App;
// App.module.scss

.container {
  height: 100dvh;
  width: 100dvw;
}

.header {
  display: flex;
  justify-content: center;
  align-items: center;

  background-color: green;
  width: 100%;
  height: 200px;
  color: white;
}

.footer {
  display: flex;
  justify-content: center;
  align-items: center;

  background-color: navy;
  width: 100%;
  height: 200px;
  color: white;
}



클릭이벤트로 모달 트리거

각 숫자를 클릭하면 모달창이 나타나게 된다

그렇지만 아직 모달에 별다른 스타일링을 진행하지 않았으므로 맨 아래부분에 나타나게 된다




css로 모달창 위치 변경하기

  1. confirm 모달 ( 전체 화면 + 뒷 배경 클릭을 방지)

    .modalContainer {
      position: absolute; // body에 걸리게 되므로 body기준
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
    
      background-color: rgba(0, 0, 0, 0.5);
    
      // .modalContent 중앙 정렬
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    .modalContent {
      width: 500px;
      height: 700px;
    
      background: #ffffff;
      border-radius: 20px;
    
      display: flex;
      justify-content: center;
      align-items: center;
      flex-direction: column;
    }
    • position: absolute; 는 상위 요소에 static을 제외한 요소를 찾다가 결국 body에 걸리게 된다 즉, 모달의 위치를 최상위 요소인 body 기준으로 정하게 된다
    • top: 0; left: 0; right: 0; bottom: 0; 전체화면을 차지하게 한다 그리고 배경을 약간 어둡고 흐리게 하면서 사실은 모달이 전체화면을 차지하고 뒷 배경도 보이게 된다 이렇게 함으로써 뒷 배경 클릭도 방지할 수 있다
    • position: relative; 는 상위 요소인 .modalContainer를 기준으로 한다 흰색 배경인 모달의 내용부분을 .modalContainer 기준 (=body태그) 기준으로 위치를 정할 수 있는 것이다


  1. 화면 일부만 차지 + 뒷 배경 클릭도 가능한 경우

    대표적으로 확인버튼을 눌렀을 때, “제출되었습니다” 와 같은

    1회성 알림창 용도로 쓰인다

    .modalContainer {
      position: absolute; // body기준
      background-color: red; // 이해를 위해 추가
      top: 20%;
      left: 50%;
    
      width: 200px;
      height: 100px;
    
      transform: translate(-50%, -50%);
    }
    
    .modalContent {
      width: 100%;
      height: 100%;
      
      border-radius: 20px;
      background: #ffffff;
    
      display: flex;
      justify-content: center;
      align-items: center;
    }
    - position: absolute; 으로 body를 기준으로 잡는다 그렇지만 이번엔 전체화면으로 사용하지 않고 실제 사용 크기만 지정하고 위치만 조정한다. 이렇게 하면 뒷 배경은 여전히 클릭이 가능한 상태로 화면의 일부에만 모달창을 사용할 수 있다

세로 스크롤이 생길때 해당 모달이 따라오길 원한다면
position을 absolute 말고 fixed로 사용하면 된다



z-index를 사용하지 않은 이유

기본적으로 position속성에서

static 속성과 그 외(relative, absolute, fixed, sticky)의 속성이 만나면

static이 아닌 요소가 position 속성이 static인 요소 위로올라간다

그러므로 굳이 z-index없이도 모달 컨테이너에 absolute 를 부여해서

동일한 효과를 부여했다.

을 최상위에 선언함으로써 absolute를 부여하면 최대한
별 탈 없이 body에 걸리게 한 것이다

absolute를 부여하면 위로 타고타고 올라가다가 body가 아닌

중간에 staic이 아닌것에 걸리면 또 복잡해지니까



그렇지만 가끔은 z-index를 적용해야 하는 경우가 있다

아래 사진처럼, 모달을 띄웠는데도 뒤에 있는 작은 x 버튼이 눌리고 있다.

  • 현재 작은 x 버튼은 이미지 박스 부분에 위치하기 위해 position: absolute 를 적용을 해 놓은 상태이다.
  • position이 static이 아닌 요소끼리는 겹치는 부분이 생기면 HTML 문서에서 나중에 선언된 부분이 위로 올라가기 때문에 모달의 배경 컨테이너보다 x 버튼이 위로 올라가있는 것이다

(HTML에서 모달 선언 부분이 x버튼의 선언 부분보다 위에 있다고 가정한 상황이다)

즉, 서로 position 속성이 static이 아닌 경우로 동일 할 때,
이런 경우에는 z-index를 통해 모달을 위로 올려야 한다

post-custom-banner

1개의 댓글

comment-user-thumbnail
2024년 6월 5일

나 조민호인데..

답글 달기