트러블이슈/이약저약

컴파운드 컴포넌트 패턴으로 모달 만들기

Ahyeon, Jung 2024. 4. 24. 15:11

컴파운드 컴포넌트 패턴으로 컴포넌트를 만들면서, 가장 큰 강점인 확장성과 재사용성을 가져가기 위해서 여러 케이스를 인지한 상태에서 컴포넌트를 구성해야한다는 점을 이해했다. 사실 이해하진 못했고, 처음에 만들어 놓고 나중에 가서 다른 케이스가 나오니까 해당 컴포넌트를 사용한 부분을 다 뒤집어야했다. 역시 일단 양으로 승부해야한다..

모달 자체도 처음에는 그냥 페이지 이동 없이 띄우는게 닫는게 다 모달이라 생각했는데, 찾아보니까 모달 안에서도 많은 구성 요소가 있었다. 처음에 Modal.OpenButton, Modal.ContentsBox 이런식으로 이름지었다가 구성 요소 파악하고 이름을 다 바꿨다. 개발자들끼리 소통하기 위해서 UI 구성 요소의 정의도 알아야겠따.


모달

모달 이해하기

웹에서 새 창을 띄우는 팝업창과는 달리 같은 브라우저 내부에서 상위 레이어를 띄우는 방식을 사용하는 창

기존에 열려있는 컴포넌트 위에 컴포넌트를 띄우거나 z-index로 가장 상위로 보내는 컴포넌트다. 부모에 속해있기 때문에 상태 관리 만으로 값을 공유할 수 있다.  URL History에 남지않아 뒤로가기를 했을때 모달을 열고 닫는게 아닌 그 전의 페이지로 이동할 수 있다.

 

일단 Overlay의 여부에 따라서 Modal과 Non-Modal로 나누어진다.그리고 그 아래에서 Dialog, Navigation Drwer, Sheet, Snackbar, Banner, Menu, Tooltip 등으로 나뉘어 지는데, 위치, 역할, 논 모달만 가능하거나 모달만 가능한 형태 등으로 구분할 수 있다. 즉 Scrim(Overlay)가 있냐없냐, 상자로 드냐, 토스트로 뜨냐, 바텀에 있냐, 어떤 역할을 하냐 등으로 나눠진다고 볼 수 있다.

Modal의 종류

팝업(popup, Modal dialogs, Dialog, alert)

컴포넌트 스터디: ①팝업, 바텀시트, 스낵바 ❘ 요즘IT (wishket.com)

바텀 시트(Bottom sheet)

- 기존 화면을 함께 사용해야한다면 Script(Overlay) 없는 Non-Modal 타입

컴포넌트 스터디: ①팝업, 바텀시트, 스낵바 ❘ 요즘IT (wishket.com)

스낵바(Snackbar, Toasts)

컴포넌트 스터디: ①팝업, 바텀시트, 스낵바 ❘ 요즘IT (wishket.com)

Chakra UI의 Modal

 저 그래프를 보고서 모달이 생각보다 더 많은 구성으로 채워진다는걸 알았지만, 그래서 내가 뭘 만들어야하는거지라는 마음이 컸다. 그래서 Chakra UI의 Modal을 살펴보면서 좀 이해를 할 수 있었다. 사실 저건 그냥 컴포넌트 종류를 나열한 것 뿐이라는 사실도,, 어쨌든 직접 컴파운트 컴포넌트로 다 만들고 나서야 살펴봤는데, 필요하다는 사실을 깨닫고 추가된 컴포넌트들이 다 담겨있었고, 더 확장할 방향을 잡아볼 수 있었다. 

  • Modal: 하위 컴포넌트에 대해 Context를 제공하는 컴포넌트
  • ModalOverlay: 배경을 어둡게 하는 오버레이
  • ModalContent: 모달의 콘텐츠를 담는 컨테이너
  • ModalHeader: 모달 내부의 헤더(라벨링)
  • ModalFooter: 모달 내부의 푸터
  • ModalBody: 모달 바디(주요 내용)
  • ModalCloseButton: 모달을 닫는 버튼



컴파운드 컴포넌트 패턴으로 모달 만들기

가장 큰 두가지 케이스는 모달의 오픈 상태를 컴포넌트가 가지고 있을 것이냐, 부모 컴포넌트가 가고 있냐이다. 단순히 열고 닫는 형태의 모달이라면 컴포넌트 내에서 상태를 선언하고 관리하는 것이 편하다. 그러나 부모 컴포넌트에서 콜백함수를 전달하고, 콜백 함수의 실행 이후 조건에 따라 모달이 닫혀야한다면, 부모 컴포넌트에서 상태를 생성해 관리해줘야한다. 즉 ChakraUI의 useDisclouse를 이용해 모달의 열림여부 상태를 외부에서 주입하는 경우 외에 그냥 안에서 오픈 상태를 관리할 수도 있는 것이다. 

 

