构建对话框组件

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

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

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

如果你更喜欢视频,可以参阅此博文的 YouTube 版本:

概览

<dialog> 元素非常适合页内背景信息或操作。考虑在什么情况下用户体验可以从同页操作(而非多页操作)中受益:可能是因为表单很小,或者用户要求执行的唯一操作是确认或取消。

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

浏览器支持

  • 37
  • 79
  • 98
  • 15.4

来源

我发现该元素缺少一些内容,因此在此 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>

dialog 元素为可以收集数据和用户互动的完整视口元素提供了坚实的基础。这些基本要素有助于在网站或应用中进行一些非常有趣且有效的互动。

无障碍

dialog 元素具有非常好的内置无障碍功能。与我平常添加的功能不同,许多功能已经添加。

正在恢复焦点

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

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

遗憾的是,如果您希望为对话框添加动画效果,或者为对话框添加动画效果,此功能将无法使用。在“JavaScript”部分,我将恢复此功能。

陷阱重点

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

浏览器支持

  • 102
  • 102
  • 112
  • 15.5

来源

inert 之后,文档的任何部分都可以“冻结”,以便不再是焦点目标或与鼠标互动。它不是限制焦点,而是将焦点引导至文档中唯一的互动部分。

打开元素并自动聚焦

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

使用 Esc 键结束

请务必让用户可以轻松关闭这个可能造成干扰的元素。幸运的是,dialog 元素会为您处理 Esc 键,让您摆脱编排方面的负担。

风格

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

使用开放道具设置样式

为了加快自适应颜色和整体设计一致性,我无耻地引入了 CSS 变量库 Open Props。除了免费提供的变量之外,我还导入标准化文件和一些按钮,Open Props 将这两者作为可选的导入项提供。这些导入可以帮助我专注于自定义对话框和演示,同时不需要大量样式来支持它并使其看起来很美观。

设置 <dialog> 元素的样式

拥有展示广告媒体资源

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

dialog {
  display: grid;
}

通过更改 display 属性值并使其拥有所有权(如上面的 CSS 代码段中所示),您需要管理大量样式,以促进适当的用户体验。首先,对话框的默认状态为关闭。您可以直观地表示此状态,并阻止对话框接收与以下样式的互动:

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

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

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

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

虽然 color-scheme 会为您的文档选择浏览器提供的自适应颜色主题,以适应浅色和深色系统偏好设置,但我想自定义的对话框元素更多。Open Props 提供几种表面颜色,这些颜色可根据浅色和深色系统偏好设置自动调整,类似于使用 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,这会使对话框内容位于视口的底部。通过一些样式调整,我可以将对话框变成更接近用户拇指的操作表:

@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
  • 9

来源

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

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

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

一个超级对话框的屏幕截图,其中叠加了彩色头像的模糊处理背景。

样式设置 extra

我将此部分称为“extras”,因为它与对话框元素演示相比,它与对话框元素通常具有更多关系。

滚动包含

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

正常情况下,overscroll-behavior 是我的常用解决方案,但根据相关规范,它对对话框没有影响,因为它不是滚动端口,也就是说,它不是滚动条,因此没有要阻止的内容。我可以使用 JavaScript 监控本指南中的新事件(例如“closed”和“opened”),并在文档上切换 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>

此元素的作用是为对话框内容提供标题,并提供易于找到的关闭按钮。系统还为它指定了 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 布局信息的屏幕截图。

设置标题关闭按钮的样式

由于演示版使用的是 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>

文章元素在此对话框中发挥着特殊作用:该元素是一个在较长或较长的对话框的情况下可滚动的空间。

为此,父表单元素为自己设定了一些上限,以便在文章元素过高时限制其达到上限。设置 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 开发者工具的屏幕截图,其中叠加了页脚菜单项上的 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 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 可以检索该字符串,以深入了解该对话框的关闭方式。您会发现,每次从各个按钮调用该函数时,我都提供了关闭字符串,以便为应用提供有关用户互动的上下文。

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

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 是一个异步函数;然后,它就可以对返回的 promise 执行 await 操作,并放心地前进到关闭的事件。

添加打开活动和已打开事件

添加这些事件并不那么容易,因为内置对话框元素不会像关闭事件那样提供打开事件。我使用 MutationObserver 来提供有关对话框属性变化的数据分析。在这个观察器中,我将监视打开属性的变化,并相应地管理自定义事件。

与启动结束事件和结束事件的方式类似,创建两个名为 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。如果移除了对话框,系统会移除点击和关闭事件以释放内存,并分派已移除的自定义事件。

移除加载属性

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

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

社区混剪作品

资源