![DropDown 컴파운드 패턴으로 공통 컴포넌트 구현하기](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLb9mC%2FbtsIPJ9q0B6%2FbrK97cD2fQSwGwp0Du8zo0%2Fimg.png)
시작하며...
이전 프로젝트에서 Menu 와 DropDown 컴포넌트를 구현하였다. 그러나 공통 컴포넌트라는 성격과 거리가 멀었다.
그래서 이번 프로젝트에서 DropDown 공통 컴포넌트를 범용성이 좋게 만들기 위해 컴파운드(Compound) 패턴으로 만들게 되었다!
컴파운드(Compound) 패턴
컴파운드 패턴이란?
컴파운드 패턴이란 무엇일까?
컴파운드 패턴
React에서 복잡한 컴포넌트를 효율적으로 관리하고 재사용성을 높이기 위한 디자인 패턴
DropDown 이나 Select 등 컴포넌트를 여러 작은 컴포넌트로 분리하여 부모/자식 관계로 이루어진 컴포넌트를 독립적이면서 통합되어 하나의 컴포넌트처럼 사용할 수 있게 만드는 디자인 패턴 중 하나이다.
장점
- 유연성 증가
- 가독성 증가
- 유지보수성 증가
- 재사용성 향상
- 컴포넌트 간 명확한 역할 분담
단점
- 복잡성 증가
- 러닝커브 증가
DropDown 컴파운드 패턴으로 구현
구조 정리하기
DropDown 을 구성하는 요소를 다음 4가지로 생각하여 구현하였다.
- DropDown : 드롭다운 컨테이너
- Trigger : 드롭다운을 열거나 닫는 버튼
- Menu : 드롭다운 메뉴의 본체
- Item : 드롭다운 메뉴의 각 항목
1. DropDown
먼저 DropDown 의 기본적인 구조인 DropDown 컴포넌트를 작성하였다.
index.tsx
Menu 의 위치가 부모 위치의 기준으로 고정시켜야 하기 때문에 relative 스타일을 적용시켰다.
그리고 DropDown 컴포넌트와 그 하위 컴포넌트들을 하나의 인터페이스로 묶어주었다. 이를 통해 필요한 하위 컴포넌트를 쉽게 접근하고 사용할 수 있게 된다.
import { ReactNode } from "react";
import useClickOutside from "@/hooks/useClickOutside";
import DropDownItem from "./Item";
import DropDownMenu from "./Menu";
import DropDownTrigger from "./Trigger";
interface DropDownProps {
/** 드롭다운 컴포넌트 안에 포함될 내용입니다. */
children: ReactNode;
/** 드롭다운 close 함수입니다. */
handleClose: () => void;
}
const DropDown = ({ children, handleClose }: DropDownProps) => {
const dropDownRef = useClickOutside(handleClose);
return (
<div ref={dropDownRef} className="relative">
{children}
</div>
);
};
// DropDown 객체에 하위 컴포넌트를 속성으로 추가
DropDown.Item = DropDownItem;
DropDown.Menu = DropDownMenu;
DropDown.Trigger = DropDownTrigger;
export default DropDown;
useClickOutside.ts
useClickOutside 훅을 만들어, 외부 영역 클릭 시 DropDown 이 닫히는 기능을 구현하였다.
import { useEffect, useRef } from "react";
const useClickOutside = (callback: () => void) => {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
callback();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [callback]);
return ref;
};
export default useClickOutside;
2. Trigger
DropDown 을 열거나 닫는 Trigger 버튼을 작성하였다.
Trigger.tsx
클릭 이벤트와 키보드 이벤트로 DropDown 메뉴를 제어할 수 있도록 하였다.
import { ReactNode } from "react";
interface DropDownTriggerProps {
/** 드롭다운을 트리거할 요소를 포함될 내용입니다. */
children: ReactNode;
/** 드롭다운을 트리거할 요소의 클릭 함수입니다. */
onClick: () => void;
}
const DropDownTrigger = ({ children, onClick }: DropDownTriggerProps) => (
<button
type="button"
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Escape") {
onClick();
}
}}
>
{children}
</button>
);
export default DropDownTrigger;
3. Menu
DropDown 의 Menu 를 렌더링 하는 컴포넌트를 작성하였다.
Menu.tsx
부모요소 기준으로 위치를 고정하기 위해 absolute 스타일을 적용했다. 그리고 Framer Motion 애니메이션도 적용하였다.
그리고 Menu 의 위치를 수정할 수 있도록 position 이라는 props 를 받았다. 또한 단순히 div 태그를 사용하지 않고 시맨틱 태그를 사용하여 웹 접근성을 향상 시킬 수 있게 고려하여 만들었다.
import { AnimatePresence, motion } from "framer-motion";
import { ReactNode } from "react";
interface DropDownMenuProps {
/** 드롭다운 메뉴 안에 포함될 내용입니다. */
children: ReactNode;
/** 드롭다운 메뉴 open 여부입니다. */
isOpen: boolean;
/** 드롭다운 메뉴 위치입니다. 기본값 top-30 right-0 */
position?: string;
}
const DropDownMenu = ({
children,
isOpen,
position = "top-30 right-0",
}: DropDownMenuProps) => (
<AnimatePresence>
{isOpen && (
<motion.div
className={`${position} absolute z-10 w-120 overflow-hidden rounded-12 border border-border-primary bg-background-secondary text-text-primary`}
initial={{ opacity: 0, scale: 0.5, x: 20, y: -50 }}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
exit={{ opacity: 0, scale: 0.5, x: 20, y: -50 }}
>
<ul className="text-center">{children}</ul>
</motion.div>
)}
</AnimatePresence>
);
export default DropDownMenu;
4. Item
DropDown 메뉴의 각 항목인 Item 컴포넌트를 작성하였다.
Item.tsx
Framer Motion 을 사용하여 hover 했을 때와 tap 했을 때 애니메이션을 적용하였다.
여기도 시맨틱 태그를 사용하였다.
import { motion } from "framer-motion";
import { ReactNode } from "react";
interface DropDownItemProps {
/** 드롭다운 메뉴 안 각 요소의 내용입니다. */
children: ReactNode;
/** 드롭다운 메뉴 안 각 요소의 클릭 함수입니다. */
onClick?: () => void;
}
const DropDownItem = ({ children, onClick }: DropDownItemProps) => (
<motion.li
whileHover={{ backgroundColor: "rgba(30, 162, 181, 0.2)" }}
whileTap={{ scale: 0.9, backgroundColor: "rgba(25, 140, 160, 0.2)" }}
className="cursor-pointer rounded-12 pb-11 pt-12 text-md-regular"
onClick={onClick}
>
{children}
</motion.li>
);
export default DropDownItem;
Storybook 작성하기
현재 프로젝트에서 Storybook 을 사용하고 있기 때문에 Storybook 도 작성하였다.
메타 데이터 설정
subcomponents 를 설정하여 DropDown 컴포넌트의 하위 컴포넌트를 지정하여 Storybook 에서 쉽게 컴파운드 패턴을 확인할 수 있다.
const meta = {
title: "Components/DropDown",
parameters: {
componentSubtitle: "컴파운드 패턴으로 작성된 DropDown 컴포넌트",
},
component: DropDown,
subcomponents: {
Trigger: DropDown.Trigger as ComponentType,
Menu: DropDown.Menu as ComponentType,
Item: DropDown.Item as ComponentType,
},
tags: ["autodocs"],
} as Meta<typeof DropDown>;
export default meta;
스토리 정의
DropDown 컴포넌트의 기본 사용 예시를 보여주는 Primary 스토리를 작성하였다.
useToggle 이라는 훅을 만들어 DropDown 을 쉽게 열고 닫을 수 있게 하였다.
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
render: () => {
const DropDownStoryBook = () => {
const { value, handleOff, handleToggle } = useToggle();
return (
<div className="flex h-200 justify-center text-14">
<DropDown handleClose={handleOff}>
<DropDown.Trigger onClick={handleToggle}>
<span className="cursor-pointer text-16 text-text-primary">
⋮
</span>
</DropDown.Trigger>
<DropDown.Menu isOpen={value}>
<DropDown.Item onClick={() => console.log("마이 히스토리 클릭")}>
마이 히스토리
</DropDown.Item>
<DropDown.Item onClick={() => console.log("계정 설정 클릭")}>
계정 설정
</DropDown.Item>
<DropDown.Item onClick={() => console.log("로그아웃 클릭")}>
로그아웃
</DropDown.Item>
</DropDown.Menu>
</DropDown>
</div>
);
};
return <DropDownStoryBook />;
},
};
스토리북 결과
결과물
코드
DropDown 컴포넌트의 구조를 쉽게 파악하기 쉽게 만들어진 것을 확인할 수 있다.
동작
애니메이션도 적용되어 예쁘게 잘 동작하는 것을 확인할 수 있다.
마치며...
올해 초반까지는 DropDown 컴포넌트를 구현하지 못해서 MUI 라이브러리를 사용하여 구현하였던 게 엊그제 같았는데 벌써 혼자 DropDown 컴포넌트도 만들어보고 더 발전해서 지금은 Compound 패턴을 사용하여 구현할 수 있게 되었다.
또한 단순 구현 능력뿐만 아니라 스타일이나 애니메이션 등을 적절하게 잘 활용하면서 사용자 경험성 측면에서도 고려하게 되었다.
확실히 Compound 패턴을 사용하니깐 한눈에 해당 컴포넌트의 구조를 잘 이해할 수 있게 되었고, 공통 컴포넌트라는 특성에 맞게 다양한 상황에서 유연하게 사용할 수 있게 된 거 같다!
![](https://t1.daumcdn.net/keditor/emoticon/friends2/large/002.png)
'💜 프로젝트 구현' 카테고리의 다른 글
[Next.js] 무한 캐러셀 컴포넌트 직접 구현하기 (feat. Tailwind CSS) (2) | 2024.09.02 |
---|---|
[GitHub Action / Chromatic] 스토리북 PR 미리보기 배포 (feat. yarn) (0) | 2024.08.08 |
[Next.js] React Quill 로 이미지 업로드 구현하기 (1) | 2024.07.17 |
[Next.js / Zustand] 로그인 확인 및 로그아웃 구현 (0) | 2024.07.15 |
프로젝트 초기 세팅 (with 템플릿, eslint, prettier, commitLint, tailwind) (1) | 2024.07.10 |
FE 개발자가 되고 싶은 짱잼이
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!