Контекстное меню с вложенными уровнями — это композиция нескольких плавающих элементов, связанных между собой и с якорными элементами (reference elements). В основе лежит позиционирование каждого уровня относительно родительского пункта меню, а также управление состоянием открытых веток.
Ключевые составляющие:
Библиотека 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 → referencesubmenu → 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`
});
});
}
Мгновенное открытие/закрытие подменю создаёт “дёрганое” поведение. Используются таймеры:
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()
]
Поведение:
Для меню с глубокой вложенностью важно:
Каждый уровень:
computePositionПример цепочки:
Menu → Submenu → Subsubmenu
Каждый следующий уровень позиционируется относительно предыдущего
li.
При изменении размеров окна или прокрутке позиция должна
пересчитываться. Для этого используется 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() при закрытии менюДля доступности необходимо поддерживать навигацию с клавиатуры:
Пример обработки:
item.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') {
openSubmenu(item, submenu);
}
if (e.key === 'Escape') {
submenu.style.display = 'none';
}
});
Чтобы избежать “разрывов” между меню и подменю, важно:
offset)Иногда применяют “треугольник безопасности” (safe polygon), но это требует дополнительной логики.
Каждое новое подменю должно быть выше предыдущего:
.menu { z-index: 1000; }
.submenu { z-index: 1100; }
.subsubmenu { z-index: 1200; }
Или динамически:
submenu.style.zIndex = 1000 + level;
При большом количестве вложенных меню важно:
Пример делегирования:
menu.addEventListener('mouseover', (e) => {
const item = e.target.closest('.menu-item');
if (!item) return;
const submenu = item.querySelector('.submenu');
if (submenu) {
openSubmenu(item, submenu);
}
});
Подменю “мигает”:
Уходит за экран:
flip или shiftНеправильное позиционирование:
Лаги при скролле:
autoUpdateFloating UI позволяет:
Пример виртуального 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/react)Это упрощает:
computePositionТакая схема обеспечивает предсказуемое и стабильное поведение даже для сложных и глубоко вложенных меню.