Создание диалогового компонента

Базовый обзор того, как создавать адаптивные к цвету, отзывчивые и доступные мини- и мегамодальные окна с помощью элемента <dialog> .

В этой публикации я хочу поделиться своими мыслями о том, как создавать адаптивные к цвету, отзывчивые и доступные мини- и мегамодальные окна с помощью элемента <dialog> . Попробуйте демо-версию и посмотрите исходный код !

Демонстрация мега и мини диалогов в светлой и темной тематике.

Если вы предпочитаете видео, вот версия этого поста на YouTube:

Обзор

Элемент <dialog> отлично подходит для отображения контекстной информации или действий на странице. Подумайте, когда пользовательский опыт может выиграть от действия на одной странице вместо многостраничного: например, если форма небольшая или от пользователя требуется только подтвердить или отменить действие.

Элемент <dialog> недавно стал стабильным во всех браузерах:

Browser Support

  • Хром: 37.
  • Край: 79.
  • Firefox: 98.
  • Сафари: 15.4.

Source

Я обнаружил, что элементу не хватает нескольких вещей, поэтому в этом GUI Challenge я добавляю ожидаемые мной элементы пользовательского опыта разработчика: дополнительные события, исчезновение света, пользовательские анимации, а также мини- и мега-типы.

Разметка

Элемент <dialog> обладает скромными возможностями. Он автоматически скрывается и имеет встроенные стили для наложения вашего контента.

<dialog>
  …
</dialog>

Мы можем улучшить этот базовый уровень.

Традиционно элемент диалога имеет много общего с модальным окном, и часто их названия взаимозаменяемы. Я позволил себе использовать элемент диалога как для небольших всплывающих диалоговых окон (mini), так и для полностраничных диалоговых окон (mega). Я назвал их mega и mini, слегка адаптировав оба варианта для разных вариантов использования. Я добавил атрибут modal-mode , позволяющий указать тип:

<dialog id="MegaDialog" modal-mode="mega"></dialog>
<dialog id="MiniDialog" modal-mode="mini"></dialog>

Скриншот мини- и мега-диалогов в светлой и темной темах.

Не всегда, но обычно элементы диалогового окна используются для сбора информации о взаимодействии. Формы внутри элементов диалогового окна создаются для совместного использования . Рекомендуется заключать содержимое диалогового окна в элемент формы, чтобы JavaScript мог получить доступ к данным, введённым пользователем. Кроме того, кнопки внутри формы, использующие method="dialog" могут закрывать диалоговое окно без JavaScript и передавать данные.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    …
    <button value="cancel">Cancel</button>
    <button value="confirm">Confirm</button>
  </form>
</dialog>

Мега диалог

Мегадиалоговое окно содержит три элемента внутри формы: <header> , <article> и <footer> . Они служат семантическими контейнерами, а также целевыми стилями для представления диалогового окна. Заголовок даёт название модальному окну и содержит кнопку закрытия. Статья предназначена для ввода данных и информации в форме. Нижний колонтитул содержит <menu> с кнопками действий.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    <header>
      <h3>Dialog title</h3>
      <button onclick="this.closest('dialog').close('close')"></button>
    </header>
    <article>...</article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

Первая кнопка меню имеет autofocus и встроенный обработчик событий onclick . Атрибут autofocus получает фокус при открытии диалогового окна, и я считаю, что лучше всего размещать его на кнопке отмены, а не на кнопке подтверждения. Это гарантирует, что подтверждение будет преднамеренным, а не случайным.

Мини-диалог

Мини-диалоговое окно очень похоже на мега-диалоговое окно, только в нём отсутствует элемент <header> . Это позволяет сделать его меньше и более встраиваемым.

<dialog id="MiniDialog" modal-mode="mini">
  <form method="dialog">
    <article>
      <p>Are you sure you want to remove this user?</p>
    </article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

Элемент «Диалог» обеспечивает прочную основу для полноценного элемента области просмотра, который может собирать данные и отслеживать взаимодействие с пользователем. Эти базовые элементы могут стать основой для интересных и эффективных взаимодействий на вашем сайте или в приложении.

