Привет, друзья!
В этой небольшой статье я расскажу вам о новом свойстве события toggle — source, а также о новом атрибуте HTML-элемента dialog — closedby.
Свойство source позволяет определять источник переключения видимости поповера (popover), а атрибут closedby позволяет декларативно управлять логикой закрытия dialog, но обо всем по порядку.
❯ ToggleEvent.source
Доступное только для чтения свойство source интерфейса ToggleEvent - это экземпляр объекта Element, представляющий собой элемент управления поповером (popover control element), инициировавший переключение (toggle) видимости поповера.
На сегодняшний день это свойство поддерживается всеми основными браузерами (в Safari пока только в качестве экспериментальной возможности):

Элементом управления поповером может быть:
<button>с атрибутом commandfor или popovertarget<input type="button">с атрибутомpopovertarget
Поповерами в данном контексте являются следующие элементы:
dialogлюбой элемент с атрибутом popover
Если видимость поповера переключается программно, например, с помощью метода showPopover, source будет иметь значение null.
Возьмем пример из заметки про Invoker Commands API с dialog, отображаемым при нажатии кнопки с атрибутами commandfor и command, и немного расширим его:
<div> <button commandfor="my-dialog" command="show-modal"> Show modal dialog </button> <dialog id="my-dialog"> <h3>Do you like modern Web APIs?</h3> <div style="display: flex; gap: 10px"> <button commandfor="my-dialog" command="close" data-answer="yes"> Yes </button> <button commandfor="my-dialog" command="close" data-answer="sure"> Sure </button> </div> </dialog> <p>No answer yet</p> </div>
В dialog у нас имеется две кнопки для его закрытия. Наша задача — вывести значение атрибута data-answer в <p>. Сделать это проще простого:
// Ссылка на параграф const $p = document.querySelector("p"); // Обрабатываем переключение видимости `dialog` document.querySelector("dialog").addEventListener("toggle", (e) => { // Ничего не делаем, если видимость переключается программно if (!(e.source instanceof HTMLButtonElement)) return; const { answer } = e.source.dataset; // Нас интересуют только кнопки закрытия if (answer) { $p.textContent = `Answer: ${answer}`; } });
Мелочь, а приятно, согласитесь?
❯ HTMLDialogElement.closedBy
Атрибут closedby элемента dialog (свойство closedBy интерфейса HTMLDialogElement) определяет, какие действия пользователя приводят к закрытию dialog.
На сегодняшний день этот атрибут поддерживается всеми основными браузерами (в Safari пока только в качестве экспериментальной возможности):

