Чтобы закрыть меню по клику вне элемента на JavaScript, проверь, входит ли цель события в обёртку меню. Метод contains() учитывает и сам элемент, и его дочерние узлы.
Ниже — пример для выпадающего меню: нажатие снаружи, клавиша Escape и уход фокуса. Затем разберём вариант с HTML-атрибутом popover, где открытие и закрытие выполняет браузер.
1. Клик вне меню: contains() и pointerdown
Добавь HTML меню, затем подключи JavaScript после разметки или с атрибутом defer. Пример рассчитан на одну обёртку .dropdown: она включает и кнопку, и раскрывающуюся панель.
<div class="dropdown">
<button class="dropdown__toggle" type="button" aria-expanded="false" aria-controls="dropdown-menu">
Меню
</button>
<div class="dropdown__menu" id="dropdown-menu" hidden>
<a href="/blog/">Блог</a>
<a href="/contacts/">Контакты</a>
</div>
</div>
const dropdown = document.querySelector('.dropdown');
const toggle = dropdown.querySelector('.dropdown__toggle');
const menu = dropdown.querySelector('.dropdown__menu');
function setOpen(open) {
menu.hidden = !open;
toggle.setAttribute('aria-expanded', String(open));
}
toggle.addEventListener('click', () => {
setOpen(menu.hidden);
});
document.addEventListener('pointerdown', (event) => {
if (!menu.hidden && !dropdown.contains(event.target)) {
setOpen(false);
}
});
Почему так:
- Проверяется вся обёртка
.dropdown, а не только меню. Иначе нажатие на кнопку сначала закроет меню как «клик снаружи», а затем обработчик кнопки снова его откроет. contains()возвращаетtrueи для самого элемента, и для любого вложенного: иконки, текста, ссылки.pointerdownсрабатывает для мыши, пальца и пера сразу при нажатии. Сclickесть подвох: если пользователь начал нажатие внутри меню, например выделяя текст, и отпустил снаружи, событие придёт на общего родителя, и меню неожиданно закроется.- Атрибут
hiddenубирает меню и из отображения, и из порядка фокуса.
2. Закрытие по Escape и уходу фокуса
Для клавиатуры нужны отдельные обработчики: Escape закрывает меню, а Tab позволяет перейти к следующему элементу страницы. Добавь этот код после основного скрипта:
dropdown.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !menu.hidden) {
setOpen(false);
toggle.focus();
}
});
dropdown.addEventListener('focusout', (event) => {
if (event.relatedTarget && !dropdown.contains(event.relatedTarget)) {
setOpen(false);
}
});
После Escape фокус возвращается на кнопку открытия. В обработчике focusout свойство relatedTarget указывает элемент, который получает фокус. Если он находится вне обёртки, меню закрывается.
Значение null пропускаем: оно бывает, например, при нажатии на часть меню, которая не получает фокус. Нажатие снаружи уже обрабатывается через pointerdown.
Проверка клика вне элемента в Shadow DOM
При событиях из Shadow DOM свойство target может указывать на хост компонента. Для открытого Shadow DOM проверяй composedPath(). Следующий обработчик заменяет прежний pointerdown, а не добавляется к нему. Закрытое shadow-дерево скрывает внутренние узлы от внешнего обработчика.
document.addEventListener('pointerdown', (event) => {
if (!menu.hidden && !event.composedPath().includes(dropdown)) {
setOpen(false);
}
});
3. Без JavaScript: атрибут popover
Для меню, подсказок и небольших панелей в браузерах есть встроенный механизм. Элемент с атрибутом popover сам закрывается по клику снаружи и по Escape, показывается поверх остального контента, а кнопка получает связь с ним для скринридеров:
<button type="button" popovertarget="site-menu">Меню</button>
<div id="site-menu" popover>
<a href="/blog/">Блог</a>
<a href="/contacts/">Контакты</a>
</div>
Проверь поддержку Popover API в браузерах своей аудитории по таблице в документации MDN. Старым браузерам может понадобиться запасной вариант.
Открытый поповер находится в верхнем слое: z-index и overflow: hidden родителей его не ограничивают. По умолчанию он центрируется в окне; положение рядом с кнопкой нужно задать отдельно через CSS.
Обычный popover закрывается по нажатию снаружи и Escape. Уход фокуса по Tab сам по себе его не закрывает. Для режима без автоматического закрытия используй popover="manual" и отдельную кнопку управления.
Ошибки при закрытии меню по клику снаружи
event.stopPropagation()внутри меню, чтобы «клик не дошёл до документа». Это ломает другие обработчики на странице: аналитику, закрытие других меню.- Прозрачный слой на весь экран под меню. Он перехватывает прокрутку и требует отдельной работы с фокусом. Для модальных окон есть
<dialog>сshowModal(). - Обработчик на документе добавляется при каждом открытии и не удаляется. Достаточно одного постоянного обработчика с проверкой
menu.hidden, как в примере.
Если меню открывается по наведению, на сенсорных экранах учти особенности :hover — о них статья про :hover и :active на мобильных.