본문 바로가기

헤더 컴포넌트의 DropDown 메뉴 만들기

헤더 컴포넌트의 DropDown 메뉴 만들기
Next.js 13과 redux/toolkit, styled-components 등으로 북마크 기능이 들어있는 쇼핑몰 앱을 구현한 프로젝트이며, 설명에 필요한 코드만 최소한으로 서술되었습니다. :
(1) gitHub - README 및 전체 코드 보러 가기   
(2) 프로젝트 요구 명세서 보기 (해당 프로젝트 첫 포스팅)
 

GitHub - Doyu-Lee/COZ-shopping: This project consists of a main page, a product list page, and a bookmark page, offering respons

This project consists of a main page, a product list page, and a bookmark page, offering responsive design and features like random and bookmarked product display, filtering, modal windows, bookmar...

github.com

 


 

헤더는 금방 완성이 되었다.

 

중요한 것은 우측 햄버거 버튼을 누르면 드롭다운 메뉴가 펼쳐지는데 이것을 어떻게 구현할까? 였다.

 

구현 결과

아래와 같이 사용자 편의를 위한 구현에 신경을 많이 쓰느라 시간이 꽤 소요가 된 것 같다.

  • 햄버거 버튼을 hover 했을 때 햄버거 버튼이 살짝 확대가 된다.
  • 말풍선 모양의 드롭다운 메뉴가 부드럽게 나타난다.
  • 메뉴를 클릭하거나, 바깥쪽을 클릭했을 때만 드롭다운 메뉴가 사라진다.

 

어떻게 보면 정말 간단한 기능인데도.. 신경써야 할 부분이 많구나, 라는 것을 느끼게 되었다.

 

구현 과정

 

1. 삼각형 모양 만들기

  • 말풍선 컴포넌트는 삼각형 + 사각형 의 조합으로 만들 수 있었다..! (충격과 공포)
  • 물론 UI 라이브러리가 있겠지...?

 

 

내가 작성한 코드 중 발췌 (더 자세한 코드는 2번 챕터에 있다)
    border: 12px solid transparent; /* border의 굵기를 12로 지정하고 투명하게 설정 */
    border-top-width: 0;            /* 위 border의 너비를 0으로 조정 (삼각형의 방향 설정) */
    border-bottom-color: white;     /* 바닥 border의 색만 원하는 색으로 조정 */

 

2. 드롭다운 컴포넌트 생성하기

 

드롭다운 컴포넌트를 만들어서 Header 컴포넌트 안에 추가해주었다. (중간 중간에 tailwindCSS가 들어있다.)

export default function DropdownMenu() {
  const [DropDownIsOpen, ImgRef, DropDownHandler] = useDetectClose();

  return (
    <div>
        <DropdownContainer ref={ImgRef}>
        
        <!-- 햄버거 아이콘 (...은 생략의 의미) -->
          <Image ... />

          <Menu className={DropDownIsOpen ? "isdropped" : null}>
          
            <div className="hover:cursor-default mt-4">
              {`Doyu`} 님, 안녕하세요!
            </div>
            
            <Ul>
            <!--Next.js 방식으로 패스를 지정해주었다.-->
            
              <Link href="/product/list" onClick={DropDownHandler}>
                <li className="flex mr-2 hover:text-myBlue hover:font-bold transition-all ease-in-out">
                  <Image ... />
                  상품리스트 페이지
                </li>
              </Link>
              
              <Link href="/bookmark" onClick={DropDownHandler}>
                <li className="flex mr-2 hover:text-myBlue hover:font-bold transition-all ease-in-out">
                  <Image ... />
                  북마크 페이지
                </li>
              </Link>
              
            </Ul>
          </Menu>
        </DropdownContainer>
    </div>
  );
}

 

CSS는 다음과 같이 해줬다. (중요한 부분만 발췌)

const DropdownContainer = styled.div`
  position: relative;
`;

const Menu = styled.div.attrs(() => ({ role: "dropdown-menu" }))`
  position: absolute;
  .
  .
  .
  opacity: 0;                /* isDropped 클래스가 추가되지 않았을 때는 숨겨둔다 */
  visibility: hidden;
  transition: all 0.4s ease; /* isDropped CSS 로의 부드러운 전환효과 */
  z-index: 999;

  &:after {         /* 말풍선의 삼각형 만들어준 CSS   */
    content: "";
    position: absolute;
    top: -4.5%;
    left: 73%;
    border: 12px solid transparent; 
    border-top-width: 0;            
    border-bottom-color: white;     
  }

  &.isDropped {     /* isDropped 클래스가 동적으로 추가될 때, 컴포넌트가 나타난다. */
    opacity: 1;
    visibility: visible;
  }
`;

 

동적인 값을 props로 넣어줘도 된다.

