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

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

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

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

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

Обзор

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

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

Поддержка браузера

  • Хром: 37.
  • Край: 79.
  • Фаерфокс: 98.
  • Сафари: 15.4.

Источник

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

Разметка

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

<dialog>
  …
</dialog>

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

Традиционно элемент диалога имеет много общего с модальным элементом, и часто их имена взаимозаменяемы. Здесь я позволил себе использовать элемент диалога как для небольших всплывающих диалоговых окон (мини), так и для полностраничных диалогов (мега). Я назвал их «мега» и «мини», причем оба диалога слегка адаптированы для разных случаев использования. Я добавил атрибут 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>

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

Доступность

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

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

Как мы это делали вручную в разделе «Создание компонента Sidenav» , важно, чтобы при открытии и закрытии чего-либо правильно фокусировался на соответствующих кнопках открытия и закрытия. Когда эта боковая панель открывается, фокус переключается на кнопку закрытия. При нажатии кнопки закрытия фокус возвращается к кнопке, которая ее открыла.

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

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

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

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

Поддержка браузера

  • Хром: 102.
  • Край: 102.
  • Фаерфокс: 112.
  • Сафари: 15.5.

Источник

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

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

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

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

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

Стили

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

Стилизация с открытым реквизитом

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

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

Владение свойством отображения

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

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 в мегадиалог:

Поддержка браузера

  • Хром: 76.
  • Край: 79.
  • Фаерфокс: 103.
  • Сафари: 18.

Источник

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

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

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

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

Стильные дополнения

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

Сдерживание прокрутки

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

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

Поддержка браузера

  • Хром: 105.
  • Край: 105.
  • Фаерфокс: 121.
  • Сафари: 15.4.

Источник

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;
}

Снимок экрана: инструменты разработчика накладывают информацию о макете сетки на строки.

Стилизация диалогового окна <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 накладывает информацию о макете флексбокса на заголовок диалогового окна.

Стилизация кнопки закрытия заголовка

Поскольку в демо-версии используются кнопки «Открыть реквизиты», кнопка «Закрыть» превращается в кнопку с круглым значком, например:

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>

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

Для этого родительский элемент формы установил для себя некоторые максимумы, которые ограничивают достижение этого элемента статьи, если он становится слишком высоким. Установите 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 накладывает информацию о макете флексбокса на элемент нижнего колонтитула.

Элемент menu используется для размещения кнопок действий для диалогового окна. Он использует макет флексбокса с 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 накладывает информацию о флексбоксе на элементы меню нижнего колонтитула.

Анимация

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

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

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

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

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

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

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

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

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

@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;
  }
}

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

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

@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 . Затем прослушайте встроенное событие закрытия в диалоговом окне. Отсюда установите диалог на 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 , чтобы получить представление об изменении атрибутов диалога. В этом наблюдателе я буду следить за изменениями атрибута open и соответствующим образом управлять пользовательскими событиями.

Аналогично тому, как мы запускали события закрытия и закрытия, создайте два новых события с именами 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)
    }
  })
})

Функция обратного вызова наблюдателя мутаций будет вызываться при изменении атрибутов диалога, предоставляя список изменений в виде массива. Перебирайте изменения атрибута в поисках открытого attributeName . Затем проверьте, имеет ли элемент атрибут или нет: это сообщает, стал ли диалог открытым. Если он был открыт, удалите атрибут 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)
      }
    })
  })
})

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

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

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

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 : содержит строку закрытия, передаваемую при вызове события диалога close() . В событии dialogClosed очень важно знать, был ли диалог закрыт, отменен или подтвержден. Если это подтверждено, сценарий затем захватывает значения формы и сбрасывает форму. Сброс полезен тем, что при повторном отображении диалогового окна оно оказывается пустым и готовым к новой отправке.

Заключение

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

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

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

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

Ресурсы

,

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

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

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

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

Обзор

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

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

Поддержка браузера

  • Хром: 37.
  • Край: 79.
  • Фаерфокс: 98.
  • Сафари: 15.4.

Источник

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

Разметка

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

<dialog>
  …
</dialog>

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

Традиционно элемент диалога имеет много общего с модальным элементом, и часто их имена взаимозаменяемы. Здесь я позволил себе использовать элемент диалога как для небольших всплывающих диалоговых окон (мини), так и для полностраничных диалогов (мега). Я назвал их «мега» и «мини», причем оба диалога слегка адаптированы для разных случаев использования. Я добавил атрибут 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>

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

Доступность

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

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

Как мы это делали вручную в разделе «Создание компонента Sidenav» , важно, чтобы при открытии и закрытии чего-либо правильно фокусировался на соответствующих кнопках открытия и закрытия. Когда эта боковая панель открывается, фокус переключается на кнопку закрытия. При нажатии кнопки закрытия фокус возвращается к кнопке, которая ее открыла.

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

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

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

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

Поддержка браузера

  • Хром: 102.
  • Край: 102.
  • Фаерфокс: 112.
  • Сафари: 15.5.

Источник

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

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

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

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

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

Стили

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

Стилизация с открытым реквизитом

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

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

Владение свойством отображения

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

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 в мегадиалог:

Поддержка браузера

  • Хром: 76.
  • Край: 79.
  • Фаерфокс: 103.
  • Сафари: 18.

Источник

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

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

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

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

Стильные дополнения

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

Сдерживание прокрутки

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

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

Поддержка браузера

  • Хром: 105.
  • Край: 105.
  • Фаерфокс: 121.
  • Сафари: 15.4.

Источник

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;
}

Снимок экрана: инструменты разработчика накладывают информацию о макете сетки на строки.

Стилизация диалогового окна <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 накладывает информацию о макете флексбокса на заголовок диалогового окна.

Стилизация кнопки закрытия заголовка

Поскольку в демо-версии используются кнопки «Открыть реквизиты», кнопка «Закрыть» превращается в кнопку с круглым значком, например:

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>

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

Для этого родительский элемент формы установил для себя некоторые максимумы, которые ограничивают достижение этого элемента статьи, если он становится слишком высоким. Установите 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 накладывает информацию о макете флексбокса на элемент нижнего колонтитула.

Элемент menu используется для размещения кнопок действий для диалогового окна. Он использует макет флексбокса с 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 накладывает информацию о флексбоксе на элементы меню нижнего колонтитула.

Анимация

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

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

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

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

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

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

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

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

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

@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;
  }
}

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

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

@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 . Затем прослушайте встроенное событие закрытия в диалоговом окне. Отсюда установите диалог на 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 , чтобы получить представление об изменении атрибутов диалога. В этом наблюдателе я буду следить за изменениями атрибута open и соответствующим образом управлять пользовательскими событиями.

Аналогично тому, как мы запускали события закрытия и закрытия, создайте два новых события с именами 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)
    }
  })
})

Функция обратного вызова наблюдателя мутаций будет вызываться при изменении атрибутов диалога, предоставляя список изменений в виде массива. Перебирайте изменения атрибута в поисках открытого attributeName . Затем проверьте, имеет ли элемент атрибут или нет: это сообщает, стал ли диалог открытым. Если он был открыт, удалите атрибут 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)
      }
    })
  })
})

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

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

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

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 : содержит строку закрытия, передаваемую при вызове события диалога close() . В событии dialogClosed очень важно знать, был ли диалог закрыт, отменен или подтвержден. Если это подтверждено, сценарий затем захватывает значения формы и сбрасывает форму. Сброс полезен тем, что при повторном отображении диалогового окна оно оказывается пустым и готовым к новой отправке.

Заключение

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

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

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

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

Ресурсы