Как сверстать выпадающее меню

Эта статья основана на одном из практических заданий verstaem.online.

Вы можете читать материал как обычное руководство или сначала попробовать выполнить задание самостоятельно, а затем сравнить свое решение с подробным разбором.

Перейти к заданию

В этом задании мы создадим выпадающее меню профиля пользователя. На практике разберем, как правильно располагать меню относительно кнопки, управлять его отображением с помощью JavaScript, закрывать меню при клике вне области и организовывать подобные интерфейсы с использованием семантической HTML-разметки.

Создаем HTML-разметку

Сначала создадим базовую HTML-разметку с помощью Emmet. Для этого достаточно ввести ! и нажать Tab. Обратите внимание на атрибут lang. Поскольку основной контент страницы написан на русском языке, здесь используем значение ru.

<!doctype html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Выпадающее меню</title>
  </head>
  <body>
  </body>
</html>

Основная часть страницы состоит из шапки и демонстрационного блока. Именно в шапке располагается кнопка профиля, по нажатию на которую будет открываться выпадающее меню. Сделаем лого ссылкой, меню через nav, а правую часть дивом, в котором будет кнопка и собственно наше меню.

<header class="header">
  <a href="#" class="header__logo">PULSE.</a>
  <nav class="header__nav">
    <ul class="header__list">
      <li class="header__item">
        <a href="#" class="header__link">Пространство</a>
      </li>
      <li class="header__item">
        <a href="#" class="header__link">Проекты</a>
      </li>
      <li class="header__item">
        <a href="#" class="header__link">Команды</a>
      </li>
    </ul>
  </nav>
  <div class="header__info">
    <button class="header__btn" data-dropdown-btn>
      <img src="img/avatar.png" alt="Антон" class="header__avatar">
      Антон
    </button>
    <div class="dropdown" data-dropdown>
      <div class="dropdown__top">
        <img src="img/avatar.png" alt="Антон Смирнов" class="dropdown__avatar">
        <div class="dropdown__text">
          <div class="dropdown__name">Антон Смирнов</div>
          <div class="dropdown__position">Креативный директор</div>
        </div>
      </div>
      <nav class="dropdown__nav">
        <ul class="dropdown__list">
          <li class="dropdown__item">
            <a href="#" class="dropdown__link" data-dropdown-close>Профиль</a>
          </li>
          <li class="dropdown__item">
            <a href="#" class="dropdown__link" data-dropdown-close>
              Уведомления
              <div class="dropdown__count">8</div>
            </a>
          </li>
          <li class="dropdown__item">
            <a href="#" class="dropdown__link" data-dropdown-close>
              Внешний вид
            </a>
          </li>
          <li class="dropdown__item">
            <a href="#" class="dropdown__link" data-dropdown-close>
              Оплата
            </a>
          </li>
        </ul>
      </nav>
      <button class="dropdown__logout" data-dropdown-close>Выйти</button>
    </div>
  </div>
</header>

Обратите внимание на элемент .header__info. Именно он станет контейнером сразу для двух элементов:

  • кнопки открытия меню;
  • самого выпадающего списка.

Такое расположение позволит без проблем позиционировать меню относительно кнопки. И кстати, у кнопки мы добавляем дата-атрибут data-dropdown-btn, чтобы можно было привязаться к нему через JS.

Само меню также имеет дата-атрибут, чтобы можно было обратиться к нему. В самом меню у нас и верхняя информация о пользователе, и меню навигации, и кнопка выхода из аккаунта. Этот элемент не входит в навигацию, т. к. не осуществляет никакой навигации, лишь выходить из аккаунта.

Также, все пункты меню имеют дата-атрибут data-dropdown-close, чтобы по клику на них можно было тоже закрыть меню.

Ниже идет основная часть страницы. Здесь будет секция с классом hero, который часто принято указывать у первых секций сайта. Внутри секцию мы поделим на две части — левую и правую. Справа будет набор элементов-преимуществ, а слева просто текст и кнопка.

<main>
  <section class="hero">
    <div class="hero__text">
      <h1 class="hero__title">
        Твори
        без
        лимитов
      </h1>
      <p class="hero__descr">
        Идеи заслуживают смелого воплощения. Сотрудничайте со своей командой, черпайте вдохновение и выполняйте творческую работу быстрее.
      </p>
      <a href="#" class="hero__link">Начать проект</a>
    </div>
    <div class="hero__items">
      <div class="hero__item">
        <div class="hero__digit">
          52
        </div>
        Активных задачи
      </div>
      <div class="hero__item">
        <div class="hero__digit">
          18
        </div>
        Членов команды онлайн
      </div>
      <div class="hero__item">
        <div class="hero__digit">
          92%
        </div>
        Еженедельных целей закрыто
      </div>
    </div>
  </section>
</main>

Добавляем стили