Существует три метода закрытия dialog:
Клик за его пределами, по затенению (overlay) при наличии (light dismiss — легкая отмена; аналогично
popover="auto").Действие пользователя, специфичное для платформы, например, нажатие клавиши
Escна десктопе.Механизм, определенный разработчиком, например, кнопка с обработчиком клика, вызывающая HTMLDialogElement.close().
closedby принимает следующие значения:
any—dialogзакрывается всеми указанными выше методамиcloserequest—dialogзакрывается методами 2 и 3none—dialogзакрывается только методом 3
Дефолтным значением closedby является:
closerequest— еслиdialogоткрыт с помощью метода showModalnone— в других случаях
Таким образом, closedby - это еще один недостающий пазл полностью декларативного модального окна: до его появления механизм закрытия dialog при клике по оверлею можно было реализовать только с помощью JavaScript (хук useClickOutside в React и т.п.).
Реализуем три модальных окна с разными closedby с помощью только HTML и CSS:
<button commandfor="dialog-first" command="show-modal" class="open"> Open first dialog </button> <!-- Первое модальное окно --> <dialog id="dialog-first" closedby="any"> <div class="dialog-content"> <div class="dialog-header"> <h3>First dialog</h3> <button commandfor="dialog-first" command="close">✖</button> </div> <p> This is the first dialog. Click outside, press Esc, or click the "Close" button to close it. </p> <div class="dialog-footer"> <button commandfor="dialog-first" command="close" class="cancel"> Close </button> <button class="confirm" commandfor="dialog-second" command="show-modal" > Open second dialog </button> </div> </div> </dialog> <!-- Второе модальное окно --> <!-- В данном случае `closedby` можно опустить --> <dialog id="dialog-second" closedby="closerequest"> <div class="dialog-content"> <div class="dialog-header"> <h3>Second dialog</h3> <button commandfor="dialog-second" command="close">✖</button> </div> <p> This is the second dialog. Press Esc or click the "Close" button to close it. </p> <div class="dialog-footer"> <button commandfor="dialog-second" command="close" class="cancel"> Close </button> <button class="confirm" commandfor="dialog-third" command="show-modal" > Open third dialog </button> </div> </div> </dialog> <!-- Третье модальное окно --> <dialog id="dialog-third" closedby="none"> <div class="dialog-content"> <div class="dialog-header"> <h3>Third dialog</h3> <button commandfor="dialog-third" command="close">✖</button> </div> <p>This is the third dialog. Click the "Close" button to close it.</p> <div class="dialog-footer"> <button commandfor="dialog-third" command="close" class="cancel"> Close </button> </div> </div> </dialog>
Добавим немного стилей для красоты:
:root { --p: 4px; } html, body { height: 100%; } body { margin: 0; display: flex; justify-content: center; align-items: center; font-family: system-ui, sans-serif; } /* Диалог */ dialog { padding: 0; background: none; border: none; box-shadow: 0 var(--p) calc(var(--p) * 2) rgba(0, 0, 0, 0.1); /* Затенение */ &::backdrop { background: rgba(0, 0, 0, 0.15); } } /* Содержимое диалога */ .dialog-content { padding: calc(var(--p) * 4); background: white; border-radius: var(--p); min-width: 320px; max-width: 480px; display: flex; flex-direction: column; gap: calc(var(--p) * 4); & > p { margin: 0; } } /* Шапка диалога */ .dialog-header { display: flex; justify-content: space-between; align-items: center; gap: calc(var(--p) * 2); & > h3 { margin: 0; } & > button { padding: var(--p); background: none; font-size: 1rem; } } /* Подвал диалога */ .dialog-footer { display: flex; justify-content: flex-end; gap: calc(var(--p) * 2); & > button { &.confirm { background-color: #198754; color: white; } &.cancel { background-color: #dc3545; color: white; } } } /* Кнопка */ button { padding: calc(var(--p) * 2) calc(var(--p) * 4); border: none; border-radius: var(--p); cursor: pointer; &:focus-visible { outline: 2px solid black; } /* Кнопка открытия диалога */ &.open { background-color: #0d6efd; color: white; } }
Легко и просто.
Заметили проблему? Обратите внимание на затенение. Поскольку оно у нас прозрачное на 85% (rgba(0, 0, 0, 0.15)), оверлеи открытых dialog "умножаются", т.е. общий визуальный оверлей страницы с каждым новым открытым dialog становится все темнее и темнее. Хотелось бы этого избежать. Хотелось бы отображать только верхний оверлей.
К сожалению, насколько мне известно, на сегодняшний день средствами CSS эту проблему не решить. Вероятно, в будущем у нас появится специальный селектор для таких кейсов, поскольку браузер точно знает, какой dialog открыт последним.

Chrome добавляет тег top-layer(n) рядом с <dialog>, где n — порядковый номер открытого dialog, начиная с 1. Кроме того, под разметкой появляется стек открытых dialog - #top-layer, в котором последний открытый dialog находится в самом низу (стек растет вниз).
Самое простое решение — класс CSS и MutationObserver.
Правим стили:
/* Затенение */ &::backdrop { background: transparent; } &.active::backdrop { background: rgba(0, 0, 0, 0.15); }
Оверлей будет отображаться только у самого верхнего dialog с классом active.
Следим за изменением атрибутов dialog:
// Стек открытых диалогов let dialogs = [] const mutationObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { // Нас интересует только этот атрибут if (mutation.attributeName === 'open') { const dialog = mutation.target // Добавляем или удаляем диалог из стека if (dialog.open) { dialogs.push(dialog) } else { dialogs = dialogs.filter((d) => d !== dialog) } dialogs.forEach((d, i) => { // Активируем верхний диалог if (i === dialogs.length - 1) { d.classList.add('active') } else { d.classList.remove('active') } }) } }) }) // Включаем наблюдение document.querySelectorAll('dialog').forEach((d) => { mutationObserver.observe(d, { attributes: true }) })
Надеюсь, в будущем подобное можно будет реализовать с помощью одного правила CSS. И это мы еще не анимируем dialog и оверлей.
Это все, чем я хотел поделиться с вами в этой небольшой заметке. Надеюсь, вы узнали что-то новое и, следовательно, не зря потратили время.
Happy coding!

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.
