Главная / Статьи / Замена библиотек подсказок на API Popover и позиционирование элементов с помощью CSS Anchor

Замена библиотек подсказок на API Popover и позиционирование элементов с помощью CSS Anchor

Зачем нужны подсказки: Popper и Floating UI — встроенные функции, решающие проблемы стекирования, позиционирования и закрытия подсказок, и когда использование JavaScript-библиотеки всё ещё оправдано.

2090 слов

Показ строки текста рядом с кнопкой кажется простым делом, однако существуют Popper.js, Floating UI и множество других пакетов-оберток именно потому, что раньше это было невозможно. Подсказка на самом деле представляет собой три отдельных проблемы, сложенные друг на друга, и до недавнего времени платформа не предлагала явного решения ни для одной из них. В этом руководстве проблемы разбираются по отдельности, показывается, сколько места занимает каждая из них в готовом JavaScript-коде, и они связываются с двумя функциями браузера, которые теперь решают типовые случаи: API Popover и CSS Anchor Positioning. К концу вы узнаете, какие части библиотеки подсказок можно удалить, а какие всё ещё могут понадобиться.

Три проблемы, скрытые внутри одной подсказки

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

  • Расположение элементов. Будет ли подсказка отображаться над всем остальным или её скроет свойство overflow: hidden у какого-либо родителя элемента?
  • Позиционирование. Знает ли подсказка, где на экране находится элемент-триггер, и следует ли она за этой позицией при прокрутке и изменении размеров?
  • Закрытие. Закрывается ли она автоматически, когда пользователь нажимает в другом месте или нажимает клавишу Escape?

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

Расположение элементов: почему свойство z-index не может выйти за пределы своего контекста

Каждый элемент принадлежит к контексту наложения, который определяет, что будет отрисовано поверх чего. Параметр z-index устанавливает порядок элементов внутри одного контекста наложения; он не может вывести элемент из контекста, к которому тот принадлежит.

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

Традиционным способом решения этой проблемы был портал: маркировка подсказки размещалась в другой части документа, обычно после тега <body>, чтобы она больше не наследовала свойства обрезки и стекинга от родителя. Именно по этой причине существует функция createPortal в React. Это скорее решение проблемы, с которой не мог справиться CSS, чем собственная функция React.

Позиционирование: абсолютное позиционирование учитывает только предков

Атрибут position: absolute размещает элемент относительно ближайшего к нему элемента с заданным положением, то есть ближайшего элемента в иерархии, у которого значение position равно relative, absolute, fixed или sticky. Ключевым моментом здесь являются предки: элемент-ориентир должен находиться в той же ветви DOM, выше подсказки.

Как только подсказка и элемент-триггер становятся братьями по структуре, или подсказка перемещается в элемент <body> для решения проблемы наложения элементов, триггер больше не является их предком. В CSS не существовало способа указать «разместить это относительно того не связанного элемента». Проблема заключалась не в самом механизме позиционирования в CSS, а в отсутствии способа определения позиций, игнорирующего структуру документа.

Библиотеки заполнили этот пробел с помощью измерений. Они используют метод getBoundingClientRect(), чтобы получить координаты элемента-триггера относительно области отображения, вычисляют координаты подсказки и повторяют эти расчеты при каждом прокручивании или изменении размера страницы, поскольку цифры постоянно меняются. Именно этот непрерывный цикл измерения и размещения составляет основную часть работы библиотек позиционирования во время выполнения программы.

Удаление подсказки: поведение, которое невозможно описать с помощью маркировки

До появления API Popover в HTML и CSS не существовало возможности «закрыть элемент при нажатии вне него или клавиши Escape». Всё реализовывалось с помощью скриптов: обработчик кликов на document, проверяющий, находится ли цель события вне подсказки, обработчик нажатий клавиш, ожидающий нажатия Escape, а также код для очистки обоих механизмов при удалянии компонента, чтобы избежать утечек ресурсов. В отличие от первых двух проблем, здесь нет вопросов геометрии — речь идёт исключительно о поведении, но это всё равно третий элемент кода во время выполнения, который браузер не предоставлял из коробки.

