Контекстные меню с подменю

Контекстное меню с вложенными уровнями — это композиция нескольких плавающих элементов, связанных между собой и с якорными элементами (reference elements). В основе лежит позиционирование каждого уровня относительно родительского пункта меню, а также управление состоянием открытых веток.

Ключевые составляющие:

  • якорный элемент (элемент списка или кнопка)
  • плавающий контейнер меню
  • система координат и вычисления позиции
  • управление состоянием (какое подменю открыто)
  • обработка взаимодействий (hover, click, keyboard)

Библиотека Floating UI решает задачу позиционирования, предоставляя низкоуровневые функции вроде computePosition, а также готовые middleware для настройки поведения.


Базовое позиционирование меню

Для простого контекстного меню используется функция computePosition:

import { computePosition, offset, flip, shift } from '@floating-ui/dom';

computePosition(referenceEl, floatingEl, {
  placement: 'right-start',
  middleware: [
    offset(4),
    flip(),
    shift({ padding: 8 })
  ]
}).then(({ x, y }) => {
  Object.assign(floatingEl.style, {
    left: `${x}px`,
    top: `${y}px`
  });
});

Основные middleware:

  • offset — добавляет отступ от якоря
  • flip — переворачивает меню при нехватке места
  • shift — сдвигает в пределах viewport

Для подменю чаще всего используется placement: 'right-start', чтобы оно открывалось справа от родителя.


Организация вложенных меню

Каждое подменю — это отдельный floating-элемент, связанный с конкретным пунктом родительского меню.

Структура DOM:

<ul class="menu">
  <li class="menu-item">Файл</li>
  <li class="menu-item has-submenu">
    Правка
    <ul class="submenu">
      <li>Копировать</li>
      <li>Вставить</li>
    </ul>
  </li>
</ul>

Связь элементов:

  • menu-item → reference
  • submenu → floating

Каждое подменю позиционируется относительно своего родительского пункта.


Управление состоянием

Для корректной работы необходимо отслеживать:

  • активный пункт меню
  • открытые подменю
  • задержки открытия/закрытия

Пример простой логики:

let activeSubmenu = null;

function openSubmenu(item, submenu) {
  if (activeSubmenu && activeSubmenu !== submenu) {
    activeSubmenu.style.display = 'none';
  }

  submenu.style.display = 'block';
  activeSubmenu = submenu;

  computePosition(item, submenu, {
    placement: 'right-start',
    middleware: [offset(4), flip(), shift()]
  }).then(({ x, y }) => {
    Object.assign(submenu.style, {
      left: `${x}px`,
      top: `${y}px`
    });
  });
}

Hover и задержки

Мгновенное открытие/закрытие подменю создаёт “дёрганое” поведение. Используются таймеры:

let openTimeout;
let closeTimeout;

item.addEventListener('mouseenter', () => {
  clearTimeout(closeTimeout);
  openTimeout = setTimeout(() => {
    openSubmenu(item, submenu);
  }, 150);
});

item.addEventListener('mouseleave', () => {
  clearTimeout(openTimeout);
  closeTimeout = setTimeout(() => {
    submenu.style.display = 'none';
  }, 200);
});

Это создаёт плавный UX и предотвращает случайные закрытия.


Учет границ экрана

Подменю должно автоматически менять направление, если справа недостаточно места. Middleware flip решает эту задачу:

middleware: [
  offset(4),
  flip({
    fallbackPlacements: ['left-start']
  }),
  shift()
]

Поведение:

  • сначала пробует открыть справа
  • если не помещается — открывает слева

Работа с вложенностью нескольких уровней

Для меню с глубокой вложенностью важно:

  • изолировать состояние каждого уровня
  • корректно передавать reference для каждого подменю
  • учитывать z-index

Каждый уровень:

  • имеет свой computePosition
  • использует собственные middleware
  • хранит ссылку на родителя

Пример цепочки:

Menu → Submenu → Subsubmenu

Каждый следующий уровень позиционируется относительно предыдущего li.


Использование autoUpdate

При изменении размеров окна или прокрутке позиция должна пересчитываться. Для этого используется autoUpdate:

import { autoUpdate } from '@floating-ui/dom';

const cleanup = autoUpdate(referenceEl, floatingEl, () => {
  computePosition(referenceEl, floatingEl).then(({ x, y }) => {
    Object.assign(floatingEl.style, {
      left: `${x}px`,
      top: `${y}px`
    });
  });
});

Важно:

  • вызывать cleanup() при закрытии меню
  • иначе возможны утечки ресурсов

Управление фокусом и клавиатурой

Для доступности необходимо поддерживать навигацию с клавиатуры:

  • стрелки → перемещение по пунктам
  • Enter → открыть подменю
  • Escape → закрыть

Пример обработки:

item.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowRight') {
    openSubmenu(item, submenu);
  }

  if (e.key === 'Escape') {
    submenu.style.display = 'none';
  }
});

Взаимодействие с pointer-events

Чтобы избежать “разрывов” между меню и подменю, важно:

  • минимизировать расстояние (offset)
  • использовать непрерывные области hover

Иногда применяют “треугольник безопасности” (safe polygon), но это требует дополнительной логики.


Управление слоями (z-index)

Каждое новое подменю должно быть выше предыдущего:

.menu { z-index: 1000; }
.submenu { z-index: 1100; }
.subsubmenu { z-index: 1200; }

Или динамически:

submenu.style.zIndex = 1000 + level;

Оптимизация производительности

При большом количестве вложенных меню важно:

  • не создавать лишние слушатели
  • удалять autoUpdate при закрытии
  • использовать делегирование событий

Пример делегирования:

menu.addEventListener('mouseover', (e) => {
  const item = e.target.closest('.menu-item');
  if (!item) return;

  const submenu = item.querySelector('.submenu');
  if (submenu) {
    openSubmenu(item, submenu);
  }
});

Частые проблемы

Подменю “мигает”:

  • слишком маленькие задержки
  • неправильная область hover

Уходит за экран:

  • отсутствует flip или shift

Неправильное позиционирование:

  • reference элемент не тот
  • CSS влияет на layout (transform, overflow)

Лаги при скролле:

  • отсутствует autoUpdate

Расширенные возможности

Floating UI позволяет:

  • использовать кастомные middleware
  • учитывать scroll-контейнеры
  • работать с виртуальными элементами (например, координаты курсора)

Пример виртуального reference:

const virtualEl = {
  getBoundingClientRect() {
    return {
      x: mouseX,
      y: mouseY,
      width: 0,
      height: 0,
      top: mouseY,
      left: mouseX,
      right: mouseX,
      bottom: mouseY
    };
  }
};

Используется для контекстного меню по правому клику.


Композиция с фреймворками

В React/Vue/Svelte обычно:

  • состояние хранится в компонентах
  • Floating UI используется через адаптеры (@floating-ui/react)

Это упрощает:

  • управление вложенностью
  • синхронизацию состояния
  • повторное использование логики

Практическая схема работы

  1. Пользователь наводит курсор на пункт
  2. Срабатывает таймер открытия
  3. Вызывается computePosition
  4. Подменю появляется рядом
  5. При уходе курсора запускается таймер закрытия
  6. При переходе в подменю таймер отменяется
  7. При закрытии очищаются обработчики и autoUpdate

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