Доступность

Элемент диалога имеет очень хорошую встроенную доступность. Вместо того, чтобы добавлять эти функции, как я обычно делаю, многие из них уже есть.

Восстановление фокуса

Как мы делали вручную при создании компонента боковой навигации , важно, чтобы при открытии и закрытии компонента фокус попадал на соответствующие кнопки открытия и закрытия. При открытии боковой навигации фокус перемещается на кнопку закрытия. При нажатии кнопки закрытия фокус возвращается к кнопке, которая её открыла.

Для элемента dialog это встроенное поведение по умолчанию:

К сожалению, если вы хотите анимировать появление и исчезновение диалогового окна, эта функция будет потеряна. В разделе, посвящённом JavaScript, я восстановлю эту функцию.

Захват фокуса

Элемент dialog управляет inert в документе. До появления inert для отслеживания перехода фокуса с элемента использовался JavaScript, который в этот момент перехватывал фокус и возвращал его обратно.

Browser Support

  • Хром: 102.
  • Край: 102.
  • Firefox: 112.
  • Сафари: 15.5.

Source

После inert любые части документа могут быть «заморожены» таким образом, что они больше не будут фокусироваться и не будут взаимодействовать с мышью. Вместо того, чтобы удерживать фокус, он направляется в единственную интерактивную часть документа.

Открыть и автоматически сфокусировать элемент

По умолчанию элемент диалогового окна устанавливает фокус на первый фокусируемый элемент в разметке диалогового окна. Если этот элемент не подходит пользователю по умолчанию, используйте атрибут autofocus . Как уже говорилось, я считаю, что лучше всего размещать его на кнопке отмены, а не на кнопке подтверждения. Это гарантирует, что подтверждение будет преднамеренным, а не случайным.

Закрытие с помощью клавиши Escape

Важно сделать так, чтобы закрыть этот потенциально мешающий элемент было легко. К счастью, элемент диалога автоматически нажмёт клавишу ESC, избавив вас от необходимости оркестровки.

Стили

Существует простой и сложный пути стилизации элемента диалогового окна. Простой путь достигается за счёт того, что свойство display диалогового окна не меняется, а учитывается его ограничения. Я же выбрал сложный путь, чтобы реализовать пользовательские анимации открытия и закрытия диалогового окна, перехватывая свойство display и многое другое.

Стилизация с помощью Open Props

Чтобы ускорить адаптивную цветопередачу и обеспечить общую согласованность дизайна, я беззастенчиво использовал свою библиотеку CSS-переменных Open Props . Помимо бесплатных переменных, я также импортирую файл нормализации и несколько кнопок , которые Open Props предоставляет в качестве опциональных импортов. Этот импорт помогает мне сосредоточиться на настройке диалогового окна и демо-версии, не требуя при этом множества стилей для их поддержки и придания им привлекательного вида.

Стилизация элемента <dialog>

Владение выставочной недвижимостью

По умолчанию поведение отображения и скрытия элемента диалога переключает свойство display с block на none . К сожалению, это означает, что его нельзя анимировать, только при появлении и исчезновении. Я хотел бы реализовать анимацию и при появлении, и для этого первым шагом будет установка собственного свойства display :

dialog {
  display: grid;
}

Изменяя значение свойства display, а следовательно, и контролируя его, как показано в приведенном выше фрагменте CSS, необходимо управлять значительным количеством стилей для обеспечения корректного пользовательского опыта. Во-первых, по умолчанию диалоговое окно закрыто. Вы можете визуально представить это состояние и запретить диалоговому окну взаимодействовать с ним с помощью следующих стилей:

dialog:not([open]) {
  pointer-events: none;
  opacity: 0;
}

Теперь диалоговое окно невидимо и с ним нельзя взаимодействовать, когда оно закрыто. Позже я добавлю JavaScript-код для управления атрибутом inert диалогового окна, чтобы пользователи клавиатуры и программ чтения с экрана также не могли получить доступ к скрытому диалоговому окну.

