Перейти к содержимому
Поиск
Разработка

Floating UI: подсказки, меню и панели на практике

Разбираем Floating UI на живых интерфейсах: лаборатория положения и границ, рабочая панель заявок с меню, фильтрами, карточками участников и быстрым редактированием. Пояснения, доступность и готовые исходники.

Александр Некрасов
Floating UI — подсказки и меню: панели рядом с кнопками, стрелки и адаптация к краям экрана

Меню действий должно открываться рядом с заявкой, подсказка — рядом с непонятным показателем, а фильтры — оставаться доступными у края экрана. На широком мониторе это выглядит просто. Но стоит прокрутить таблицу, увеличить масштаб или открыть страницу на телефоне, и панель может уйти за границу, перекрыть кнопку или потерять связь с ней.

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

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

Какую задачу решает библиотека

У всплывающего элемента есть опора: кнопка, строка, выделенный текст или даже точка щелчка мыши. Floating UI измеряет опору и панель, выбирает координаты, затем возвращает результат. Применение CSS, открытие и закрытие, бизнес-действия остаются в коде приложения.

Для обычного JavaScript нужен пакет @floating-ui/dom. Он не создаёт готовый выпадающий список и не добавляет автоматически клавиатурную навигацию. В отдельном пакете для React есть средства для взаимодействий и управления фокусом; переносить их названия в vanilla-код нельзя.

Задача Floating UI Код приложения
Поместить меню рядом с кнопкой Измеряет элементы и возвращает x/y Применяет стили и показывает содержимое
Не упереться в границу Middleware меняют сторону, сдвиг и размеры Задаёт разумную ширину и прокрутку
Открыть, закрыть, выбрать действие DOM-пакет не определяет этот сценарий Обрабатывает кнопки, Escape, фокус и состояние
Сохранить заявку Не относится к позиционированию Проверяет данные и вызывает свой API

Библиотека полезна там, где простого top: 100% мало: внутри таблиц, около края окна, в редакторах и на адаптивных панелях. Если на странице одна статичная подсказка и достаточно возможностей CSS, зависимость может не понадобиться. Выбор начинается с поведения компонента, а не с количества библиотек.

Подробнее: пакеты Floating UI и разделение позиционирования и взаимодействий.

Лаборатория: посмотрите, как меняется положение

Перемещайте опорную кнопку, выбирайте сторону и выравнивание, меняйте отступ и ширину панели. Сравнивайте запрошенный placement с фактическим: при недостатке места они могут отличаться. Координаты рядом с результатом позволяют увидеть, что изменилось после включения каждого правила.

Начните у края. Нажмите «Верхний край»: желаемой позицией станет top-start, а flip перенесёт панель вниз. Переключите режим на «Без смены стороны», затем попробуйте autoPlacement. В первом автоматическом режиме библиотека старается сохранить желаемую сторону, во втором — ищет больше свободного места.

Теперь увеличьте ширину панели и включите ограничение размера. Сдвиг не способен поместить прямоугольник, который сам шире доступной области: нужно менять размер или компоновку. На телефоне повторите опыт с теми же настройками — результат зависит от реальной геометрии, поэтому единственного «правильного числа x» для всех экранов нет.

Меняйте по одному параметру, чтобы видеть его влияние. Большой отступ от кнопки и большой запас до границы одновременно оставляют панели меньше места. Стрелка помогает узнать опору, но не исправляет неудобный размер и не заменяет подпись. Сохранённую конфигурацию удобно использовать как отправную точку своего компонента.

Под сценой можно переключить исходник между JavaScript и JSON настроек, скопировать его или скачать автономный HTML. В этом HTML библиотеки и стили уже внутри; интернет для работы не нужен. Сброс возвращает настройки сохранённого снимка. При отключении защиты панель может обрезаться границей учебной сцены — так видна сама проблема, а страница не получает лишнюю горизонтальную прокрутку.

Рабочая панель: одно приложение, разные задачи

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

