Вы не знаете как должны работать модальные окна

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


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


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


    Этот список сформирован на основе спецификаций 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>
    │
    └─  <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? Так вот это пример хорошей работы с фокусом: каждый раз, при переходе на новый экран в фокус ставится элемент, с которым вы скорее всего будете взаимодействовать — кнопка "Далее" или "Обзор".


    Подводя итог


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

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


    Средняя зарплата в IT

    110 450 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 7 043 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 19

      0
      Интерактивным элементом для открытия диалогового окна должна выступать кнопка.

      Во-первых, почему? Всё остальное в посте очень хорошо объяснено, а в конкретно этот момент почему-то просто предлагается поверить. А на мой субъективный взгляд, кнопка наоборот менее семантична чем ссылка.


      Во-вторых, а как делать кнопку, если я хочу, чтобы сайт был работоспособен без js? С ссылкой проще было бы. Кнопки закрытия это тоже касается.

        +6

        Ссылка предполагает переход куда-то. Думая про ссылку, вы можете захотеть открыть её в новой вкладке, например, сохранить или переслать. Я считаю так: если компонент ссылается куда-то (на часть этого документа, на другой и т.д) то он может быть ссылкой. Но если он просто выполняет какие-то действия в пределах текущего документа — тогда это кнопка

          0

          Всё именно так — ссылка действительно ссылается на модальное окно и при клике переходит на него, и я на своих сайтах делаю такие модальные окна, которые можно открыть в новой вкладке (тогда вместо модальных окон будут обычные страницы). У моих модальных окон есть отдельные адреса (при наличии JS делаю history.pushState соответственно), и их и в самом деле можно сохранить или переслать :) Так что всё ещё не понял, почему не нужно использовать ссылку

            0

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

              0

              Окей, тогда буду считать, что я опытный)
              За весь остальной пост спасибо, буду подтягивать доступность своих модальных окон

              +1
              В моей картине мира:

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

              Если модальное окно не является диалоговым, т.е. оно несёт новое содержание (форма там например, текст аферты и т.д.) — то по идее можно отдельный роут на него заводить и открывать ссылкой (если говорить про SPA). Т.е. вполне вероятен кейс, когда юзер захочет пошарить это содержимое с кем-то (отправить ему ссылку).
              0
              Ссылка позволяет обеспечить переход на страницу с альтернативным контентом — той же формой или иной информацией — на случай сломанного / не приехавшего JS.
            +2

            Очень хорошая мотивация, но вы упустили важное:


            существует тег <dialog>. Его вы и должны использовать

            Элемент <dialog> в нынешнем его состоянии использовать не рекомендуется. Почему — рассказывает Скотт Охара.


            А что использовать? Например, вот этот компонент Filament Group.


            Надеюсь, вы найдёте время поправить пост.

              0
              fg-modal, к сожалению, не блокирует перемотку фона
              0

              Слава богу не все ещё разрабатывают только под веб. Можно как-то помечать в заголовке, что статья касается только его? Я блин весь первый абзац прочитал прежде чем понял ( в мобильном интерфейсе теги на нее открытой статье не видно )

                0

                Видимо, разрабатывать что-то эффективное теперь моветон. Спасибо, учту на будущее

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

                  Как бороться с автоматически всплывающей клавиатурой на тач-устройствах?
                    +1
                    Если в диалоге предполагается ввод данных, то зачем «бороться» с клавиатурой?
                    Если не предполагается, то и не будет клавиатуры.
                      0
                      Просто на маленьких устройствах она занимает чуть ли не более 50% экрана. Минус статус-бары браузера и телефона, остаётся очень мало места, как раз для того одного инпута. И зачастую чтобы понять что от тебя хотят, нужно сначала убрать клавиатуру, окинуть взглядом всю форму, поскроллить туда-сюда при необходимости, и потом уже целенаправленно фокусироваться на нужном элементе.

                      Это я к тому, что на таких тач-устройствах всё-таки автоматический фокус на поле ввода — скорее ухудшит UX, особенно заметно на мамах\бабушках. Оно реально пугает их. Т.е. пока ты ещё не понял что от тебя хотят, а уже что-то вводить нужно.
                        0

                        Мне кажется это камень скорее в сторону дизайна.
                        Почему вашему пользователю нужно рассмотреть весь диалог, всю форму, чтобы понять что от него вообще хотят? Разве его предыдущие действия не привели его к этому окну? Если вы нажимаете на понятную кнопку "Подписаться" и попадаете в текстовое поле "Введите email" у вас не должно случаться конфуза. Другое дело, когда модалка выскакивает сама по себе, её внешний вид не расчитан на небольшие устройства, тогда да, всё что видит пользователь — одно текстовое поле. Но это не проблема юзабилити а следствие проблемы с дизайном, на мой взгляд.

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

                          Действительно, могут быть случаи, когда открытая клавиатура перекрывает слишком много, и не понятно, что от пользователя требуется.
                          вот тут уже должен UI/UX-разработчик хорошо думать.
                            0
                            Кстати, проверил (на Chrome/Android). Сейчас уже атрибут autofocus не вызывает автоматом клавиатуру пока не тапнешь по полю (хотя оно уже в фокусе). Хотя раньше это было. Видимо разработчики браузеров/OS и так к этому пришли..)
                        0
                        а результат стараний автора в деле где-то можно глянуть в живую или достаточно только буйного воображения? :)

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

                        Самое читаемое