Подключим файл стилей через <link> и зададим несколько базовых правил. Для этого задания достаточно переопределить box-sizing, сбросить отступ у <body> и подключить шрифт Manrope, который используется в макете.

* {
  box-sizing: border-box;
}

@font-face {
  font-family: "Manrope";
  src: url("../fonts/ManropeExtraLight.woff2");
  font-display: swap;
  font-weight: 200;
}

@font-face {
  font-family: "Manrope";
  src: url("../fonts/ManropeSemiBold.woff2");
  font-display: swap;
  font-weight: 600;
}

@font-face {
  font-family: "Manrope";
  src: url("../fonts/ManropeBold.woff2");
  font-display: swap;
  font-weight: 700;
}

@font-face {
  font-family: "Manrope";
  src: url("../fonts/ManropeExtraBold.woff2");
  font-display: swap;
  font-weight: 800;
}

body {
  margin: 0;
  padding: 40px;
  background: #ffe95a;
  font-family: "Manrope", sans-serif;
}

Теперь стили контента. Это просто блок с обводкой, тенью и фоном, размещенный по центру.

.content {
  border: 5px solid #111;
  margin: 0 auto;
  max-width: 1180px;
  box-shadow: 14px 14px 0 0 #111;
  background: #fff;
}

Далее переходим к шапке. Она построена с помощью flexbox, как и навигация внутри. Все, чтобы сделать элементы в одну строчку.

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  border-bottom: 5px solid #111;
  padding: 24px 32px;
}

.header__logo {
  font-weight: 800;
  font-size: 30px;
  line-height: 120%;
  text-decoration: none;
  color: #111;
}

.header__list {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
  gap: 12px;
}

.header__link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 3px solid #111;
  padding: 12px 16px;
  font-weight: 700;
  font-size: 16px;
  line-height: 120%;
  text-decoration: none;
  color: #111;
  transition: background 0.3s, color 0.3s;
}

.header__link:hover {
  color: #fff;
  background: #111;
}

.header__info {
  position: relative;
}

.header__btn {
  display: flex;
  align-items: center;
  gap: 12px;
  border: 3px solid #111;
  padding: 8px 12px;
  font-family: inherit;
  font-weight: 700;
  font-size: 16px;
  line-height: 120%;
  color: #111;
  background: #00e88f;
  cursor: pointer;
  transition: background 0.3s;
}

.header__btn:hover {
  background: #03c379;
}

.header__avatar {
  display: block;
  border: 3px solid #111;
  border-radius: 100%;
  width: 54px;
  height: 54px;
  object-fit: cover;
}

В кнопке мы также используем flexbox, но важно не это. Важно то, что для header__info мы задаем position: relative, ведь именно относительно этого элемента и будет располагаться наше меню.

Но перед тем как его стилизовать, давайте быстренько стилизуем hero и его содержимое. Здесь по факту все берем из макета, я лишь скажу о расположении. Самой секции мы задаем display: flex, левой части flex: 1, чтобы тянулась на все доступное пространство, а правую часть сделаем с фиксированной шириной.

.hero {
  display: flex;
}

.hero__text {
  flex: 1;
  border-right: 5px solid #111;
  padding: 60px;
  background: #ff8458;
}

.hero__title {
  margin: 0;
  margin-bottom: 20px;
  max-width: 410px;
  font-weight: 700;
  font-size: 82px;
  line-height: 90%;
  text-transform: uppercase;
  color: #111;
}

.hero__descr {
  margin: 0;
  margin-bottom: 20px;
  max-width: 615px;
  font-weight: 600;
  font-size: 22px;
  line-height: 120%;
  color: #111;
}

.hero__link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 4px solid #111;
  padding: 16px 28px;
  font-weight: 800;
  font-size: 16px;
  line-height: 120%;
  text-decoration: none;
  color: #111;
  background: #fff;
  transition: background 0.3s, color 0.3s;
}

.hero__link:hover {
  color: #fff;
  background: #111;
}

.hero__items {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 28px;
  width: 340px;
  background: #fff;
}

.hero__item {
  border: 4px solid #111;
  padding: 20px;
  font-weight: 200;
  font-size: 16px;
  line-height: 120%;
  color: #111;
  background: #f5f5f5;
}

.hero__digit {
  margin-bottom: 4px;
  font-weight: 700;
  font-size: 32px;
  line-height: 120%;
}

Теперь стили для меню. Делаем его с помощью position: absolute, поднимаем повыше засчет высокого значения z-index. Само меню расположено справа, поэтому делаем right: 0, а вот отступ сверху сделаем умно — calc: (100% + 8px). Это позволит сделать меню всегда с одним и тем же отступом в 8 пикселей, даже если header__info изменит свою высоту.

