최근 Shadcn, NextUI, MUI 등등 UI 라이브러리를 많이 사용해 보고 있다.
사용하다보니, 확실히 내가 직접 구현한 컴포넌트보다 사용성이 좋다고 느껴졌다.
라이브러리들의 사용성과 비슷하게 구현해보고 싶었다.
<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>
<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>
메뉴를 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를 숨길 수 있어야한다.
스타일 변경이 자유롭도록 className을 받는다.
children은 당연히 받아야한다.
interface Props {
children?: ReactNode;
className?: string;
}
<Dropdown.Trigger/>
<Dropdown.Trigger/>
의 역할은 open 상태를 변경할 수 있어야한다.
trigger를 눌렀을 때, 특정 동작을 할 수 있도록 onclick을 추가로 받는다.
interface Props {
children?: ReactNode;
onClick?: () => void;
className?: string;
}
<Dropdonw.Content/>
<Dropdown.Item />
을 식별하여 렌더링해야한다.
다른 컴포넌트가 children으로 들어온다면, 무시한다.
interface Props {
children?: ReactNode;
className?: string;
}
<Dropdown.Item />
<Dropdown.Item />
을 클릭했을 때, 특정 동작을 수행할 수 있어야한다.
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;
};
이 함수를 사용해 특정 컴포넌트를 찾아보자.
<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 함수 사용하면 끝!
라이브러리를 참고하며 구현해보니, 코드 수준이 올라간것 같다!
다른 컴포넌트들도 하나씩 구현해봐야겠다!