Маршрут Что попробовать Какой результат проверить
Подсказка SLA Откройте пояснение мышью, клавиатурой и нажатием Текст доступен без необходимости угадывать значение
Действия заявки Архивируйте SUP-104 и отмените действие Количество заявок: 4 → 3 → 4
Фильтры Выберите «В работе» и «Высокий» Останется SUP-106
Карточка сотрудника Откройте Анну и примените фильтр по ней Показаны две её заявки
Уведомления Отметьте все как прочитанные Число непрочитанных изменится с 2 на 0
Настройки вида Поменяйте плотность и акцентный цвет Изменится общий вид той же панели
Контекстное меню Щёлкните правой кнопкой или используйте «Контекст» Те же действия доступны без правой кнопки мыши
Быстрое редактирование Введите слишком короткую тему, затем исправьте Ошибка остаётся рядом с полем; корректная тема обновляется

Для отдельных упражнений удобнее возвращать исходное состояние: архивирование и фильтры влияют на то, какие строки видны. Экспорт JSON и самостоятельного HTML сохраняет текущий набор, включая изменённые названия и настройки. Файлы создаются в браузере; сохранённый HTML можно открыть отдельно и продолжить опыт с полученного состояния.

Все всплывающие инструменты решают разные задачи. Подсказка объясняет, меню выбирает команду, фильтр собирает условия, форма принимает данные. Их можно одинаково точно расположить, но нельзя назначить им одинаковые правила фокуса и семантику.

Подключение и первый работающий компонент

В примерах закреплены @floating-ui/dom 1.8.0 и @floating-ui/core 1.8.0, лицензия MIT. Для проекта со сборкой установите DOM-пакет и импортируйте необходимые функции. Bootstrap 5 можно использовать для оформления; React и серверная часть для расчёта координат не требуются.

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

Ниже полный минимальный HTML. Он открывает информационную панель по кнопке, учитывает прокрутку и закрывается по Escape, нажатию снаружи или уходу фокуса. Сохраните файл и откройте в браузере. Для загрузки библиотек с CDN нужен интернет; в скачиваемом проекте зависимости находятся локально.

<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Панель с Floating UI</title>
<style>
  body { font: 16px/1.5 system-ui; padding: 32px; }
  button { font: inherit; min-height: 44px; }
  #panel { position: fixed; top: 0; left: 0; box-sizing: border-box;
    width: 280px; max-width: calc(100vw - 24px); padding: 16px;
    background: #eef8f5; border: 1px solid #28776d; border-radius: 12px;
    max-height: calc(100dvh - 24px); overflow: auto; z-index: 10; }
</style>
<button id="trigger" aria-expanded="false" aria-controls="panel">
  Условия ответа
</button>
<section id="panel" hidden aria-labelledby="panel-title">
  <h2 id="panel-title">Когда ждать ответа</h2>
  <p>Обычно отвечаем в течение рабочего дня.</p>
  <button id="close">Закрыть</button>
</section>
<p id="status" role="status"></p>
<script src="https://cdn.jsdelivr.net/npm/@floating-ui/core@1.8.0/dist/floating-ui.core.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.8.0/dist/floating-ui.dom.umd.min.js"></script>
<script>
const { computePosition, autoUpdate, offset, flip, shift } = FloatingUIDOM;
const trigger = document.querySelector('#trigger');
const panel = document.querySelector('#panel');
const closeButton = document.querySelector('#close');
const status = document.querySelector('#status');
let stop = null, revision = 0, focusAfterPosition = false;
function closePanel(restoreFocus = false) {
  revision++;
  focusAfterPosition = false;
  stop?.(); stop = null;
  panel.hidden = true;
  trigger.setAttribute('aria-expanded', 'false');
  if (restoreFocus) trigger.focus();
}
async function positionPanel() {
  const ownRevision = ++revision;
  try {
    const result = await computePosition(trigger, panel, {
      strategy: 'fixed', placement: 'bottom-start',
      middleware: [offset(8), flip({padding: 12}), shift({padding: 12})]
    });
    if (ownRevision !== revision || panel.hidden) return;
    Object.assign(panel.style, {
      left: result.x + 'px', top: result.y + 'px', visibility: 'visible'
    });
    if (focusAfterPosition) {
      focusAfterPosition = false;
      closeButton.focus();
    }
  } catch {
    if (ownRevision !== revision) return;
    closePanel(true);
    status.textContent = 'Не удалось открыть панель. Попробуйте ещё раз.';
  }
}
function openPanel() {
  if (!panel.hidden) return;
  status.textContent = '';
  panel.hidden = false;
  panel.style.visibility = 'hidden';
  trigger.setAttribute('aria-expanded', 'true');
  focusAfterPosition = true;
  stop = autoUpdate(trigger, panel, positionPanel);
}
trigger.addEventListener('click', () => panel.hidden ? openPanel() : closePanel());
closeButton.addEventListener('click', () => closePanel(true));
document.addEventListener('keydown', event => {
  if (event.key === 'Escape' && !panel.hidden) {
    event.preventDefault(); closePanel(true);
  }
});
document.addEventListener('pointerdown', event => {
  if (!panel.contains(event.target) && !trigger.contains(event.target)) closePanel();
});
document.addEventListener('focusin', event => {
  if (!panel.contains(event.target) && !trigger.contains(event.target)) closePanel();
});
</script>
</html>

