[React + TS] KebabButton 컴포넌트 만들기

복숭아는딱복·2024년 8월 28일
0

토이프로젝트 3

목록 보기
1/3
post-thumbnail

패스트캠퍼스 토이프로젝트3를 진행하며 KebabButton 컴포넌트를 담당하게 되었다. 더보기 점 세개 아이콘을 Kebab이란 이름으로 부르는지 처음 알았다. Kebab 꼬치모양 처럼 생겼다고 붙여진 이름이라고 한다.

먼저 내가 하고 싶은 기능은 아래 2개다.
✅ 케밥 버튼을 누르면 미니모달 토글
✅ 케밥 버튼이 아닌 영역 클릭 시 모달 닫히도록

menuRef로 케밥 버튼의 Dom을 잡고, isOpen를 의존성 배열에 담아 버튼을 열고 닫을때만 onOutsideClose 함수가 실행되도록 했다. onOutsideClose는 케밥 버튼의 Dom과 클릭 이벤트가 일어나는 node를 포함하고 있으면 true를 반환하고 미니모달을 닫는 함수이다.
만약 일치하지 않으면 false를 반환하고 아무 동작도 하지 않는다.

즉 onOutsideClose를 실행되면 menuRef 이외의 요소를 클릭하면 미니모달이 닫히게 된다.

Node.contains() 메서드
특정 노드가 다른 노드를 자신의 하위 노드로 포함하고 있는지를 확인하는 데 사용된다. true or false를 반환한다.
https://developer.mozilla.org/en-US/docs/Web/API/Node/contains

removeEventListener를 해주는 이유는 이벤트 리스너가 계속해서 중복 등록되거나, 컴포넌트가 사라졌음에도 불구하고 이벤트가 계속 작동하는 것을 방지하기 위해서이다. useEffect 훅에서 이벤트 리스너를 추가할 때, 해당 컴포넌트가 언마운트되거나, useEffect 훅이 재실행될 때(예를 들어, isOpen 상태가 변경될 때) 이전에 등록된 이벤트 리스너를 제거해주어야 한다.

완성 코드

//KebabButton.tsx
import { useState, useRef, useEffect } from 'react';
import { css } from '@emotion/react';
import { EllipsisVertical } from 'lucide-react';
import IconButton from '@/components/IconButton';
import colors from '@/constants/colors';
import { fontSize } from '@/constants/font';

interface KebabButtonProps {
  menuItems: Array<{
    label: string;
    onClick: () => void;
  }>;
}

const KebabButton: React.FC<KebabButtonProps> = ({ menuItems }) => {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);

  const onMenuItemClick = (onClick: () => void) => {
    onClick();
    setIsOpen(false);
  };

  useEffect(() => {
    const onOutsideClose = (event: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    };
    document.addEventListener('click', onOutsideClose);

    return () => document.removeEventListener('click', onOutsideClose);
  }, [isOpen]);

  return (
    <div css={kebabButtonStyle} ref={menuRef}>
      <IconButton
        IconComponent={EllipsisVertical}
        color='gray'
        onClick={() => {
          setIsOpen(!isOpen);
        }}
      />
      {isOpen && (
        <ul css={menuModalStyle}>
          {menuItems.map((item, index) => (
            <li
              key={`${item.label}-${index}`}
              onClick={() => onMenuItemClick(item.onClick)}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

const kebabButtonStyle = css`
  position: relative;
  width: fit-content;
`;

const menuModalStyle = css`
  position: absolute;
  top: 28px;
  right: 0;
  width: 124px;
  background-color: ${colors.white};
  border-radius: 4px;
  box-shadow:
    0px 3px 14px 2px rgba(0, 0, 0, 0.12),
    0px 8px 10px 1px rgba(0, 0, 0, 0.14),
    0px 5px 5px -3px rgba(0, 0, 0, 0.2);

  li {
    font-size: ${fontSize.md};
    padding: 8px 16px;
    :hover {
      background-color: ${colors.gray01};
    }
  }
`;

export default KebabButton;

사용법

import KebabButton from '@/components/KebabButton';

const App = () => {
  const menuItems = [
    {
      label: '수정',
      onClick: () => console.log('수정 클릭'),
    },
    {
      label: '삭제',
      onClick: () => console.log('삭제 클릭'),
    },
    {
      label: '상세보기',
      onClick: () => console.log('상세보기 클릭'),
    },
  ];

  return (
    <div>
      <KebabButton menuItems={menuItems} />
    </div>
  );
};

export default App;

0개의 댓글