构建对话框组件

简要介绍如何使用 <dialog> 元素构建可自适应颜色、响应式且易于访问的迷你模态框和大型模态框。

在这篇博文中,我想分享一下我对于如何使用 <dialog> 元素构建自适应颜色、响应式且易于访问的迷你模态框和巨型模态框的想法。试用演示版查看源代码

演示了巨型对话框和迷你对话框在浅色主题和深色主题下的显示效果。

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

概览

<dialog> 元素非常适合用于提供网页上的上下文信息或操作。考虑一下在哪些情况下,用户体验可以从单页操作中受益,而不是多页操作:可能是因为表单很小,或者用户唯一需要执行的操作是确认或取消。

<dialog> 元素最近在各个浏览器中都已稳定:

Browser Support

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

Source

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

Markup

<dialog> 元素的基本要求并不高。该元素将自动隐藏,并内置了用于叠加内容的样式。

<dialog>
  …
</dialog>

我们可以改进此基准。

从传统意义上讲,对话框元素与模态框有很多相似之处,并且名称通常可以互换。在这里,我大胆地将对话框元素用于小型对话框弹出式窗口(迷你)以及全页对话框(巨型)。我将它们分别命名为 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>

第一个菜单按钮具有 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>

对话框元素为可收集数据和用户互动的全视口元素提供了坚实的基础。这些基本要素可让您的网站或应用实现一些非常有趣且强大的互动。

无障碍

对话框元素具有非常出色的内置无障碍功能。与我通常的做法不同,这次无需添加这些功能,因为它们已经存在。

恢复焦点

正如我们在构建边栏导航组件中手动操作的那样,正确打开和关闭某项内容时,应将焦点放在相关的打开和关闭按钮上。当该侧边导航栏打开时,焦点会放在关闭按钮上。当用户按下关闭按钮时,焦点会恢复到打开该按钮的按钮。

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

遗憾的是,如果您想为对话框添加进入和退出动画,此功能将丢失。在 JavaScript 部分中,我将恢复该功能。

捕获焦点

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

Browser Support

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

Source

inert 之后,文档的任何部分都可以“冻结”,使其不再是焦点目标或无法通过鼠标进行互动。焦点不会被困住,而是会引导到文档中唯一可互动的部分。

打开并自动聚焦元素

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

使用 Esc 键关闭

请务必让用户能够轻松关闭此可能会中断操作的元素。幸运的是,对话框元素会为您处理 Esc 键,让您摆脱编排负担。

样式

设置对话框元素的样式有两种方法:简单方法和复杂方法。简单的方法是不更改对话框的 display 属性,并接受其限制。我走的是一条艰难的道路,为打开和关闭对话框提供自定义动画,接管 display 属性等。

使用 Open Props 设置样式

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

设置 <dialog> 元素的样式

拥有展示房源的产权

对话框元素的默认显示和隐藏行为是将 display 属性从 block 切换为 none。遗憾的是,这意味着它无法实现淡入淡出动画效果,只能实现淡入效果。我想让它在显示和隐藏时都带有动画效果,第一步是设置我自己的 display 属性:

dialog {
  display: grid;
}

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

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

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

为对话框提供自适应颜色主题

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

虽然 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 精心打造的 shadow props 之一实现深度:

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

自定义背景幕伪元素

我选择对背景进行非常轻微的调整,仅使用 backdrop-filter 为超级对话框添加模糊效果:

Browser Support

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

Source

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

我还选择在 backdrop-filter 上添加过渡效果,希望浏览器将来能够允许过渡背景幕元素:

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

屏幕截图:覆盖在模糊的彩色头像背景上的巨型对话框。

样式设置额外参数

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

滚动限制

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

通常,overscroll-behavior 是我的常用解决方案,但根据规范,它对对话框没有影响,因为对话框不是滚动端口,也就是说,它不是滚动器,因此没有什么需要阻止。我可以使用 JavaScript 来监控本指南中的新事件(例如“closed”和“opened”),并切换文档中的 overflow: hidden,或者等待 :has() 在所有浏览器中稳定下来:

Browser Support

  • Chrome: 105.
  • Edge: 105.
  • Firefox: 121.
  • Safari: 15.4.

Source

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

现在,当大型对话框打开时,HTML 文档具有 overflow: hidden

<form> 布局

除了是收集用户互动信息的重要元素之外,我还使用它来布局标题、页脚和文章元素。通过此布局,我打算将文章子级指定为可滚动区域。我使用 grid-template-rows 来实现此目的。 为 article 元素指定了 1fr,并且表单本身的最大高度与对话框元素相同。设置此固定高度和固定行大小可让 article 元素在溢出时受到限制并滚动:

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

设置对话框 <article> 的样式

article 元素在此对话框中具有特殊作用:它是一个空间,用于在对话框较高或较长时进行滚动。

为此,父表单元素为自身设置了一些最大值,这些最大值会限制此 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 Props 来调整:

@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 属性的变化,并相应地管理自定义事件。

与我们启动关闭和关闭事件的方式类似,创建两个名为 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 元素。最后,与关闭和已关闭事件类似,立即调度打开事件,等待动画完成,然后调度已打开事件。

添加已移除的活动

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

您可以使用另一个突变观测器来实现此目的。这次,我们不会观察对话框元素上的属性,而是观察 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 上构建的所有方式。

制作演示视频,通过 Twitter 向我发送链接,我会将其添加到下方的社区混音部分!

社区混音作品

资源