Перевод статьи Popover API or Dialog API: Which to Choose?

После продолжительного изучения темы выяснилось, что Popover API и Dialog API кардинально отличаются с точки зрения доступности. Если вы стоите перед выбором, придерживайтесь такого правила:

  • Используйте Popover API для большинства поповеров.
  • Используйте Dialog API только для модальных диалогов.

Поповеры и диалоги

Взаимосвязь между поповерами и диалогами кажется запутанной, но на деле всё просто. Диалоги — это просто подмножество поповеров. А модальные диалоги — подмножество диалогов. Именно поэтому Popover API можно применять даже к элементу <dialog>:

<!-- Popover на элементе dialog -->
<dialog popover>...</dialog>

Визуальное различие между поповерами и модальными окнами тоже весьма наглядно:

  • Модальные окна должны отображать подложку (::backdrop).
  • Поповеры — нет.

Поэтому никогда не стилизуйте ::backdrop у поповера. Это визуально превратит поповер в диалог, что влечёт целый ворох проблем. Стилизуйте ::backdrop только у модального окна.

Popover API и доступность

Создать поповер с помощью Popover API несложно. Нужно указать три вещи:

  • атрибут popovertarget на триггере,
  • атрибут id на самом поповере,
  • атрибут popover на элементе поповера.

Значение popovertarget должно совпадать с id.

<button popovertarget="the-popover"> ... </button>
<dialog popover id="the-popover"> Содержимое поповера </dialog>

Обратите внимание: здесь используется элемент <dialog>, чтобы задать роль dialog. Это необязательно, но рекомендуется, поскольку большинство поповеров по сути и являются диалогами.

Эти две строки кода дают в нагрузку целый набор встроенных функций доступности:

  • Автоматическое управление фокусом — фокус перемещается на поповер при открытии и возвращается на триггер при закрытии.
  • Автоматическая ARIA-связь — браузер сам управляет атрибутами aria-expanded, aria-popup и aria-controls.
  • Автоматическое «лёгкое закрытие» — поповер закрывается при клике за его пределами или нажатии клавиши Esc.

Dialog API и доступность

В отличие от Popover API, Dialog API по умолчанию лишён многих встроенных функций:

  • Нет автоматического управления фокусом.
  • Нет автоматической ARIA-связи.
  • Нет автоматического «лёгкого закрытия».

Всё это придётся реализовывать вручную с помощью JavaScript. Именно поэтому Popover API превосходит Dialog API почти во всём — за одним исключением: когда речь идёт о модальных окнах.

Dialog API предоставляет метод showModal(). При его вызове браузер:

  1. автоматически делает остальные элементы инертными (inert),
  2. запрещает переключение фокуса на другие элементы по Tab,
  3. скрывает остальные элементы от программ экранного доступа.

Это работает настолько эффективно, что ловушка фокуса внутри модального окна больше не нужна.

Тем не менее управление фокусом и ARIA-атрибутами при использовании Dialog API придётся брать на себя. Вот минимальный HTML-каркас для функционирующего диалога:

<button 
  class="modal-invoker" 
  data-target="the-modal" 
  aria-haspopup="dialog"
>...</button>

<dialog id="the-modal">Содержимое модального окна</dialog>

Обратите внимание: атрибут aria-expanded намеренно не добавлен в HTML. Причины таковы: это снижает сложность разметки; атрибуты aria-expanded, aria-controls и управление фокусом всё равно требуют JavaScript — логично перенести их туда; такая разметка легче переиспользуется.

Инициализация

Сначала перебираем все элементы .modal-invoker и устанавливаем начальное состояние: aria-expanded="false" и aria-controls, указывающий на нужный диалог.

const modalInvokers = Array.from(document.querySelectorAll('.modal-invoker'))

modalInvokers.forEach(invoker => {
  const dialogId = invoker.dataset.target
  const dialog = document.querySelector(`#${dialogId}`)
  invoker.setAttribute('aria-expanded', false)
  invoker.setAttribute('aria-controls', dialogId)
})

Открытие модального окна

По клику на триггере меняем aria-expanded на true и вызываем showModal():

modalInvokers.forEach(invoker => {
  // ...

  invoker.addEventListener('click', event => {
    invoker.setAttribute('aria-expanded', true)
    dialog.showModal()
  })
})

Закрытие модального окна

По умолчанию showModal() не поддерживает «лёгкое закрытие», поэтому необходимо добавить кнопку закрытия внутри диалога:

<dialog id="the-modal">
  <button class="modal-closer">✕</button>
  <!-- Остальное содержимое -->
</dialog>

По клику на кнопке закрытия нужно: вернуть aria-expanded="false" на триггер, закрыть диалог методом close() и вернуть фокус на триггер:

const modalClosers = Array.from(document.querySelectorAll('.modal-closer'))

modalClosers.forEach(closer => {
  const dialog = closer.closest('dialog')
  const dialogId = dialog.id
  const invoker = document.querySelector(`[data-target="${dialogId}"]`)
  
  closer.addEventListener('click', event => {
    dialog.close()
    invoker.setAttribute('aria-expanded', false)
    invoker.focus()
  })
})

Можно ли создать модальное окно с помощью Popover API?

Да, можно. Но тогда придётся самостоятельно реализовать:

  1. инертность остальных элементов,
  2. ловушку фокуса.

Установка aria-expanded, aria-controls и управление фокусом, которые рассматривались выше, — значительно проще, чем ручная реализация инертности и ловушки фокуса.

Dialog API может стать намного удобнее в будущем

Существует предложение по командам-инициаторам (invoker commands), благодаря которому Dialog API сможет поддерживать декларативное управление аналогично Popover API. Пока стандарт не принят, придётся вручную реализовывать всё необходимое для доступности.

Погружение в детали: профессиональные поповеры и модальные окна

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

А пока — надеемся, что этот материал помог разобраться, когда выбирать Popover API, а когда — Dialog API. Помните: не нужно использовать оба. Чаще всего достаточно одного.


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники