이전에 모달 컴포넌트는 Mui의 Modal을 조금만 커스텀해서 사용했었는데 이번에는 직접 만들어보았다...!
모달(팝업)창은 페이지 전체를 덮을 수 있도록 화면의 최상단에 위치해야 한다.
그래서 Next.js 앱의 가장 최상단 레이아웃에 페이지들이 렌더링 되는 영역 위로 modal-root를 만들어 어디서든 그 modal-root에 모달이 렌더링되도록 해 볼 것이다.
app 폴더 루트에 있는 layout.tsx
import './layout.css';
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<div id="main-layout">
<div id="root">{children}</div>
<div id="modal-root" />
</div>
</body>
</html>
);
}
그리고 Modal.tsx 에서 부모 컴포넌트 바깥의 DOM 노드(modal-root div)에 렌더링하려면 ReactDom.createPortal을 사용하면 된다.
ReactDom.createPortal(child, container)
첫 번째 인자 child는 포탈을 사용해 밖으로 내보낼 컴포넌트 = 모달 컴포넌트,
두 번째 인자 container는 포탈로 이동할 목적지, 즉 child를 랜더링 할 DOM 요소 = modal-root를 넣어준다.
그리고 Modal 컴포넌트에는 모달의 열림 여부인 isOpen과 모달을 닫는 함수 onClose를 전달했다.
모달이 isOpen 상태이면 'modal-root'의 className에 'visible'을 추가해서 모달이 보이도록, isOpen상태가 아니면 'visible'을 삭제해서 모달이 더 이상 보이지 않도록 만들어 줄 것이다.
css 코드는 이렇게..
#main-layout {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
#root {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
#modal-root {
position: fixed;
top: 0;
left: 0;
display: flex;
width: 100%;
height: 100%;
z-index: 3000;
visibility: hidden;
opacity: 0;
transition: opacity 0.3s ease;
}
#modal-root.visible {
opacity: 1;
visibility: visible;
}
1차 완성된 Modal.tsx
'use client';
import React, { useState, useEffect } from 'react';
import ReactDom from 'react-dom';
import * as styles from './Modal.css';
const Modal = ({
isOpen,
onClose,
children,
}: ModalProps) => {
const [modalRoot, setModalRoot] = useState<HTMLElement | null>(null);
useEffect(() => {
setModalRoot(document.getElementById('modal-root'));
}, []);
const handleClose = () => {
onClose();
};
useEffect(() => {
if (modalRoot) {
if (isOpen) {
modalRoot.classList.add('visible');
} else {
modalRoot.classList.remove('visible');
}
}
}, [isOpen, modalRoot]);
if (!modalRoot) return null;
return ReactDom.createPortal(
isOpen ? (
<div className={styles.modalBackdrop} onClick={handleClose}>
<div
className={styles.modalContainer}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
) : null,
modalRoot,
);
};
export default Modal;
컴포넌트가 마운팅될 때, modal-root 요소를 찾아 modalRoot 상태에 할당하고 createPortal에 두번째 인자로 전달한다.
isOpen상태가 변할 때 마다는 modalRoot에 visible 클래스를 추가하거나 제거해준다.
(Next.js의 서버 사이드 렌더링에서는 DOM 요소를 직접 읽어올 수 없기 때문에, modalRoot가 null일 때 발생할 수 있는 오류를 방지하기 위해 modalRoot를 의존성 배열에 추가하고 if (modalRoot) 조건문으로 체크했다.)
모달 배경이 되는 backdrop div영역을 클릭하면 모달이 닫히도록 onClick에 handleClose함수를 전달해주되 그 안에 있는 modalContainer div 영역을 클릭했을 때는 이벤트 버블링으로 handleClose가 실행되지 않도록 e.stopPropagation()를 넣어주었다.
닫기 금지 옵션 & 타이밍 기능 추가
프로젝트에서 사용할 모달들이 로딩이 끝나거나 n초가 지나야 닫을 수 있는 것들이 대부분이라 ModalProps에 timerSec과 closeBlocked 옵션도 추가해주었다.
export type ModalProps = {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
timerSec?: number;
closeBlocked?: boolean;
};
timerSec를 timer 상태로 받아 모달이 열리면 타이머가 시작될 수 있게 하고
const [timer, setTimer] = useState<number>(timerSec || -1);
useEffect(() => {
if (isOpen && timerSec && timer > 0) {
const intervalId = setInterval(() => {
setTimer((prevTimer) => prevTimer - 1);
}, 1000);
return () => clearInterval(intervalId);
}
}, [isOpen, timerSec, timer]);
// 모달이 열릴 때마다 타이머 초기화
useEffect(() => {
setTimer(timerSec || -1);
}, [isOpen]);
handleClose함수도 이렇게 바꿔주었다.
const handleClose = () => {
closeBlocked || timer > 0 ? () => {} : onClose();
};
사용 예시
+ useModal 커스텀 훅
useModal Hook을 만들면 모달 컴포넌트를 사용할 때 훨씬 편해진다.
import { useState } from 'react';
import Modal from '../_components/Modal/Modal';
const useModal = () => {
const [isOpen, setIsOpen] = useState(false);
const openModal = () => setIsOpen(true);
const closeModal = () => setIsOpen(false);
return { Modal, isOpen, openModal, closeModal };
};
export default useModal;
모달을 사용하려는 페이지에서 import useModal만 해주면 필요한 요소들을 다 가져올 수 있을 것이다.
'Front-End' 카테고리의 다른 글
[Next.js] Parallel Routes 모달에 적용하기 (0) | 2024.05.03 |
---|---|
[Stmop.js/React] 소켓으로 페이지네이션 UI 구현하기 (1) | 2024.04.29 |
[React Native] react-native-keychain 사용하기 (0) | 2024.04.14 |
[Next.js] Next.js 14에서 페이지 이동 감지하기 (0) | 2024.04.12 |
[React / Next.js + Zustand] StompJS로 채팅 구현하기 (2) - 실시간 채팅 방 상태 관리하기 (2) | 2024.04.05 |