Jiseong's Web Dev blog
Dropdown, 합성 컴포넌트 패턴으로 구현하기 본문

😗 Intro
최근 Shadcn, NextUI, MUI 등등 UI 라이브러리를 많이 사용해 보고 있다.
사용하다보니, 확실히 내가 직접 구현한 컴포넌트보다 사용성이 좋다고 느껴졌다.
라이브러리들의 사용성과 비슷하게 구현해보고 싶었다.
💻 라이브러리를 살펴보자
Shadcn - Dropdown
<DropdownMenu>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Team</DropdownMenuItem>
<DropdownMenuItem>Subscription</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
- Dropdown
- DropdownMenuTrigger
- DropdownMenuLabel
- DropdwonMenuItem
네 가지의 컴포넌트로 구성 되어 있다.
NextUI - Dropdown
<Dropdown>
<DropdownTrigger>
<Button variant="bordered">
Open Menu
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem key="new">New file</DropdownItem>
<DropdownItem key="copy">Copy link</DropdownItem>
<DropdownItem key="edit">Edit file</DropdownItem>
<DropdownItem key="delete" className="text-danger" color="danger">
Delete file
</DropdownItem>
</DropdownMenu>
</Dropdown>
- Dropdown
- DropdownTrigger
- DropdownMenu
- DropdwonItem
공통점
메뉴를 open할 수 있는 Trigger, open하면 보이는 Menu로 구성되어있다.
또한 커스텀이 쉽다.
🛠️ 설계를 해보자.
<Dropdown>
<Dropdown.Trigger>
트리거
</Dropdown.Trigger>
<Dropdown.Content>
<Dropdown.Item>메뉴1</Dropdown.Item>
<Dropdown.Item>메뉴2</Dropdown.Item>
<Dropdown.Item>메뉴3</Dropdown.Item>
</Dropdown.Content>
</Dropdown>
합성컴포넌트를 사용하는 방향으로 설계를 했다.
Dropdown만 import를 하더라도 Dropdown 전체를 구성할 수 있도록 하기위해서다.
<Dropdown/>
역할
<Dropdown/> 컴포넌트의 역할은 Trigger, Content 컴포넌트의 위치를 잡아주는 역할이다.
또한, open 상태를 가지며 이 상태에 따라 Content를 숨길 수 있어야한다.
interface
스타일 변경이 자유롭도록 className을 받는다.
children은 당연히 받아야한다.
interface Props {
children?: ReactNode;
className?: string;
}
<Dropdown.Trigger/>
역할
<Dropdown.Trigger/>의 역할은 open 상태를 변경할 수 있어야한다.
interface
trigger를 눌렀을 때, 특정 동작을 할 수 있도록 onclick을 추가로 받는다.
interface Props {
children?: ReactNode;
onClick?: () => void;
className?: string;
}
<Dropdonw.Content/>
역할
<Dropdown.Item />을 식별하여 렌더링해야한다.
다른 컴포넌트가 children으로 들어온다면, 무시한다.
interface
interface Props {
children?: ReactNode;
className?: string;
}
<Dropdown.Item />
역할
<Dropdown.Item />을 클릭했을 때, 특정 동작을 수행할 수 있어야한다.
interface
interface Props {
children?: ReactNode;
onClick?: MouseEventHandler<HTMLButtonElement>;
}
🧱 특정 컴포넌트를 식별하는 방법
<Dropdown /> 에서는 Trigger, Content 컴포넌트를 식별,<Dropdonw.Content /> 에서는 Item 컴포넌트를 식별할 수 있어야한다.
interface Props {
children: ReactNode;
target: JSX.Element;
}
const getComponentFromChildren = ({ children, target }: Props) => {
const childrenArray = Children.toArray(children);
const targetComponent = childrenArray.filter(
(child) => isValidElement(child) && child.type === target.type
);
return targetComponent;
};
- children을 배열로 변환
- JSX.Element의 type을 비교하여 filter한다.
이 함수를 사용해 특정 컴포넌트를 찾아보자.
🔨 구현하기
<Dropdown />
const Dropdown = ({ children, className }: Props) => {
const { isActive: isOpen, toggle, setIsActive } = useToggle();
const triggerRef = useRef<HTMLDivElement>(null);
const dropdownTrigger = getComponentFromChildren({ children, target: <DropdownTrigger /> });
const dropdownContent = getComponentFromChildren({ children, target: <DropdownContent /> });
return (
<div className={twMerge('relative inline-block', className)}>
<div aria-hidden="true" role="button" ref={triggerRef} className="w-full" onClick={toggle}>
{dropdownTrigger}
</div>
{isOpen && <div className="absolute left-1/2 -translate-x-1/2">{dropdownContent}</div>}
</div>
);
};
getComponentFromChildren 함수를 사용해 Trigger, Content를 식별하여 원하는 위치에 렌더링한다.
dropdownTrigger를 감싸는 div에는 onClick으로 isOpen 상태를 제어할 수 있도록한다.
dropdonwContent는 isOpen이 true일 때만 렌더링한다.
aria-hidden 속성을 준 이유?
스크린리더가 접근하는 것은 원치 않지만, 시각적으로 디자인을 주기 위해서 보여지게 하고 싶은경우에 사용한다.
div에 onClick을 부여하면, 사용자는 키보드로 제어할 수 없게된다.
그래서 button을 써야하지만, dropdownTrigger가 button 컴포넌트이기 때문에, button 컴포넌트가 중첩된다면
키보드 tab으로 제어했을때, 감싸는 button -> dropdwonTrigger 순으로 접근된다.
aria-hidden 속성을 true로 준다면,스크린 리더로 접근할 수 없게된다.
감싸는 button을 접근하지 못하게 하더라도 이벤트 버블링이 발생하기 때문에, 괜찮다.
useEffect(() => {
const handleWindowClick = (e: MouseEvent) => {
const isInsideClick =
triggerRef.current && e.target instanceof Element && triggerRef.current.contains(e.target);
if (!isInsideClick) {
setIsActive(false);
}
};
window.addEventListener('click', handleWindowClick);
return () => window.removeEventListener('click', handleWindowClick);
}, [setIsActive]);
Dropdown 컴포넌트 바깥을 클릭했을 때, 메뉴가 닫히게 하기위해 window에 click 이벤트를 추가해준다.
<DropdownTrigger />
const DropdownTrigger = ({ children, onClick, className }: Props) => (
<button type="button" onClick={onClick} className={twMerge('w-full', className)}>
{children}
</button>
);
export default DropdownTrigger;
별거없다.
onClick 받는 button 역할만 하면 된다.
<DropdownContent/>
const DropdownContent = ({ children, className }: Props) => {
const dropdownItem = getComponentFromChildren({ children, target: <DropdownItem /> });
return (
<div
className={twMerge(
'flex flex-col items-center justify-center divide-y divide-gray-100 min-w-[72px] rounded-[10px] overflow-hidden shadow',
className
)}
>
{dropdownItem}
</div>
);
};
getComponentFromChildren함수로 children에서 Item을 식별하여 렌더링한다.
<DropdownItem />
const DropdownItem = ({ children, onClick }: Props) => (
<button
className="hover:bg-redGray-50 bg-white w-full text-center p-[10px] h-full typo-body-12-regular"
type="button"
onClick={onClick}
>
{children}
</button>
);
얘도 별거없다.
button이다.
합성 컴포넌트로 접근할 수 있게하자.
// Dropdown.tsx
export default Object.assign(Dropdown, {
Trigger: DropdownTrigger,
Content: DropdownContent,
Item: DropdownItem
});
Object.assign 함수 사용하면 끝!
😀 후기
라이브러리를 참고하며 구현해보니, 코드 수준이 올라간것 같다!
다른 컴포넌트들도 하나씩 구현해봐야겠다!
'React' 카테고리의 다른 글
| useEffect 남용을 줄이자. (0) | 2025.03.02 |
|---|---|
| 선언적으로 에러처리 아주 깔끔하게 하기 (Next.js 15) (0) | 2024.10.29 |
| 웹뷰에서 소셜 로그인시 문제 해결하기 (2) | 2024.09.15 |
| optimistic update를 사용하면서 고민했던 상황 (0) | 2024.06.09 |
| 반응형으로 말줄임 CSS + 더보기 버튼 구현하기 (0) | 2024.05.31 |