외부에서 주입받는 Modal

컴파운드 컴포넌트가 애초에 그냥 부모 아래 하위 컴포넌트를 묶어놓는 개념이기 때문에 context를 생성해서 부모에서 Provider로 context를 주입해주면 된다.

import { ModalContext } from "../hooks/useModal";

interface ModalRootProps {
	isOpen: boolean;
	onOpen: () => void;
	onClose: () => void;
	toggleOpen: () => void;
	children: React.ReactNode;
}

export default function ModalRoot({
	isOpen,
	onOpen,
	onClose,
	toggleOpen,
	children,
}: ModalRootProps) {
	const value = { isOpen, onOpen, onClose, toggleOpen };
	return (
		<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
	);
}

 

그리고 하위 컴포넌트들을 만들고, 컴포넌트 루트에서 부모 아래 하위 컴포넌트를 할당시키면 기본적인 컴포넌트 구성은 끝난다. 다만 확장성과 재사용성을 고려해서 하위 컴포넌트를 잘 나누어야한다. 지금 다시 만들라고 하면 Chakra UI처럼 Overlay와 Header, Footer, Body로 좀 더 상세히 만들고 싶다. 특히 Overlay를 그냥 전역 스타일로 줘버리니까, 어디까지 background 영역인지 보기 어려웠다.


  • framer-motion의 m과 LazyMotion을 사용하면 번들사이즈를 줄일 수 있다. 처음에 m만 import해왔는데 애니메이션이 적용안되어서 살펴보니, m이 애니메이션을 가지고 있지않았기 때문에 번들 사이즈가 줄어드는 개념이었다. ( Reduce bundle size | Framer for Developers )
import { HTMLMotionProps, LazyMotion, domAnimation, m } from "framer-motion";

import stopEvent from "@/utils/stopEvent";
import styles from "../styles/Content.module.scss";
import { useModal } from "../hooks/useModal";

interface ContentProps extends HTMLMotionProps<"div"> {
	children: React.ReactNode;
	className?: string;
}

export default function Content({
	children,
	className,
	...props
}: ContentProps) {
	const { isOpen, toggleOpen } = useModal();
	if (!isOpen) return null;

	return (
		<LazyMotion features={domAnimation}>
			<div className="background" onClick={toggleOpen}>
				<m.div
					{...props}
					className={`${styles.container} ${className}`}
					initial={{ opacity: 0, y: "100%" }}
					animate={{ opacity: 1, y: 0 }}
					exit={{ opacity: 0, y: "100%" }}
					transition={{ duration: 0.3 }}
					onClick={stopEvent("propagation")}
				>
					<div className={styles.element} />
					{children}
				</m.div>
			</div>
		</LazyMotion>
	);
}

 

어찌되었건 하위 컴포넌트를 만들고 나서 할당하면된다. 

import Close from "./UI/Close";
import Content from "./UI/Content";
import ModalRoot from "./UI/ModalRoot";
import Trigger from "./UI/Trigger";

const Modal = Object.assign(ModalRoot, {
	Trigger,
	Content,
	Close,
});

// Modal.Trigger = Trigger도 가능

export default Modal;

 

사용은 루트에 만들어준 컴포넌트를 불러와서 부모 컴포넌트.하위 컴포넌트로 사용하면 된다. 처음에는 props를 누구에게 줘야하는가에 대해 정리가 잘 안됐는데, 하위 컴포넌트들이 다같이 사용할 것이라 context로 관리할 것들만 Modal에, 아니면 그냥 하위 컴포넌트에 바로 줘도 될 듯하다.

export interface PostReviewBody {
	title: string;
	medicineId: number;
	tagList: number[];
	content: string;
	star: number;
}

