Уверен, многие хоть раз создавали всплывающее модальное окно. Но задумывались ли вы об определении этого компонента? Как он должен работать?


В этом материале я постарался собрать максимально полный свод правил, рекомендаций и примеров реализации по которым модальные окна должны работать.


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


Этот список сформирован на основе спецификаций WAI-ARIA, HTML Living Standard и моего личного опыта. И хотя я буду говорить про веб, большинство правил и рекомендаций применимы для модальных окон где угодно.


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


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


Теги и атрибуты


Интерактивным элементом для открытия диалогового окна должна выступать кнопка. Не <div>, не <span> не <a>, не любой другой тег. Исключительно <button>. И касается не только диалоговых окон, <button> — самый надежный и доступный способ создавать интерактивные элементы на странице.


Простейшая реализация кнопки открывающая диалог по его id:


<button data-modal="dialogId" onclick="document.getElementById(this.dataset.modal).showModal()">
    Открыть
</button>

<dialog>


Для различных диалогов, уведомлений и прочих перекрывающих документ элементов существует тег <dialog>. Его вы и должны использовать. К огромному сожалению, его поддержка не самая лучшая:


  • Chromium — полная поддержка.
  • Firefox — поддержка за флагом.
  • Safari не поддерживает вовсе.

Так что для этих браузеров нужно подгружать polyfill:


if (!document.createElement('dialog').showModal) {
  // Браузер нативно не поддерживает элемент dialog

  import('/dist/dialog-polyfill.js') // Подгружаем polyfill
    .then(dialogPolyfill =>
      document.querySelectorAll('dialog')
        .forEach(dialogPolyfill.registerDialog) // Применяем его для всех элементов на странице
    )
}

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


<section role="dialog" aria-modal="true">
...
</section>

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


Внешний вид и содержание


Вскользь коснусь внешнего вида.


На небольших экранах диалоговое окно должно занимать 100% его размера. Если ваш диалог будет большим:


  1. Его будет легче "нащупать". Дело в том, что пользователь может взаимодействовать со страницей следующим образом: он водит пальцем по дисплею, а программа чтения с экрана озвучивает то, что в данный момент находится под пальцем.
  2. Пользователю гарантированно не будут озвучиваться элементы "под ним". Иначе, например, VoiceOver на iPad может озвучивать отдельные фрагменты страницы под модальным окном даже "сквозь" оверлей блокирующий доступ указателю.
  3. Вы скроете прокрутку фона на некоторых устройствах при прокрутке контента в диалоговом окне.
  4. Удобнее для одной руки. Если окно растянуто на всю высоту – то у вас есть возможность прижать кнопки управления к нижней части дисплея. Туда намного проще дотянуться одной рукой пользователям современных смартфонов.
  5. Больше места для контента на устройствах с маленьким экраном, таких как iPhone SE.

Заголовок обязателен


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


Настоятельно рекомендуется использовать для заголовка тег <h1>-<h6>.


Но просто добавить заголовок в диалоговое окно недостаточно. Их нужно ещё и логически "связать". Сделать это можно с помощью атрибута aria-labelledby следующим образом:


<dialog aria-labeledby="subscribe-header">
  <h2 id="subscribe-header">Предложение подписки</h2>
</dialog>

Теперь, при попадании пользователя в диалоговое окно, в случае с экранным диктором, будет зачитан не только факт наличия диалога, но и его заголовок.


Статический контент должен быть связан с окном


Если в вашем диалоговом окне есть какое-то не интерактивное содержание, например, абзац текста, его стоит связать с диалогом подобно заголовку. Иначе, в некоторых случаях программы чтения с экрана не будут озвучивать такой контент.


Делается это атрибутом aria-describedby:


<dialog aria-labeledby="subscribe-header" aria-describedby="subscribe-content">
    <h2 id="subscribe-header">Предложение подписки</h2>
    <p id="subscribe-content">
        Вы можете подписаться на нашу еженедельную рассылку.
        В ней представлены только лучшие публикации.
    </p>
</dialog>

Если в вашем диалоговом окне много контента, тогда стоит обернуть его в один <div> и связать элемент диалога уже с ним:


<dialog aria-labeledby="subscribe-header" aria-describedby="subscribe-content">
    <h2 id="subscribe-header">Условия подписки</h2>

    <div id="subscribe-content">
        <p>Ниже представлены условия нашей подписки.</p>
        <p>...</p>
        <ul>...</ul>
        <p>...</p>
        ...Много контента
    </div>
</dialog>

Важно! Заголовок и любые кнопки не относящиеся к содержимому, а служащие для управления диалоговым окном, не должны быть включены в элемент на который указывает aria-describedby. Они должны быть вынесены отдельно:


