You are currently viewing React에서 multi-level dropdown menu 만들기

React에서 multi-level dropdown menu 만들기

“이 문서는 LogRocket 블로그에 게시된 ‘React에서 다중 레벨 드롭다운 메뉴를 만드는 방법’ 글을 참조하여 작성되었습니다. 원문의 핵심 내용을 바탕으로 한글로 번역하고, 더 쉽게 이해할 수 있도록 내용을 재구성했습니다. 이 과정에서 저작권을 존중하고자 원 저자의 아이디어를 명확히 인용하고, 원문의 주요 개념만을 참고하여 독창적인 해설을 추가했습니다. 만약 이 내용이 저작권에 문제가 될 소지가 있다면, 즉시 알려주시기 바랍니다.”

리액트에서 multi-level dropdown menu를 만드는 방법에 대해 설명합니다. multi-leveldropdown menu는 웹 디자인에서 자주 사용되며, 네비게이션 바를 동적이고 조직적으로 만들어 여러 옵션을 제공합니다.

리액트나 리액트 기반 프로젝트인 Gatsby나 Next.js와 같은 프로젝트에서 작업하는 개발자에게 이 튜토리얼은 dropdown 기능을 구현하는 단계별 과정이 도움이 될 것입니다.

이 안내서를 따라 진행하면 최종적으로 아래와 같은 메뉴를 만들 수 있습니다:

“서비스” 및 “소개” 메뉴 항목 아래에 다중 수준 드롭다운 컴포넌트가 있는 프론트엔드 메뉴의 최종 프로젝트 결과가 나타나면서 커서가 표시됩니다.

먼저 진행에 앞서 React에 대한 기본적인 이해가 필요하고 컴퓨터에 Node.js가 설치되어 있어야 합니다. 작업을 위해서 VS code를 사욯합니다.

다중 수준 드롭다운 메뉴 프로젝트 설정

create-react-app CLI를 사용하여 새로운 React 프로젝트를 만들어 보겠습니다:

npx create-react-app react-multilevel-dropdown-menu

그런 다음 다음을 수행하세요:

cd react-multilevel-dropdown-menu
code ./
npm start

리액트 프로젝트 구조
프로젝트를 시각화하고 사용자 인터페이스를 작은 컴포넌트 조각으로 나누어 보겠습니다:

위 이미지의 번호표는 다음과 같은 컴포넌트 이름에 해당합니다:

  • App: 부모/루트 컴포넌트
  • Header: 로고 및 네비게이션 바 콘텐츠를 렌더링합니다.
  • Navbar: MenuItems 컴포넌트를 렌더링합니다.
  • MenuItems: 개별 항목과 드롭다운을 렌더링합니다.
  • Dropdown: 메뉴 항목을 렌더링합니다.

이와 같이 다섯 가지 다른 컴포넌트를 만들 것입니다. 라우팅을 시작하면 더 많은 컴포넌트를 추가하겠습니다.

이 프로젝트는 페이지 상단에 메뉴 내비게이션을 렌더링합니다. 그러나 사이드바에 네비게이션을 렌더링하는 데도 동일한 프로세스가 적용될 것입니다.

프로젝트 파일 생성하기

src 폴더에서 다음과 같은 구조를 따르도록 파일 트리를 확인해봅시다:

 ...
  ├── src
  │    ├── components
  │    │     ├── Dropdown.jsx
  │    │     ├── Header.jsx
  │    │     ├── MenuItems.jsx
  │    │     └── Navbar.jsx
  │    ├── App.css
  │    ├── App.jsx
  │    └── index.js
  │    └── menuItemsData.js
메뉴 구조

src/App.jsx 파일에서 간단한 텍스트를 렌더링하도록 합시다:

import logo from "./logo.svg";
import "./App.css";

function App() {
  return <div className="App">Hello! World - React</div>;
}

export default App;

파일을 저장하세요. 이제 src/index.js 파일의 내용을 다음과 같이 바꿉니다:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

// styles
import './App.css';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

CSS 파일을 가져와 프로젝트에 원하는 룩앤필을 추가했습니다.

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: sans-serif;
}

header {
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.07), 0 1px 2px 0 rgba(0, 0, 0, 0.05);
  color: #212529;
}

.nav-area {
  display: flex;
  align-items: center;
  max-width: 1200px;
  margin: 0 auto;
  padding: 10px 20px;
}

.logo {
  text-decoration: none;
  font-size: 25px;
  color: inherit;
  margin-right: 20px;
}

/* menu on desktop */
.desktop-nav .menus {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  list-style: none;
}

.desktop-nav .menu-items {
  position: relative;
  font-size: 14px;
}

.desktop-nav .menu-items a {
  display: block;
  font-size: inherit;
  color: inherit;
  text-decoration: none;
}

.desktop-nav .menu-items button {
  display: flex;
  align-items: center;
  color: inherit;
  font-size: inherit;
  border: none;
  background-color: transparent;
  cursor: pointer;
  width: 100%;
}

.desktop-nav button span {
  margin-left: 3px;
}

.desktop-nav .menu-items > a,
.desktop-nav .menu-items button {
  text-align: left;
  padding: 0.7rem 1rem;
}

.desktop-nav .menu-items a:hover,
.desktop-nav .menu-items button:hover {
  background-color: #f2f2f2;
}

.desktop-nav .arrow::after {
  content: "";
  display: inline-block;
  margin-left: 0.28em;
  vertical-align: 0.09em;
  border-top: 0.42em solid;
  border-right: 0.32em solid transparent;
  border-left: 0.32em solid transparent;
}

