본문 바로가기

React

[ React ] styled-components로 드롭다운 메뉴 구현

반응형

 

styled-components로 드롭다운 메뉴를 직접 구현해보자. 결과물은 아래와 같다. 샘플 코드는 이 글의 끝부분에서 확인할 수 있다. css에 대한 설명은 생략하고, 어떤 원리로 구현했는지를 위주로 적을 생각이다.

미리 보는 결과물

 

구현 전략

너무 복잡하게 생각하지 말고 쉽게 접근해보자. 어떤 행위를 하는지, 각 행위마다 어떤 변화가 있는지를 생각해보면 된다. 행위(원인)에 따른 결과를 정리하면 크게 세 가지 케이스로 분류할 수 있다. 참고로 '드롭다운 메뉴'란 위 그림에서 마이페이지, 게시판을 의미하며, '세부 메뉴 박스'란 '메뉴1 ~ 메뉴3'이 있는 박스를 말한다. 그리고 편의상 화면 상에 있는 것을 mount, 화면 상에 없는 것을 unmount로 부르겠다. 

 

1. 세부 메뉴 없는 상태에서 드롭다운 메뉴를 클릭 → 세부 메뉴 박스 mount
2. 세부 메뉴 있는 상태에서 드롭다운 메뉴를 클릭 → 세부 메뉴 박스 unmount 
3. 세부 메뉴 있는 상태에서 드롭다운 메뉴 이외의 공간을 클릭 → 세부 메뉴 박스 unmount

 

이제 위 세 가지 케이스를 구현하기만 하면 된다. 위 케이스들을 조금만 더 구체화 시켜보자. 세 가지 케이스에서 공통적으로 들어가는건 '상태'다. 즉, 구현을 위해서는 세부 메뉴 박스가 mount, unmount인지를 나타내주는 상태값이 필요하다. 이 상태값을 'isOpen'이라는 변수에 저장해보자. isOpen이 참이면 세부 메뉴 박스가 mount 되었다는 뜻이고, isOpen이 거짓이면 세부 메뉴 박스가 unmount 되었다는 뜻이다. 이제 위 세 가지 케이스가 아래와 같이 간단해졌다. 

 

1. isOpen === false, 드롭다운 메뉴를 클릭 → isOpen === true
2. isOpen === true, 드롭다운 메뉴를 클릭 → isOpen === false
3. isOpen === true, 드롭다운 메뉴 이외의 공간을 클릭 → isOpen === false

 

 

위 케이스로부터 isOpen이라는 상태값은 드롭다운 메뉴와 클릭 이벤트로 인해 바뀌는 것을 알 수 있다. 그리고 드롭다운 메뉴끼리의 isOpen은 독립적이어야 한다. 즉, 마이페이지와 게시판은 각각 isOpen이라는 상태값을 저장하고 있어야 한다. 케이스 1,2는 단순히 드롭다운 메뉴에 클릭 이벤트를 걸어주면 쉽게 구현할 수 있다. 문제는 케이스 3이다. 케이스 3을 구현하기 위해서는 useEffect를 활용해야 한다. 

 

구현

useEffect로 드롭다운 메뉴 이외의 공간 클릭 감지

const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null);

useEffect(() => {
    const onClick = (e) => {
      if (ref.current !== null && !ref.current.contains(e.target)) {
        // 드롭다운 메뉴 이외의 공간 클릭
        setIsOpen(!isOpen)
      } 
    };

    if (isOpen) {
      window.addEventListener("click", onClick);
    }

    return () => {
      window.removeEventListener("click", onClick);
    };
}, [isOpen]);

 

 

케이스 3은 드롭다운 메뉴를 참조하는 값과 클릭 이벤트의 event.target을 사용하면 구현 가능하다.

 

먼저, 드롭다운 메뉴를 참조하는 값은 useRef로 쉽게 해결할 수 있다. 단순히 드롭다운 메뉴에 ref를 걸어주기만 하면 된다. 

 

우리가 구현하고 싶은 것은 드롭다운 메뉴 이외의 공간 클릭을 감지하는 것이다. 이는 드롭다운 메뉴를 클릭 했을 때의 여집합(!를 붙인 것)과 똑같다. 따라서 드롭다운 메뉴를 클릭했는지만 구별할 수 있으면 된다. 

 

