Уверен, многие хоть раз создавали всплывающее модальное окно. Но задумывались ли вы об определении этого компонента? Как он должен работать?
В этом материале я постарался собрать максимально полный свод правил, рекомендаций и примеров реализации по которым модальные окна должны работать.
Я покажу, как просто создавать сложные, удобные, производительные и доступные модальные окна независимо от браузера, платформы, устройства или способа взаимодействия пользователя.
Этот список сформирован на основе спецификаций 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% его размера. Если ваш диалог будет большим:
- Его будет легче "нащупать". Дело в том, что пользователь может взаимодействовать со страницей следующим образом: он водит пальцем по дисплею, а программа чтения с экрана озвучивает то, что в данный момент находится под пальцем.
- Пользователю гарантированно не будут озвучиваться элементы "под ним". Иначе, например, VoiceOver на iPad может озвучивать отдельные фрагменты страницы под модальным окном даже "сквозь" оверлей блокирующий доступ указателю.
- Вы скроете прокрутку фона на некоторых устройствах при прокрутке контента в диалоговом окне.
- Удобнее для одной руки. Если окно растянуто на всю высоту – то у вас есть возможность прижать кнопки управления к нижней части дисплея. Туда намного проще дотянуться одной рукой пользователям современных смартфонов.
- Больше места для контента на устройствах с маленьким экраном, таких как 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>
из коробки).
Но:
- Не рассчитывайте, что пользовать всегда может нажать на оверлей и так закрыть диалог.
- Как я писал ранее, во многих случаях диалоговое окно может занимать всю или большую часть экрана. Таким образом попасть в него может быть сложно или невозможно.
- Такой оверлей семантически не считается интерактивным элементом. Он не может быть в фокусе и на него невозможно "нажать" клавишами.
- Не рассчитывайте, что у пользователя под рукой есть клавиатура, чтобы нажать Escape.
- Существует множество устройств, программ и различных инструментов, способных читать веб-сайты и давать пользователю взаимодействовать с ними, но не так как в браузере. Во многих случаях единственным рабочим вариантом остаётся кнопка внутри.
Простейшая реализация кнопки закрывающей родительский диалог:
<button onclick="this.closest('dialog').close()">
Закрыть
</button>
А если вы делаете кнопку с иконкой, то не забывайте про подпись, чтобы передать ёё назначение:
<button onclick="this.closest('dialog').close()" aria-label="Закрыть">
×
</button>
Поведение фокуса
При открытии диалога
Во время открытия диалогового окна фокус должен быть перемещён на элемент внутри него. На какой именно — зависит от содержания.
В общем случае фокус перемещается на первый интерактивный элемент. Именно так ведет себя нативный <dialog>
в браузере. Но нельзя делать сам элемент окна фокусируемым и перемещать фокус на него.
Например, для диалога с формой первый интерактивный элемент это первый <input>
. Если ваше диалоговое окно носит чисто информативный характер, например, уведомление об успешной подписке, тогда первым и единственным элементом будет кнопка закрывающая диалог.
Но есть и несколько исключений:
- Запрос подтверждения чего-либо. Если ваш диалог запрашивает у пользователя подтверждения перед выполнением каких-то необратимых действий (удаление чего-то или выполнение финансовых операций), тогда фокус автоматически должен ставится на кнопку "отмены" этих действий, независимо от её расположения.
- Ситуации, когда в диалоговом окне много статического контента и первый интерактивный элемент не помещается в видимую область. Проблема тут в том, что в таком случае браузер автоматически проскролит вниз к кнопке в фокусе. Это вынудит пользователя скролить обратно вверх, а потом снова вниз. Для таких случаев есть два подхода:
- Переместить или продублировать интерактивные элементы так, чтобы первый из них был в видимой части экрана. Например, выполнить кнопку закрыть в виде крестика и закрепить в верхней части диалогового окна.
- Заголовок или первый абзац текста нужно сделать фокусируемым при помощи
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>
и браузер полностью оставляет это на усмотрение разработчика.
Но и тут есть одно исключение: если элемент более не доступен, тогда фокус нужно вернуть туда, откуда наиболее логично для пользователя продолжить работу.
Пример
Предлагаю разобрать на примере. Представим систему из трех диалоговых окон:
- Сообщает пользователю об наличии подписки. В нем две кнопки: "Условия подписки" и "Подписаться"
- Отображается по клику на "Условия подписки". Открывается поверх первого.
- Отображается по клику на "Подписаться". Заменяет собой первое.
В примерах ниже я специально пропустил дополнительные атрибуты и элементы, для упрощения кода.
Итак, у нас есть стартовая кнопка.
<button>Рассылка</button> <!-- in focus -->
По нажатию на неё открывается первый диалог. Фокус автоматически перемещается на первый интерактивный элемент. А закрытие диалога должно возвращать фокус назад.
┌►<button>Рассылка</button>
│
└─ <dialog open>
<button>Подписаться</button> <!-- in focus -->
<button>Условия подписки</button>
</dialog>
Далее пользователь перемещает фокус на "Условия подписки" и нажимает. Открывается второй диалог поверх первого. Фокус перемещается в него, а возвращаться должен на эту же кнопку в первом диалоге:
┌►<button>Рассылка</button>
│
└─ <dialog open>
<h2>Рассылка</h2>
<button>Подписаться</button>
┌────► <button>Условия подписки</button>
│ </dialog>
│
└─ <dialog open>
<h2>Условия подписки</h2>
<button>Ок</button> <!-- in focus -->
</dialog>
После закрытия второго диалога ваш JavaScript должен вернуть фокус на кнопку "Условия подписки" в первом.
┌►<button>Рассылка</button>
│
└─ <dialog open>
<button>Подписаться</button>
<button>Условия подписки</button> <!-- in focus -->
</dialog>
После чего пользователь нажимает кнопку "Подписаться". По условиям нашей задачи открывается третий диалог. Фокус автоматически перемещается в него. А первый диалог закрывается:
┌►<button>Рассылка</button>
│
└─ <dialog>
<h2>Рассылка</h2>
┌────× <button>Подписаться</button>
│ <button>Условия подписки</button>
│ </dialog>
│
│ <dialog>
│ <h2>Условия подписки</h2>
│
│ <button>Ок</button>
│ </dialog>
│
└─ <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>
│
└─ <dialog open>
<h2>Введите email</h2>
<button>Подтвердить</button> <!-- in focus -->
</dialog>
Безусловно, в вашем конкретном случае может быть более логичное поведение для возвращения фокуса. Например, у вас диалог создания новой записи в таблице. В таком случае, может быть логичнее возвращать фокус на только что созданную запить.
Помните, как во время установки программы в Windows можно просто нажимать Enter? Так вот это пример хорошей работы с фокусом: каждый раз, при переходе на новый экран в фокус ставится элемент, с которым вы скорее всего будете взаимодействовать — кнопка "Далее" или "Обзор".
Подводя итог
- Используйте семантические теги
<button>
и<dialog>
. - Делайте ваши окна достаточно большими.
- В модальных окнах должен быть заголовок.
- Заголовок и содержимое должны быть соответствующим образом связаны с элементом модального окна.
- Убедитесь что в диалоге есть кнопка для закрытия окна.
- Перемещайте фокус внутрь при открытии, не выпускайте его из модального окна и возвращайте туда, где он был после закрытия.
Дополнительные ссылки
- Modal Dialog Design Pattern in WAI-ARIA Authoring Practices 1.2
- HTML Living Standard
- Polyfill for the HTML dialog element
- Filament group Modal
Автор: Kozack