.desktop-nav .dropdown {
  position: absolute;
  right: 0;
  left: auto;
  box-shadow: 0 10px 15px -3px rgba(46, 41, 51, 0.08),
    0 4px 6px -2px rgba(71, 63, 79, 0.16);
  font-size: 0.875rem;
  z-index: 9999;
  min-width: 10rem;
  padding: 0.5rem 0;
  list-style: none;
  background-color: #fff;
  border-radius: 0.5rem;
  display: none;
}

.desktop-nav .dropdown.show {
  display: block;
}

.desktop-nav .dropdown .dropdown-submenu {
  position: absolute;
  left: 100%;
  top: -7px;
}

.mobile-nav {
  display: none;
}

/* menu on mobile */
@media screen and (max-width: 960px) {
  .nav-area {
    justify-content: space-between;
  }

  .desktop-nav {
    display: none;
    text-align: right;
  }

  .mobile-nav {
    display: block;
  }
  .mobile-nav .mobile-nav__menu-button {
    color: inherit;
    font-size: inherit;
    border: none;
    background-color: transparent;
    cursor: pointer;
    position: relative;
  }
  .mobile-nav .menus {
    list-style: none;
    position: absolute;
    top: 50px;
    right: 20px;
    box-shadow: 0 10px 15px -3px rgba(46, 41, 51, 0.08),
      0 4px 6px -2px rgba(71, 63, 79, 0.16);
    z-index: 9999;
    min-width: 12rem;
    padding: 0.5rem 0;
    background-color: #fff;
    border-radius: 0.5rem;
  }

  .mobile-nav .menu-items a {
    display: block;
    font-size: inherit;
    color: inherit;
    text-decoration: none;
  }

  .mobile-nav .menu-items button {
    display: flex;
    align-items: center;
    color: inherit;
    font-size: inherit;
    border: none;
    background-color: transparent;
    cursor: pointer;
    width: 100%;
  }

  .mobile-nav .menu-items > a,
  .mobile-nav .menu-items button {
    text-align: left;
    padding: 0.7rem 1rem;
  }

  .mobile-nav .menu-items a:hover,
  .mobile-nav .menu-items button:hover {
    background-color: #f2f2f2;
  }

  .mobile-nav .arrow::after {
    content: "";
    display: inline-block;
    margin-left: 1.2em;
    vertical-align: 0.09em;
    border-top: 0.42em solid;
    border-right: 0.32em solid transparent;
    border-left: 0.32em solid transparent;
  }
  .mobile-nav .arrow-close::after {
    content: "";
    display: inline-block;
    margin-left: 1.2em;
    vertical-align: 0.09em;
    border-bottom: 0.42em solid;
    border-right: 0.32em solid transparent;
    border-left: 0.32em solid transparent;
  }

  .mobile-nav .dropdown {
    margin-left: 1.2em;
    font-size: 0.9rem;
    padding: 0.5rem 0;
    list-style: none;
    display: none;
  }

  .mobile-nav .dropdown.show {
    display: block;
  }
}

/* page content */
.content {
  max-width: 1200px;
  margin: 0 auto;
  padding: 3rem 20px;
}

.content h1 {
  font-size: 2rem;
}

#error-page {
  /* center content on the page */
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: 100vh;
  gap: 20px;
}

이제 src/App.css 파일의 스타일을 다음과 같이 다중 드롭다운 메뉴 프로젝트의 스타일로 대체해 보겠습니다. 모든 파일을 저장하고 브라우저에서 App 컴포넌트의 내용이 렌더링되는 것을 확인하세요.

Visiaul Studio Code 확장팩 설치

React 작업을 위해 아래와 같은 확장팩을 설치합니다.

최상위 메뉴 항목 렌더링하기

우리는 최상위 메뉴 항목을 렌더링하는 것으로 시작하겠습니다. 이를 위해 메뉴 항목을 저장할 src/menuItemsData.js 파일을 만들어 봅시다:

export const menuItemsData = [
  {
    title: 'Home',
    url: '/',
  },
  {
    title: 'Services',
    url: '/services',
  },
  {
    title: 'About',
    url: '/about',
  },
];

프로젝트 이미지를 처음에 기억한다면, App.jsx 파일은 로고와 Navbar 컴포넌트를 보유하며 Header 컴포넌트를 렌더링할 것입니다. 따라서 components/Header.jsx 파일에 다음 코드를 추가해봅시다:

import Navbar from './Navbar';

const Header = () => {
  return (
    <header>
      <div className="nav-area">
        <a href="/" className="logo">
          Logo
        </a>
        <Navbar />
      </div>
    </header>
  );
};

export default Header;

Navbar 컴포넌트를 가져왔다는 것에 주목하세요. 따라서 components/Navbar.jsx로 이동하여 다음 코드를 추가해봅시다:

const Navbar = () => {
  return (
    <nav className="desktop-nav">
      <ul className="menus">Nav Items here</ul>
    </nav>
  );
};

export default Navbar;

다음으로, App.jsx 파일을 업데이트하여 Header 컴포넌트를 렌더링하도록 합시다:

import Header from "./components/Header";
import "./App.css";

function App() {
  return (
    <div className="App">
      <Header />
    </div>
  );
}

export default App;

다음으로 Navbar.jsx 파일에서 ItemsData 데이터 메뉴를 가져와 루프를 거친 다음 JSX에서 렌더링합니다

import { menuItemsData } from '../menuItemsData';

