构建对话框组件

本课程概要介绍了如何使用 <dialog> 元素构建颜色自适应、响应式且易于访问的迷你模态窗口和超级模态窗口。

在这篇博文中,我想分享一下我如何使用 <dialog> 元素构建自适应、响应式且无障碍的迷你和大型模态窗口。试用演示查看源代码

演示浅色和深色主题下的超级对话框和迷你对话框。

如果您更喜欢视频,请观看此帖子的 YouTube 版本:

概览

<dialog> 元素非常适合用于页面内情境信息或操作。考虑在什么情况下,用户体验可以从单页操作(而非多页操作)中受益:可能是表单较小,或者用户只需执行确认或取消操作。

<dialog> 元素最近在各浏览器中已变为稳定版:

浏览器支持

  • Chrome:37.
  • Edge:79。
  • Firefox:98.
  • Safari:15.4。

来源

我发现该元素缺少一些内容,因此在此 GUI 挑战中,我添加了预期的开发者体验项:其他事件、轻量关闭、自定义动画,以及迷你和超级类型。

Markup

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

Mega 对话框

大型对话框的表单内包含三个元素:<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>

第一个菜单按钮包含 autofocusonclick 内嵌事件处理程序。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 元素为可以收集数据和用户互动的完整视口元素提供了坚实的基础。这些基本要素可以让您的网站或应用中出现一些非常有趣且强大的互动。

无障碍

dialog 元素具有非常好的内置无障碍功能。很多功能已经存在,而无需像我通常做的那样添加这些功能。

恢复焦点

正如我们在构建侧边导航栏组件中手动完成的那样,正确地打开和关闭某个内容时,必须将焦点放在相关的打开和关闭按钮上,这一点非常重要。该侧边栏打开后,焦点会放在关闭按钮上。按下关闭按钮后,焦点会恢复到打开该窗口的按钮。

对于对话框元素,这是内置的默认行为:

遗憾的是,如果您想为对话框显示和隐藏添加动画效果,则无法使用此功能。在 JavaScript 部分中,我将恢复该功能。

捕获焦点

对话框元素会在文档中为您管理 inert。在 inert 之前,JavaScript 用于监控焦点离开元素的情况,并在焦点离开时拦截并将其放回。

浏览器支持

  • Chrome:102.
  • Edge:102.
  • Firefox:112。
  • Safari:15.5。

来源

inert 之后,文档的任何部分都可以被“冻结”,即不再是焦点目标,也不再可通过鼠标进行互动。系统会引导焦点进入文档中唯一的互动部分,而不是将焦点锁定在该部分。

打开元素并自动对其进行对焦

默认情况下,对话框元素会将焦点分配给对话框标记中的第一个可聚焦元素。如果这不是用户的默认首选元素,请使用 autofocus 属性。如前所述,我认为最佳做法是将此文本放在取消按钮上,而不是确认按钮上。这样可以确保确认是刻意而为,而不是意外。

使用 Esc 键关闭

请务必让用户能够轻松关闭此可能干扰的元素。幸运的是,对话框元素会为您处理退出键,让您无需承担编排工作。

样式

您可以通过简单的方式和困难的方式为对话框元素设置样式。不更改对话框的显示属性并适应其限制,即可实现简单路径。我采用了较难的路径,为打开和关闭对话框提供了自定义动画,接管了 display 属性等。

使用 Open 属性设置样式

为了加快自适应颜色和整体设计的一致性,我无耻地引入了 CSS 变量库 Open Props。除了提供的自由变量之外,我还导入了 normalize 文件和一些 buttons,Open Props 将这两者都作为可选导入项提供。这些导入有助于我专注于自定义对话框和演示,而无需使用大量样式来支持它并使其看起来美观。

设置 <dialog> 元素的样式

拥有显示属性

对话框元素的默认显示和隐藏行为可将显示属性从 block 切换为 none。很遗憾,这意味着它无法进行进出动画,只能进行进场动画。我想同时为进入和退出添加动画效果,第一步是设置自己的 display 属性:

dialog {
  display: grid;
}

如上方 CSS 代码段所示,通过更改 display 属性值(从而拥有该值),需要管理大量样式,以便提供适当的用户体验。首先,对话框的默认状态是关闭状态。您可以直观地表示此状态,并通过以下样式阻止对话框接收互动:

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

现在,该对话框不可见,并且在未打开时无法与之互动。稍后,我会添加一些 JavaScript 来管理对话框的 inert 属性,以确保键盘和屏幕阅读器用户也无法访问隐藏的对话框。

为对话框设置自适应颜色主题

显示浅色和深色主题的 Mega 对话框,演示 Surface 颜色。

虽然 color-scheme 会将文档选项设为浏览器提供的自适应配色主题(以适应浅色和深色系统偏好设置),但我希望对对话框元素进行更深入的自定义。Open Props 提供了一些 Surface 颜色,这些颜色会自动适应系统的浅色和深色偏好设置,类似于使用 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,这会将对话框内容移至视口底部。通过调整一些样式,我可以将对话框转换为更靠近用户拇指的 ActionSheet:

@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 为超级对话框添加了模糊处理效果:

浏览器支持

  • Chrome:76.
  • Edge:79。
  • Firefox:103。
  • Safari:18.

来源

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

我还选择为 backdrop-filter 添加了转场效果,希望浏览器将来允许对背景幕元素进行转场:

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

