Dropdown, 합성 컴포넌트 패턴으로 구현하기

Jiseong·2024년 5월 30일
0

react

목록 보기
6/6
post-thumbnail

😗 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/> 컴포넌트의 역할은 Trigger, Content 컴포넌트의 위치를 잡아주는 역할이다.
또한, open 상태를 가지며 이 상태에 따라 Content를 숨길 수 있어야한다.

interface

스타일 변경이 자유롭도록 className을 받는다.
children은 당연히 받아야한다.

interface Props {
  children?: ReactNode;
  className?: string;
}

역할

<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 />을 클릭했을 때, 특정 동작을 수행할 수 있어야한다.

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;
};
  1. children을 배열로 변환
  2. JSX.Element의 type을 비교하여 filter한다.

이 함수를 사용해 특정 컴포넌트를 찾아보자.

🔨 구현하기


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 이벤트를 추가해준다.

const DropdownTrigger = ({ children, onClick, className }: Props) => (
  <button type="button" onClick={onClick} className={twMerge('w-full', className)}>
    {children}
  </button>
);

export default DropdownTrigger;

별거없다.
onClick 받는 button 역할만 하면 된다.

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을 식별하여 렌더링한다.

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 함수 사용하면 끝!

😀 후기

라이브러리를 참고하며 구현해보니, 코드 수준이 올라간것 같다!
다른 컴포넌트들도 하나씩 구현해봐야겠다!

0개의 댓글