Придание диалоговому окну адаптивной цветовой темы

Мегадиалог, демонстрирующий светлую и темную тему, демонстрирующий цвета поверхности.

Хотя color-scheme подбирает для вашего документа адаптивную цветовую тему, предоставляемую браузером в соответствии с системными настройками (светлой или тёмной), мне хотелось настроить элемент диалогового окна более детально. Open Props предоставляет несколько цветов поверхности , которые автоматически подстраиваются под системные настройки (светлой или тёмной), аналогично использованию color-scheme . Они отлично подходят для создания слоёв в дизайне, и мне нравится использовать цвет для визуальной поддержки такого внешнего вида слоёв. Цвет фона — var(--surface-1) ; чтобы разместить его поверх этого слоя, используйте var(--surface-2) :

dialog {
  
  background: var(--surface-2);
  color: var(--text-1);
}

@media (prefers-color-scheme: dark) {
  dialog {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }
}

Позже для дочерних элементов, таких как верхний и нижний колонтитулы, будут добавлены адаптивные цвета. Я считаю, что для диалогового элемента они лишние, но очень важны для создания привлекательного и качественного дизайна диалогового окна.

Адаптивное изменение размера диалоговых окон

Диалоговое окно по умолчанию делегирует свой размер содержимому, что, как правило, отлично. Моя цель — ограничить max-inline-size читабельным размером ( --size-content-3 = 60ch ) или 90% от ширины области просмотра. Это гарантирует, что диалоговое окно не будет растягиваться от края до края на мобильном устройстве и не будет настолько широким на экране настольного компьютера, что его будет трудно читать. Затем я добавляю max-block-size , чтобы диалоговое окно не превышало высоту страницы. Это также означает, что нам нужно указать, где находится прокручиваемая область диалогового окна, если это высокий элемент диалога.

dialog {
  
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  overflow: hidden;
}

Заметьте, что я дважды указал max-block-size ? В первом случае используется 80vh — физическая единица измерения области просмотра. Мне действительно нужно, чтобы диалоговое окно отображалось в относительном потоке для международных пользователей, поэтому во втором объявлении я использую логическую, более новую и лишь частично поддерживаемую единицу dvb , которая появится, когда станет стабильнее.

Мега-диалоговое позиционирование

Чтобы упростить позиционирование элемента диалогового окна, стоит разбить его на две части: полноэкранный фон и контейнер диалогового окна. Фон должен закрывать всё, создавая эффект тени, который подчёркивает, что диалоговое окно находится на переднем плане, а содержимое за ним недоступно. Контейнер диалогового окна может свободно центрироваться относительно этого фона и принимать любую форму, соответствующую его содержимому.

Следующие стили фиксируют элемент диалога в окне, растягивая его до каждого угла, и используют margin: auto для центрирования содержимого:

dialog {
  
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
Стили мобильного мегадиалога

На небольших экранах я немного иначе оформляю это мегамодальное окно на всю страницу. Я устанавливаю нижнее поле равным 0 , что позволяет разместить содержимое диалогового окна в нижней части экрана. С помощью нескольких настроек стиля я могу превратить диалоговое окно в список действий, расположенный ближе к большим пальцам пользователя:

@media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    margin-block-end: 0;
    border-end-end-radius: 0;
    border-end-start-radius: 0;
  }
}

Скриншот инструментов разработчика, накладывающих отступы в открытом мега-диалоге как на десктопе, так и на мобильном устройстве.

Позиционирование мини-диалога

При использовании более крупной области просмотра, например, на настольном компьютере, я решил разместить мини-диалоговые окна над вызывающим их элементом. Для этого мне нужен JavaScript. Вы можете найти используемый мной приём здесь , но, на мой взгляд, он выходит за рамки этой статьи. Без JavaScript мини-диалоговое окно отображается в центре экрана, как и мегадиалоговое окно.

Сделай это попсовым