아래의 코드는 isdropped라는 prop 값에 따라 스타일을 동적으로 설정하는 기능을 구현한 것이다.

  • CSS-in-JS 라이브러리인 styled-components의 템플릿 리터럴 문법을 사용한 것이다.
  • ${({ isdropped }) => ...} 부분은 ES6의 디스트럭처링 문법을 활용하여 isdropped라는 prop 값을 가져온다.
  • isdropped 값이 true일 경우, 지정한 스타일들이 적용된다.
// {...} 구문은 JavaScript에서 객체 전개 연산자(Object Spread Operator)를 나타낸다. 
// 이 연산자를 사용하면 객체의 속성을 복사하거나 새로운 속성을 추가할 수 있다.
// 아래의 {...(DropDownIsOpen ? { isdropped: "true" } : {})}는 조건부로 객체를 병합하는 역할을 한다.

// DropDownIsOpen이 참인 경우, { isdropped: "true" } 객체를 복사하여 isdropped 속성을 추가한다.
// DropDownIsOpen이 거짓인 경우, 빈 객체 {}를 사용하여 아무 속성도 추가하지 않는다. 

 <Menu {...(DropDownIsOpen ? { isdropped: "true" } : {})} > 
.
.
.
${( {isDropped} ) =>
    isDropped &&
    css`
      opacity: 1;
      visibility: visible;
      transform: translate(-70%, -20px);
    `};

 

3. Custom Hook 만들기

  • 바깥쪽과 메뉴를 눌렀을 때 드롭다운 컴포넌트가 닫히고,
  • 그외의 곳을 눌렀을 때는 컴포넌트가 닫히지 않도록 custom hook을 만들어주었다.
  • 해당 블로그 글 참고 

 

useDetectClose.js
import { useEffect, useState, useRef } from "react";

export default function useDetectClose() {
  const [isOpen, setIsOpen] = useState(false);
  const ref = useRef(null);
  
  const toggleHandler = () => setIsOpen(!isOpen);

  const handleClickOutside = (e) => {   // 🟣 설명(1) 
    if (!ref.current.contains(e.target)) {
      setIsOpen(false); 
    }
  };

  useEffect(() => {  // 🟣 설명(2) 
   if(isOpen) { window.addEventListener("click", handleClickOutside); 
    return () => window.removeEventListener("click", handleClickOutside);
  }}, [isOpen]);

  return [isOpen, ref, toggleHandler];
}

// 드롭다운 컴포넌트 -> const [DropDownIsOpen, Ref, DropDownHandler] = useDetectClose();

// DropDownIsOpen :: 드롭다운 컨테이너에 넣어준다. ( isDropped={DropDownIsOpen} )
// Ref :: 드롭다운 컨테이너 + 햄버거 아이콘까지 다 감싸주는 바깥 컨테이너에 넣어준다. ref={ref}
// DropDownHandler :: 햄버거 버튼, 드롭다운 속 메뉴에 넣어준다.

 

🟣 설명 (1) handleClickOutside

말 그대로 드롭다운 박스 바깥의 공간을 클릭했을 때를 처리해주는 함수이다.

 

DropDown.js