Подсказки против модалок

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

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

Спецификации прямо описывают это различие:

  • popover="auto" позволяет легко закрывать элемент без блокировки: он закрывается при нажатии снаружи или нажатии клавиши Escape, оставляя фокус свободным. В эту категорию входят подсказки и выпадающие меню.
  • popover="manual" остаётся открытым до тех пор, пока ваш скрипт его не закроет; легкого способа закрытия нет, что подходит для постоянных уведомлений в виде поп-апов.
  • <dialog>, открытый с помощью .showModal(), является блокирующей версией: он появляется на самом верху экрана, имеет фоновое изображение, удерживает фокус и делает всё, что находится позади него, неактивным.
  • Открытие того же <dialog> с помощью .show() даёт неблокирующий элемент, который ведёт себя почти как popover.

Что требует подход с использованием JavaScript

Указанные размеры после сжатия взяты с npm и относятся только к самим библиотекам:

popper.js (v1, now deprecated)              7.1 KB
Tippy.js (bundles @popperjs/core)          14.1 KB
Floating UI, vanilla (@floating-ui/dom)     8.1 KB
Floating UI, React bindings                30.1 KB
react-tooltip (@floating-ui/dom + clsx)    14.1 KB

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

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

API Popover обрабатывает размещение и закрытие

Библиотеку заменили два отдельных стандарта, которые не делят задачи так, как можно было бы ожидать. Атрибут popover сразу обрабатывает вопросы размещения и закрытия, практически без использования скриптов.

<button popovertarget="my-tooltip">Hover me</button>
<div id="my-tooltip" popover="auto">
  This is the tooltip content.
</div>

Атрибут popovertarget связывает кнопку с элементом, имеющим соответствующий id. При использовании popover="auto" элемент перемещается на верхний уровень браузера при открытии подсказки, что позволяет избежать эффекта overflow: hidden и проблем с контекстами стекирования, описанных ранее; кроме того, подсказка автоматически закрывается при нажатии снаружи или клавиши Escape без необходимости использования дополнительного кода-обработчика.

Одно исправление в формулировке описания маркировки: popovertarget включается при активации, то есть при клике, нажатии или нажатии клавиш, а не при наведении. Настоящий подсказочный инструмент при наведении всё равно требует небольшого количества кода для вызова функций showPopover() и hidePopover() при событиях указателя и фокуса, или более современного декларативного механизма, когда соответствующие браузеры его поддержат. Тем не менее для стекирования и закрытия больше не требуется библиотека. Остается проблема позиционирования, которая относится к другому спецификационному документу.

Позиционирование анкоров — связывает элементы по имени

CSS Anchor Positioning решает единственную проблему, которую API Popover игнорирует. Он позволяет любым двум элементам в документе ссылаться друг на друга по имени, а не через структуру родителя и дочернего элемента.

.trigger {
  anchor-name: --my-anchor;
}

.tooltip {
  position: absolute;
  position-anchor: --my-anchor;
  top: anchor(--my-anchor bottom);
  left: anchor(--my-anchor left);
}

anchor-name регистрирует триггер под косой маркером, который используется в синтаксисе пользовательских свойств. Параметр position-anchor в подсказке указывает на это имя, а функция anchor() определяет конкретную границу анкора (top, right, bottom, left или center), чтобы подсказка могла выровняться по ней.

Важно понимать, что эти элементы не обязаны находиться друг внутри друга. Браузер теперь автоматически выполняет то, что раньше требовалось ручно вычислять с помощью getBoundingClientRect() при каждом прокручивании. Если вы используете это в сочетании с подвижным окном сообщения, имейте в виду, что стили пользователя предоставляют элементам [popover] свойства inset: 0 и margin: auto для центрирования; если подсказка игнорирует ваши параметры анкора, обычно решением является сброс этих свойств.

Включение функции обработки выхода за границы без слушателя прокрутки