.dropdown {
  position: absolute;
  right: 0;
  top: calc(100% + 8px);
  z-index: 10;
  border: 5px solid #111;
  width: 320px;
  box-shadow: 10px 10px 0 0 #111;
  background: #fff;
  transition: opacity 0.3s, visibility 0.3s;
  opacity: 0;
  visibility: hidden;
}

.dropdown--visible {
  opacity: 1;
  visibility: visible;
}

Здесь также важно добавить, что изначально меню мы скрываем с помощью opacity и visibility, и сразу добавляем класс для видимости меню. Внутренности меню делаются элементарно по макету, здесь я лишь добавлю, что чтобы сделать разные цвета для элементов меню, я решил использовать псевдоклассы: nth-child (). Это можно сделать и БЭМ-модификаторами, если вам так удобнее.

.dropdown__top {
  display: flex;
  align-items: center;
  gap: 12px;
  border-bottom: 5px solid #111;
  padding: 20px;
  background: #00e88f;
}

.dropdown__avatar {
  display: block;
  border: 3px solid #111;
  border-radius: 100%;
  width: 54px;
  height: 54px;
  object-fit: cover;
}

.dropdown__name {
  margin-bottom: 4px;
  font-weight: 700;
  font-size: 16px;
  line-height: 120%;
  color: #111;
}

.dropdown__position {
  font-weight: 200;
  font-size: 16px;
  line-height: 120%;
  color: #111;
}

.dropdown__list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
}

.dropdown__link {
  display: flex;
  align-items: center;
  border-bottom: 3px solid #111;
  padding: 20px;
  font-weight: 800;
  font-size: 16px;
  line-height: 120%;
  text-decoration: none;
  color: #111;
  transition: background 0.3s, color 0.3s;
}

.dropdown__count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin-left: auto;
  padding: 2px;
  min-width: 24px;
  height: 24px;
  font-weight: 800;
  font-size: 16px;
  line-height: 120%;
  color: #fff;
  background: #111;
  transition: background 0.3s, color 0.3s;
}

.dropdown__item:first-child .dropdown__link {
  background: #fff;
}

.dropdown__item:nth-child(2) .dropdown__link {
  background: #d9ff70;
}

.dropdown__item:nth-child(3) .dropdown__link {
  background: #9eebff;
}

.dropdown__item:last-child .dropdown__link {
  background: #ffd86b;
}

.dropdown__link:hover {
  color: #fff;
  background: #111 !important;
}

.dropdown__link:hover .dropdown__count {
  color: #111;
  background: #fff;
}

.dropdown__logout {
  display: flex;
  align-items: center;
  border: none;
  padding: 20px;
  width: 100%;
  font-family: inherit;
  font-weight: 800;
  font-size: 16px;
  line-height: 120%;
  color: #111;
  cursor: pointer;
  background: #d8bcff;
  transition: background 0.3s, color 0.3s;
}

.dropdown__logout:hover {
  color: #fff;
  background: #111;
}

Пишем JavaScript

Теперь переходим к логике. Сначала получаем кнопку и само меню.

const btn = document.querySelector('[data-dropdown-btn]');
const dropdown = document.querySelector('[data-dropdown]');

Перед выполнением кода проверяем, существуют ли элементы.

if (btn && dropdown) {

}

Это хороший тон. Если разметка изменится или элемент будет отсутствовать, JavaScript не завершится ошибкой.

Далее открываем меню. Здесь достаточно просто добавить класс, который мы уже указали в CSS.

btn.addEventListener('click', (e) => {
  e.stopPropagation();
  dropdown.classList.toggle('dropdown--visible');
});

Обратите внимание на e.stopPropagation();. Он запрещает событию всплывать выше. Без него меню открывалось бы и сразу закрывалось, поскольку обработчик документа тоже получает событие клика.

Теперь нужно реализовать закрытие меню. Здесь код будет выглядеть сложнее, но на деле все просто.

document.addEventListener('click', (e) => {
  if (e.target.closest('[data-dropdown-close]') || (!dropdown.contains(e.target) && e.target !== btn)) {
    dropdown.classList.remove('dropdown--visible');
  }
});

Мы вешаем обработчик клика на весь документ, но проверяем условием, а куда именно мы кликнули. Первое — это если мы кликнули на элемент с data-dropdown-close

e.target.closest('[data-dropdown-close]')

Второе — это еси мы кликнули куда-то вне окна.

!dropdown.contains(e.target)

Если выполняется какое-то из этих условий, мы просто удаляем класс у меню. В результате меню автоматически закрывается как после выбора любого пункта, так и после клика в любое место страницы.

Теперь вы умеете создавать выпадающие меню, которые открываются по нажатию на кнопку и автоматически закрываются при выборе пункта или клике вне области меню. Подобные компоненты используются практически во всех современных интерфейсах: личных кабинетах, административных панелях, корпоративных сервисах и веб-приложениях.

Решение задачи

предыдущий пост следующий пост