<dialog aria-labeledby="subscribe-header" aria-describedby="subscribe-content">
    <h2 id="subscribe-header">Условия подписки</h2>

    <div id="subscribe-content">
        <p>Ниже представлены условия нашей подписки.</p>
        <p>...</p>
        <ul>...</ul>
        <p>...</p>
        ...Много контента
    </div>

    <div>
        <button>Принять</button>
        <button>Отказаться и закрыть</button>
    </div>
</dialog>

Интерактивные элементы связывать не нужно


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


<dialog aria-labeledby="subscribe-header">
    <h2 id="subscribe-header">Данные для подписки</h2>

    <form>
        <label>
            Введите ваш email
            <input type="email">
        </label>
    </form>

    <div>
        <button>Подписаться</button>
        <button>Отказаться и закрыть</button>
    </div>
</dialog>

Элементы формы являются интерактивными. И они будут озвучены скринридером, когда пользователь начнёт с ними взаимодействовать.


Если скомбинировать и статический текст и форму:


<dialog aria-labeledby="subscribe-header" aria-describedby="subscribe-content">
    <h2 id="subscribe-header">Подпишитесь на рассылку</h2>

    <p id="subscribe-content">
        Вы можете подписаться на нашу еженедельную рассылку.
    </p>

    <form>
        <label>
            Введите ваш email
            <input type="email">
        </label>
    </form>

    <div>
        <button>Подписаться</button>
        <button>Отказаться и закрыть</button>
    </div>
</dialog>

Способы закрыть окно


Внутри диалогового окна обязана быть кнопка чтобы его закрыть. Не <div>, не <span> не <a>, не любой другой тег. Исключительно <button>. Это самый надежный способ гарантировать, что любой пользователь сможет закрыть диалоговое окно. Вы же не любите модальные окна которые невозможно закрыть?


Дополнительно, в зависимости от вашей логики, вы можете позволить пользователю закрыть диалог кликнув за его пределами или нажав Escape (встроено в <dialog> из коробки).


Но:


  1. Не рассчитывайте, что пользовать всегда может нажать на оверлей и так закрыть диалог.
    1. Как я писал ранее, во многих случаях диалоговое окно может занимать всю или большую часть экрана. Таким образом попасть в него может быть сложно или невозможно.
    2. Такой оверлей семантически не считается интерактивным элементом. Он не может быть в фокусе и на него невозможно "нажать" клавишами.
  2. Не рассчитывайте, что у пользователя под рукой есть клавиатура, чтобы нажать Escape.
  3. Существует множество устройств, программ и различных инструментов, способных читать веб-сайты и давать пользователю взаимодействовать с ними, но не так как в браузере. Во многих случаях единственным рабочим вариантом остаётся кнопка внутри.

Простейшая реализация кнопки закрывающей родительский диалог:


<button onclick="this.closest('dialog').close()">
Закрыть
</button>

А если вы делаете кнопку с иконкой, то не забывайте про подпись, чтобы передать ёё назначение:


<button onclick="this.closest('dialog').close()" aria-label="Закрыть">
 ?
</button>

Поведение фокуса


При открытии диалога


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


В общем случае фокус перемещается на первый интерактивный элемент. Именно так ведет себя нативный <dialog> в браузере. Но нельзя делать сам элемент окна фокусируемым и перемещать фокус на него.


Например, для диалога с формой первый интерактивный элемент это первый <input>. Если ваше диалоговое окно носит чисто информативный характер, например, уведомление об успешной подписке, тогда первым и единственным элементом будет кнопка закрывающая диалог.


Но есть и несколько исключений:


  1. Запрос подтверждения чего-либо. Если ваш диалог запрашивает у пользователя подтверждения перед выполнением каких-то необратимых действий (удаление чего-то или выполнение финансовых операций), тогда фокус автоматически должен ставится на кнопку "отмены" этих действий, независимо от её расположения.
  2. Ситуации, когда в диалоговом окне много статического контента и первый интерактивный элемент не помещается в видимую область. Проблема тут в том, что в таком случае браузер автоматически проскролит вниз к кнопке в фокусе. Это вынудит пользователя скролить обратно вверх, а потом снова вниз. Для таких случаев есть два подхода:
    1. Переместить или продублировать интерактивные элементы так, чтобы первый из них был в видимой части экрана. Например, выполнить кнопку закрыть в виде крестика и закрепить в верхней части диалогового окна.
    2. Заголовок или первый абзац текста нужно сделать фокусируемым при помощи tabindex="-1" и перемещать фокус на него. Но при этом подходе некоторые программы чтения с экрана могут озвучивать заданный текст дважды: сначала как заголовок и описание окна, а потом как содержание выделенного элемента.

Управлять куда именно попадёт фокус при открытии модального окна можно с помощью атрибута autofocus:


<dialog aria-labeledby="subscribe-header">
    <h2 id="subscribe-header">Необратимые действия</h2>

    <form>
        <label>
            Введите пароль для подтверждения
            <input type="password">
        </label>
    </form>

    <div>
        <button>Подтверждаю</button>
        <button autofocus>Отказаться и закрыть</button> <!-- Будет выбрана эта кнопка -->
    </div>