우리는 클릭 이벤트의 event.target이 클릭한 요소를 가리키는 것을 알고 있다. 어떤 요소를 클릭했는지 알 수 있고, 목표로 하는 요소를 ref가 참조하고 있으므로 모든 조건은 충족됐다. 드롭다운 메뉴의 클릭은 event.target과 ref.current이 같아지는 때다. 우리는 이와 반대되는 상황을 원하므로 !를 붙여주기만 하면 된다. 위 코드에서 주석 처리한 부분을 참고하자. 

 

이벤트를 등록하고, clean up 함수에서 다시 이벤트를 해제하는 이유는 메모리 누수를 방지학 위함이다. useEffect는 컴포넌트가 mount, update, unmount 될때 계속 실행되는데 이벤트를 해제하지 않으면 동일한 이벤트가 계속 등록될 것이다. 실제로 clean up 함수를 지우고 addEventListener 밑에 콘솔을 찍어보면 무수히 많은 이벤트가 등록되는 것을 확인할 수 있다.

 

드롭다운 메뉴 클릭 감지

케이스 1,2에 해당하는 부분이다. 굉장히 간단하다. 드롭다운 메뉴를 클릭했을 때 isOpen의 상태값만 반전시키면 된다. 클릭 이벤트 핸들러 함수를 만들어서 드롭다운 메뉴의 onClick 콜백 함수로 등록하기만 하면 된다. 코드로 살펴보면 아래와 같다. 

 

const removeHandler = () => {
    setIsOpen(!isOpen);
}

...

<DropdownButton 
onClick={myPageHandler} 
ref={myPageRef}>
    마이페이지
</DropdownButton>

...

 

커스텀 훅으로 빼기

지금까지 설명한 것을 커스텀 훅으로 빼면 코드가 간결해진다. 코드는 아래와 같다. 

 

import { useEffect, useState, useRef } from "react";

const useDetectClose = (initialState) => {
  const [isOpen, setIsOpen] = useState(initialState);
  const ref = useRef(null);
  
  const removeHandler = () => {
    setIsOpen(!isOpen);
  }

  useEffect(() => {
    const onClick = (e) => {
      if (ref.current !== null && !ref.current.contains(e.target)) {
        setIsOpen(!isOpen)
      } 
    };
    
    if (isOpen) {
      window.addEventListener("click", onClick);
    }

    return () => {
      window.removeEventListener("click", onClick);
    };
  }, [isOpen]);

  return [isOpen, ref, removeHandler];
};

export default useDetectClose;

 

 

반환값을 살펴보자.

 

isOpen은 boolean 값이기 때문에 세부 메뉴 박스 조건부 렌더링에 사용될 수 있다. 초기값(initialState)은 항상 false다.

ref는 드롭다운 메뉴에 ref로 걸어주고, removeHandler는 드롭다운 메뉴의 onClick 이벤트 콜백 함수로 등록해준다.

 

아래는 커스텀 훅을 불러와서 마이페이지 드롭다운 메뉴를 구현한 코드의 일부분이다.

 

import useDetectClose from './hooks/useDetectClose';

const DropdownMenu = () => {
    const [myPageIsOpen, myPageRef, myPageHandler] = useDetectClose(false);

    return (
        <DropdownContainer>
            <DropdownButton 
            onClick={myPageHandler} 
            ref={myPageRef}>
                마이페이지
            </DropdownButton>
            <Menu 
            isDropped={myPageIsOpen}>
                <Ul>
                    <Li>
                        <LinkWrapper href="#1-1">
                            메뉴1
                        </LinkWrapper>
                    </Li>
                    <Li>
                        <LinkWrapper href="#1-2">
                            메뉴2
                        </LinkWrapper>
                    </Li>
                    <Li>
                        <LinkWrapper href="#1-3">
                            메뉴3
                        </LinkWrapper>
                    </Li>
                </Ul>
            </Menu>
        </DropdownContainer>
    )
}

 

 

최종 코드 및 결과

 

 

 

반응형