Задача
СкопированоСоздать навигационное меню с несколькими уровнями и вложенными внутрь элементами.
Готовое решение
СкопированоПри создании меню на сайте важно использовать семантические теги, чтобы обеспечить базовую доступность и избежать дополнительных действия с JavaScript.
Готовая разметка многоуровневого меню выглядит следующим образом:
<body> <header class="header"> <nav class="site-nav enhanced" aria-label="Сайт" > <ul class="menu"> <li class="menu__item" data-has-children> <button class="menu__btn" aria-expanded="false" aria-controls="doka-submenu" > Дока </button> <!-- Первый уровень вложенности --> <ul class="menu" id="doka-submenu" hidden> <li class="menu__item"> <a href="#" class="menu__link" aria-current="page" > Рецепты </a> <li> <li class="menu__item"> <button class="menu__btn" aria-expanded="false" aria-controls="html-submenu" > HTML </button> <!-- Второй уровень вложенности --> <ul class="menu" id="html-submenu" hidden> <li class="menu__item"> <a href="#" class="menu__link"> Основы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Форматирование </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Семантика </a> </li> </ul> </li> <li class="menu__item"> <button class="menu__btn" aria-expanded="false" aria-controls="css-submenu" > CSS </button> <!-- Второй уровень вложенности --> <ul class="menu" id="css-submenu" hidden> <li class="menu__item"> <a href="#" class="menu__link"> Основы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Селекторы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Псевдоклассы </a> </li> </ul> </li> <li class="menu__item"> <a href="#" class="menu__link"> JavaScript </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Доступность </a> </li> </ul> </li> <li class="menu__item"> <a href="#" class="menu__link"> Новости </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Блог </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Архив </a> </li> </ul> </nav> </header></body>
<body> <header class="header"> <nav class="site-nav enhanced" aria-label="Сайт" > <ul class="menu"> <li class="menu__item" data-has-children> <button class="menu__btn" aria-expanded="false" aria-controls="doka-submenu" > Дока </button> <!-- Первый уровень вложенности --> <ul class="menu" id="doka-submenu" hidden> <li class="menu__item"> <a href="#" class="menu__link" aria-current="page" > Рецепты </a> <li> <li class="menu__item"> <button class="menu__btn" aria-expanded="false" aria-controls="html-submenu" > HTML </button> <!-- Второй уровень вложенности --> <ul class="menu" id="html-submenu" hidden> <li class="menu__item"> <a href="#" class="menu__link"> Основы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Форматирование </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Семантика </a> </li> </ul> </li> <li class="menu__item"> <button class="menu__btn" aria-expanded="false" aria-controls="css-submenu" > CSS </button> <!-- Второй уровень вложенности --> <ul class="menu" id="css-submenu" hidden> <li class="menu__item"> <a href="#" class="menu__link"> Основы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Селекторы </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Псевдоклассы </a> </li> </ul> </li> <li class="menu__item"> <a href="#" class="menu__link"> JavaScript </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Доступность </a> </li> </ul> </li> <li class="menu__item"> <a href="#" class="menu__link"> Новости </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Блог </a> </li> <li class="menu__item"> <a href="#" class="menu__link"> Архив </a> </li> </ul> </nav> </header> </body>
ul, li { list-style: none; padding: 0; margin: 0; text-align: start;}button:focus-visible,a:focus-visible { outline: 2px solid; outline-offset: -3px;}.header { display: flex; align-items: center; background-color: #C56FFF; padding: 0 50px;}.menu { display: flex; min-width: max-content; background: #C56FFF; color: #000000;}.menu-submenu { background: #FFFFFF;}.menu__btn,.menu__link { display: flex; width: 100%; gap: .5em; align-items: center; padding: .75rem 1.5rem; font-size: 1.125rem; font-weight: 300; font-family: inherit; color: #000000; cursor: pointer; border: none; background: transparent; transition: background-color 0.2s linear;}.menu__link:hover,.menu__btn:hover,.menu__btn[aria-expanded="true"] { background-color: #FFFFFF;}.menu-submenu .menu__link:hover,.menu-submenu .menu__btn:hover,.menu-submenu .menu__btn[aria-expanded="true"] { background-color: #C56FFF;}.menu-submenu .menu__link:focus-visible,.menu-submenu .menu__btn:focus-visible { outline-width: 2px; outline-offset: -3px; outline-style: solid; outline-color: #000000;}.menu__btn-icon { color: inherit; transition: transform .1s linear;}.menu-submenu .menu__btn-icon { transform: rotate(-90deg);}.menu__btn[aria-expanded="true"] .menu__btn-icon { transform: rotate(180deg);}.menu-submenu .menu__btn[aria-expanded="true"] .menu__btn-icon { transform: rotate(90deg);}.menu__item { position: relative;}.menu__link { text-decoration: none;}a[aria-current="page"] { font-weight: 500; color: #000000;}/* Вложенное меню */.menu .menu { display: flex; flex-direction: column; gap: 8px; padding-inline-start: 3rem;}/* Первый уровень вложенности */.enhanced .menu .menu { position: absolute; top: 110%; left: 0; padding-inline-start: 0;}/* Второй уровень вложенности */.enhanced .menu .menu .menu { top: 0; left: 104%;}
ul, li { list-style: none; padding: 0; margin: 0; text-align: start; } button:focus-visible, a:focus-visible { outline: 2px solid; outline-offset: -3px; } .header { display: flex; align-items: center; background-color: #C56FFF; padding: 0 50px; } .menu { display: flex; min-width: max-content; background: #C56FFF; color: #000000; } .menu-submenu { background: #FFFFFF; } .menu__btn, .menu__link { display: flex; width: 100%; gap: .5em; align-items: center; padding: .75rem 1.5rem; font-size: 1.125rem; font-weight: 300; font-family: inherit; color: #000000; cursor: pointer; border: none; background: transparent; transition: background-color 0.2s linear; } .menu__link:hover, .menu__btn:hover, .menu__btn[aria-expanded="true"] { background-color: #FFFFFF; } .menu-submenu .menu__link:hover, .menu-submenu .menu__btn:hover, .menu-submenu .menu__btn[aria-expanded="true"] { background-color: #C56FFF; } .menu-submenu .menu__link:focus-visible, .menu-submenu .menu__btn:focus-visible { outline-width: 2px; outline-offset: -3px; outline-style: solid; outline-color: #000000; } .menu__btn-icon { color: inherit; transition: transform .1s linear; } .menu-submenu .menu__btn-icon { transform: rotate(-90deg); } .menu__btn[aria-expanded="true"] .menu__btn-icon { transform: rotate(180deg); } .menu-submenu .menu__btn[aria-expanded="true"] .menu__btn-icon { transform: rotate(90deg); } .menu__item { position: relative; } .menu__link { text-decoration: none; } a[aria-current="page"] { font-weight: 500; color: #000000; } /* Вложенное меню */ .menu .menu { display: flex; flex-direction: column; gap: 8px; padding-inline-start: 3rem; } /* Первый уровень вложенности */ .enhanced .menu .menu { position: absolute; top: 110%; left: 0; padding-inline-start: 0; } /* Второй уровень вложенности */ .enhanced .menu .menu .menu { top: 0; left: 104%; }
const nav = document.querySelector('.site-nav')nav.classList.add('enhanced')const submenus = document.querySelectorAll( '.menu__item[data-has-children]')const dropdowns = document.querySelectorAll( '.menu__item[data-has-children] > .menu')const icon = '<svg>...</svg>'// Находим подменю, заменяем в нём span на кнопкуsubmenus.forEach((item) => { const dropdown = item.querySelector(':scope > .menu') dropdown.setAttribute('hidden', '') const span = item.querySelector(':scope > span') const text = span.innerText const ariaControlsId = span.dataset.controls const button = document.createElement('button') // Добавляем класс и необходимые aria-атрибуты button.classList.add('menu__btn') button.setAttribute('aria-expanded', 'false') button.setAttribute('aria-controls', ariaControlsId) button.innerText = text // Добавляем иконку к кнопке, чтобы визуально было // понятно открыто меню или нет button.innerHTML += icon span.replaceWith(button) button.addEventListener('click', function (e) { toggleDropdown(button, dropdown) }) // Обрабатываем нажатие на Esc dropdown.addEventListener('keydown', (e) => { e.stopImmediatePropagation() if (e.keyCode === 27 && focusIsInside(dropdown)) { toggleDropdown(button, dropdown) button.focus() } }, false)})function toggleDropdown(button, dropdown) { if (button.getAttribute('aria-expanded') === 'true') { button.setAttribute('aria-expanded', 'false') dropdown.setAttribute('hidden', '') } else { button.setAttribute('aria-expanded', 'true') dropdown.removeAttribute('hidden') }}function focusIsInside(element) { return element.contains(document.activeElement)}function collapseDropdownsWhenTabbingOutsideNav(e) { if (e.keyCode === 9 && !focusIsInside(nav)) { dropdowns.forEach(function (dropdown) { dropdown.setAttribute('hidden', '') const btn = dropdown.parentNode.querySelector('button') btn.setAttribute('aria-expanded', 'false') }) }}function collapseDropdownsWhenClickingOutsideNav(e) { const target = e.target dropdowns.forEach(function (dropdown) { if (!dropdown.parentNode.contains(target)) { dropdown.setAttribute('hidden', '') const btn = dropdown.parentNode.querySelector('button') btn.setAttribute('aria-expanded', 'false') } });}// Закрываем навигацию, если протапались за её пределыdocument.addEventListener('keyup', collapseDropdownsWhenTabbingOutsideNav)// Закрываем навигацию, если кликнули вне навигацииwindow.addEventListener('click', collapseDropdownsWhenClickingOutsideNav)
const nav = document.querySelector('.site-nav') nav.classList.add('enhanced') const submenus = document.querySelectorAll( '.menu__item[data-has-children]' ) const dropdowns = document.querySelectorAll( '.menu__item[data-has-children] > .menu' ) const icon = '<svg>...</svg>' // Находим подменю, заменяем в нём span на кнопку submenus.forEach((item) => { const dropdown = item.querySelector(':scope > .menu') dropdown.setAttribute('hidden', '') const span = item.querySelector(':scope > span') const text = span.innerText const ariaControlsId = span.dataset.controls const button = document.createElement('button') // Добавляем класс и необходимые aria-атрибуты button.classList.add('menu__btn') button.setAttribute('aria-expanded', 'false') button.setAttribute('aria-controls', ariaControlsId) button.innerText = text // Добавляем иконку к кнопке, чтобы визуально было // понятно открыто меню или нет button.innerHTML += icon span.replaceWith(button) button.addEventListener('click', function (e) { toggleDropdown(button, dropdown) }) // Обрабатываем нажатие на Esc dropdown.addEventListener('keydown', (e) => { e.stopImmediatePropagation() if (e.keyCode === 27 && focusIsInside(dropdown)) { toggleDropdown(button, dropdown) button.focus() } }, false) }) function toggleDropdown(button, dropdown) { if (button.getAttribute('aria-expanded') === 'true') { button.setAttribute('aria-expanded', 'false') dropdown.setAttribute('hidden', '') } else { button.setAttribute('aria-expanded', 'true') dropdown.removeAttribute('hidden') } } function focusIsInside(element) { return element.contains(document.activeElement) } function collapseDropdownsWhenTabbingOutsideNav(e) { if (e.keyCode === 9 && !focusIsInside(nav)) { dropdowns.forEach(function (dropdown) { dropdown.setAttribute('hidden', '') const btn = dropdown.parentNode.querySelector('button') btn.setAttribute('aria-expanded', 'false') }) } } function collapseDropdownsWhenClickingOutsideNav(e) { const target = e.target dropdowns.forEach(function (dropdown) { if (!dropdown.parentNode.contains(target)) { dropdown.setAttribute('hidden', '') const btn = dropdown.parentNode.querySelector('button') btn.setAttribute('aria-expanded', 'false') } }); } // Закрываем навигацию, если протапались за её пределы document.addEventListener('keyup', collapseDropdownsWhenTabbingOutsideNav) // Закрываем навигацию, если кликнули вне навигации window.addEventListener('click', collapseDropdownsWhenClickingOutsideNav)
Итоговый результат выглядит так:
Разбор решения
СкопированоПервый уровень
СкопированоПри создании многоуровневого меню сначала создадим первый, базовый уровень. Семантическая вёрстка будет выглядеть следующим образом:
<nav class="site-nav" aria-label="Сайт"> <ul class="menu"> <li class="menu__item"> <a href="#" class="menu__link">Дока</a> </li> <!-- Другие элементы --> <li class="menu__item"> <a href="#" class="menu__link">Блог</a> </li> </ul></nav>
<nav class="site-nav" aria-label="Сайт"> <ul class="menu"> <li class="menu__item"> <a href="#" class="menu__link">Дока</a> </li> <!-- Другие элементы --> <li class="menu__item"> <a href="#" class="menu__link">Блог</a> </li> </ul> </nav>
Для базовой обёртки в большинстве случаев лучше использовать тег <nav>
. Он явно указывает браузеру на свою роль: говорит о том, что является ориентиром.
Встроенная роль <nav>
позволяет вспомогательным технологиям понять, для чего нужен элемент и корректно рассказать о нём пользователям. Например, пользователи скринридеров смогут попасть в такое меню с помощью специальных сочетаний клавиш и, тем самым, быстрее взаимодействовать со страницей.
Хорошо, когда у навигации есть доступное имя. Оно поможет отличить одно меню от другого, когда на сайте несколько вариантов меню. Например, основная навигация по сайту и навигация с хлебными крошками по категориям товаров. В примере для задания доступного имени используем ARIA-атрибут aria
.
Также важно рассказать пользователям о том, что они взаимодействуют с набором связанных элементов. Для этого будем использовать тег <ul>
, который подскажет вспомогательным технологиям сколько элементов в списке. Использование данного тега и aria
также помогут в определении уровня меню, на котором сейчас находится пользователь.
Вложенные уровни
СкопированоВнутрь базового уровня меню можно вложить ещё один. Для этого добавьте внутрь элемента списка другой список и заголовок нового уровня. В нашем примере в пункт меню «Дока» добавлен ещё один список с классом .menu
.
В большинстве случаев для элемента заголовка используют кнопку <button>
. Использование кнопки позволяет попасть на элемент меню с помощью клавиши Tab и повесить на неё событие click
, которое вызывается с помощью нажатия на Enter или пробел. Это особенно важно для людей, которые не используют мышку для навигации по сайту.
<nav class="site-nav" aria-label="Сайт"> <ul class="menu"> <li class="menu__item"> <button class="menu__btn" aria-expanded="false" aria-controls="doka-menu" > Дока </button> <ul class="menu" id="doka-menu"> <a href="#" class="menu__link">HTML</a> <a href="#" class="menu__link">CSS</a> <a href="#" class="menu__link">JavaScript</a> <a href="#" class="menu__link">Доступность</a> </ul> </li> <!-- Другие элементы --> <li class="menu__item"> <a href="#" class="menu__link"> Блог </a> </li> </ul></nav>
<nav class="site-nav" aria-label="Сайт"> <ul class="menu"> <li class="menu__item"> <button class="menu__btn" aria-expanded="false" aria-controls="doka-menu" > Дока </button> <ul class="menu" id="doka-menu"> <a href="#" class="menu__link">HTML</a> <a href="#" class="menu__link">CSS</a> <a href="#" class="menu__link">JavaScript</a> <a href="#" class="menu__link">Доступность</a> </ul> </li> <!-- Другие элементы --> <li class="menu__item"> <a href="#" class="menu__link"> Блог </a> </li> </ul> </nav>
В примере к кнопке добавлены ARIA-атрибуты, которые помогают вспомогательным технологиям лучше взаимодействовать с элементами на странице. Атрибут aria
указывает открыт ли пункт меню или нет. aria
связывает кнопку со списком, который она разворачивает или сворачивает.
В таком случае нужно будет написать небольшой скрипт на JavaScript, чтобы можно изменять значение атрибута aria
при взаимодействии с кнопкой.
button.addEventListener('click', function (e) { toggleDropdown(button, dropdown)})function toggleDropdown(button, dropdown) { if (button.getAttribute('aria-expanded') === 'true') { button.setAttribute('aria-expanded', 'false') dropdown.setAttribute('hidden', '') } else { button.setAttribute('aria-expanded', 'true') dropdown.removeAttribute('hidden') }}
button.addEventListener('click', function (e) { toggleDropdown(button, dropdown) }) function toggleDropdown(button, dropdown) { if (button.getAttribute('aria-expanded') === 'true') { button.setAttribute('aria-expanded', 'false') dropdown.setAttribute('hidden', '') } else { button.setAttribute('aria-expanded', 'true') dropdown.removeAttribute('hidden') } }
Если нужно, чтобы элемент навигации был одновременно и ссылкой на родительскую директорию, и содержал вложенную информацию, можно обернуть текст в <a>
, а рядом с ней расположить <button>
со стрелкой, которая будет открывать и закрывать список. В рецепте не рассматриваем этот паттерн, но его не так сложно реализовать самостоятельно.
Процесс вложения одного списка в другой может повторяться столько раз, сколько нужно. На практике довольно редко встречается больше трёх уровней.
Стили
СкопированоСтили для меню могут быть абсолютно разными. Чаще всего встречаются горизонтальные и вертикальные варианты расположения элементов навигации. Для создания одноуровневого горизонтального меню воспользуемся следующими стилями:
.menu { /* Сбрасываем браузерные стили */ list-style: none; padding: 0; margin: 0; /* Задаём горизонтальное направление */ display: flex; gap: 16px;}
.menu { /* Сбрасываем браузерные стили */ list-style: none; padding: 0; margin: 0; /* Задаём горизонтальное направление */ display: flex; gap: 16px; }
В примере разметка горизонтального многоуровневого меню базируется на CSS-позиционировании. Всем элементам списка <li>
задаётся относительное позиционирование, а вложенному меню <ul>
— абсолютное. Первый уровень вложенного меню оставляем без смещения, а для второго установим смещение влево на 100%, чтобы меню прилипало к правой границе первого меню.
/* Первый уровень вложенности */.menu .menu { display: flex; flex-direction: column; gap: 8px; position: absolute; top: 110%; left: 0;}/* Второй уровень вложенности */.menu .menu .menu { top: 0; left: 100%;}
/* Первый уровень вложенности */ .menu .menu { display: flex; flex-direction: column; gap: 8px; position: absolute; top: 110%; left: 0; } /* Второй уровень вложенности */ .menu .menu .menu { top: 0; left: 100%; }
Также при создании многоуровневых меню можно часто встретить вариант, когда элементы меню появляются при наведении на них курсора мыши, — по событию hover
. В таком случае базовая вёрстка останется аналогичной примеру, только нужно будет доработать стили появления — скрывать вложенное меню по умолчанию свойством display
и показывать при наведении мыши.
В мобильной версии меню выглядит как аккордеон. Часто мобильное меню прячут за иконкой с тремя линиями или точками (бургер) или чем-то подобным. При такой реализации помните о доступности и скрывайте меню полностью, чтобы пользователи не могли сделать на нём фокус с помощью клавиши Tab. Для этого можно использовать свойство display
или HTML-атрибут hidden
. Данные методы прячут меню из дерева доступности, но не дают анимировать открытие и закрытие меню.