Разработка адаптивного мегаменю в React
Мегаменю популярны при разработке многофункциональных навигационных систем на веб-сайтах. Они отличаются от обычных выпадающих меню тем, что позволяют быстрее отображать глубоко вложенные меню веб-сайта, предлагая эффективный способ навигации по большому объему контента.
Распространенные варианты использования мегаменю включают веб-сайты электронной коммерции, сайты недвижимости или любые другие крупные веб-сайты, требующие иерархической организации информации. В этом руководстве мы рассмотрим, как создать адаптивное и доступное мегаменю с нуля с помощью React.
К концу этого урока мы создадим меню, похожее на приведенное ниже:
Это мега-меню также адаптивно для отображения на экранах меньшего размера, например, так:
Вы можете ознакомиться с окончательным проектом здесь и ознакомиться с исходным кодом здесь. Итак, давайте начнем!
Почему бы не использовать библиотеки UI?
Несмотря на множество преимуществ, предлагаемых библиотеками пользовательского интерфейса, они создают некоторые проблемы, такие как кривая обучения и раздутый код, которые могут им сопутствовать. Кроме того, их предопределенные стили могут ограничить нашу способность полностью настраивать внешний вид и поведение мегаменю.
Выбор в пользу создания компонента с нуля дает нам полный контроль над его дизайном, поведением и функциональностью.
Планирование проекта mega menu
Прежде чем погрузиться в код, важно спланировать структуру нашего mega menu. Как мы видели на изображениях предварительного просмотра выше и в реальном проекте, в веб-приложении также представлены другие компоненты, такие как баннер hero.
Чтобы сосредоточиться на функции mega menu, мы предоставили стартовый проект React, в который мы можем интегрировать компонент mega menu. В следующем разделе мы клонируем проект и приступаем к работе с кодом.
Настройка проекта React
Давайте клонируем и запустим проект React starter, созданный с помощью Vite. Откройте терминал и выполните следующие команды:
git clone git@github.com:Ibaslogic/mega-menu-starter.git
cd mega-menu-starter
npm install
npm run dev
Интерфейс должен отображаться без меню навигации:
Структура проекта
Структура для стартового проекта выглядит следующим образом:
mega-menu-starter/
|-- src/
| |-- components/
| | |-- ...
| | |-- Navigation.jsx
| |-- routes/
| |-- index.css
| |-- main.jsx
Мы реализовали маршрутизацию с помощью react-router-dom
, а значки, используемые в проекте, взяты из Lucide React. Это позволяет нам сосредоточиться на реализации мега-меню.
Вы можете найти компоненты маршрута в папке src/routes
. Файл index.css
содержит все правила стиля проекта, в то время как файл components/Navigation.jsx
отображает то, что мы в данный момент видим на верхней панели, то есть логотип и две кнопки с надписями Log in и Sign up соответственно.
Файл Navigation.jsx
также будет содержать код для мега-меню.
Создание компонента MegaMenu
Давайте создадим файл Components/MegaMenu.jsx
и добавим простой компонент MegaMenu
:
const MegaMenu = () => {
return (
<div className="nav__container">
<nav>mega menu items</nav>
</div>
);
};
export default MegaMenu;
Далее нам нужно настроить код для реализации мегаменю на больших экранах и экранах меньшего размера. Давайте начнем с кода для больших экранов.
Навигационное меню для больших экранов
Внутри файла Navigation.jsx
добавьте MegaMenu
между логотипом и профилем пользователя:
// ...
import MegaMenu from './MegaMenu';
const Navigation = () => {
return (
<header className="nav__header">
{/* logo here */}
<div className="hidden md:block">
<MegaMenu />
</div>
{/* UserProfile */}
</header>
);
};
export default Navigation;
Мы поместили компонент MegaMenu
в div
с классами hidden
и md:block
. Эти классы обеспечивают отображение MegaMenu
только на больших экранах. Мы определили правила стиля в файле src/index.css
:
.hidden {
display: none;
}
@media (min-width: 996px) {
.md\:block {
display: block;
}
}
Позже мы повторно будем использовать компонент MegaMenu
внутри панели навигации для экранов меньшего размера.
Настройка данных меню
Чтобы наше мегаменю можно было масштабировать по своему усмотрению, мы должны тщательно структурировать данные меню, используя массив объектов. Давайте создадим файл src/menuData.js
и скопируем данные меню из проекта во вновь созданный файл. Структура должна выглядеть так:
export const menuData = [
{
label: 'Buy properties',
href: '/buy',
children: [
{
heading: 'Homes for sale',
submenu: [
{
label: 'Lorem ipsum dolor sit amet consectetur',
href: '#',
},
{ label: 'Ipsam sequi provident', href: '#' },
{ label: 'Porro impedit exercitationem', href: '#' },
],
},
// ...
],
},
// ...
{ label: 'News & Insights', href: '/news' },
]
Каждый объект представляет узел элемента прейскуранта в навигации. Элемент мега-прейскуранта имеет свойство children
со значением, представляющим вложенные уровни содержимого в выпадающем списке mega.
Отображение основных навигационных ссылок
В файле MegaMenu.jsx
давайте импортируем массив menuData
и пройдемся по нему, чтобы отобразить каждый пункт меню:
import { menuData } from '../menuData';
import MenuItem from './MenuItem';
const MegaMenu = () => {
return (
<div className="nav__container">
<nav>
<ul>
{menuData.map(({ label, href, children }, index) => {
return (
<MenuItem key={index} {...{ label, href, children }} />
);
})}
</ul>
</nav>
</div>
);
};
export default MegaMenu;
В этом коде мы визуализируем компонент MenuItem
для обработки каждого пункта меню. Давайте создадим файл components/MenuItem.jsx
и добавим следующий код:
import { NavLink } from 'react-router-dom';
const MenuItem = ({ label, href, children }) => {
return (
<li>
<div className="nav_item_content">
<NavLink
to={href}
className={({ isActive }) => (isActive ? 'active' : '')}
>
{label}
</NavLink>
</div>
{children && <div className="dropdown">dropdown content</div>}
</li>
);
};
export default MenuItem;
Для каждого элемента li
код проверяет, существует ли свойство children
для отображения мегараскрывающегося списка.
С помощью класса .dropdown
мы задаем мегаменю сначала скрытым, а затем отображаемым при наведении курсора мыши. Кроме того, мы разместили раскрывающийся список абсолютно под пунктом меню:
.dropdown {
position: absolute;
/* ... */
visibility: hidden;
}
.nav__container ul li:hover .dropdown {
visibility: visible;
}
См. ожидаемый результат ниже:
В настоящее время в выпадающем списке отображается только некоторый текст-заполнитель, считывающий содержимое dropdown content
. Далее мы отобразим некоторый фиктивный контент, который больше напоминает реальный контент.
Отображение ссылок на раскрывающийся список контента
Сначала обновите файл MenuItem.jsx
, чтобы отобразить компонент DropdownContent
, и передайте массив children
через свойство submenuscontent
:
// ...
import Container from './Container';
import DropdownContent from './DropdownContent';
const MenuItem = ({ label, href, children }) => {
return (
<li>
{/* ... */}
{children && (
<div className="dropdown">
<Container>
<DropdownContent submenuscontent={children} />
</Container>
</div>
)}
</li>
);
};
export default MenuItem;
Далее, давайте создадим файл DropdownContent.jsx
и возьмем submenuscontent
prop, пройдемся по нему и отобразим блок содержимого мега-меню:
import React from 'react';
import { Link } from 'react-router-dom';
const DropdownContent = ({ submenuscontent }) => {
return (
<div className="dropdown_content">
{submenuscontent.map((item, index) => (
<React.Fragment key={index}>
<section>
<h4>{item.heading}</h4>
<ul>
{item.submenu.map(({ label, href }, index) => (
<li key={index}>
<Link to={href}>{label}</Link>
</li>
))}
</ul>
</section>
</React.Fragment>
))}
</div>
);
};
export default DropdownContent;
Ожидаемое содержимое выпадающего списка мегаменю теперь должно отображаться под каждым пунктом меню при наведении курсора мыши:
Меню навигации для небольших экранов
Для управления мобильной навигацией мы активируем боковую панель, когда нажимаем кнопку-гамбургер. Внутри этого ящика мы будем повторно использовать компонент MegaMenu
.
В файле сomponents/Navigation.jsx
добавим компонент <MobileNavigationDrawer/>
после компонента <UserProfile/>
:
import { useState } from 'react';
// ...
const Navigation = () => {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
return (
<header className="nav__header">
<Container>
<div className="toolbar">
<button
//...
onClick={() => setIsDrawerOpen(true)}
>
{/* Mobile Hamburger menu */}
<AlignJustify />
</button>
{/* Userprofile */}
<div className="md:hidden absolute">
<MobileNavigationDrawer
{...{ isDrawerOpen, setIsDrawerOpen }}
/>
</div>
</div>
</Container>
</header>
);
};
export default Navigation;
В этом коде мы обернули MobileNavigationDrawer
в элемент div
, который скрывает компонент на больших экранах.
Мы добавили событие onClick
к кнопке-гамбургеру, чтобы обновить значение состояния isDrawerOpen
до значения true
при нажатии кнопки. Затем мы передали состояние isDrawerOpen
и установщик setIsDrawerOpen
в MobileNavigationDrawer
, чтобы мы могли условно отображать ящик и управлять состоянием соответственно.
Давайте создадим компонент MobileNavigationDrawer
и воспользуемся реквизитами isDrawerOpen
и setIsDrawerOpen
:
import { X } from 'lucide-react';
import MegaMenu from './MegaMenu';
const MobileNavigationDrawer = ({
isDrawerOpen,
setIsDrawerOpen,
}) => {
return (
<div className="mobile_navigation">
{isDrawerOpen && (
<div
className="backdrop"
onClick={() => setIsDrawerOpen(false)}
></div>
)}
<div
className={`drawer_content ${isDrawerOpen ? 'active' : ''}`}
>
<div className="close_drawer">
<button onClick={() => setIsDrawerOpen(false)}>
<X size={30} />
</button>
</div>
<div>
<MegaMenu />
</div>
</div>
</div>
);
};
export default MobileNavigationDrawer;
Мы использовали состояние isDrawerOpen
для динамического отображения фона и применили класс .active
для переключения панели навигации. Мы также добавили событие click
для сброса состояния и закрытия панели, когда пользователь нажимает фон или кнопку закрытия.
С помощью класса .drawer_content
мы создали эффект выдвижного ящика, используя следующий CSS:
.drawer_content {
/* ... */
transition: 0.5s;
transform: translateX(-100%);
}
.drawer_content.active {
transform: translateX(0);
}
По умолчанию ящик расположен за пределами экрана слева. При добавлении класса .active
он плавно переходит в исходное положение, делая его видимым на экране.
Смотрите ожидаемый результат ниже:
Механизм переключения подменю
Мы добавим значки курсора или стрелки вверх и вниз, чтобы пользователи могли переключать мобильные подменю. Мы также реализуем логику, позволяющую выполнять одно расширение за раз. Если вы читали наш предыдущий урок по созданию виджета accordion, вы, возможно, помните, что мы рассматривали эту функциональность.
Используя индекс элемента прейскуранта, мы можем определить, какой из элементов активен, и только разворачивать или сворачивать выпадающий список активного элемента.
Отслеживание активного пункта меню
Давайте откроем файл компонента MobileNavigationDrawer
и добавим состояние для обработки выбранного элемента:
import { useState } from 'react';
// ...
const MobileNavigationDrawer = ({...}) => {
const [clicked, setClicked] = useState(null);
const handleToggle = (index) => {
if (clicked === index) {
return setClicked(null);
}
setClicked(index);
};
return (
// ...
<MegaMenu
handleToggle={handleToggle}
clicked={clicked}
/>
// ...
);
};
export default MobileNavigationDrawer;
Мы подключим обработчик handleToggle
к кнопке значка курсора, которую создадим чуть позже, чтобы она могла запускать обновление состояния при нажатии кнопки. Этот обработчик handleToggle
ожидает, что индекс элемента обновит состояние.
Поскольку мы передали обработчик и состояние компоненту MegaMenu
, давайте возьмем и используем их:
const MegaMenu = ({ handleToggle, clicked }) => {
return (
<div className="nav__container">
<nav>
<ul>
{menuData.map(({ label, href, children }, index) => {
return (
<MenuItem
// ...
onToggle={() => handleToggle && handleToggle(index)}
active={clicked === index}
/>
);
})}
</ul>
</nav>
</div>
);
};
export default MegaMenu;
Теперь мы можем использовать логическое значение, возвращаемое активным свойством, для условного развертывания или свертывания подменю. Мы также будем использовать эту опору для отображения значков курсора.
Теперь в файле компонента MenuItem
возьмите реквизиты и отобразите кнопку курсора после ссылки на элемент:
// ...
import { ChevronDown, ChevronUp } from 'lucide-react';
const MenuItem = ({ label, href, children, onToggle, active }) => {
return (
<li>
<div className="nav_item_content">
<NavLink ...>{label}</NavLink>
{children && (
<button
className="md:hidden"
onClick={onToggle}
>
{active ? (
<ChevronUp size={20} />
) : (
<ChevronDown size={20} />
)}
</button>
)}
</div>
{/* ... */}
</li>
);
};
export default MenuItem;
Далее в том же файле найдите следующий код:
{children && (
<div className="dropdown">
<Container>
<DropdownContent submenuscontent={children} />
</Container>
</div>
)}
Замените приведенный выше код на следующий:
{children && (
<div
className={`dropdown ${
active ? 'h-auto' : 'h-0 overflow-hidden md:h-auto'
}`}
>
<Container>
<DropdownContent submenuscontent={children} />
</Container>
</div>
)}
Этот код условно проверяет, имеет ли значение свойства active
значение true
, а затем применяет определенный класс, который расширяет раскрывающийся список подменю. В противном случае он применяет классы, которые сворачивают раскрывающийся список. Вы можете открыть файл CSS, чтобы просмотреть правила стиля.
Закрытие ящика после выбора пункта меню
На традиционных веб-сайтах любая перезагрузка страницы из-за навигации автоматически закрывает ящики. Однако в одностраничном приложении, таком как React, нам придется позаботиться об этой функциональности вручную.
Для этого мы добавим событие click к элементам меню и вызовем функцию setIsDrawerOpen
при нажатии на любой из элементов. При вызове эта функция сбросит состояние и закроет ящик.
Давайте передадим setIsDrawerOpen
из компонента MobileNavigationDrawer
в MegaMenu
:
<MegaMenu
// ...
setIsDrawerOpen={setIsDrawerOpen}
/>
Затем возьмите свойство из MegaMenu
и передайте его в MenuItem
:
const MegaMenu = ({ handleToggle, clicked, setIsDrawerOpen }) => {
return (
<div className="nav__container">
<nav>
<ul>
{menuData.map(({ ... }, index) => {
return (
<MenuItem
key={index}
{...{
// ...
setIsDrawerOpen,
}}
/>
);
})}
{/* ... */}
Внутри MenuItem
давайте теперь добавим событие onClick
и сбросим состояние isDrawerOpen
на false
. Мобильный ящик будет закрываться при каждом нажатии основной навигационной ссылки:
const MenuItem = ({
// ...
setIsDrawerOpen,
}) => {
return (
// ...
<NavLink
// ...
onClick={() => {
setIsDrawerOpen && setIsDrawerOpen(false);
}}
>
{label}
</NavLink>
// ...
);
};
export default MenuItem;
Чтобы убедиться, что ящик также закрывается для дочерних выпадающих ссылок, мы передадим setIsDrawerOpen
в DropdownContent
:
const MenuItem = ({
// ...
}) => {
return (
<li>
{/* ... */}
<Container>
<DropdownContent
submenuscontent={children}
setIsDrawerOpen={setIsDrawerOpen}
/>
</Container>
{/* ... */}
</li>
);
};
Затем мы получим доступ к prop и добавим событие onClick
:
const DropdownContent = ({ submenuscontent, setIsDrawerOpen }) => {
return (
// ...
<li
key={index}
onClick={() => {
setIsDrawerOpen && setIsDrawerOpen(false);
}}
>
...
</li>
// ...
);
};
export default DropdownContent;
Теперь у нас должна быть возможность закрыть ящик при нажатии на элемент:
Оптимизация доступности
Как всегда, важно обеспечить доступность создаваемой нами функции. Два способа сделать это для нашего мегаменю — использовать роли и атрибуты ARIA и сделать меню доступным для навигации с помощью клавиатуры.
Роли и атрибуты ARIA
Давайте посмотрим, как использовать роли и атрибуты ARIA, чтобы передать предполагаемое поведение нашего мегавыпадающего списка пользователям вспомогательных технологий. Просто обновите компонент MenuItem
, включив в него атрибуты ARIA:
const MenuItem = ({...}) => {
return (
<li>
<div className="nav_item_content">
{/* item link */}
{children && (
<button
// ...
aria-label="Toggle dropdown"
aria-haspopup="menu"
aria-expanded={active ? 'true' : 'false'}
>
{/* caret icon */}
</button>
)}
</div>
{children && (
<div
role="menu"
// ...
>
{/* dropdown content */}
</div>
)}
</li>
);
};
export default MenuItem;
Здесь мы добавили атрибуты ARIA к кнопке, которая открывает меню, чтобы указать доступность и тип всплывающего окна, а также то, развернуто или свернуто всплывающее окно.
Навигация с помощью клавиатуры
Мы можем позволить пользователям перемещаться по нашему мегаменю с помощью настольных и мобильных клавиатур. Начнем с реализации навигации с помощью клавиатуры на рабочем столе.
Навигация с помощью клавиатуры рабочего стола
В настоящее время на больших экранах мы можем открыть мегаменю при наведении курсора на элемент с помощью следующего CSS:
.nav__container ul li:hover .dropdown {
visibility: visible;
}
Давайте обеспечим доступность клавиатуры, также применив псевдокласс CSS :focus-within
к li
:
.nav__container ul li:focus-within .dropdown,
.nav__container ul li:hover .dropdown {
visibility: visible;
}
Таким образом, если пользователь сосредоточится на пункте меню или любом из его потомков с помощью клавиши табуляции или щелчка мыши, откроется мегараскрывающийся список.
Оптимизация навигации с помощью клавиатуры
При нажатии на пункт меню мегараскрывающийся список будет постоянно оставаться открытым, пока мы не щелкнем за его пределами, чтобы убрать фокус. Это непреднамеренное последствие приводит к тому, что раскрывающийся список наведенного элемента перекрывает любой предыдущий раскрывающийся список.
Чтобы решить эту проблему, мы реализуем функцию, которая гарантирует, что элемент, находящийся в фокусе, теряет фокус при нажатии ссылки меню. В компонент MenuItem
добавим функцию handleClick
и вызовем ее в onClick
:
const MenuItem = ({...}) => {
const handleClick = () => {
// Blur the active element to lose focus
const activeElement = document.activeElement;
activeElement.blur();
};
return (
<li>
<div className="nav_item_content">
<NavLink
// ...
onClick={() => {
setIsDrawerOpen && setIsDrawerOpen(false);
handleClick();
}}
>
{label}
</NavLink>
{/* ... */}
</li>
);
};
Функция handleClick
получает ссылку на текущий активный элемент на странице и вызывает метод размытия, чтобы удалить фокус с элемента.
Давайте сделаем то же самое для пунктов мегаменю. В этом же файле передадим функцию компоненту DropdownContent
:
<Container>
<DropdownContent
// ...
handleClick={handleClick}
/>
</Container>
Мы возьмем handleClick
и вызовем его в onClick
:
const DropdownContent = ({
// ...
handleClick,
}) => {
return (
// ...
<ul>
{item.submenu.map(({ label, href }, index) => (
<li
key={index}
onClick={() => {
setIsDrawerOpen && setIsDrawerOpen(false);
handleClick();
}}
>
{/* ... */}
</li>
))}
</ul>
// ...
);
};
export default DropdownContent;
Теперь, когда мы увидели, как реализовать и оптимизировать навигацию с помощью клавиатуры для пользователей настольных компьютеров, давайте обратим внимание на навигацию с клавиатуры на мобильных устройствах.
Навигация с помощью мобильной клавиатуры
В настоящее время навигация с помощью клавиши табуляции работает до тех пор, пока мы не откроем ящик. Как мы видим ниже, нормальный порядок вкладок соблюдается, даже когда ящик открыт:
Однако мы хотим, чтобы пользователи клавиатуры могли немедленно взаимодействовать с содержимым ящика после его открытия.
Во-первых, когда ящик открыт, мы получим фокус без необходимости фокусироваться на нем вручную. В MobileNavigationDrawer
мы получим ссылку на ящик и применим логику фокуса в хуке useEffect
:
import { useState, useRef, useEffect } from 'react';
// ...
const MobileNavigationDrawer = ({}) => {
const drawerRef = useRef(null);
useEffect(() => {
if (isDrawerOpen && drawerRef.current) {
// Focus the drawer when it opens
drawerRef.current.focus();
}
}, [isDrawerOpen]);
// ...
return (
<div className="mobile_navigation" ref={drawerRef}>
{/* ... */}
</div>
);
};
export default MobileNavigationDrawer;
Далее мы будем использовать атрибут tabIndex
для управления поведением ящика в фокусе и его порядком в навигации по фокусу. Давайте обновим элемент контейнера div
ящика в MobileNavigationDrawer
, чтобы включить tabIndex
:
return (
<div
className="mobile_navigation"
ref={drawerRef}
tabIndex={isDrawerOpen ? 0 : -1}
>
{/* ... */}
</div>
);
Когда ящик открывается, tabIndex
устанавливается в 0
. Это означает, что элемент-контейнер ящика является фокусируемым и будет включен в обычный порядок табуляции документа. Когда ящик закрыт, для tabIndex
установлено значение -1
, а элемент div
ящика не фокусируется и удаляется из порядка табуляции.
См. ожидаемое поведение ниже:
Фокусировка кнопки меню при закрытии ящика
Обратите внимание, что на GIF-изображении выше кнопка-гамбургер не получает фокус, когда ящик закрывается. Ожидаемое поведение — получение фокуса, поэтому давайте исправим это сейчас.
В компоненте Navigation
получим ссылку на кнопку-гамбургер и передадим ее компоненту MobileNavigationDrawer
:
import { useState, useRef } from 'react';
// ...
const Navigation = () => {
const drawerButtonRef = useRef(null);
// ...
return (
<header className="nav__header">
<Container>
<div className="toolbar">
<button
ref={drawerButtonRef}
// ...
>
{/* ... */}
</button>
{/* ... */}
<div className="md:hidden absolute">
<MobileNavigationDrawer
{...{ isDrawerOpen, setIsDrawerOpen, drawerButtonRef }}
/>
</div>
</div>
</Container>
</header>
);
};
export default Navigation;
Далее в MobileNavigationDrawer
возьмем переменную drawerButtonRef
:
const MobileNavigationDrawer = ({
// ...
drawerButtonRef,
}) => {
// ...
};
export default MobileNavigationDrawer;
Далее в MobileNavigationDrawer
возьмем переменную drawerButtonRef
:
onClick={() => setIsDrawerOpen(false)}
Обновите обработчик onClick
, включив в него логику, которая применяет фокус к меню-гамбургеру:
onClick={() => {
setIsDrawerOpen(false);
// Focus the drawer button when it closes
if (drawerButtonRef.current) {
drawerButtonRef.current.focus();
}
}}
Если вы проверите это сейчас, вы увидите, что ожидаемое поведение работает правильно.
Закрытие ящика кнопкой Esc на клавиатуре
В MobileNavigationDrawer
давайте применим событие onKeyDown
к ящику, послушаем, когда пользователь нажимает клавишу Esc
, и закроем мегаменю:
const MobileNavigationDrawer = ({...}) => {
// ...
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setIsDrawerOpen(false);
}
};
return (
<div
// ...
onKeyDown={handleKeyDown}
>
{/* ... */}
</div>
);
};
export default MobileNavigationDrawer;
Давайте также убедимся, что мы применяем фокус к гамбургеру после закрытия ящика. Обновите функцию handleKeyDown
следующим образом:
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setIsDrawerOpen(false);
// Focus the drawer button when it closes
if (drawerButtonRef.current) {
drawerButtonRef.current.focus();
}
}
};
Теперь проект должен работать как положено.
Заключение
В этом подробном руководстве мы рассмотрели пошаговый процесс разработки надежного, доступного и отзывчивого мегаменю в React. Если вам понравился этот урок, постарайтесь поделиться этим руководством в Интернете.
См. демонстрационный проект здесь и исходный код здесь.