构建对话框组件

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

在本文中,我想分享一下如何使用 <dialog> 元素构建颜色自适应、响应迅速且易于访问的小模态窗口和超级模态窗口。试用演示版查看源代码

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

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

概览

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

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

浏览器支持

  • Chrome:37.
  • 边缘: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 元素,这是内置的默认行为:

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

捕获焦点

对话框元素会在文档中为您管理 inert。在 inert 之前,JavaScript 用于观察焦点是否离开元素,届时它会截获并将其放回。

浏览器支持

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

来源

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

打开元素并自动聚焦

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

使用 Esc 键关闭

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

样式

您可以通过简单的方式和困难的方式为对话框元素设置样式。简便路径的实现方法是不更改对话框的显示属性,也不考虑其局限性。我沿着艰难的道路继续努力,提供用于打开和关闭对话框的自定义动画,并接管 display 属性等。

使用 Open 属性设置样式

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

设置 <dialog> 元素的样式

拥有显示属性

对话框元素的默认显示和隐藏行为会将 display 属性从 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。这非常适合在设计中创建图层,而且我喜欢使用颜色来帮助在视觉上支持图层 Surface 的这种外观。背景颜色为 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。
  • Edge:105.
  • Firefox:121.
  • Safari:15.4.

来源

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

现在,当打开超级对话框时,HTML 文档中会显示 overflow: hidden

<form> 布局

除了是收集用户互动信息的重要元素之外,我还会在此处使用它来排列标题、页脚和文章元素。通过这种布局,我打算将文章子项定义为可滚动区域。我使用 grid-template-rows 来实现这一点。为 article 元素指定 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> 设置样式

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 属性。之前,指南将“display”设置为“grid”,但从未将其设置为“none”。这样一来,您就可以实现进入和退出动画效果了。

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

  1. “减少动作”是默认的转换效果,即简单的透明度淡入淡出。
  2. 如果允许移动,则添加滑动和缩放动画。
  3. 将超级对话框的自适应移动设备布局调整为滑出。

安全且有意义的默认转换

虽然 Open Props 自带用于淡入和淡出的关键帧,但我更喜欢将这种分层过渡方法作为默认方法,并将关键帧动画作为潜在升级。我们之前已经使用不透明度设置了对话框的可见性,并根据 [open] 属性协调了 10。如需在 0% 和 100% 之间转换,请告知浏览器您想要的加/减速类型及时长:

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

为转场效果添加动画

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

@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 可以检索此字符串,以深入了解对话框是如何关闭的。您会发现,我还在每次通过各种按钮调用该函数时提供了关闭字符串,以便向应用提供有关用户互动的上下文。

添加结束活动和已关闭活动

dialog 元素附带一个关闭事件:调用对话框 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 函数(也在构建消息框组件中使用)会根据动画和过渡 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 属性

为防止对话框动画添加到页面或在页面加载时播放其退出动画,我们为对话框添加了一个加载属性。以下脚本会等待对话框动画运行完毕,然后移除该属性。现在,对话框可以随意以动画形式出现和退出,并且我们有效隐藏了一个会分散注意力的动画。

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 微博中发送链接,然后我会将其添加到下面的“社区混剪”部分!

社区混剪作品

资源