본문 바로가기
✨FRONTEND/📍Next.js

드롭다운 메뉴 직접 구현하기

by 짱돌보리 2025. 4. 4.
728x90

Next.js에서 드롭다운 메뉴 구현하기🔼🔽

게시글 목록을 정렬하기 위해 드롭다운 메뉴를 구현해보았다.
드롭다운을 클릭하면 메뉴가 열리고, 옵션을 선택하면 해당 기준으로 정렬되도록 만들었다.

1. Dropdown 컴포넌트 구현

먼저 드롭다운 UI와 상태를 관리하는 컴포넌트를 만든다.

  • isOpen : 드롭다운 열림 / 닫힘 상태 관리
  • selectedOption : 현재 선택된 옵션 관리
  const [isOpen, setIsOpen] = useState(false)
  const [selectedOption, setSelectedOption] = useState('최신순')

  const dropdownRef = useRef<HTMLDivElement>(null)

 

  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (
        dropdownRef.current &&
        !dropdownRef.current.contains(e.target as Node)
      ) {
        setIsOpen(false)
      }
    }

    document.addEventListener('mousedown', handleClickOutside)

    return () => {
      document.removeEventListener('mousedown', handleClickOutside)
    }
  }, [])

외부 클릭 시 드롭다운 닫힘

드롭다운은 외부를 클릭했을 때 닫히도록 구현했다.

이를 위해 useRef와 useEffect를 사용했다.

useRef

useRef는 DOM 요소에 직접 접근할 수 있게 해주는 React 훅이다.

const dropdownRef = useRef<HTMLDivElement>(null)

 

드롭다운 루트 div에 ref를 연결해두면, 클릭한 요소가 드롭다운 내부인지 외부인지 확인할 수 있다.

 

외부 클릭 감지

if (
  dropdownRef.current &&
  !dropdownRef.current.contains(e.target as Node)
) {
  setIsOpen(false)
}

 

이 조건의 의미는 다음과 같다.

by GPT

 

  • dropdownRef.current
    👉 드롭다운 DOM 요소가 실제로 존재하는지 확인
  • !dropdownRef.current.contains(e.target as Node)
    👉 클릭한 요소(e.target)가 드롭다운 안에 속하지 않은 경우

-> "드롭다운이 있고, 사용자가 드롭다운 바깥을 클릭했다면" setIsOpen(false) 해서 드롭다운을 닫는다

 

⚠️ 드롭다운 메뉴 자동 닫힘 오류

처음 구현했을 때 문제가 있었다.

 

옵션을 클릭하면 드롭다운이 닫혀야 하는데 계속 열려 있었다.

 

원인은 이벤트 버블링 때문이었다.

기존 코드에서는 드롭다운 전체 영역에 toggleDropdown이 걸려 있었다.

 

기존코드))

<div
  ref={dropdownRef}
  onClick={toggleDropdown}
>

 

그래서 드롭다운 메뉴 아이템을 클릭할 때도
toggleDropdown이 실행되어 메뉴가 다시 열리게 되는 문제가 발생했다.

 

변경 후))

드롭다운을 여는 이벤트를 버튼 영역에만 적용했다.

<div ref={dropdownRef}>
  <div onClick={toggleDropdown}>

 

→ 드롭다운 버튼 클릭 시만 toggleDropdown 실행 ref div 안에있는 메뉴만 있는 div에 toggleDropdown을 넣어줘야한다!

 

이후 selectOption 함수를 통해 드롭다운을 닫아주면 된다.

  const selectOption = (option: string) => {
    setSelectedOption(option)
    onSelect(option)
    setIsOpen(false) // 옵션 선택 후 드롭다운 닫기
  }

 

풀코드))

type DropdownProps = {
  onSelect: (option: string) => void
}

export default function Dropdown({ onSelect }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [selectedOption, setSelectedOption] = useState('최신순')

  const dropdownRef = useRef<HTMLDivElement>(null)

  const options = ['최신순', '좋아요순', '조회순']

  const toggleDropdown = () => {
    setIsOpen((prev) => !prev)
  }

  const selectOption = (option: string) => {
    setSelectedOption(option)
    onSelect(option)
    setIsOpen(false)
  }

  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (
        dropdownRef.current &&
        !dropdownRef.current.contains(e.target as Node)
      ) {
        setIsOpen(false)
      }
    }

    document.addEventListener('mousedown', handleClickOutside)

    return () => {
      document.removeEventListener('mousedown', handleClickOutside)
    }
  }, [])

  return (
    <div
      ref={dropdownRef}
      className="bg-custom-gray-300 border-custom-gray-200 text-custom-white relative flex cursor-pointer rounded-lg border px-4 py-2"
    >
      <div
        onClick={toggleDropdown}
        className="flex w-full items-center justify-between"
      >
        <span>{selectedOption}</span>
        <span className="ml-2">{isOpen ? '🔼' : '🔽'}</span>
      </div>

      {isOpen && (
        <div className="bg-custom-gray-300 border-custom-gray-200 absolute left-0 mt-10 w-full rounded-lg border shadow-md">
          {options.map((option, index) => (
            <div
              key={index}
              onClick={() => selectOption(option)}
              className="cursor-pointer rounded-lg px-4 py-2 hover:bg-gray-950"
            >
              {option}
            </div>
          ))}
        </div>
      )}
    </div>
  )
}

2. 드롭다운 기능을 활용한 게시글 목록 정렬 구현하기

  • 기본값은 최신순
  • handleSortChange는 드롭다운에서 선택된 정렬 옵션에 따라 프로젝트 배열을 정렬하는 함수
  • 원본 projects 배열을 직접 변경하지 않기 위해 복사본을 생성후 정렬해준다!!
  const [sortedProjects, setSortedProjects] = useState<Project[]>(projects)
  const [sortOption, setSortOption] = useState('최신순')

  const handleSortChange = (option: string) => {
    setSortOption(option)
    const sorted = [...projects]

    switch (option) {
      case '최신순':
        sorted.sort(
          (a, b) =>
            new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
        )
        break
      case '좋아요순':
        sorted.sort((a, b) => b.likes - a.likes)
        break
      case '조회순':
        sorted.sort((a, b) => b.views - a.views)
        break
      default:
        break
    }

    setSortedProjects(sorted)
  }

  return (
    <div>
      <ProjectBanner />
      <div className="mx-auto w-full max-w-[1200px] px-10 py-10">
        <div className="flex items-center justify-start gap-4 py-10">
          <Dropdown onSelect={handleSortChange} />
          <SearchBar />
        </div>
        <ProjectList
          currentPage={currentPage}
          projects={sortedProjects}
          itemsPerPage={itemsPerPage}
        />
        <Pagination
          currentPage={currentPage}
          setCurrentPage={setCurrentPage}
          totalPages={totalPages}
        />
      </div>
    </div>
  )
}

 

여기서 중요한 점은

원본 배열을 직접 수정하지 않도록 복사본을 만들어 정렬하는 것이다!!

<Dropdown onSelect={handleSortChange} />

 

 

구현 끝!