Часть библиотеки позиционирования, в которой находится большая часть логики, — это обработка выхода за границы: определение того, что подсказка вот-вот выйдет за пределы области отображения, и выбор другого места размещения в первую очередь. Функция Anchor positioning решает эту проблему с помощью параметра position-try-fallbacks.

.tooltip {
  position: absolute;
  position-anchor: --my-anchor;
  position-area: top center;
  position-try-fallbacks: flip-block, flip-inline;
}

Здесь position-area: top center устанавливает стандартное место размещения, а position-try-fallbacks перечисляет альтернативы, которые браузер пробует в определенном порядке, когда указанное место приводит к выходу подсказки за границы ее контейнера или области отображения. flip-block отражает подсказку по оси блока, так что верхняя часть становится нижней, а flip-inline отражает ее по оси строки, так что левая часть становится правой. Браузер переоценивает это во время формирования макета, при отсутствии обработчика прокрутки и скрипта на основном потоке, который бы обнаруживал выход за границы.

Когда простого зеркала недостаточно, правило @position-try позволяет определить именованные альтернативные местоположения — небольшие блоки объявлений о позиционировании, между которыми может переключаться браузер.

@position-try --below {
  position-area: bottom center;
  margin-top: 8px;
}

@position-try --above {
  position-area: top center;
  margin-bottom: 8px;
}

.tooltip {
  position-anchor: --my-anchor;
  position-try-fallbacks: --above, --below;
}

Это то же решение, которое принимает Floating UI в JavaScript при каждом событии прокрутки: оно заранее задаётся в виде данных, которые анализирует движок макета. Обратите внимание, что правило .tooltip в этом фрагменте предполагает, что элемент уже имеет абсолютное или фиксированное позиционирование, как в предыдущих примерах; позиционирование по якорю не влияет на элементы с статическим позиционированием.

Когда всё ещё оправдано использование библиотеки позиционирования

Для подсказок, простых выпадающих списков или списков автодополнения теперь разумным стандартом является использование встроенных средств. Роль библиотеки сократилась, хотя и не исчезла полностью.

Первым ограничением является поддержка браузерами. Браузеры Chromium поддерживают позиционирование элементов anchor с версии 125, причем @position-try достиг уровня Baseline позже, чем сам anchor(). Поддержка в Safari и Firefox появилась позже, и цифры, приводимые в интернете, различаются, поэтому лучше проверять актуальную таблицу совместимости, а не полагаться на какой-либо один список версий. В случае отсутствия поддержки не существует гладкого перехода к работе только на основе CSS: браузер, который не понимает anchor(), просто не может правильно расположить элемент. Если вам необходимо поддерживать старые версии Safari или мобильные браузеры с устаревшими движками, обязательно предусмотрите запасной вариант; наша инструкция по безопасной реализации современного CSS охватывает обнаружение функций и поэтапное улучшение работы элементов anchor.

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

В обычном случае, который характерен для большинства проектов, трудно оправдать использование от 8 до 30 КБ JavaScript для выполнения задач, которые браузер может сделать сам.

Основные выводы

  • Инструменты подсказок связаны с тремя проблемами: укладкой элементов, позиционированием и закрытием подсказок. Библиотеки появились потому, что все три проблемы приходилось решать с помощью скриптов.
  • API Popover решает проблему наложения элементов за счёт использования верхнего слоя, а закрытие осуществляется с помощью лёгкого сигнала; параметры popover="manual" и тег вместе с методом .showModal() покрывают случаи постоянного отображения и блокировки интерфейса.
  • CSS Anchor Positioning позволяет задавать положение элементов без наличия родительских элементов, а параметры position-try-fallbacks и @position-try заменяют старую логику обработки переполнения, которая доминировала в библиотеках ранее.
  • Необходимо учитывать поведение элементов при наведении курсора и поддержку браузерами; перед удалением библиотеки проверьте данные о совместимости для вашей аудитории.
  • Используйте Floating UI там, где требуется поддержка устаревших браузеров или позиционирование на основе данных, а в остальных случаях предпочтите стандартные решения платформы. Сами библиотеки не стали хуже; браузеры наконец взяли на себя задачу, которую более десяти лет выполнял JavaScript.