[JavaScript, HTML, Usability, Accessibility] Вы не знаете как должны работать модальные окна

Автор Сообщение
news_bot ®

Стаж: 6 лет 8 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
02-Окт-2020 13:32

Уверен, многие хоть раз создавали всплывающее модальное окно. Но задумывались ли вы об определении этого компонента? Как он должен работать?
В этом материале я постарался собрать максимально полный свод правил, рекомендаций и примеров реализации по которым модальные окна должны работать.
Я покажу, как просто создавать сложные, удобные, производительные и доступные модальные окна независимо от браузера, платформы, устройства или способа взаимодействия пользователя.
Этот список сформирован на основе спецификаций 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>.
  • Делайте ваши окна достаточно большими.
  • В модальных окнах должен быть заголовок.
  • Заголовок и содержимое должны быть соответствующим образом связаны с элементом модального окна.
  • Убедитесь что в диалоге есть кнопка для закрытия окна.
  • Перемещайте фокус внутрь при открытии, не выпускайте его из модального окна и возвращайте туда, где он был после закрытия.

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

===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_html, #_usability, #_accessibility, #_html, #_javascript, #_dialog, #_modal_dialog, #_accessibility, #_javascript, #_html, #_usability, #_accessibility
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 01-Ноя 06:21
Часовой пояс: UTC + 5