export default function ReviewPostModal({
	isEditing = false,
	reviewId,
}: {
	isEditing?: boolean;
	reviewId?: number;
}) {
	// ...

	return (
		<Modal
			isOpen={isOpen}
			onClose={onClose}
			toggleOpen={toggleOpen}
			onOpen={onOpen}
		>
			{!isEditing && (
				<Modal.Trigger
					openElement={<button className={styles.button}>후기 작성하기</button>}
				/>
			)}
			<Modal.Content>
				<Form
					validationSchema={medicineReviewPostValidation}
					pageDefaultValues={initialData}
					onSubmit={onSubmit}
				>
					<Form.Input<PostReviewBody>
						name="title"
						title="리뷰 제목"
						placeholder="리뷰 제목을 입력해주세요"
					/>
					<Form.StarRating />
					<Form.TagBoard title="태그 선택" tags={tags ?? []} name="tagList" />
					<Form.ImgsInput addImgFile={addImgFile} />
					<Form.Textarea
						name="content"
						title="후기 작성"
						placeholder="리뷰를 입력해주세요(최소 50자 이상)"
					/>
					<Form.Button
						text={isEditing ? "후기 수정완료" : "후기 작성완료"}
						variant="dark"
					/>
				</Form>
			</Modal.Content>
		</Modal>
	);
}

 

주입받아도 되지 않는 경우 추가하기

사실 맨처음에 컴포넌트 분리하면서 외부 상태를 모달 내에서 생성하면 편하겠다는 생각에 일단 부모 컴포넌트에서 생성하고 주입했다. 근데 이후에 api 연결을 하면서 onSuccess에서 모달을 닫아야하기 때문에 다시 외부에서 주입받는 방식으로 변경한 거였다. 하지만 굳이 Open 상태를 밖에서 관리하지 않는 경우가 조금씩 있어서 방법을 찾다가 그냥 두가지 케이스를 활용할 수 있게 만들었다. 하위의 컴포넌트는 공통으로 생성할 거기 때문에 그냥 부모 컴포넌트에서 아예 케이스를 고정해 놓는 것이다.

import { ModalContext } from "../hooks/useModal";
import useOpen from "@/hooks/useOpen";

interface ModalRootProps {
	isOpen: boolean;
	onOpen: () => void;
	onClose: () => void;
	toggleOpen: () => void;
	children: React.ReactNode;
}

export default function ModalRoot({
	isOpen,
	onOpen,
	onClose,
	toggleOpen,
	children,
}: ModalRootProps) {
	const value = { isOpen, onOpen, onClose, toggleOpen };
	return (
		<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
	);
}

export function ModalTemplate({ children }: { children: React.ReactNode }) {
	const { isOpen, onClose, onOpen, toggleOpen } = useOpen();

	const value = { isOpen, onOpen, onClose, toggleOpen };

	return (
		<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
	);
}

 

가이드라인 템플릿 추가하기

사실 이 지점은 다른 사람의 컴파운드 컴포넌트를 사용하다보니까 하위 컴포넌트가 많으면 헷갈려서 아예 통으로 줘버릴까 하는 마음이 있었다. 근데 작성해놓고 보니까 오히려 더 복잡한가 싶기도 해서 잘 모르겠다. 게다가 하위 컴포넌트가 추가되면 두 가지의 파일을 바꿔야하는 것이기 때문에 아직 자신이 없다. 확실히 프로젝트 처음단계인 지금은 있으면 소통하기 좋긴 하지만 확장이 된 시점에서는 오히려 불편할 수도 있겠다 싶다. 

import Close from "./Close";
import Content from "./Content";
import { ModalContext } from "../hooks/useModal";
import React from "react";
import Trigger from "./Trigger";
import useOpen from "@/hooks/useOpen";

interface ModalRootProps {
	isOpen: boolean;
	onOpen: () => void;
	onClose: () => void;
	toggleOpen: () => void;
	children: React.ReactNode;
}

export default function ModalRoot({
	isOpen,
	onOpen,
	onClose,
	toggleOpen,
	children,
}: ModalRootProps) {
	const value = { isOpen, onOpen, onClose, toggleOpen };
	return (
		<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
	);
}

export function ModalWithOpen({ children }: { children: React.ReactNode }) {
	const { isOpen, onClose, onOpen, toggleOpen } = useOpen();

	const value = { isOpen, onOpen, onClose, toggleOpen };

	return (
		<ModalContext.Provider value={value}>{children}</ModalContext.Provider>
	);
}

interface ModalFullTemplate {
	trigger: React.ReactElement;
	content: React.ReactElement;
	close: boolean;
}

export function ModalTemplate({ trigger, content, close }: ModalFullTemplate) {
	const { isOpen, onClose, onOpen, toggleOpen } = useOpen();

	const value = { isOpen, onOpen, onClose, toggleOpen };

	return (
		<ModalContext.Provider value={value}>
			<Trigger openElement={trigger} />
			<Content>{content}</Content>
			{close && <Close />}
		</ModalContext.Provider>
	);
}

 


Reference

모달과 팝업, 정확히 알아야 하는 이유 (brunch.co.kr)

컴포넌트 스터디: ①팝업, 바텀시트, 스낵바 | 요즘IT (wishket.com)