javascript

JavaScript: заметка о свойствах source ToggleEvent и closedBy HTMLDialogElement

  • пятница, 27 февраля 2026 г. в 00:00:07
https://habr.com/ru/companies/timeweb/articles/984310/

Привет, друзья!

В этой небольшой статье я расскажу вам о новом свойстве события togglesource, а также о новом атрибуте HTML-элемента dialogclosedby.

Свойство 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:

  1. Клик за его пределами, по затенению (overlay) при наличии (light dismiss — легкая отмена; аналогично popover="auto").

  2. Действие пользователя, специфичное для платформы, например, нажатие клавиши Esc на десктопе.

  3. Механизм, определенный разработчиком, например, кнопка с обработчиком клика, вызывающая HTMLDialogElement.close().

closedby принимает следующие значения:

  • anydialog закрывается всеми указанными выше методами

  • closerequestdialog закрывается методами 2 и 3

  • nonedialog закрывается только методом 3

Дефолтным значением closedby является:

  • closerequest — если dialog открыт с помощью метода showModal

  • none — в других случаях

Таким образом, 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">&#10006;</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">&#10006;</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">&#10006;</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!


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