Вложенные меню и подменю

В библиотеке Floating UI управление позиционированием всплывающих элементов строится на концепции reference element и floating element. Для вложенных меню и подменю это особенно важно, так как каждое подменю должно корректно позиционироваться относительно своего родительского элемента, учитывая границы экрана, перекрытия и смещения.

Reference element – это элемент DOM, к которому привязывается всплывающее меню. Для подменю reference elementом будет пункт родительского меню. Floating element – это само подменю, которое должно появляться рядом с reference element с учетом заданных стратегий позиционирования.


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

Для корректного отображения подменю используется функция computePosition, которая возвращает координаты, необходимые для абсолютного позиционирования элемента. Пример базового вызова:

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

const parentMenuItem = document.querySelector('#parent-item');
const submenu = document.querySelector('#submenu');

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

Ключевые моменты:

  • placement определяет начальное расположение подменю относительно родительского пункта (right-start, left-start, bottom-start и т.д.).
  • offset добавляет смещение, чтобы подменю не прилегало вплотную к родительскому элементу.
  • flip позволяет автоматически менять сторону отображения подменю, если пространство на экране ограничено.
  • shift корректирует позицию, чтобы подменю не выходило за границы окна.

Управление открытием и закрытием подменю

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

parentMenuItem.addEventListener('mouseenter', () => {
  submenu.style.display = 'block';
  computePosition(parentMenuItem, submenu, { 
    placement: 'right-start', 
    middleware: [offset(5), flip(), shift({ padding: 8 })]
  }).then(({ x, y }) => {
    Object.assign(submenu.style, { left: `${x}px`, top: `${y}px` });
  });
});

parentMenuItem.addEventListener('mouseleave', () => {
  submenu.style.display = 'none';
});

Особенности управления вложенными меню:

  • Важно учитывать, что при уходе курсора на подменю родительское меню должно оставаться открытым.
  • Для поддержки клавиатурной навигации необходимо отслеживать события focus и blur для каждого пункта меню и подменю.
  • Подменю должно закрываться автоматически, если пользователь покинул область меню или нажал Esc.

Использование нескольких уровней вложенности

Для меню с несколькими уровнями подменю стратегия позиционирования остается той же, но reference element теперь меняется на элемент родительского подменю:

const submenuLevel1 = document.querySelector('#submenu-level-1');
const submenuLevel2 = document.querySelector('#submenu-level-2');

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

Рекомендации при работе с несколькими уровнями:

  • Для каждого уровня меню создается отдельный computePosition, чтобы middleware корректно учитывал границы экрана.
  • Middleware flip и shift особенно важны на нижних уровнях, где подменю могут выходить за видимую область.
  • Обработка событий мыши должна быть иерархической: подменю не должно закрывать родительское меню при перемещении курсора между уровнями.

Middleware для подменю

Floating UI предоставляет гибкий набор middleware для управления поведением подменю:

  • offset – смещение подменю от родительского пункта.
  • flip – автоматическое изменение стороны отображения, если нет места.
  • shift – сдвиг подменю внутрь видимой области.
  • arrow – позиционирование стрелки подменю относительно родителя.
  • hide – скрытие подменю, если оно выходит за границы экрана.

Пример с использованием стрелки подменю:

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

const arrowEl = document.querySelector('#submenu-arrow');

computePosition(parentMenuItem, submenu, {
  placement: 'right-start',
  middleware: [offset(5), flip(), shift({ padding: 8 }), arrow({ element: arrowEl })],
}).then(({ x, y, middlewareData }) => {
  Object.assign(submenu.style, { left: `${x}px`, top: `${y}px` });
  const { x: arrowX, y: arrowY } = middlewareData.arrow;
  Object.assign(arrowEl.style, { left: `${arrowX}px`, top: `${arrowY}px` });
});

Middleware arrow позволяет динамически подстраивать положение визуальной стрелки, указывающей на родительский пункт меню.


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

Для сложных меню с несколькими уровнями важно минимизировать количество вычислений позиции:

  • Вызывать computePosition только при открытии подменю и изменении размеров окна.
  • Использовать requestAnimationFrame при динамическом отслеживании позиции подменю при наведении мыши.
  • Кэшировать вычисленные позиции для повторного использования, если подменю часто открываются на одном и том же пункте.

Обработка мобильных устройств и сенсорных экранов

Подменю на мобильных устройствах часто открываются по клику, а не по наведению:

parentMenuItem.addEventListener('click', () => {
  const isOpen = submenu.style.display === 'block';
  submenu.style.display = isOpen ? 'none' : 'block';
  if (!isOpen) {
    computePosition(parentMenuItem, submenu, { placement: 'bottom-start', middleware: [offset(5), flip(), shift({ padding: 8 })] })
      .then(({ x, y }) => {
        Object.assign(submenu.style, { left: `${x}px`, top: `${y}px` });
      });
  }
});

Особенности мобильного UX:

  • Подменю не появляются при наведении, поэтому клик или тач-gestures управляют открытием.
  • Размер подменю и расстояния должны быть увеличены для удобного сенсорного взаимодействия.
  • Middleware flip и shift предотвращают обрезание подменю на маленьких экранах.

Итоговые рекомендации по вложенным меню

  • Каждый уровень подменю должен иметь свой reference element и отдельный вызов computePosition.
  • Middleware offset, flip, shift и arrow позволяют динамически адаптировать подменю к размеру окна и положению родительского элемента.
  • Обработка событий мыши, клавиатуры и сенсорного ввода должна быть иерархической и согласованной.
  • Оптимизация вычислений позиции критична для меню с большим количеством уровней.

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