const Navbar = () => {
  return (
    <nav className="desktop-nav">
      <ul className="menus">
        {menuItemsData.map((menu, index) => {
          return (
            <li className="menu-items" key={index}>
              <a href={menu.url}>{menu.title}</a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
};

export default Navbar;

모든 파일을 저장하고 프론트엔드를 확인해봅시다. 아래와 같이 로고와 내비게이션 텍스트가 표시되어야 합니다:

이것은 기본적인 내비게이션 메뉴입니다. 다음으로, Navbar.jsx 파일에서 menuItemsData 데이터를 가져와 매핑을 통해 반복하고 JSX에 렌더링하도록 하겠습니다.

단일 수준 드롭다운 메뉴 렌더링하기

먼저 menuItemsData.js 파일로 이동하여 Services 링크에 서브메뉴를 포함하는 데이터를 업데이트합니다.

export const menuItemsData = [
  // ...
  {
    title: 'Services',
    url: '/services',
    submenu: [
      {
        title: 'Web Design',
        url: 'web-design',
      },
      {
        title: 'Web Development',
        url: 'web-dev',
      },
      {
        title: 'SEO',
        url: 'seo',
      },
    ],
  },
  // ...
];

여기서 우리는 Services 링크에 서브메뉴를 추가하여 드롭다운 메뉴로 만들었습니다. 파일을 저장하세요.

참고로 서브메뉴 URL에는 앞선 /를 무시해도 됩니다. 이 경우에는 추가하든 안 하든 상관없습니다. 그러나 동적 중첩 라우트를 구현하려면 포함하지 않아야 합니다.

현재 Navbar가 코드에서 메뉴 항목을 렌더링하고 있습니다. 디자인을 다시 살펴보면 Navbar의 직계 자식인 MenuItems 컴포넌트가 이러한 항목을 렌더링하는 역할을 담당합니다.

그래서 Navbar를 수정하여 다음과 같이 만들어 보겠습니다.

import { menuItemsData } from '../menuItemsData';
import MenuItems from './MenuItems';

const Navbar = () => {
  return (
    <nav className="desktop-nav">
      <ul className="menus">
        {menuItemsData.map((menu, index) => {
          return <MenuItems items={menu} key={index} />;
        })}
      </ul>
    </nav>
  );
};

export default Navbar;

이 코드에서는 메뉴 항목 데이터를 items prop을 통해 MenuItems 컴포넌트로 전달하고 있습니다. 이것은 prop drilling이라는 프로세스로, 이는 기본적인 리액트 원칙입니다.

MenuItems 컴포넌트에서는 items prop을 받아 메뉴 항목을 표시합니다. 또한 항목이 서브메뉴를 가지고 있는지 확인한 후 드롭다운을 표시합니다.

components/MenuItems.jsx 파일을 열고 다음 코드를 추가합니다.

import Dropdown from './Dropdown';

const MenuItems = ({ items }) => {
  return (
    <li className="menu-items">
      {items.submenu ? (
        <>
          <button type="button" aria-haspopup="menu">
            {items.title}
          </button>
          <Dropdown submenus={items.submenu} />
        </>
      ) : (
        <a href={items.url}>{items.title}</a>
      )}
    </li>
  );
};

export default MenuItems;

이 코드에서는 드롭다운 메뉴를 열기 위해 버튼 요소를 사용합니다. 링크 태그를 사용할 경우, 보조 기술(스크린 리더 등)이 우리의 프로젝트와 작동하도록 하려면 role=”button”을 추가해야 합니다.

또한 이 코드에서는 Dropdown 컴포넌트를 가져와 prop을 통해 서브메뉴 항목을 전달합니다. 이제 components/Dropdown.jsx 파일을 열고 prop에 접근하여 서브메뉴를 렌더링하도록 합시다.

const Dropdown = ({ submenus }) => {
  return (
    <ul className="dropdown">
      {submenus.map((submenu, index) => (
        <li key={index} className="menu-items">
          <a href={submenu.url}>{submenu.title}</a>
        </li>
      ))}
    </ul>
  );
};

export default Dropdown;

모든 파일을 저장하세요.

드롭다운 메뉴를 볼 수 있도록 하려면 src/App.css 파일을 열고 CSS의 display: none; 부분을 주석 처리하세요:

.desktop-nav .dropdown {
 ...
 /* display: none; */
}

우리는 드롭다운을 기본적으로 숨기기 위해 display: none; 속성을 추가했으며, 메뉴와 상호 작용할 때만 열도록 설정했습니다.

display: none; 속성을 제거하면 메뉴가 다음과 같이 보일 것입니다:

Navbar, Menu Items, And Services Dropdown Menu

좋아요. 여기까지 왔습니다!

드롭다운 메뉴 토글하기

이제 드롭다운 메뉴 항목이 클릭되었을 때 이를 감지하여 드롭다운 상자를 동적으로 표시하거나 숨기는 논리를 정의해 보겠습니다. 이를 위해 상태를 추가하고 드롭다운 메뉴 클릭 시 상태를 업데이트해야 합니다.

components/MenuItems.jsx 파일에서 상태를 포함하도록 코드를 업데이트해 보겠습니다:

import { useState } from "react";
// ...
const MenuItems = ({ items }) => {
 const [dropdown, setDropdown] = useState(false);

 return (
  <li className="menu-items">
   {items.submenu ? (
    <>
     <button
      // ...
      aria-expanded={dropdown ? "true" : "false"}
      onClick={() => setDropdown((prev) => !prev)}
     >
      {items.title}{" "}
     </button>
     <Dropdown 
      // ...
      dropdown={dropdown} 
     />
    </>
   ) : (
    // ...
   )}
  </li>
 );
};

이 코드에서는 기본값이 false인 dropdown이라는 상태 변수를 정의하고, 드롭다운 버튼이 클릭될 때 상태를 토글하는 setDropdown 업데이터를 사용합니다. 이는 onClick 이벤트에서 볼 수 있습니다.

이렇게 하면 화면 판독 도구에 유용한 aria-expanded 속성에 값을 동적으로 추가할 수 있으며, 드롭다운 토글을 처리할 수 있는 dropdown 변수를 Dropdown 컴포넌트에 prop으로 전달할 수 있습니다.

이제 components/Dropdown.jsx를 열어 dropdown prop에 액세스하고 드롭다운 메뉴가 클릭되었을 때 클래스 이름을 동적으로 추가할 수 있습니다:

const Dropdown = ({ submenus, dropdown }) => {
 return (
  <ul className={`dropdown ${dropdown ? "show" : ""}`}>
   {/* ... */}
  </ul>
 );
};

export default Dropdown;

show 클래스 이름은 드롭다운이 활성화될 때 추가됩니다. 우리는 또한 드롭다운을 표시하기 위해 CSS 파일에 display: block; 스타일을 추가했습니다.

이제 src/App.css에서 .desktop-nav.dropdown 클래스 선택기에 display: none;을 다시 추가합니다:

.desktop-nav .dropdown {
 ...
 display: none;
}

파일을 저장하세요. 이제 메뉴 드롭다운을 토글할 수 있어야 합니다.

다중 수준 드롭다운 메뉴 구성 요소 추가하기

단일 수준 드롭다운과 마찬가지로, 다중 수준 드롭다운을 추가하려면 src/menuItemsData.js 파일을 열고 다음과 같이 Web Development 링크를 업데이트하여 다중 수준 하위 메뉴 구성 요소를 포함합니다.

export const menuItemsData = [
  // ...
  {
    title: 'Web Development',
    url: 'web-dev',
    submenu: [
      {
        title: 'Frontend',
        url: 'frontend',
      },
      {
        title: 'Backend',
        submenu: [
          {
            title: 'NodeJS',
            url: 'node',
          },
          {
            title: 'PHP',
            url: 'php',
          },
        ],
      },
    ],
  },
  // ...
];

Web Development 링크에 하위 메뉴를 추가하고 Backend 링크에 또 다른 하위 메뉴를 추가한 후 파일을 저장하세요.

다중 수준 드롭다운 메뉴 렌더링하기

드롭다운 항목은 또 다른 메뉴 항목, 또 다른 드롭다운 항목 등을 포함할 수 있다는 점을 알고 있습니다. 이러한 디자인을 구현하기 위해 메뉴 항목을 재귀적으로 렌더링할 것입니다. Dropdown 컴포넌트에서는 MenuItems 컴포넌트에게 메뉴 항목을 렌더링할 것을 위임합니다.

components/Dropdown.jsx 파일을 열고 MenuItems를 import하고, 서브메뉴를 items props를 통해 전달합니다.

import MenuItems from "./MenuItems";

const Dropdown = ({ submenus, dropdown }) => {
 return (
  <ul className={`dropdown ${dropdown ? "show" : ""}`}>
   {submenus.map((submenu, index) => (
    <MenuItems items={submenu} key={index} />
   ))}
  </ul>
 );
};

export default Dropdown;

파일을 저장하고 드롭다운을 테스트하면 작동하지만 드롭다운이 서로 겹쳐 있는 것을 알 수 있습니다.

다음 단계는, 웹 개발 서브메뉴를 클릭할 때 해당 드롭다운을 오른쪽으로 논리적으로 배치하려고 합니다. 이를 위해 드롭다운의 깊이 수준을 감지하는 것으로 이를 달성할 수 있습니다.

Detecting the menu depth level

메뉴의 깊이 수준을 감지하는 것은 몇 가지 작업을 수행할 수 있게 해줍니다. 먼저, 드롭다운이 있는지를 보여주는 다양한 화살표를 동적으로 추가할 수 있습니다. 두 번째로는 “두 번째 이상” 수준의 드롭다운을 감지하고, 따라서 서브메뉴 오른쪽에 드롭다운을 논리적으로 배치할 수 있습니다.

components/Navbar.jsx 파일을 열고 return 문 위에 다음을 추가하십시오:

const depthLevel = 0;

또한, MenuItems를 통해 값을 전달하도록 하여 props로 전달되도록합니다. 우리의 코드는 이제 다음과 같습니다:

// ...
 return (
  // ...
   {menuItemsData.map((menu, index) => {
    return <MenuItems items={menu} key={index} depthLevel={depthLevel} />;
   })}
  // ...
 );
// ...

다음으로, MenuItems 컴포넌트에서 depthLevel에 접근하여 드롭다운 화살표를 표시합니다:

const MenuItems = ({ items, depthLevel }) => {
 // ...
 return (
  <li className="menu-items">
   {items.submenu ? (
    <>
     <button
      // ...
     >
      {items.title}{" "}
      {depthLevel > 0 ? <span>&raquo;</span> : <span className="arrow" />}
     </button>
     <Dropdown
      depthLevel={depthLevel}
      // ...
     />

depthLevel이 0보다 큰 경우에는 HTML 엔티티 이름 »을 사용하여 오른쪽 화살표를 표시합니다. 그렇지 않으면 .arrow 클래스 이름을 추가하여 사용자 지정 아래쪽 화살표를 스타일링합니다. 스타일 시트에는 아래쪽 화살표를 위한 스타일을 추가했습니다.

depthLevel을 Dropdown으로 props를 통해 전달하고, 거기에서 드롭다운 메뉴에 대해 증가시키도록합니다.

components/Dropdown.jsx 파일에서 depthLevel props에 액세스하고 증가시킨 다음, 값이 1보다 큰지 확인하여 사용자 정의 클래스를 드롭다운에 추가하십시오. 사용자 정의 클래스에 대한 스타일링을 스타일 시트에 추가했습니다.

또한, MenuItems에 depthLevel을 props로 전달하는 것을 확인하십시오:

const Dropdown = ({ submenus, dropdown, depthLevel }) => {
 depthLevel = depthLevel + 1;
 const dropdownClass = depthLevel > 1 ? "dropdown-submenu" : "";

 return (
  <ul className={`dropdown ${dropdownClass} ${dropdown ? "show" : ""}`}>
   {submenus.map((submenu, index) => (
    <MenuItems 
     // ... 
     depthLevel={depthLevel} 
    />
   ))}
  </ul>
 );
};

export default Dropdown;

파일을 저장하고 프로젝트를 테스트합니다.

Closing the dropdown menu when users click outside it

드롭다운 메뉴 바깥을 클릭하면 메뉴를 닫도록 하려고 합니다. 이를 위해 드롭다운 상태를 기본값인 false로 설정하여 메뉴를 닫을 것입니다. 드롭다운 영역 외부를 클릭하는 것을 감지하는 로직을 정의하겠습니다.

components/MenuItems.jsx 파일을 열고 useEffect 및 useRef 훅을 가져오도록 import를 업데이트합니다.

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

다음으로 useRef를 사용하여 드롭다운의 DOM 요소에 액세스합니다. 대상 노드에 참조 객체를 전달합니다.

const MenuItems = ({ items, depthLevel }) => {
 // ...
 let ref = useRef();
 return (
  <li className="menu-items" ref={ref}>
   {/* ... */}
  </li>
 );
};

export default MenuItems;

그런 다음 return 문 위에 다음 코드를 추가하십시오.

useEffect(() => {
 const handler = (event) => {
  if (dropdown && ref.current && !ref.current.contains(event.target)) {
   setDropdown(false);
  }
 };
 document.addEventListener("mousedown", handler);
 document.addEventListener("touchstart", handler);
 return () => {
  // 이벤트 리스너 정리
  document.removeEventListener("mousedown", handler);
  document.removeEventListener("touchstart", handler);
 };
}, [dropdown]);

파일을 저장하고 프로젝트를 테스트합니다. 작동합니다!

useEffect 훅에서는 드롭다운이 열려 있는지 확인한 다음 클릭된 DOM 노드가 드롭다운의 외부에 있는지 확인합니다. 그런 다음 드롭다운을 닫습니다.

Toggling dropdown on a mouse hover for bigger screens

큰 화면에서 마우스를 올리면 드롭다운을 토글하는 기능을 추가하겠습니다.

components/MenuItems.jsx에서 JSX의 li를 업데이트하여 onMouseEnter 및 onMouseLeave 이벤트를 포함하도록합니다.

const MenuItems = ({ items, depthLevel }) => {
 // ...
 return (
  <li
   // ...
   onMouseEnter={onMouseEnter}
   onMouseLeave={onMouseLeave}
  >
   {/* ... */}
  </li>
 );
};

export default MenuItems;

그런 다음 return 문 위에 다음 이벤트 핸들러 함수를 추가하십시오.

const onMouseEnter = () => {
 setDropdown(true);
};

const onMouseLeave = () => {
 setDropdown(false);
};

파일을 저장하고 프로젝트를 테스트합니다.

이 코드로 onMouseEnter 핸들러는 마우스 포인터가 메뉴 항목 위로 이동 할 때 호출되어 드롭다운을 엽니다. 마우스가 떠나면 onMouseLeave 함수가 호출되어 드롭다운이 닫힙니다.

마우스가 메뉴 항목을 벗어나면 onMouseLeave 핸들러를 호출하여 드롭다운을 닫습니다.

라우팅 구현

현재 프로젝트에서는 내부 페이지를 연결하는 데 HTML 태그를 사용하고 있습니다. 이는 메뉴 항목 중 하나를 클릭할 때마다 전체 페이지를 다시로드합니다.

React 애플리케이션의 경우 Link 또는 NavLink 구성 요소와 같은 최적화 된 링크 태그를 사용해야합니다. 마찬가지로 네비게이션 항목이 해당 페이지를 가리키고 렌더링되도록해야합니다. 이렇게하면 URL에 따라 다른 뷰를 표시하는 React Router를 사용합니다. 라이브러리:

npm install react-router-dom@6

다음으로 모든 페이지를 보유하는 routes 폴더를 만들고 해당 폴더에 모든 페이지를 생성하십시오. 또는 GitHub에서 페이지 파일에 액세스 할 수 있습니다. 예를 들어, About 페이지를 만들려면 routes/about.jsx 파일을 만들고 다음 코드를 포함하십시오.

import React from "react";

const About = () => {
  return <h1>About 페이지 내용</h1>;
};
export default About;

menuItemsData.js 파일의 나머지 링크에 대해 동일한 작업을 수행 할 수 있습니다.

Connecting the pages to our React app using React Router

src/index.js 파일에서 다음과 같이 변경해봅시다:

// ...
import App from "./App";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Root from "./routes/root";
import ErrorPage from "./error-page";
import About from "./routes/about";
import Services from "./routes/services";
import WebDesign from "./routes/web-design";
import WebDev from "./routes/web-dev";
import Frontend from "./routes/frontend";
import Php from "./routes/php";
import NodeJs from "./routes/node";
import SEO from "./routes/seo";

const router = createBrowserRouter([
  {
    element: <Root />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: "/",
        element: <App />,
      },
      {
        path: "services",
        element: <Services />,
      },
      {
        path: "about",
        element: <About />,
      },
      {
        path: "web-design",
        element: <WebDesign />,
      },
      {
        path: "web-dev",
        element: <WebDev />,
      },
      {
        path: "frontend",
        element: <Frontend />,
      },
      {
        path: "node",
        element: <NodeJs />,
      },
      {
        path: "php",
        element: <Php />,
      },
      {
        path: "seo",
        element: <SEO />,
      },
    ],
  },
]);

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
     <RouterProvider router={router} />
  </React.StrictMode>
);

우리는 createBrowserRouter API를 사용하여 라우터 상수를 가져와 저장했습니다. 또한 App.jsx 컴포넌트를 우리의 RouterProvider로 교체하고 라우터를 전달했습니다.

우리는 createBrowserRouter API에서 element, errorElement, 그리고 children props을 가지고 있습니다. element props는 Root 컴포넌트를 수용합니다. 이 Root 컴포넌트는 “루트 경로”이며 페이지의 레이아웃을 포함할 것입니다.

errorElement는 사용자가 존재하지 않는 페이지로 이동할 경우 앱에 표시할 페이지를 수용합니다. 마지막으로 children props는 해당 URL에 대한 모든 페이지를 포함합니다.

루트 디렉토리에서 root.jsx라는 파일을 생성하고 다음 코드를 추가해주세요:

import { Outlet } from "react-router-dom";
import Header from "../components/Header";

export default function Root() {
  return (
    <div>
      <Header />
      <div className="content">
        <Outlet />
      </div>
    </div>
  );
}

이제 사용자가 어떤 페이지로 이동하더라도 Header가 포함될 것입니다.

App.jsx 파일 업데이트
이제 루트 레이아웃이 있으므로 App.jsx 파일에서 Header 컴포넌트가 필요하지 않습니다:

import React from "react";

const App = () => {
  return <h1>App 페이지 내용</h1>;
};
export default App;

내부 링크 업데이트
Header 및 MenuItems 컴포넌트 모두 내부 페이지를 연결하는 데 HTML 태그를 사용합니다. 우리는 router 라이브러리에서 Link 컴포넌트로 태그를 교체할 것입니다. components/Header.jsx를 열고 Link를 가져와 다음과 같이 사용하세요:

// ...
import { Link } from 'react-router-dom';

const Header = () => {
  return (
    <header>
      <div className="nav-area">
        <Link to="/" className="logo">
          Logo
        </Link>
        <Navbar />
      </div>
    </header>
  );
};

export default Header;

비슷하게, components/MenuItems.jsx에서 HTML 태그를 대체하기 위해 Link를 가져옵니다:

// ...
import { Link } from 'react-router-dom';

const MenuItems = ({ items, depthLevel }) => {
  // ...
  return (
    <li
    className="menu-items"
    ref={ref}
    onMouseEnter={onMouseEnter}
    onMouseLeave={onMouseLeave}
    onClick={closeDropdown}>
    {items.submenu ? (
      // ...
    ) : (
      <Link to={items.url}>{items.title}</Link>
    )}
    </li>
  );
};

export default MenuItems;

파일을 저장하고 애플리케이션을 테스트하면 페이지를 다시로드하지 않고도 내비게이션할 수 있습니다.

링크할 수 있는 드롭다운 버튼
현재 서비스 또는 웹 개발 버튼 중 하나를 클릭하면 그 드롭다운을 토글합니다. 그러나 때로는 서비스와 같은 드롭다운 메뉴 버튼이 자체 페이지인 /services로 가도록하고, 큰 화면에서는 호버시 드롭다운 항목을 표시하는 것이 좋습니다.

호버시 드롭다운을 토글하는 기능을 추가했으므로 이제 드롭다운 버튼을 링크로 변경할 수 있습니다. 다음과 같이 MenuItems.jsx 파일을 업데이트하겠습니다.

이를 위해 링크하려는 드롭다운 메뉴 항목이 해당 URL 경로를 포함하는지 확인해야 합니다. 예를 들어, src/menuItemsData.js에서 서비스 메뉴 항목에는 URL 링크가 있습니다:

export const menuItemsData = [
  // ...
  {
    title: 'Services',
    url: '/services',
    submenu: [
      // ...
    ],
  },
  // ...
];

그다음, components/MenuItems.jsx 파일에서 조건부 확인을 확장할 것입니다. 현재 submenu의 존재 여부만 확인하여 버튼 요소를 렌더링하는 것처럼:

return (
  <li
    className="menu-items"
    ref={ref}
    onMouseEnter={onMouseEnter}
    onMouseLeave={onMouseLeave}
    onClick={closeDropdown}>
    {items.submenu ? (
      <>
        <button>
          {items.title}{' '}
          {/* ... */}
        </button>
      </>
    ) : (
      <Link to={items.url}>{items.title}</Link>
    )}
  </li>
);

이제 submenu뿐만 아니라 URL도 확인한 후 버튼이 링크될 수 있도록 확인할 것입니다:

const MenuItems = ({ items, depthLevel }) => {
  // ...
  const onMouseLeave = () => {
    setDropdown(false);
  };

  const toggleDropdown = () => {
    setDropdown((prev) => !prev);
  };

  const closeDropdown = () => {
    dropdown && setDropdown(false);
  };

  return (
    <li
      className="menu-items"
      ref={ref}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}>
      {items.url && items.submenu ? (
        <>
          <button
            type="button"
            aria-haspopup="menu"
            aria-expanded={dropdown ? "true" : "false"}
            onClick={() => toggleDropdown()}>
            <Link to={items.url}>{items.title}</Link>
            {depthLevel > 0 ? <span>&raquo;</span> : <span className="arrow" />}
          </button>
          <Dropdown
            depthLevel={depthLevel}
            submenus={items.submenu}
            dropdown={dropdown}
          />
        </>
      ) : !items.url && items.submenu ? (
        <>
          <button
            type="button"
            aria-haspopup="menu"
            aria-expanded={dropdown ? "true" : "false"}>
            {items.title}
            {depthLevel > 0 ? <span>&raquo;</span> : <span className="arrow" />}
          </button>
          <Dropdown
            depthLevel={depthLevel}
            submenus={items.submenu}
            dropdown={dropdown}
          />
        </>
      ) : (
        <Link to={items.url}>{items.title}</Link>
      )}
    </li>
  );
};

export default MenuItems;

위의 코드에서 먼저 메뉴 항목이 서브메뉴와 URL을 모두 가지고 있는지 확인합니다. 그렇다면 버튼을 제목과 URL로 링크하고, 서브메뉴를 props로 전달하여 드롭다운 컴포넌트를 렌더링합니다.

메뉴 항목이 서브메뉴는 있지만 URL이 없는 경우 버튼을 제목과 드롭다운 컴포넌트로 렌더링합니다. 마지막으로, 메뉴 항목이 서브메뉴도 없고 URL도 없는 경우 링크와 제목을 렌더링합니다.

이제 우리는 서비스 메뉴 항목을 클릭하여 서비스 페이지로 이동합니다. 웹 개발 메뉴 항목도 같은 방식입니다.

Making the multilevel dropdown responsive on smaller screens

작업 중인 프로젝트는 데스크톱 및 모바일 모두에서 작동할 것이지만, 사용자 경험은 React에서의 메뉴 바 접근성뿐만 아니라 네비게이션 메뉴를 디자인할 때 중요합니다.

우리는 네브바 메뉴의 디자인을 각각의 드롭다운마다 개별 카드가 아닌 단일 카드로 조정할 수 있습니다. 또한 사용자가 링크 텍스트를 클릭하면 해당 페이지로 이동하도록 드롭다운 버튼(링크)을 조정할 수 있습니다.

사용자가 드롭다운 아이콘을 클릭하면 새로운 페이지로 이동하지 않고 드롭다운 메뉴의 가시성만 토글되도록합니다. 이를 위해 더 작은 화면용 네브바 메뉴를 구축해야 합니다.

그래서 960px보다 큰 화면의 경우 현재 네브바가 사용자에게 표시됩니다. 그러나 너비가 960px보다 작은 화면의 경우 새로운 네브바 MobileNav가 렌더링됩니다. 이를 만들고 구현해 봅시다.

components 폴더에 MobileNav.jsx라는 파일을 만들고 다음 코드를 포함하세요:

import React, { useEffect, useRef, useState } from "react";
import { menuItemsData } from "../menuItemsData";
import MobileMenuItems from "./MobileMenuItems";

const MobileNav = () => {
  const depthLevel = 0;
  const [showMenu, setShowMenu] = useState(false);
  let ref = useRef();

  useEffect(() => {
    const handler = (event) => {
      if (showMenu && ref.current && !ref.current.contains(event.target)) {
        setShowMenu(false);
      }
    };
    document.addEventListener("mousedown", handler);
    document.addEventListener("touchstart", handler);
    return () => {
      // 이벤트 리스너 정리
      document.removeEventListener("mousedown", handler);
      document.removeEventListener("touchstart", handler);
    };
  }, [showMenu]);

  return (
    <nav className="mobile-nav">
      <button
        className="mobile-nav__menu-button"
        type="button"
        onClick={() => setShowMenu((prev) => !prev)}>
        Menu
      </button>

      {showMenu && (
        <ul className="menus" ref={ref}>
          {menuItemsData.map((menu, index) => {
            return (
              <MobileMenuItems
                items={menu}
                key={index}
                depthLevel={depthLevel}
                showMenu={showMenu}
                setShowMenu={setShowMenu}
              />
            );
          })}
        </ul>
      )}
    </nav>
  );
};

export default MobileNav;

위의 코드에서는 메뉴 버튼이 있습니다. 이를 클릭하면 모바일 드롭다운 메뉴의 가시성이 토글됩니다. 또한 useEffect 및 useRef Hook을 사용하여 사용자가 메뉴 외부를 클릭할 때 메뉴가 닫히도록합니다.

이제 MobileMenuItems 컴포넌트를 만들어 보겠습니다.

import { useState } from "react";
import { Link } from "react-router-dom";
import MobileDropdown from "./MobileDropdown";

const MobileMenuItems = ({ items, depthLevel, showMenu, setShowMenu }) => {
  const [dropdown, setDropdown] = useState(false);

  const closeDropdown = () => {
    dropdown && setDropdown(false);
    showMenu && setShowMenu(false);
  };

  const toggleDropdown = (e) => {
    e.stopPropagation();
    setDropdown((prev) => !prev);
  };

  return (
    <li className="menu-items" onClick={closeDropdown}>
      {items.url && items.submenu ? (
        <>
          <button
            type="button"
            aria-haspopup="menu"
            aria-expanded={dropdown ? "true" : "false"}>
            <Link to={items.url} onClick={closeDropdown}>
              {items.title}
            </Link>
            <div onClick={(e) => toggleDropdown(e)}>
              {dropdown ? (
                <span className="arrow-close" />
              ) : (
                <span className="arrow" />
              )}
            </div>
          </button>
          <MobileDropdown
            depthLevel={depthLevel}
            submenus={items.submenu}
            dropdown={dropdown}
          />
        </>
      ) : !items.url && items.submenu ? (
        <>
          <button
            type="button"
            aria-haspopup="menu"
            aria-expanded={dropdown ? "true" : "false"}>
            {items.title}{" "}
            <div onClick={(e) => toggleDropdown(e)}>
              {dropdown ? (
                <span className="arrow-close" />
              ) : (
                <span className="arrow" />
              )}
            </div>
          </button>
          <MobileDropdown
            depthLevel={depthLevel}
            submenus={items.submenu}
            dropdown={dropdown}
          />
        </>
      ) : (
        <Link to={items.url}>{items.title}</Link>
      )}
    </li>
  );
};

export default MobileMenuItems;

MobileMenuItems 컴포넌트는 MenuItems 컴포넌트와 거의 동일합니다. 차이점은 메뉴 항목에서 onMouseEnter 및 onMouseLeave 함수를 제거했다는 점입니다. 또한 이전에 MobileNav 컴포넌트에 추가한 “외부 클릭” 함수도 제거했습니다.

MenuItems 컴포넌트에서 다음과 같은 부분을 찾을 수 있습니다:

<button
  type="button"
  aria-haspopup="menu"
  aria-expanded={dropdown ? "true" : "false"}
  onClick={() => setDropdown((prev) => !prev)}>
  <Link to={items.url}>{items.title}</Link>
  {depthLevel > 0 ? <span>&raquo;</span> : <span className="arrow" />}
</button>

MobileMenuItems에서는 다음과 같습니다:

<button
  type="button"
  aria-haspopup="menu"
  aria-expanded={dropdown ? "true" : "false"}>
  <Link to={items.url} onClick={closeDropdown}>
    {items.title}
  </Link>
  <div onClick={(e) => toggleDropdown(e)}>
    {dropdown ? (
      <span className="arrow-close" />
    ) : (
      <span className="arrow" />
    )}
  </div>
</button>

여기서 onClick 함수를 버튼에서 아이콘으로 옮겼습니다. 모바일에서는 드롭다운 메뉴 버튼에 마우스를 올려도 드롭다운이 표시되지 않으므로, 클릭하면 드롭다운이 표시되기 전에 페이지로 리디렉션됩니다. 이제 아이콘을 클릭하면 링크 텍스트를 클릭하지 않는 한 페이지로 이동하지 않고 드롭다운이 표시됩니다.

toggleDropdown 함수에서의 e.stopPropagation();은 메뉴 카드 내에서 클릭할 때마다 드롭다운 메뉴가 닫히는 것을 방지하기 위해 매우 중요합니다.

이제 MobileDropdown.jsx를 만들어 봅시다:

import MobileMenuItems from "./MobileMenuItems";

const MobileDropdown = ({ submenus, dropdown, depthLevel }) => {
  depthLevel = depthLevel + 1;
  const dropdownClass = depthLevel > 1 ? "dropdown-submenu" : "";

  return (
    <ul className={`dropdown ${dropdownClass} ${dropdown ? "show" : ""}`}>
      {submenus.map((submenu, index) => (
        <MobileMenuItems items={submenu} key={index} depthLevel={depthLevel} />
      ))}
    </ul>
  );
};

export default MobileDropdown;

MobileDropdown 컴포넌트는 Dropdown 컴포넌트와 동일한 코드를 가지고 있습니다. 유일한 차이점은 MenuItems 컴포넌트 대신 MobileMenuItems 컴포넌트를 가져오는 것입니다.

이제 MobileNav를 Header 컴포넌트에 가져와 보겠습니다:

import MobileNav from "./MobileNav";
import Navbar from "./Navbar";
import { Link } from "react-router-dom";

const Header = () => {
  return (
    <header>
      <div className="nav-area">
        <Link to="/" className="logo">
          Logo
        </Link>

        {/* for large screens */}
        <Navbar />
        {/* for small screens */}
        <MobileNav />
      </div>
    </header>
  );
};

export default Header;

브라우저에서 변경 사항을 확인하면 이제 메뉴가 작은 화면과 큰 화면 양쪽에서 응답 및 접근 가능하게 되었습니다.

Optimizing dropdown menu performance

드롭다운 메뉴의 성능을 향상시키는 것은 특정한 디자인 고려 사항이 필요합니다. 사용자 경험을 개선하기 위해 다음과 같은 팁을 적용할 수 있습니다:

지연 로딩: 서브메뉴 항목에 대한 지연 로딩을 구현할 수 있습니다. 이 기술은 필요할 때까지 콘텐츠 로딩을 연기하여 초기 로드 시간을 줄입니다. 사용자가 특정 드롭다운과 상호 작용할 때, 보이는 항목만 가져오므로 전체적인 성능을 향상시킵니다.
다양한 화면 크기에 대한 반응형 디자인: 반응형 디자인 원칙을 사용하여 작은 화면에 대한 메뉴를 최적화할 수 있습니다. 앞서 설명한 단계에서 보았듯이 미디어 쿼리를 사용하여 다양한 화면 크기에 대해 레이아웃, 글꼴 크기 및 간격을 조정할 수 있습니다. 이를 통해 터치 친화적 상호 작용을 통한 모바일 장치에서의 원활한 경험을 제공할 수 있습니다.
애니메이션 및 전환 최소화: 애니메이션은 드롭다운을 멋지게 만들어줄 수 있지만, 과도한 사용은 전체적인 성능에 영향을 미칠 수 있습니다. 특히 깊게 중첩된 메뉴에서는 애니메이션을 최소화하거나 최적화해야 합니다. 섬세한 전환을 사용하여 속도를 저해하지 않고도 정교한 외관을 유지할 수 있습니다.
효율적인 데이터 가져오기 및 캐싱: 화면에 보이는 드롭다운에 필요한 내용만 가져와서 데이터 가져오기를 최적화할 수 있습니다. 이전에 로드된 콘텐츠를 저장하여 중복된 가져오기를 줄이고 빠른 사용자 경험을 보장하는 캐싱 전략을 구현할 수 있습니다.

결론

이제 React 프로젝트에서 다중 수준의 드롭다운 메뉴를 구현할 수 있습니다. 이 튜토리얼에서 구현한 대로 데이터 파일에 많은 메뉴와 서브메뉴를 추가하면 다중 수준의 드롭다운 메뉴가 마법처럼 프론트엔드에 나타납니다. 그러나 사용자를 압도하지 않도록 추가하는 드롭다운 수준에 주의해야 합니다.

답글 남기기