В UMD-варианте сначала подключается core, затем dom; функции доступны через FloatingUIDOM. Не используйте async на этих двух скриптах: он может поменять порядок выполнения. Это пример немодальной панели, поэтому остальная страница остаётся доступной и фокус не запирается внутри.

Подробнее: npm, ESM и UMD-подключение.

Опора, панель и фактический placement

computePosition(reference, floating, options) возвращает Promise с координатами. Функция не устанавливает left и top за вас. Опора и панель должны присутствовать в DOM и иметь измеряемые размеры. Сначала снимите hidden или display: none; от первоначального мигания защищает visibility: hidden.

// reference и floating — существующие DOM-элементы.
floating.hidden = false;
floating.style.visibility = 'hidden';
const result = await FloatingUIDOM.computePosition(reference, floating, {
  strategy: 'absolute',
  placement: 'bottom-end'
});
Object.assign(floating.style, {
  position: result.strategy,
  left: `${result.x}px`, top: `${result.y}px`, visibility: 'visible'
});
floating.dataset.placement = result.placement;

Есть четыре стороны и варианты start/end: например, bottom, bottom-start и bottom-end. Выравнивание логическое и учитывает направление текста. После flip используйте возвращённый result.placement, особенно для стрелки и анимации. Начальное значение описывает пожелание, а не обязательный итог.

При изменении текста размеры панели меняются. Не измеряйте короткую заглушку, а затем незаметно подставляйте длинное содержимое без обновления позиции. Сначала обновите DOM, затем пересчитайте положение или позвольте активному autoUpdate заметить изменение размера.

Подробнее: измерение, координаты и стратегии позиционирования.

Отступ, перенос и сдвиг: собираем правила

Middleware выполняются по порядку и корректируют расчёт. Обычно сначала задают расстояние до опоры, затем выбирают сторону, после — сдвигают панель в доступную область. У каждого шага своя работа: увеличение z-index не заменяет ни один из них.

const { offset, flip, shift, limitShift } = FloatingUIDOM;
const middleware = [
  offset(10),
  flip({ padding: 12, fallbackAxisSideDirection: 'end' }),
  shift({ padding: 12, limiter: limitShift() })
];

offset(10) оставляет зазор между опорой и панелью. flip предпочитает указанную сторону, но пробует другие при нехватке места. Для узкого экрана полезен переход на перпендикулярную ось: меню справа не всегда помещается и слева. shift двигает панель вдоль стороны, сохраняя её направление относительно опоры.

Включение crossAxis у shift может привести к перекрытию опоры. Это иногда намеренно, но для обычного меню чаще удобнее перенос. limitShift помогает не оторвать панель слишком далеко от элемента, к которому она относится.

Подробнее: расстояние через offset.

Подробнее: перенос и fallbackAxisSideDirection.

Подробнее: сдвиг и limitShift.