Наконец, добавьте немного изюминку диалоговому окну, чтобы оно выглядело как мягкая поверхность, возвышающаяся над страницей. Мягкость достигается за счёт скругления углов диалогового окна. Глубина достигается с помощью одного из тщательно проработанных теневых реквизитов Open Props:

dialog {
  
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
}

Настройка псевдоэлемента фона

Я решил не особо увлекаться фоном, добавив лишь эффект размытия с помощью backdrop-filter к мегадиалоговому окну:

Browser Support

  • Хром: 76.
  • Край: 79.
  • Firefox: 103.
  • Сафари: 18.

Source

dialog[modal-mode="mega"]::backdrop {
  backdrop-filter: blur(25px);
}

Я также решил применить переход к backdrop-filter в надежде, что браузеры разрешат переход элемента фона в будущем:

dialog::backdrop {
  transition: backdrop-filter .5s ease;
}

Скриншот мегадиалога, наложенного на размытый фон из разноцветных аватаров.

Дополнительные элементы стиля

Я называю этот раздел «дополнительно», потому что он больше связан с моей демонстрацией элемента диалога, чем с элементом диалога в целом.

Удерживание свитка

Когда диалоговое окно отображается, пользователь все равно может прокручивать страницу за ним, что мне не нужно:

Обычно я бы выбрал overscroll-behavior , но, согласно спецификации , он не влияет на диалоговое окно, поскольку это не порт прокрутки, то есть не скроллер, поэтому ему ничего не мешает. Я мог бы использовать JavaScript для отслеживания новых событий из этого руководства, таких как «закрыто» и «открыто», и включения/выключения свойства overflow: hidden в документе, или дождаться, пока :has() станет стабильной во всех браузерах:

Browser Support

  • Хром: 105.
  • Край: 105.
  • Firefox: 121.
  • Сафари: 15.4.

Source

html:has(dialog[open][modal-mode="mega"]) {
  overflow: hidden;
}

Теперь, когда открыто мегадиалоговое окно, в html-документе отображается overflow: hidden .

Макет <form>

Помимо того, что это очень важный элемент для сбора информации о взаимодействии с пользователем, я использую его здесь для размещения элементов заголовка, нижнего колонтитула и статьи. В этом макете я хочу представить дочерний элемент статьи как прокручиваемую область. Я добился этого с помощью grid-template-rows . Элементу статьи задано значение 1fr , а сама форма имеет ту же максимальную высоту, что и элемент диалога. Установка этой фиксированной высоты и фиксированного размера строки позволяет ограничить элемент статьи и прокручивать его при выходе за пределы:

dialog > form {
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-items: start;
  max-block-size: 80vh;
  max-block-size: 80dvb;
}

Скриншот devtools, накладывающих информацию о макете сетки на строки.

Стилизация диалогового окна <header>

Этот элемент предназначен для предоставления заголовка для содержимого диалогового окна и размещения кнопки закрытия, которую легко найти. Он также имеет цвет поверхности, чтобы визуально создать впечатление, что он находится за содержимым статьи диалогового окна. Эти требования приводят к появлению контейнера Flexbox, вертикально выровненных элементов с отступами по краям, а также отступов и зазоров для размещения заголовка и кнопок закрытия:

dialog > form > header {
  display: flex;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  background: var(--surface-2);
  padding-block: var(--size-3);
  padding-inline: var(--size-5);
}

@media (prefers-color-scheme: dark) {
  dialog > form > header {
    background: var(--surface-1);
  }
}

Скриншот Chrome Devtools, накладывающий информацию о макете Flexbox на заголовок диалогового окна.

Оформление кнопки закрытия заголовка

Поскольку в демоверсии используются кнопки Open Props, кнопка закрытия оформлена в виде круглой кнопки с иконкой, как показано ниже:

dialog > form > header > button {
  border-radius: var(--radius-round);
  padding: .75ch;
  aspect-ratio: 1;
  flex-shrink: 0;
  place-items: center;
  stroke: currentColor;
  stroke-width: 3px;
}

Скриншот Chrome Devtools, накладывающий информацию о размерах и отступах для кнопки закрытия заголовка.

Стилизация диалогового окна <article>

