Вы не знаете как должны работать модальные окна
- суббота, 3 октября 2020 г. в 00:29:04
Уверен, многие хоть раз создавали всплывающее модальное окно. Но задумывались ли вы об определении этого компонента? Как он должен работать?
В этом материале я постарался собрать максимально полный свод правил, рекомендаций и примеров реализации по которым модальные окна должны работать.
Я покажу, как просто создавать сложные, удобные, производительные и доступные модальные окна независимо от браузера, платформы, устройства или способа взаимодействия пользователя.
Этот список сформирован на основе спецификаций 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>
. Его вы и должны использовать. К огромному сожалению, его поддержка не самая лучшая:
Так что для этих браузеров нужно подгружать 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% его размера. Если ваш диалог будет большим:
У модального окна, как у любой обычной страницы, должен быть свой заголовок. Короткий, точно описывающий его предназначение. Наличие заголовка намного упрощает восприятие пользователем.
Настоятельно рекомендуется использовать для заголовка тег <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>
из коробки).
Но:
Простейшая реализация кнопки закрывающей родительский диалог:
<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>
.