Если предпочтительной стороны нет, выберите autoPlacement. Например, пояснение к свободно расположенной метке может искать сторону с большим пространством. Это альтернатива flip: вместе в одном массиве они не используются.

const { offset, autoPlacement, shift } = FloatingUIDOM;
const middleware = [
  offset(8),
  autoPlacement({ alignment: 'start', padding: 12 }),
  shift({ padding: 12 })
];

Подробнее: автоматический выбор стороны.

Длинная панель: размер и внутренняя прокрутка

У списка уведомлений может оказаться двадцать элементов. Переставить его сверху вниз недостаточно: высота всё равно больше экрана. Middleware size сообщает доступные размеры; приложение применяет ограничения и включает прокрутку содержимого.

const { offset, flip, shift, size } = FloatingUIDOM;
const middleware = [
  offset(8), flip({padding: 12}), shift({padding: 12}),
  size({
    padding: 12,
    apply({availableWidth, availableHeight, elements}) {
      Object.assign(elements.floating.style, {
        maxWidth: `${Math.max(0, availableWidth)}px`,
        maxHeight: `${Math.max(0, availableHeight)}px`
      });
    }
  })
];
.floating-panel {
  box-sizing: border-box;
  width: 340px;
  overflow: auto;
  overflow-wrap: anywhere;
  overscroll-behavior: contain;
}
.floating-panel input, .floating-panel select { max-width: 100%; }

Значения доступной ширины и высоты могут быть отрицательными, поэтому перед записью в CSS они ограничены нулём. Но почти нулевая панель всё равно бесполезна человеку. Если места мало, предложите развёрнутый вид, отдельный экран или другой способ редактирования. Библиотека рассчитывает геометрию, а удобный сценарий выбирает разработчик.

Для списка, который должен совпадать по ширине с полем, можно использовать rects.reference.width внутри apply. Сохраняйте ограничение доступной ширины: фиксированное совпадение с очень широким полем не должно создавать горизонтальную прокрутку.

FloatingUIDOM.size({
  padding: 12,
  apply({rects, availableWidth, elements}) {
    elements.floating.style.width =
      `${Math.max(0, Math.min(rects.reference.width, availableWidth))}px`;
  }
});

Подробнее: ограничение размеров и согласование ширины с опорой.

Прокрутка, закрытие и защита от старого результата

Однократный расчёт подходит только для неподвижной сцены. На реальной странице пользователь прокручивает контейнер, меняет размер окна, открывает боковое меню. autoUpdate подписывается на нужные изменения и вызывает вашу функцию перерасчёта.

// Запускайте после появления измеряемой панели.
const cleanup = FloatingUIDOM.autoUpdate(reference, floating, updatePosition);
// При закрытии, удалении строки или уничтожении компонента:
cleanup();

Подписка нужна только открытой панели. Не создавайте наблюдатели для всех скрытых меню таблицы. Режим animationFrame: true полезен, когда опора непрерывно перемещается через CSS transform; обычным кнопкам постоянный пересчёт на каждом кадре не требуется.

Подробнее: подписки, параметры и очистка autoUpdate.

Есть и менее заметная ошибка: расчёт асинхронный. Пользователь успел закрыть меню или открыть другое, а старый Promise завершился позже. Номер ревизии позволяет не применять устаревшие координаты. Увеличивайте его и при закрытии, обрабатывайте исключения, а после удаления DOM останавливайте подписки.

let version = 0;
async function updatePosition() {
  const ownVersion = ++version;
  const result = await FloatingUIDOM.computePosition(reference, floating, options);
  if (ownVersion !== version || !floating.isConnected || floating.hidden) return;
  Object.assign(floating.style, {left: `${result.x}px`, top: `${result.y}px`});
}
function invalidatePendingPosition() { version++; }
// Вызывающий компонент также перехватывает ошибку updatePosition().

Этот приём защищает применение результата, а не отменяет сам расчёт. Если в size.apply есть изменение DOM, там тоже учитывайте жизненный цикл компонента. Для сетевых данных используйте отдельную отмену запроса; геометрический Promise не заменяет управление загрузкой.