Элемент article играет особую роль в этом диалоге: это пространство, предназначенное для прокрутки в случае длинного или высокого диалога.

Для этого родительский элемент form устанавливает для себя определённые максимальные значения, которые накладывают ограничения на элемент article, если он становится слишком высоким. Установите overflow-y: auto , чтобы полосы прокрутки отображались только при необходимости, ограничьте прокрутку с помощью overscroll-behavior: contain , а остальное будет реализовано пользовательскими стилями представления:

dialog > form > article {
  overflow-y: auto; 
  max-block-size: 100%; /* safari */
  overscroll-behavior-y: contain;
  display: grid;
  justify-items: flex-start;
  gap: var(--size-3);
  box-shadow: var(--shadow-2);
  z-index: var(--layer-1);
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: light) {
  dialog > form > article {
    background: var(--surface-1);
  }
}

Футер предназначен для размещения меню с кнопками действий. Flexbox используется для выравнивания содержимого по краю строчной оси футера, а также для создания отступов между кнопками.

dialog > form > footer {
  background: var(--surface-2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: dark) {
  dialog > form > footer {
    background: var(--surface-1);
  }
}

Скриншот Chrome Devtools, накладывающий информацию о макете Flexbox на элемент нижнего колонтитула.

Элемент menu используется для размещения кнопок действий в диалоговом окне. Он использует макет Flexbox с обёрткой и gap для обеспечения пространства между кнопками. Элементы меню имеют отступы, например, <ul> . Я также удаляю этот стиль, поскольку он мне не нужен.

dialog > form > footer > menu {
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  padding-inline-start: 0;
}

dialog > form > footer > menu:only-child {
  margin-inline-start: auto;
}

Скриншот Chrome Devtools, накладывающий информацию Flexbox на элементы меню нижнего колонтитула.

Анимация

Элементы диалоговых окон часто анимируются, поскольку они появляются и исчезают в окне. Добавление в диалоговые окна вспомогательного движения для появления и исчезновения помогает пользователям ориентироваться в потоке.

Обычно элемент диалогового окна может быть анимирован только вовнутрь, но не наружу. Это связано с тем, что браузер переключает свойство display элемента. Ранее в руководстве для display было задано значение grid, и никогда не устанавливалось значение none. Это открывает возможность анимации вовнутрь и наружу.

Open Props предлагает множество анимаций по ключевым кадрам , что делает оркестровку простой и понятной. Вот цели анимации и многоуровневый подход, который я использовал:

  1. Переход по умолчанию — это уменьшение движения, простое постепенное увеличение и уменьшение непрозрачности.
  2. Если движение в порядке, добавляются анимации скольжения и масштабирования.
  3. Адаптивный мобильный макет для мегадиалогового окна настроен так, чтобы выдвигаться.

Безопасный и осмысленный переход по умолчанию

Хотя в Open Props есть ключевые кадры для плавного появления и исчезновения, я предпочитаю использовать многоуровневый подход с переходами по умолчанию, а анимацию по ключевым кадрам — в качестве потенциального улучшения. Ранее мы уже задали видимость диалогового окна с помощью непрозрачности, установив значение 1 или 0 в зависимости от атрибута [open] . Чтобы перейти от 0% к 100%, укажите браузеру необходимую длительность и плавность:

dialog {
  transition: opacity .5s var(--ease-3);
}

Добавление движения к переходу

Если пользователь не возражает против движения, большие и мини-диалоги должны выдвигаться при входе и уменьшаться при выходе. Этого можно добиться с помощью медиазапроса prefers-reduced-motion и нескольких свойств Open:

@media (prefers-reduced-motion: no-preference) {
  dialog {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  dialog[open] {
    animation: var(--animation-slide-in-up) forwards;
  }
}

Адаптация анимации выхода для мобильных устройств

Ранее в разделе о стилизации стиль мегадиалога был адаптирован для мобильных устройств, чтобы больше походить на список действий, как будто небольшой лист бумаги выскользнул из нижней части экрана и всё ещё прикреплён к ней. Анимация выхода с уменьшением масштаба не очень хорошо вписывается в новый дизайн, и мы можем адаптировать её с помощью пары медиазапросов и нескольких OpenProps:

@media (prefers-reduced-motion: no-preference) and @media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    animation: var(--animation-slide-out-down) forwards;
    animation-timing-function: var(--ease-squish-2);
  }
}