显示彩色头像模糊背景的超级对话框的屏幕截图。

样式 extra

我将此部分称为“额外内容”,因为它与对话框元素演示有关,而不是与一般对话框元素有关。

滚动容器

显示对话框时,用户仍然可以滚动其后面的页面,这是我不希望的:

通常,overscroll-behavior 是我常用的解决方案,但根据规范,它对对话框没有影响,因为它不是滚动端口,也就是说,它不是滚动条,因此无法阻止。我可以使用 JavaScript 监听本指南中介绍的新事件(例如“已关闭”和“已打开”),并在文档中切换 overflow: hidden,也可以等待 :has() 在所有浏览器中稳定运行:

浏览器支持

  • Chrome:105。
  • 边缘:105。
  • Firefox:121。
  • Safari: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;
}

屏幕截图:DevTools 将网格布局信息叠加在行上。

设置对话框样式 <header>

此元素的作用是为对话框内容提供标题,并提供一个易于找到的关闭按钮。它还具有 Surface 颜色,使其看起来位于对话框文章内容后面。这些要求导致了 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 布局信息。

设置标题关闭按钮的样式

由于该演示使用的是“打开道具”按钮,因此关闭按钮被自定义为以圆形图标为中心的按钮,如下所示:

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 开发者工具的屏幕截图,其中叠加了标题关闭按钮的大小和内边距信息。

设置对话框样式 <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 元素用于包含对话框的操作按钮。它使用 gap 的封闭 flexbox 布局,在按钮之间留出空间。菜单项具有内边距,例如 <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 属性。之前,该指南会将显示设置为网格,而不会将其设置为“无”。这样一来,您就可以实现进入和退出动画效果了。

Open Props 附带许多可供使用的关键帧动画,可让编排变得简单且易于理解。下面是我采用的动画目标和分层方法:

  1. “减少动作”是默认的转换效果,即简单的透明度淡入淡出。
  2. 如果动作正常,系统会添加滑动和缩放动画。
  3. 调整了超级对话框的响应式移动布局,使其以滑出方式显示。

安全且有意义的默认转换

虽然 Open Props 附带用于淡入和淡出效果的关键帧,但我更倾向于将这种分层转场方法作为默认方法,并将关键帧动画作为潜在的升级方式。我们之前已经使用不透明度设置了对话框的可见性,并根据 [open] 属性协调了 10。如需在 0% 到 100% 之间进行过渡,请告知浏览器您希望的过渡时长和类型:

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

为过渡效果添加动画

如果用户可以接受动作,则超级对话框和迷你对话框都应作为进入点向上滑动,在退出时横向扩容。您可以使用 prefers-reduced-motion 媒体查询和一些 Open Props 来实现此目的:

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

针对移动设备调整退出动画

在前面的“样式设置”部分中,超级对话框样式已针对移动设备进行了调整,使其更像一个操作页,就好像有一张小纸从屏幕底部向上滑动并仍然贴在底部一样。放大退出动画不太适合这种新设计,我们可以使用一些媒体查询和一些 Open 属性来进行调整:

@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 属性,在演示中,如果用户提交了新图片,我会使用这些方法修改头像列表。

为此,请创建两个名为 closingclosed 的新事件。然后,监听对话框上的内置关闭事件。在此处,将对话框设为 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 函数(也用于构建 Toast 组件)会根据动画和过渡 promise 的完成情况返回一个 promise。这就是为什么 dialogClose 是一个异步函数;这样,它就可以await返回的 Promise,并自信地继续处理已关闭事件。

添加开始和已开始事件

添加这些事件并不那么容易,因为内置对话框元素不会像关闭事件那样提供打开事件。我使用 MutationObserver 来深入了解对话框属性的更改。在此观察器中,我将监控“open”属性的更改,并相应地管理自定义事件。

与启动 closing 和 closed 事件类似,创建两个名为 openingopened 的新事件。我们之前监听了对话框关闭事件,这次使用创建的更改观察器来监控对话框的属性。


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 元素。最后,与“closing”和“closed”事件类似,立即分派“opening”事件,等待动画播放完毕,然后分派“opened”事件。

添加已移除的事件

在单页应用中,对话框通常会根据路由或其他应用需求和状态进行添加和移除。在移除对话框时清理事件或数据可能会很有用。

您可以使用其他更改观察器实现此目的。这次,我们将观察 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)
      }
    })
  })
})

每当在文档正文中添加或移除子项时,都会调用变更观察器回调。要监控的具体更改适用于具有对话 nodeNameremovedNodes。如果移除了对话框,系统会移除点击和关闭事件以释放内存,并分派已移除的自定义事件。

移除 loading 属性

为了防止对话框动画在添加到页面或页面加载时播放退出动画,我们在对话框中添加了 loading 属性。以下脚本会等待对话框动画运行完毕,然后移除该属性。现在,对话框可以自由地进行动画进入和退出,我们也有效地隐藏了原本会造成干扰的动画。

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 事件中,了解对话框是关闭、取消还是确认非常重要。如果已确认,脚本便会提取表单值并重置表单。重置非常有用,因为当对话框再次显示时,它会是空白的,可以重新提交。

总结

现在您已经知道我是如何解决的,您会怎么做? 🙂

让我们一起采用多样化的方法,学习所有在 Web 上构建应用的方法。

制作一个演示版,在推特上向我发送链接,我会将其添加到下方的社区混剪部分!

社区混剪作品

资源