Привет, Хабр! Меня зовут Александр Григоренко, я фронтенд-разработчик. В основном, занимаюсь разработкой приложений на React, но также постоянно экспериментирую с различными технологиями.
В своей работе я часто создаю собственные или использую уже готовые UI-компоненты. Проблема с такими компонентами заключается в том, что они часто ограничены определённым фреймворком, и их реализация требует написания сложной нестандартизированной логики. В течение долгого времени для базовых UI-компонентов, таких как диалоговые окна, использовались самописные решения, а в тяжёлых случаях и встроенные в JavaScript методы alert()
, prompt()
и confirm()
.
Отличная новость в том, что такой компонент можно реализовать с использованием нативного HTML-элемента <dialog>
, который встроен в стандарт HTML5 и работает одинаково во всех современных браузерах.
В статусе рабочего черновика W3C тег <dialog>
появился в мае 2013-го года вместе с такими интерактивными элементами, как <details>
и <summary>
, предназначенными для решения классических интерфейсных задач. С 2014-го года <dialog>
был доступен только в браузерах Google Chrome и Opera, а в Firefox и Safari полноценная поддержка появилась лишь в марте 2022-го года. По этой причине <dialog>
довольно редко использовался в реальных проектах. Однако с учётом почти двухлетней поддержки основными браузерами, стандарт стал достаточно устойчивым, чтобы с уверенностью заменить самописные <div class="modal" tabindex="-1" role="dialog" aria-modal="true">
на нативную реализацию.
Давайте познакомимся с возможностями <dialog>
поближе.
Основные особенности использования
HTML-тег <dialog>
создаёт скрытое по умолчанию диалоговое окно на странице, которое может функционировать в двух режимах: в качестве всплывающего поп-апа или в роли модального окна.
Всплывающие поп-апы обычно используются для показа ненавязчивых уведомлений, таких как сообщения об использовании на сайте файлов cookie, автоматически исчезающих toast-сообщений, тултипов и даже элементов, имитирующих контекстное меню, вызываемое нажатием правой клавиши мыши.
Модальные окна применяются, когда необходимо сосредоточить внимание пользователя на конкретной задаче. Это могут быть уведомления и предупреждения, требующие от пользователя подтверждения действий на странице, сложные интерактивные формы, и, например, лайтбоксы для просмотра изображений или видеороликов.
Всплывающий поп-ап не мешает взаимодействию со страницей, в отличие от модального окна, которое открывается поверх всего документа, затемняет фон вокруг себя и блокирует любые действия с остальным контентом. Эта логика работает без необходимости в дополнительных стилях и скриптах; единственное отличие заключается в том, какой метод вызывается для открытия диалога.
Методы для открытия диалогового окна
— всплывающий поп-ап:
<dialog id="pop-up">Привет, я поп-ап!</dialog>
const popUpElement = document.getElementById("pop-up");
popUpElement.show();
— модальное окно:
<dialog id="modal">Привет, а я — модалка!</dialog>
сonst modalElement = document.getElementById("modal");
modalElement.showModal();
В обоих случаях при открытии окна тегу <dialog>
проставляется булевый атрибут open
в значении true
. Значение атрибута можно установить в true
напрямую, однако в этом случае диалоговое окно откроется как поп-ап — работать с ним как с модалкой просто не получится. Поэтому для рендеринга модальных окон необходимо использовать только соответствующий метод. Для создания изначально открытого поп-апа можно обойтись и без JS:
<dialog open>Привет, я поп-ап!</dialog>
Попробовать в деле:
Открытие поп-апа с помощью метода
.show()
: https://codepen.io/alexgriss/pen/zYeMKJEОткрытие модалки с помощью метода
.showModal()
: https://codepen.io/alexgriss/pen/jOdQMeqИзменение атрибута
open
напрямую: https://codepen.io/alexgriss/pen/wvNQzRB
Способы закрытия диалогового окна
Закрываются диалоговые окна одинаково, независимо от того, каким образом они были открыты. Вот несколько способов закрыть всплывающее или модальное окно:
— через вызов метода .close()
:
сonst dialogElement = document.getElementById("dialog");
dialogElement.close();
— через инициацию события submit
в контексте формы с атрибутом method="dialog"
:
<dialog>
<h2>Закрой меня!</h2>
<form method="dialog">
<button>Закрыть</button>
</form>
</dialog>
— нажатием клавиши Esc:
Закрытие с помощью клавиши Esc работает только для модальных окон. При закрытии таким способом сначала запускается событие cancel
, и только потом close
— так, например, удобно предупреждать пользователя о том, что изменённые данные в форме внутри модалки не сохранятся.
Попробовать в деле:
Закрытие диалогового окна через метод
close
: https://codepen.io/alexgriss/pen/GRzwjaVЗакрытие диалогового окна через
submit
формы: https://codepen.io/alexgriss/pen/jOdQVNVЗакрытие модалки через нажатие Esc: https://codepen.io/alexgriss/pen/KKJrNKW
Предотвращение закрытия модалки по Esc через прослушивание события
cancel
: https://codepen.io/alexgriss/pen/mdvQObN
Возвращаемое значение при закрытии
При закрытии диалогового окна через форму с атрибутом method="dialog"
можно получить и обработать значение, указывающее на кнопку, которая была нажата перед закрытием. Это удобно, если после нажатия разных закрывающих кнопок требуется выполнить разные действия на странице. Для этого можно обратиться к свойству элемента диалогового окна returnValue
, которое будет содержать значение атрибута value
той кнопки, на которую нажал пользователь, чтобы закрыть окно.
Попробовать в деле: https://codepen.io/alexgriss/pen/ZEwmBKx
Подробнее про механику работы
Рассмотрим более подробно механику работы диалогового окна и детали браузерной реализации.
Механика работы всплывающего поп-апа
Если элемент <dialog>
был открыт как всплывающий поп-ап через метод .show()
или напрямую через указание атрибута open
, движок браузера автоматически разместит поп-ап в виде абсолютно спозиционированного блочного элемента в том месте, где он был указан в DOM. Для этого элемента будут применены базовые CSS-стили, включая отступы и границы, а первый фокусируемый элемент внутри окна получит фокус автоматически через глобальный атрибут autofocus
. При этом сохранится возможность взаимодействия с остальной частью страницы.
Механика работы модального окна
Модальное окно устроено и работает несколько сложнее, чем поп-ап.
Перекрытие документа
При открытии модального окна с использованием метода .showModal()
элемент <dialog>
рендерится в специальном слое HTML-документа. Этот слой охватывает всю ширину и высоту видимой области страницы, располагаясь поверх всего документа. Такой слой называется верхним слоем документа (top layer), и является внутренней концепцией браузера — напрямую управлять им невозможно. В определённых браузерах, например, в Google Chrome, каждое модальное окно рендерится в отдельном DOM-узле верхнего слоя, которые можно увидеть в инспекторе элементов:
Понятие слоёв относится к концепции контекста наложения (stacking context), описывающей, как элементы располагаются относительно друг друга вдоль оси Z по отношению к пользователю, находящемуся перед экраном. Например, при задании значения CSS-свойства z-index
для элемента, мы создаём замкнутый на этом элементе контекст наложения. Так позиция элемента будет рассчитываться относительно позиций его соседей, а все значения z-index
дочерних элементов будут учитываться только в рамках контекста наложения родителя. Такую иерархию контекстов наложения можно представить в виде слоистой структуры, а открытое модальное окно всегда будет находиться наверху этой иерархии, так как оно рендерится в верхнем слое, и для него не нужно устанавливать CSS-правило z-index
.
Подробнее про stacking context можно почитать тут: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context
Подробнее про то, какие элементы рендерятся в top layer — тут: https://developer.mozilla.org/en-US/docs/Glossary/Top_layer
Блокировка документа
Когда элемент модального окна рендерится в верхнем слое, под ним создаётся псевдо-элемент подложки ::backdrop
, которому устанавливаются размеры текущей видимой области документа. Эта подложка блокирует действия на остальной странице, даже если для неё установлено CSS-свойство pointer-events: none
.
Дополнительная блокировка пользовательских действий обеспечивается путём автоматической установки глобального атрибута inert
для всех элементов, за исключением модального окна. Атрибут inert
предотвращает срабатывание событий клика и фокусировки в пределах элементов, для которых он установлен, а также прячет их от экранных дикторов (скринридеров) и других вспомогательных технологий, обеспечивающих доступность (accessibility).
Подробнее про атрибут inert
: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert
Поведение фокуса
Первый фокусируемый элемент внутри модалки автоматически попадёт в фокус в момент её открытия. Для изменения элемента, который будет иметь изначальный фокус, можно воспользоваться атрибутами autofocus
или tabindex
. Установка tabindex
для элемента диалогового окна невозможна, поскольку он, в любом случае, является единственным элементом страницы, для которого не применяется логика атрибута inert
.
При закрытии диалогового окна фокус возвращается на тот элемент, который вызвал его открытие.
Решение проблем взаимодействия с модальными окнами
К сожалению, нативная реализация элемента <dialog>
не охватывает все аспекты взаимодействия с модальными окнами. Далее, я предлагаю рассмотреть решения основных UX-проблем, которые могут возникнуть при использовании модальных окон.
Блокировка скролла
Хотя в нативной реализации модального окна и создаётся псевдоэлемент ::backdrop
, который находится поверх страницы и блокирует взаимодействие с контентом — скролл страницы всё ещё доступен. Это может отвлекать пользователя, поэтому при открытии модального окна рекомендуется обрезать содержимое body
:
body {
overflow: hidden;
}
Такое css-правило придётся динамически добавлять и убирать каждый раз при открытии и закрытии модального окна. Этого можно достичь путём манипуляции классом, содержащим данное CSS-правило:
// При открытии модалки
document.body.classList.add("scroll-lock");
// При закрытии модалки
document.body.classList.remove("scroll-lock");
Также можно воспользоваться селектором :has
, если статус поддержки этого селектора соответствует требованиям проекта:
body:has(dialog[open]) {
overflow: hidden;
}
Попробовать в деле: https://codepen.io/alexgriss/pen/XWOyVKj
Закрытие диалога по клику на свободной области
Это стандартный UX-сценарий для модального окна и он может быть реализован несколькими способами. Предлагаю ознакомиться с двумя способами решения этой проблемы:
Способ, основанный на особенностях работы псевдоэлемента подложки ::backdrop
Клик по псевдоэлементу подложки рассматривается как клик по самому элементу диалога. Следовательно, если весь контент модального окна обернуть в дополнительный <div>
и затем перекрыть им сам элемент диалога, можно будет определить, куда был направлен клик — на подложку или на содержимое модального окна.
Не забудем сбросить стандартные браузерные стили отступов и границ у элемента <dialog>
, чтобы предотвратить закрытие модального окна при случайном клике по ним:
dialog {
padding: 0;
border: none;
}
Теперь стилизацию общих для окна границ и отступов мы применяем только к внутренней обёртке.
Осталось написать функцию, которая будет закрывать модальное окно только при клике на подложку, а не на внутренний элемент обёртки:
const handleModalClick = ({ currentTarget, target }) => {
const isClickedOnBackdrop = target === currentTarget;
if (isClickedOnBackdrop) {
currentTarget.close();
}
}
modalElement.addEventListener("click", handleModalClick);
Попробовать в деле: https://codepen.io/alexgriss/pen/mdvQXpJ
Способ, основанный на определении размеров диалогового окна
В отличие от первого способа, который требовал обёртывания внутреннего содержимого модального окна в дополнительный элемент, этот способ не требует использования дополнительной обёртки. Всё, что необходимо, — это проверить, выходят ли координаты курсора за пределы области элемента окна при клике:
const handleModalClick = (event) => {
const modalRect = modalElement.getBoundingClientRect();
if (
event.clientX < modalRect.left ||
event.clientX > modalRect.right ||
event.clientY < modalRect.top ||
event.clientY > modalRect.bottom
) {
modalElement.close();
}
};
modalElement.addEventListener("click", handleModalClick);
Попробовать в деле: https://codepen.io/alexgriss/pen/NWoePVP
Стилизация диалогового окна
В отличие от многих нативных HTML-элементов, элемент <dialog>
предоставляет значительную гибкость в плане стилизации. Вот несколько готовых рецептов для стилизации диалоговых окон:
Стилизация фона подложки через селектор ::backdrop
: https://codepen.io/alexgriss/pen/ExrOQEO
Анимированное открытие и закрытие окна: https://codepen.io/alexgriss/pen/QWYJQJO
Модальное окно в виде сайдбара: https://codepen.io/alexgriss/pen/GRzwxgr
Доступность
Хотя долгое время элемент <dialog>
имел некоторые проблемы с соответствием стандартам доступности (accessibility), на данный момент основные вспомогательные технологии, такие как экранные дикторы (VoiceOver, TalkBack, NVDA), хорошо работают с диалоговыми окнами.
При открытии элемента <dialog>
, фокус экранного диктора переводится на диалоговое окно, а в случае с модалкой — остаётся в её пределах до тех пор, пока она открыта.
Нативный элемент <dialog>
по умолчанию распознаётся вспомогательными технологиями как элемент с ARIA-атрибутом role="dialog"
. Элемент <dialog>
, открытый как модальное окно, будет восприниматься как элемент с ARIA-атрибутом aria-modal="true"
.
Вот несколько рекомендаций, как улучшить доступность элемента <dialog>
:
aria-labelledby
Всегда используйте заголовок внутри диалоговых окон и указывайте атрибут aria-labelledby
для элемента <dialog>
, со значением идентификатора заголовка:
<dialog aria-labelledby="dialog-header">
<h1 id="dialog-header">Dialog Header</h1>
</dialog>
В таком случае экранные дикторы будут зачитывать содержимое этого заголовка при открытии диалогового окна.
aria-describedby
Используйте атрибут aria-describedby
для связи с содержимым диалогового окна. Некоторые скринридеры не смогут прочитать содержимое элемента <dialog>
без этого атрибута. Заголовки и любые интерактивные элементы для управления состоянием диалогового окна должны быть вынесены отдельно за пределы элемента с содержимым:
<dialog aria-labelledby="dialog-header" aria-describedby="dialog-content">
<h1 id="dialog-header">Dialog Header</h1>
<div id="dialog-content">
Dialog Content
</div>
<button id="close-btn">Close dialog</button>
</dialog>
aria-label
Всегда добавляйте кнопку для закрытия диалоговых окон, особенно внутри модалок. Для лучшей доступности необходимо использовать именно элемент <button>
. Для кнопок, которые не содержат очевидный для пользователя текст, необходимо указать этот текст в ARIA-атрибуте aria-label
:
<dialog aria-labelledby="dialog-header">
<button id="close-btn" aria-label="Close dialog">x</button>
<h1 id="dialog-header">Dialog Header</h1>
</dialog>
Браузерная поддержка
Нативный элемент диалогового окна представляет собой удобный и мощный инструмент для решения стандартных интерфейсных задач. К сожалению, его поддержка в основных браузерах была добавлена сравнительно недавно, и в более экзотических или устаревших браузерах поддержки всё ещё может не быть. При отсутствии поддержки нативного элемента <dialog>
, можно воспользоваться полифилом, разработанным командой Google Chrome.
Скрипты и стили полифила можно подключить локально, использовать CDN или установить его как npm-зависимость: npm install dialog-polyfill
.
Если полифил подключён не через импорт npm-пакета, не забудьте отдельно подключить стили: https://github.com/GoogleChrome/dialog-polyfill/blob/master/dist/dialog-polyfill.css
Если требуется стилизовать псевдоэлемент подложки модального окна ::backdrop
, убедитесь, что вы также применяете стили к соответствующему элементу с классом .backdrop
для обеспечения совместимости с более старыми браузерами:
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
dialog + .backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
Подключать полифил рекомендуется через динамический импорт и только для тех клиентов, которые не поддерживают элемент <dialog>
:
const isBrowserNotSupportDialog = window.HTMLDialogElement === undefined;
if (isBrowserNotSupportDialog) {
const dialogElement = document.getElementById("modal");
const { default: polyfill } = await import("dialog-polyfill");
polyfill.registerDialog(dialogElement);
}
В заключение
Нативный HTML-элемент <dialog>
— это относительно простой и очень мощный инструмент для реализации модальных окон и поп-апов. Он отлично поддерживается современными браузерами и может успешно использоваться как в проектах на чистом JS, так и в контексте любого фронтенд-фреймворка.
В данной статье мы охватили следующие темы:
Проблемы, которые призван решить элемент
<dialog>
;Взаимодействие с API элемента
<dialog>
;Механика работы с диалоговыми окнами на уровне браузера;
Возможные проблемы при работе с модальными окнами и их решения;
Улучшение доступности элемента
<dialog>
для вспомогательных устройств, таких как скринридеры;Расширение браузерной поддержки элемента
<dialog>
.
Напоследок приглашаю рассмотреть реализацию компонента модального окна на чистом JS, в которой учтены основные аспекты, описанные в статье: https://codepen.io/alexgriss/pen/abXPOPP
Это всё, что я хотел бы рассказать про особенности работы с HTML-элементом <dialog>
. Надеюсь, что данная статья вдохновит вас на эксперименты, жду ваших вопросов в комментариях!
Приглашаю вас подписаться на мой телеграм-канал: https://t.me/alexgriss, в котором я пишу о фронтенд-разработке, публикую полезные материалы, делюсь своим профессиональным мнением и рассматриваю темы, важные для карьеры разработчика.
Комментарии (15)
ljt
05.12.2023 08:37+1Интересно, спасибо. Осталось еще разобраться мне, как добавить обработку нативной операции Бек по истории в Андроид, так чтобы закрывался попап вместо хождения по истории и перегрузки страницы.. Буду рад советам
Mapaxa864
05.12.2023 08:37Предположу, что можно связать попап с якорем или другим изменением урла, и тогда "хождение по истории" будет его закрывать (но это не точно).
wadowad
05.12.2023 08:37+3На событие открытия модального окна добавляете элемент в историю:
window.history.pushState(null, null, document.location); show = true;
Отслеживаете изменение истории (нажатие на кнопку "назад"):
addEventListener('popstate', function(e) { if (show) { dialogElement.close(); history.back(); show = false; } }
Также на событие закрытие модального окна убираете событие в истории, чтобы на кнопку "назад" не нужно было потом кликать два раза:
history.back(); show = false;
Как-то так %)
MaxPro33
05.12.2023 08:37Какие примеры конкретных сценариев или задач вы видите, где HTML-элемент может быть особенно полезен в сравнении с традиционными подходами, такими как использование самописных решений или встроенных методов alert(), prompt() и confirm()?
AlexGriss Автор
05.12.2023 08:37Это могут любые задачи, вплоть до использования
<dialog>
в UI-библиотеках для фреймворков на боевых проектах. Лично я в своих приложениях уже давно переписал все модалки на нативный элемент и радуюсь, что в стандарте учтены основные проблемы реализации, которые раньше нужно было предусматривать самому, или же пользоваться внешними решениями типа a11y-dialog, хотя мне может не требоваться вся их функциональность.
migratech
05.12.2023 08:37Спасибо. А Вам не кажется, что с полифилом получается сложновато, а JS реализации универсальны и не требуют ни переписывания, ни полифила.
AlexGriss Автор
05.12.2023 08:37Я считаю, что JS-реализации по умолчанию хуже полноценного нативного стандарта, потому что разработаны и поддерживаются не производителями конечных технологий — браузеров, а такими же разработчиками, как и мы с вами. Как следствие, эти реализации могут перестать поддерживаться в будущем. Кроме того, они могут использовать различные хаки и нестандартизированные приёмы для того, чтобы эмулировать поведение, которое работает в нативных реализациях. К примеру, в них может эмулироваться функциональность глобального атрибута
inert
, блокирующего действия над остальной страницей под модальным окном или же поведение фокуса, которое давно стандартизированно в нативной реализации. В конце концов, такие реализации могут быть слишком тяжеловесными в рамках конкретного проекта, да и в целом это лишняя внешняя зависимость. Впрочем, некоторые проблемы в нативной реализации до сих пор остаются, подробнее можно прочитать в документации к библиотекеa11y-dialog
: https://a11y-dialog.netlify.app/further-reading/dialog-element. Но на той же странице разработчики библиотеки пишут, что использование нативного элемента на настоящий момент вполне резонно.
Полифил же подключать необязательно — поддержка браузерами уже достаточно широкая, чтобы покрывать основную массу клиентов: caniuse.com пишет о 95.75% поддержки стандарта. Его стоит использовать, когда вам необходима поддержка совсем неактуальных браузеров типа Internet Explorer. Но если в вашем проекте требуется поддержка таких старых браузеров, скорее всего вы и так используете для них множество различных полифилов. В общем, моё мнение, что по умолчанию полифил для элемента<dialog>
подключать не нужно, так как стандарт уже вполне устоялся.AlexGriss Автор
05.12.2023 08:37А ещё нативные возможности работают быстрее, чем заменяющие их скрипты.
johnfound
05.12.2023 08:37+4Ну, сделать модальный диалог только с CSS и HTML возможно и не очень сложно. И я хочу, чтобы мои страницы работали (хоть и без удобств) без JS. Так что, пожалуй, останусь пока на старых методах.
alexnozer
05.12.2023 08:37+1Желание хорошее. Но что насчёт доступности, модальности (::backdrop и inert), autofocus, закрытия по Esc, returnValue, top-layer и других возможностях, которые не реализуемы в решениях на CSS?
Ради мизерной доли процента случаев, когда js по каким-то причинам недоступен, жертвовать удобством для всех остальных. Не лучше ли использовать подходы прогрессивного улучшения в сочетании с динамическим импортом для апгрейда с решений без js на решения с js?
johnfound
05.12.2023 08:37Так все эти функции можно сделать и на JS. Но они, если отсутствуют, не будут мешать основной функциональности. А вот, если использовать
<dialog>
, то показать его можно только через JS. Хотя конечно можно попробовать сделать и параллельную поддержку через CSS. Но надо пробовать как все это уживется вместе...alexnozer
05.12.2023 08:37Так все эти функции можно сделать и на JS.
Можно. Но
<dialog>
предлагает многое из коробки без JS. Всё-же чем меньше его будет, тем лучше (меньше данных загружать по сети, не занимать основной поток лишним парсингом и исполнением).А вот, если использовать
<dialog>
, то показать его можно только через JS.На данный момент - да. Однако есть предложение Invokers от OpenUI, которое позволит декларативно открывать, в том числе,
<dialog>
без JS. Chrome и Firefox уже взяли это в работу.Тем не менее, открыть/закрыть
<dialog>
- это пару строчек, а накрутить весь перечисленный функционал при помощи JS - сотни строчек.Хотя, как вы говорите, возможно есть решение и через CSS, чтобы оно базовое работало.
alexnozer
05.12.2023 08:37Не согласен по поводу добавления
aria-describedby
с ссылкой на блок с контентом. Ведь внутри диалога можно свободно перемещаться между элементами при помощи скринридера. Чтобы зачитать контент, достаточно перенести курсор скринридера к блоку с этим контентом.А факт попадания в диалог, о чем оповестит скринридер благодаря встроенной роли
dialog
иaria-modal
(для модальных диалогов), говорит пользователю о том, что он попал в некий изолированный блок, который требует его внимания. Поэтому пользователь изучит содержимое этого диалога, в том числе и контент.
gruzoveek
Прикольная штука, и не новая уже, а я вот только узнал. Спасибо!