JavaScript

С помощью JavaScript можно добавить несколько вещей:

// dialog.js
export default async function (dialog) {
  // add light dismiss
  // add closing and closed events
  // add opening and opened events
  // add removed event
  // removing loading attribute
}

Эти дополнения обусловлены потребностью в легком закрытии (щелчком по фону диалогового окна), анимации и некоторых дополнительных событиях для лучшего расчета времени при получении данных формы.

Добавление светового сигнала

Эта задача проста и отлично подходит для элемента диалога, который не анимирован. Взаимодействие достигается путём отслеживания щелчков по элементу диалога и использования всплывающей подсказки событий для определения того, что было нажато. Функция close() будет выполняться только в том случае, если это самый верхний элемент:

export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
}

const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

Обратите внимание на dialog.close('dismiss') . Вызывается событие и предоставляется строка. Эту строку может получить другой код JavaScript, чтобы получить информацию о том, как был закрыт диалог. Вы увидите, что я также предоставляю строки закрытия каждый раз при вызове функции из различных кнопок, чтобы предоставить приложению контекст для взаимодействия с пользователем.

Добавление событий закрытия и закрытия

Элемент диалогового окна имеет событие закрытия: оно генерируется сразу после вызова функции диалогового close() . Поскольку мы анимируем этот элемент, было бы полезно иметь события до и после анимации, чтобы при необходимости получить данные или сбросить форму диалогового окна. Здесь я использую его для управления добавлением атрибута inert к закрытому диалоговому окну, а в демоверсии — для изменения списка аватаров, если пользователь отправил новое изображение.

Для этого создайте два новых события: closing и closed . Затем прослушивайте встроенное событие «close» в диалоговом окне. После этого переведите диалоговое окно в inert и отправьте событие closing . Следующая задача — дождаться завершения анимации и переходов в диалоговом окне, а затем отправить событие closed .

const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')

export default async function (dialog) {
  
  dialog.addEventListener('close', dialogClose)
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

Функция animationsComplete , которая также используется в компоненте «Создание тост-сообщения» , возвращает обещание, основанное на завершении анимации и переходов. Именно поэтому dialogCloseасинхронная функция : она может await возврата обещания и уверенно перейти к событию «закрыто».

Добавление событий открытия и открытия

Эти события добавить не так просто, поскольку встроенный элемент диалога не предоставляет событие открытия, как в случае закрытия. Я использую MutationObserver для получения информации об изменении атрибутов диалога. В этом наблюдателе я буду отслеживать изменения атрибута открытия и соответствующим образом управлять пользовательскими событиями.

Аналогично тому, как мы создавали события закрытия и закрытия, создайте два новых события с именами opening и opened . Ранее мы прослушивали событие закрытия диалога, а теперь используем созданный наблюдатель мутаций для отслеживания атрибутов диалога.


const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')

export default async function (dialog) {
  
  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })
}

const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

Функция обратного вызова Mutation Observer будет вызвана при изменении атрибутов диалогового окна, предоставив список изменений в виде массива. Переберите изменения атрибутов, найдя значение attributeName, соответствующее attributeName open. Затем проверьте, есть ли у элемента этот атрибут: это покажет, открыто ли диалоговое окно. Если оно открыто, удалите атрибут inert и установите фокус либо на элемент, запрашивающий autofocus , либо на первый найденный элемент- button в диалоговом окне. Наконец, аналогично событиям закрытия и закрытия, немедленно отправьте событие открытия, дождитесь завершения анимации, а затем отправьте событие открытия.

Добавление удаленного события

В одностраничных приложениях диалоговые окна часто добавляются и удаляются в зависимости от маршрутов или других потребностей и состояния приложения. Это может быть полезно для очистки событий или данных при удалении диалогового окна.