</dialog>

Внутри диалога


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


Чтобы блокировать указатель обычно документ накрывается полупрозрачным блоком.


Но этого недостаточно, так как остаётся ещё и навигация клавишами Tab / Shift + Tab. Также это могут быть клавиши громкости на смартфонах или специальные клавиши на дополнительных инструментах подключенных по USB/Bluetooth. Этот способ навигации тоже должен быть заблокирован.


После попадания фокуса в модальное окно пользователь может перебирать интерактивные элементы внутри этого окна, но не должен выходить за его пределы. Другими словами, такое диалоговое окно работает как ловушка для фокуса. Это поведение встроено в <dialog>, так что от вас никаких действий не требуется. А вот используя другой элемент с role="dialog" его нужно реализовывать самостоятельно средствами JavaScript.


При закрытии диалога


При закрытии диалогового окна фокус должен быть перемещён туда, где он был в момент открытия. Это поведение не является частью <dialog> и браузер полностью оставляет это на усмотрение разработчика.


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


Пример


Предлагаю разобрать на примере. Представим систему из трех диалоговых окон:


  1. Сообщает пользователю об наличии подписки. В нем две кнопки: "Условия подписки" и "Подписаться"
  2. Отображается по клику на "Условия подписки". Открывается поверх первого.
  3. Отображается по клику на "Подписаться". Заменяет собой первое.

В примерах ниже я специально пропустил дополнительные атрибуты и элементы, для упрощения кода.


Итак, у нас есть стартовая кнопка.


<button>Рассылка</button> <!-- in focus -->

По нажатию на неё открывается первый диалог. Фокус автоматически перемещается на первый интерактивный элемент. А закрытие диалога должно возвращать фокус назад.


-><button>Рассылка</button>
¦
L-  <dialog open>
        <button>Подписаться</button> <!-- in focus -->
        <button>Условия подписки</button>
    </dialog>

Далее пользователь перемещает фокус на "Условия подписки" и нажимает. Открывается второй диалог поверх первого. Фокус перемещается в него, а возвращаться должен на эту же кнопку в первом диалоге:


-><button>Рассылка</button>
¦
L-  <dialog open>
        <h2>Рассылка</h2>

        <button>Подписаться</button>
----->  <button>Условия подписки</button>
¦   </dialog>
¦
L-  <dialog open>
        <h2>Условия подписки</h2>

        <button>Ок</button> <!-- in focus -->
    </dialog>

После закрытия второго диалога ваш JavaScript должен вернуть фокус на кнопку "Условия подписки" в первом.


-><button>Рассылка</button>
¦
L-  <dialog open>
        <button>Подписаться</button> 
        <button>Условия подписки</button> <!-- in focus -->
    </dialog>

После чего пользователь нажимает кнопку "Подписаться". По условиям нашей задачи открывается третий диалог. Фокус автоматически перемещается в него. А первый диалог закрывается:


-><button>Рассылка</button>
¦
L-  <dialog>
        <h2>Рассылка</h2>

-----?  <button>Подписаться</button>
¦       <button>Условия подписки</button>
¦   </dialog>
¦
¦   <dialog>
¦       <h2>Условия подписки</h2>
¦
¦       <button>Ок</button>
¦   </dialog>
¦
L-  <dialog open>
        <h2>Введите email</h2>

        <button>Подтвердить</button> <!-- in focus -->
    </dialog>

И вот проблема: третье окно должно вернуть фокус на кнопку в первом, но первое окно больше не доступно. В таких случаях фокус нужно вернуть туда, куда указывал закрытый диалог — на кнопку "Рассылка" с которой пользовать начал.


-><button>Рассылка</button>
¦
¦   <dialog>
¦       <h2>Рассылка</h2>
¦
¦       <button>Подписаться</button>
¦       <button>Условия подписки</button>
¦   </dialog>
¦
¦   <dialog>
¦       <h2>Условия подписки</h2>
¦
¦       <button>Ок</button>
¦   </dialog>
¦
L-  <dialog open>
        <h2>Введите email</h2>

        <button>Подтвердить</button> <!-- in focus -->
    </dialog>

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


Помните, как во время установки программы в Windows можно просто нажимать Enter? Так вот это пример хорошей работы с фокусом: каждый раз, при переходе на новый экран в фокус ставится элемент, с которым вы скорее всего будете взаимодействовать — кнопка "Далее" или "Обзор".


Подводя итог


  1. Используйте семантические теги <button> и <dialog>.
  2. Делайте ваши окна достаточно большими.
  3. В модальных окнах должен быть заголовок.
  4. Заголовок и содержимое должны быть соответствующим образом связаны с элементом модального окна.
  5. Убедитесь что в диалоге есть кнопка для закрытия окна.
  6. Перемещайте фокус внутрь при открытии, не выпускайте его из модального окна и возвращайте туда, где он был после закрытия.

Дополнительные ссылки