Стрелка и исчезнувшая опора

Стрелка нужна, когда рядом несколько возможных источников подсказки. Middleware arrow возвращает координату внутри панели. Он не рисует треугольник и не определяет его цвет. Декоративный элемент скрывайте от ассистивных технологий через aria-hidden="true".

// В options: middleware: [..., FloatingUIDOM.arrow({element: arrowElement})]
function placeArrow(result, arrowElement) {
  const data = result.middlewareData.arrow;
  if (!data) return;
  const side = result.placement.split('-')[0];
  const opposite = {top: 'bottom', bottom: 'top', left: 'right', right: 'left'};
  Object.assign(arrowElement.style, {
    left: data.x != null ? `${data.x}px` : '',
    top: data.y != null ? `${data.y}px` : '',
    right: '', bottom: ''
  });
  arrowElement.style[opposite[side]] = '-5px';
}

Нулевая координата допустима: проверка if (data.x) ошибочно отбросит её. При изменении стороны очищайте старые top/right/bottom/left, иначе стрелка может остаться в прежнем углу. Сначала должны отработать правила положения панели, затем — вычисление стрелки.

Подробнее: координаты стрелки и порядок middleware.

Когда строка ушла из видимого участка прокручиваемого списка, меню может выглядеть прикреплённым к пустому месту. hide сообщает об обрезанной опоре, но ничего не закрывает сам. Для интерактивной панели лучше завершить открытый сценарий и корректно обработать фокус, чем сделать её невидимой с активным полем внутри.

const result = await FloatingUIDOM.computePosition(reference, floating, {
  middleware: [FloatingUIDOM.offset(8), FloatingUIDOM.hide()]
});
if (result.middlewareData.hide?.referenceHidden) {
  closePanel(); // функция приложения: cleanup, hidden, aria-expanded, фокус
}

Подробнее: признаки скрытой опоры; clipping не равен opacity.

Подсказка должна быть доступна без мыши

В панели поддержки SLA объясняет срок ответа. Это дополнительное пояснение, а не единственное место с важной информацией. Подсказка открывается не только при наведении: человеку с клавиатурой нужен фокус, а на телефоне — обычное нажатие или отдельная кнопка.

У неинтерактивной подсказки используется role="tooltip", а опора ссылается на неё через aria-describedby. Фокус остаётся на опоре. Если внутри нужны ссылка, поле или кнопка, это уже интерактивная панель со своим порядком переходов.

<button type="button" aria-describedby="sla-tip">Что означает SLA?</button>
<div id="sla-tip" role="tooltip" hidden>
  Плановый срок первого ответа на заявку в рабочее время.
</div>

Не закрывайте подсказку сразу, когда указатель уходит с кнопки в сторону самого текста. Иначе длинное пояснение невозможно спокойно прочитать. Обработайте Escape и не открывайте её тут же повторно, пока человек не покинул текущую область и не вернулся. Это логика взаимодействия вашего компонента, а не результат computePosition.

Подробнее: описание tooltip в WAI-ARIA APG.

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

Меню действий: фокус и результат важнее анимации

Меню заявки меняет приоритет и отправляет запись в архив. После команды пользователь должен увидеть результат в строке или списке. Для обратимого архивирования полезна заметная отмена: она позволяет исправить действие без ещё одного подтверждающего окна.

Если выбран паттерн role="menu", реализуйте его полностью: кнопка сообщает состояние, открытие переводит фокус на пункт, стрелки перемещают его, Home/End выбирают крайние пункты, Escape возвращает к опоре. Обычный список ссылок не нужно превращать в ARIA-меню только ради оформления.