드롭다운 컴포넌트의 가장 바깥 컨테이너에 넣어주었다.

  return (
    <div>
        <DropdownContainer ref={Ref}>  <!-- 여기!! -->
          <Image
            onClick={DropDownHandler}
            src="/햄버거 아이콘.svg"
            width={34}
            height={24}
            alt="햄버거 메뉴 드롭다운 버튼"
            className="transition-all duration-300 hover:scale-110 transform hover:cursor-pointer"
          />

          <Menu isDropped={DropDownIsOpen} >
            <div className="hover:cursor-default mt-4">
              {`Doyu`} 님, 안녕하세요!
            </div>

 

  const handleClickOutside = (e) => {   / 
    // console.log(e.target) // ref.current - 예) 이미지 태그
    // ref.current는 객체형태 - 모든 객체는 조건문에서 true로 계산된다.
    if (!ref.current.contains(e.target)) {
      setIsOpen(false); // 닫는다.
    }
  };

 

여기서 console.log(e.target); 을 하면 아래와 같이 내가 클릭한 요소가 출력이 된다.

  • 예를 들어 드롭다운 버튼을 열기 위해 햄버거 버튼을 클릭하면, <Img alt="햄버거 메뉴 ...  가 출력이 된다.

 

console.log(ref); 하고 햄버거 버튼을 클릭했을 때 

 

console.log(ref.current); 하고 햄버거 버튼을 클릭했을 때 

  • 속성으로 Ref를 넣어준 엘리먼트가 뜬다.

 

즉, 해당 함수는 클릭 이벤트가 ref.current를 포함하지 않을 때, setIsOpen(false)를 호출하여 드롭다운 메뉴를 닫는 역할을 합니다.

  • ref.current는 드롭다운 메뉴가 마운트되면 할당되는 DOM 요소를 참조한다.
    • ref.current는 햄버거 아이콘과 드롭다운 메뉴박스를 다 포함한 드롭다운 컨테이너이다. 
  • 클릭 이벤트의 target을 확인하여, 해당 target이 ref.current(드롭다운 컨테이너)에 포함(contains)되는 지의 여부를 확인한다.
    • 이는 클릭 이벤트가 드롭다운 메뉴 외부의 요소에서 발생했는지를 확인하는 것이다.
    • 만약 드롭다운 메뉴 외부의 요소에서 발생했다면 IsOpen 상태를 false로 바꿔준다.
if (!ref.current.contains(e.target)) {
      setIsOpen(false); // 닫는다.
    }
    
// 즉 e.target (내가 클릭한 요소가)
// ref.current (드롭다운 컨테이너에)
// contains (들어있는 아이가)
// ! (아니라면)
// setIsOpen(false) ( 드롭다운 박스를 닫아줘!)

 

여기서 contains의 쓰임새를 알아보면 더욱 이해가 잘 갈 것이다. 

  • contains는 JavaScript에서 DOM 요소를 포함하는지 확인하는 메서드이다. 이 메서드는 DOM 요소의 자식 요소 또는 자손 요소 중에서 특정 요소를 포함하는지 여부를 확인한다.

 

const parentRef = useRef(null);
const childRef = useRef(null);

useEffect(() => {
  console.log(parentRef.current.contains(childRef.current)); // true
}, []);

return (
  <div>
    <div ref={parentRef} id="parent">
      <div ref={childRef} id="child">Child element</div>
    </div>
  </div>
);

 

위의 코드는 parentRef가 childRef를 포함하는지 여부를 확인한다. 결과로서 true가 출력된다.

  • parentElement가 childElement를 직접 포함하고 있는 경우 true를 반환한다.
  • parentElement의 자식 요소 중 하나가 childElement를 포함하고 있는 경우에도 true를 반환한다.
  • 그 외의 경우, 즉 parentElement가 childElement를 포함하지 않는 경우 false를 반환한다.

🟣 설명 (2) useEffect를 쓰는 이유

useEffect를 사용하여 window 객체에 클릭 이벤트 리스너를 등록하고, 컴포넌트가 언마운트될 때 해당 리스너를 제거하는 작업을 수행한다.

  • useEffect를 사용하면 컴포넌트가 렌더링될 때나 특정 값이 변경될 때 특정 동작을 수행할 수 있다.
  • 주어진 코드에서 useEffect를 사용하는 이유는 isOpen 값이 변경될 때마다(즉, 열고 닫을 때마다) 이벤트 핸들러를 추가하고 삭제하기 위함이다.
  • 과정은 다음과 같다.
    1. DropdownMenu는 초기 렌더링 때 useDetectClose 함수를 호출하며, 해당 함수 안의 useEffect도 실행이 된다.
    2. 하지만, isOpen의 초기값은 false이기 때문에 useEffect 속 if(isOpen) 이하는 실행되지 않는다. 
    3. 햄버거 버튼을 클릭하여 isOpen이 true가(메뉴가 열림) 되는 순간, useEffect는 작동하며 if문을 통과하여 window에 이벤트핸들러를 추가한다.
    4. 그 뒤에 전역에서 클릭 이벤트가 발생했을 때 isOpen 상태를 false로 만드는 handleClickOutside 함수가 실행된다.
    5. 그 뒤, isOpen이 false가(메뉴가 닫힘) 되는 순간 3번의 useEffect 함수의 return문이 일단 실행되어 여기서 이벤트 핸들러가 삭제된다.
      • useEffect의 반환 함수는 컴포넌트가 언마운트되거나 isOpen 값이 변경될 때마다 호출된다. (clean up 단계)
      • 이를 통해 이벤트 리스너를 제거하여 메모리 누수를 방지하고, 이전에 등록된 이벤트 리스너를 정리한다.
      • 즉, useEffect를 사용하여 컴포넌트의 마운트와 언마운트 시점에서 이벤트 리스너를 추가하고 제거함으로써 클릭 이벤트 처리를 관리하는 것이다.
    6. 그 후 새로운 useEffect 함수는 if문을 통과하지 못하므로 아무 동작도 하지 않게 된다. 

 

콘솔로 찍어 보았다.
  useEffect(() => {
   if(isOpen) {
     window.addEventListener("click", handleClickOutside);
    console.log('add!')      // useEffect 호출 후, isOpen이 true일 때, 이벤트리스터 추가
      
    return () => { 
      console.log('close')   // isOpen이 false가 될 때 새로운 useEffect함수 전에 리턴 함수가 먼저 호출됨
      window.removeEventListener("click", handleClickOutside);
  }}}, [isOpen]);

 

콘솔창 

728x90
⬆︎