Этого можно добиться с помощью другого наблюдателя мутаций. На этот раз вместо наблюдения за атрибутами элемента диалога мы будем наблюдать за дочерними элементами элемента body и следить за удалением элементов диалога.


const dialogRemovedEvent = new Event('removed')

export default async function (dialog) {
  
  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })
}

const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

Обратный вызов Mutation Observer вызывается при каждом добавлении или удалении дочерних элементов из тела документа. Отслеживаются мутации для removedNodes , имеющих nodeName диалогового окна. Если диалоговое окно было удалено, события click и close удаляются для освобождения памяти, а затем отправляется пользовательское событие removed.

Удаление атрибута загрузки

Чтобы предотвратить воспроизведение анимации закрытия диалогового окна при добавлении на страницу или при её загрузке, к диалоговому окну был добавлен атрибут загрузки. Следующий скрипт дожидается завершения анимации диалогового окна, а затем удаляет этот атрибут. Теперь диалоговое окно может свободно появляться и исчезать, и мы фактически скрыли отвлекающую анимацию.

export default async function (dialog) {
  
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Подробнее о проблеме предотвращения анимации ключевых кадров при загрузке страницы можно узнать здесь .

Все вместе

Вот dialog.js целиком, теперь, когда мы объяснили каждый раздел по отдельности:

// custom events to be added to <dialog>
const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')
const dialogRemovedEvent = new Event('removed')

// track opening
const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

// track deletion
const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

// wait for all dialog animations to complete their promises
const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

// click outside the dialog handler
const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

// page load dialogs setup
export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
  dialog.addEventListener('close', dialogClose)

  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })

  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })

  // remove loading attribute
  // prevent page load @keyframes playing
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Использование модуля dialog.js

Экспортированная из модуля функция ожидает вызова и передачи элемента диалога, которому необходимо добавить эти новые события и функциональные возможности:

import GuiDialog from './dialog.js'

const MegaDialog = document.querySelector('#MegaDialog')
const MiniDialog = document.querySelector('#MiniDialog')

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

Таким образом, оба диалоговых окна обновлены: добавлены функции закрытия, исправлена ​​загрузка анимации и добавлено больше событий для работы.

Прослушивание новых пользовательских событий

Каждый обновленный элемент диалога теперь может прослушивать пять новых событий, например:

MegaDialog.addEventListener('closing', dialogClosing)
MegaDialog.addEventListener('closed', dialogClosed)

MegaDialog.addEventListener('opening', dialogOpening)
MegaDialog.addEventListener('opened', dialogOpened)

MegaDialog.addEventListener('removed', dialogRemoved)

Вот два примера обработки этих событий:

const dialogOpening = ({target:dialog}) => {
  console.log('Dialog opening', dialog)
}

const dialogClosed = ({target:dialog}) => {
  console.log('Dialog closed', dialog)
  console.info('Dialog user action:', dialog.returnValue)

  if (dialog.returnValue === 'confirm') {
    // do stuff with the form values
    const dialogFormData = new FormData(dialog.querySelector('form'))
    console.info('Dialog form data', Object.fromEntries(dialogFormData.entries()))

    // then reset the form
    dialog.querySelector('form')?.reset()
  }
}

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

Обратите внимание на dialog.returnValue : он содержит строку закрытия, переданную при вызове события dialog close() . В событии dialogClosed крайне важно знать, был ли диалог закрыт, отменён или подтверждён. Если диалог подтверждён, скрипт получает значения формы и сбрасывает её. Сброс полезен, поскольку при повторном отображении диалоговое окно будет пустым и готовым к новой отправке.

Заключение

Теперь, когда вы знаете, как я это сделал, как бы вы поступили? 🙂

Давайте разнообразим наши подходы и изучим все способы развития в Интернете.

Создайте демо, пришлите мне ссылку в Твиттер , и я добавлю ее в раздел ремиксов сообщества ниже!

Ремиксы сообщества

Ресурсы