// Фрагмент для уже открытого меню с кнопками role="menuitem".
menu.addEventListener('keydown', event => {
  const items = [...menu.querySelectorAll('[role="menuitem"]')]
    .filter(item => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
  if (!items.length) return;
  const current = items.indexOf(document.activeElement);
  let next;
  if (event.key === 'ArrowDown') next = (current + 1) % items.length;
  if (event.key === 'ArrowUp') next = (current - 1 + items.length) % items.length;
  if (event.key === 'Home') next = 0;
  if (event.key === 'End') next = items.length - 1;
  if (next !== undefined) { event.preventDefault(); items[next].focus(); }
  if (event.key === 'Escape') { event.preventDefault(); closeMenu({restoreFocus: true}); }
});

После удаления или архивирования прежняя кнопка может исчезнуть. Возвращайте фокус на доступный элемент: соседнюю заявку, заголовок списка или отмену. Попытка вызвать focus() на отсоединённой кнопке не делает интерфейс удобнее. Изменение числа заявок можно объявить коротким сообщением role="status".

Подробнее: паттерн кнопки меню и состояния ARIA.

В меню SUP-104 попробуйте архивирование, отмену и смену приоритета. В настоящем сервисе эти действия дополнительно требуют ответа API; локальная демонстрация показывает изменение интерфейса, а не сохранение на сервере.

Фильтры, карточка сотрудника и настройки

Панель с полями — не меню команд. В фильтре человеку нужно выбрать несколько условий, увидеть подписи и нажать «Применить». Используйте обычную форму, естественный Tab-порядок и явное закрытие. Не назначайте полям роль menuitem: она меняет смысл элементов для вспомогательных технологий.

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

function filterRequests(rows, filters) {
  return rows.filter(row =>
    (filters.status === 'all' || row.status === filters.status) &&
    (filters.priority === 'all' || row.priority === filters.priority) &&
    (filters.owner === 'all' || row.owner === filters.owner)
  );
}
// Это логика приложения. Floating UI отвечает за положение формы фильтра.

Уведомления удобно показывать в ограниченной по высоте панели: список прокручивается, кнопка закрытия остаётся понятной. Изменение «прочитано» должно сразу отражаться в счётчике. Настройки плотности и акцентного цвета, напротив, полезно применять к интерфейсу сразу, чтобы человек видел эффект выбора.

Клик снаружи не должен неожиданно уничтожать введённую форму. Для простых настроек допустимо мгновенное применение; для редактирования заявки лучше иметь явные «Сохранить» и «Отмена». Зафиксируйте это правило для каждого компонента, а не копируйте один обработчик закрытия во все случаи.

Сравните фильтр, карточку Анны, уведомления и настройки вида. Положение рассчитывается одинаковым инструментом, но полезный результат у каждой панели свой.

Контекстное меню и виртуальная опора

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

function pointReference(event, contextElement) {
  const x = event.clientX, y = event.clientY;
  return {
    contextElement,
    getBoundingClientRect: () => ({
      x, y, left: x, top: y, right: x, bottom: y, width: 0, height: 0
    })
  };
}
row.addEventListener('contextmenu', event => {
  event.preventDefault();
  openContextMenu(pointReference(event, row), row);
});
// openContextMenu — функция приложения: показ, computePosition и фокус.
// Для фиксированной точки закройте меню при прокрутке связанного списка.

Здесь нужны clientX/clientY, согласованные с координатами viewport, а не pageX/pageY. contextElement связывает виртуальную опору с реальным DOM-контекстом. Само запоминание координат не заставляет точку следовать за прокручиваемой строкой: приложение должно пересчитывать её или закрывать меню.

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

Подробнее: виртуальная опора и contextElement.

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

const selection = window.getSelection();
if (selection && selection.rangeCount && !selection.isCollapsed) {
  const range = selection.getRangeAt(0).cloneRange();
  const virtual = {
    contextElement: editor,
    getBoundingClientRect: () => range.getBoundingClientRect(),
    getClientRects: () => range.getClientRects()
  };
  const result = await FloatingUIDOM.computePosition(virtual, toolbar, {
    placement: 'top',
    middleware: [FloatingUIDOM.inline(), FloatingUIDOM.offset(8), FloatingUIDOM.flip()]
  });
  Object.assign(toolbar.style, {left: `${result.x}px`, top: `${result.y}px`});
}

Подробнее: многострочные опоры и выделение текста.

Быстрое редактирование без потери контекста

Небольшое поле темы удобно открыть у конкретной заявки. Человек видит, какую запись меняет, и остаётся в общем списке. Но такая форма должна уметь показать ошибку, сохранить введённый текст и завершить действие предсказуемо.

В рабочем примере тема принимается после обрезки пробелов по краям, длина — от 4 до 80 символов. Ошибка не закрывает панель. После успешного сохранения обновляется строка, а пользователь получает короткое подтверждение. Это локальное правило демонстрации; проверка на сервере нужна независимо от клиентской.

function validateSubject(raw) {
  const value = raw.trim();
  const length = value.length;
  if (length < 4 || length > 80) {
    return {ok: false, message: 'Введите тему от 4 до 80 символов'};
  }
  return {ok: true, value};
}
const result = validateSubject(subjectInput.value);
if (!result.ok) {
  errorNode.textContent = result.message;
  subjectInput.setAttribute('aria-invalid', 'true');
  subjectInput.focus();
} else {
  errorNode.textContent = '';
  subjectInput.removeAttribute('aria-invalid');
  titleNode.textContent = result.value;
}

Здесь длина измеряется обычным String.length, как в демонстрации. Для ограничений именно по видимым символам, включая составные эмодзи, нужен отдельный подсчёт графем. Пользовательский текст вставляется через textContent. Позиционирование не очищает HTML и не делает innerHTML безопасным. Для настоящего сохранения дождитесь успешного ответа API и только затем показывайте результат, либо реализуйте явный откат оптимистичного изменения.

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

Откройте быстрое редактирование и попробуйте тему из одного символа. Для серверной проверки форм пригодится материал о Symfony Validator.

overflow, z-index, portal и границы iframe

Если родитель обрезает содержимое через overflow: hidden, большой z-index не отменяет это обрезание. Если элемент находится внутри собственного stacking context, его число также не сравнивается напрямую со всеми слоями страницы. Сначала выясните, кто именно ограничивает панель.

Один из подходов — разместить слой рядом с корнем документа, вне обрезающего контейнера, а позицию считать относительно прежней кнопки. Такой перенос часто называют portal. При этом меняется DOM-порядок: необходимо отдельно продумать Tab-переходы, наследование CSS и закрытие при удалении опоры.

// Слой в конце body; опора остаётся в таблице.
document.body.append(floating);
Object.assign(floating.style, {
  position: 'fixed', top: '0px', left: '0px', zIndex: '1000'
});
const result = await FloatingUIDOM.computePosition(reference, floating, {
  strategy: 'fixed',
  middleware: [FloatingUIDOM.offset(8), FloatingUIDOM.flip(), FloatingUIDOM.shift()]
});
Object.assign(floating.style, {left: `${result.x}px`, top: `${result.y}px`});

fixed часто удобен для опоры в видимой области, но это не универсальная защита: некоторые свойства предков создают особый containing block. Стратегия JavaScript должна совпадать с CSS. Не меняйте только position, оставив прежние предположения расчёта.

Отдельное ограничение — iframe. Слой внутри встроенного документа не может рисоваться за пределами его рамки, даже с огромным z-index. У демонстраций есть ссылка «Открыть отдельно»; для приложения в iframe заранее согласуйте размеры области. Если родительская страница должна рисовать общий слой, это отдельная архитектура взаимодействия, а не одна настройка Floating UI.

Подробнее: ограничения слоёв и рекомендации по размещению.

Нативный Popover, CSS Anchor Positioning и Bootstrap

Современный браузер уже умеет показывать немодальный popover в верхнем слое документа. Атрибут popover и кнопка popovertarget дают открытие и стандартное закрытие без собственного обработчика для каждого клика. Это механизм показа; привязка к нужной стороне кнопки — отдельная задача.

<button popovertarget="native-panel">Условия обслуживания</button>
<div id="native-panel" popover>
  <p>Поддержка отвечает в рабочее время.</p>
  <button popovertarget="native-panel" popovertargetaction="hide">Закрыть</button>
</div>

Popover не делает окно модальным и не заменяет dialog.showModal(). CSS Anchor Positioning добавляет привязку к опоре и запасные положения средствами CSS. Для простого компонента этого может быть достаточно; Floating UI полезен, когда нужен управляемый расчёт в JavaScript, виртуальные опоры и единая конфигурация правил.

/* Фрагмент для браузера с поддержкой используемых anchor-свойств. */
.native-trigger { anchor-name: --support-trigger; }
.native-panel {
  position: fixed;
  position-anchor: --support-trigger;
  inset: auto;
  margin: 0;
  position-area: bottom span-right;
  position-try-fallbacks: flip-block, flip-inline;
}

Поддержку стоит проверять по конкретным возможностям. В актуальных данных MDN базовый popover доступен начиная с Chrome 114, Firefox 125 и Safari 17. У anchor positioning история сложнее: отдельные свойства появились в разных версиях и уточнялись. Проверка одного свойства не гарантирует работу всех вариантов переноса и анимации.

const nativeCapabilities = {
  popover: 'showPopover' in HTMLElement.prototype,
  anchor: CSS.supports('anchor-name', '--support-trigger') &&
          CSS.supports('position-anchor', '--support-trigger'),
  fallback: CSS.supports('position-try-fallbacks', 'flip-block')
};
console.table(nativeCapabilities);

Подробнее: Popover API и его поведение.

Подробнее: совместимость position-anchor.

В Bootstrap 5 выпадающие компоненты используют Popper. Если готовый dropdown уже решает задачу, заменять его ради самой замены не нужно. Для собственной панели можно взять CSS Bootstrap и Floating UI, но не подключать два конкурирующих обработчика к одной кнопке: data-bs-toggle="dropdown" и свой менеджер панели будут бороться за открытие, стили и фокус.

Подробнее: позиционирование dropdown в Bootstrap.

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

Что проверить на телефоне и с клавиатуры

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

.floating-panel { max-width: calc(100vw - 24px); }
.floating-panel button { min-height: 44px; }
.floating-panel input, .floating-panel select { font-size: 16px; }
.floating-panel :focus-visible { outline: 3px solid #287f87; outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
  .floating-panel { animation: none; transition: none; }
}

Клавишей Tab пройдите от кнопки к содержимому и дальше. Escape должен закрывать текущий слой, а фокус — попадать в осмысленное место. Проверьте переключение между двумя инструментами, закрытие при изменении списка и отмену действия. Нажатие снаружи не должно возвращать фокус назад, если человек уже выбрал другое поле.

Цвет и стрелка дополняют смысл, но не несут его в одиночку. У кнопок нужны названия, у ошибок — текст, у фильтра — видимое состояние. Координаты показывайте в лаборатории; пользователю службы поддержки они не помогают решить заявку. Служебная диагностика должна оставаться в инструменте разработчика.

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

Исходники и маршрут внедрения

Скачиваемый пакет содержит лабораторию, рабочую панель, локальные библиотеки и исходники. Начните с чтения README: он описывает запуск и экспорт. JSON хранит настройки и демонстрационное состояние; самостоятельный HTML позволяет перенести опыт в отдельный файл. Экспорт панели включает текущие названия и изменения заявок. Перед передачей файла другому человеку проверьте его содержимое так же, как любой сохранённый документ.

Скачать Floating UI: лабораторию, панель поддержки и исходники.

Распакуйте проект и выполните команду из его корневой папки. Затем откройте http://127.0.0.1:8080/ для панели или http://127.0.0.1:8080/lab.html для лаборатории. Здесь PHP используется только как локальный HTTP-сервер: Composer, база данных и сборка Node.js не нужны. HTTP-запуск позволяет экспортёру прочитать исходные файлы; уже выгруженный автономный HTML открывается напрямую без сервера.

php -S 127.0.0.1:8080 -t public

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

Для интерфейса с вложениями эта же панель может открывать пояснение к формату файла или короткое меню документа. Сам выбор и очередь загрузки отдельно разобраны в практикуме FilePond. Соединяйте инструменты по задаче: каждый отвечает за свою часть поведения.