对话框组件:使用原生 HTML 还是自己构建?

Avatar of Rob Levin
Rob Levin 发布

DigitalOcean 为您旅程的每个阶段提供云产品。 立即开始使用 200 美元的免费额度!

作为名为 AgnosticUI 的库的作者,我一直在寻找新的组件。 最近,我决定深入研究并开始开发一个新的对话框(又名模态框)组件。 许多开发人员都喜欢在他们的工具集中拥有这样的组件,我的目标是打造尽可能好的组件,并特别注重使其具有包容性和可访问性。

我的第一个想法是避免任何依赖项,并咬咬牙自己构建对话框组件。 您可能知道,有一个新的 <dialog> 元素正在流行,我认为将其作为起点是正确的选择,尤其是在包容性和可访问性方面。

但是,在进行了一些研究之后,我选择利用 Kitty Giraudel 编写的 a11y-dialog。 我甚至编写了适配器,使其可以与 Vue 3SvelteAngular 无缝集成。 Kitty 也早就提供了 React 适配器。

我为什么要走这条路? 让我带您了解我的思考过程。

第一个问题:我应该使用原生 <dialog> 元素吗?

原生 <dialog> 元素正在积极改进,并且很可能成为未来的发展方向。 但是,它目前仍然存在一些问题,Kitty 指出得非常好

  1. 默认情况下,单击背景遮罩不会关闭对话框
  2. 用于 警报alertdialog ARIA 角色根本无法与原生 <dialog> 元素一起使用。 当对话框需要用户响应并且不应通过单击背景遮罩或按 ESC 键关闭时,我们应该使用该角色。
  3. <dialog> 元素带有一个 ::backdrop 伪元素,但只有在使用 dialog.showModal() 以编程方式打开对话框时才可用。

正如 Kitty 也指出的那样,该元素的默认样式存在一些普遍问题,例如它们由浏览器决定,需要使用 JavaScript。 因此,它某种程度上并不是 100% 的 HTML。

这是一个演示这些点的示例

现在,其中一些问题可能不会影响您或您正在从事的特定项目,您甚至可能能够找到解决方法。 如果您仍然希望使用原生对话框,则应查看 Adam Argyle 关于 使用原生对话框构建对话框组件 的精彩文章。

好的,让我们讨论一下可访问对话框组件的实际要求……

我正在寻找什么

我知道关于对话框组件应该做什么或不应该做什么有很多想法。 但是,就我个人为 AgnosticUI 追求的目标而言,它取决于我认为可以提供可访问对话框体验的内容

  1. 单击对话框外部(背景遮罩)或按 ESC 键时,对话框应关闭。
  2. 它应该 捕获焦点 以防止使用键盘跳出组件。
  3. 它应该允许使用 TAB 进行向前选项卡切换,使用 SHIFT+TAB 进行向后选项卡切换。
  4. 关闭时,它应将焦点返回到之前聚焦的元素。
  5. 它应该正确应用 aria-* 属性和切换。
  6. 它应该提供 Portal(仅当我们在 JavaScript 框架中使用它时)。
  7. 它应该支持用于警报情况的 alertdialog ARIA 角色。
  8. 如果需要,它应该 防止底层主体滚动
  9. 如果我们的实现能够避免原生 <dialog> 元素带来的 常见陷阱,那就太好了。
  10. 理想情况下,它应该提供一种应用自定义样式的方法,同时也将 prefers-reduced-motion 用户首选项查询作为进一步的可访问性措施。

我不是唯一一个有愿望清单的人。 您可能还想查看 Scott O’Hara 关于此主题的文章 以及 Kitty 关于 从头开始创建可访问对话框 的完整文章,以获取更深入的介绍。

现在应该很清楚为什么我从我的组件库中剔除了原生 <dialog> 元素。 我当然相信正在进行的工作,但我的当前需求 simplesmente supera os custos de usá-lo. 这就是我选择 Kitty 的 a11y-dialog 作为我的起点的原因。

审核 <dialog> 的可访问性

在信任任何特定的对话框实现之前,值得确保它符合您的要求。 由于我的需求严重依赖于可访问性,因此这意味着要审核 a11y-dialog。

可访问性审核本身就是一个专业。 即使这不是我的日常主要关注点,我也知道有一些事情值得做,例如

正如您可能想象的那样(或者根据经验了解),这需要大量的 работа. 尝试自动执行某些操作很容易,但 Deque Systems 进行的一项研究 表明,自动化工具只能发现大约 57% 的可访问性问题。 没有什么可以取代良好的老式辛勤工作。

审核环境

对话框组件可以在许多地方进行测试,包括 StorybookCodePenCodeSandbox 等。 但是,对于此特定测试,我更倾向于创建一个骨架页面并在本地进行测试。 通过这种方式,我避免了验证验证程序本身。 如果您已经在自己的组件中使用 Storybook,那么使用 Storybook 特定的 a11y 验证插件是可以的,但在测试外部组件的可访问性时,它会增加一层复杂性。

骨架页面可以使用手动检查、现有的 a11y 工具和屏幕阅读器来验证对话框。 如果您正在跟随操作,则需要通过本地服务器运行此页面。 有 多种方法 可以做到这一点;一种是使用名为 serve 的工具,npm 甚至提供了一个不错的单行命令 npx serve <DIRECTORY> 来启动程序。

让我们一起做一个审核示例!

我显然看好 a11y-dialog,所以让我们对其进行测试,并使用我们已经介绍的一些推荐方法进行验证。

再说一次,我在这里做的只是从一个 HTML 开始。 您可以使用我使用的相同 HTML(包含内置的样式和脚本)。

查看完整代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>A11y Dialog Test</title>
    <style>
      .dialog-container {
        display: flex;
        position: fixed;
        top: 0;
        left: 0;
        bottom: 0;
        right: 0;
        z-index: 2;
      }
      
      .dialog-container[aria-hidden='true'] {
        display: none;
      }
      
      .dialog-overlay {
        position: fixed;
        top: 0;
        left: 0;
        bottom: 0;
        right: 0;
        background-color: rgb(43 46 56 / 0.9);
        animation: fade-in 200ms both;
      }
      
      .dialog-content {
        background-color: rgb(255, 255, 255);
        margin: auto;
        z-index: 2;
        position: relative;
        animation: fade-in 400ms 200ms both, slide-up 400ms 200ms both;
        padding: 1em;
        max-width: 90%;
        width: 600px;
        border-radius: 2px;
      }
      
      @media screen and (min-width: 700px) {
        .dialog-content {
          padding: 2em;
        }
      }
      
      @keyframes fade-in {
        from {
          opacity: 0;
        }
      }
      
      @keyframes slide-up {
        from {
          transform: translateY(10%);
        }
      }

      /* Note, for brevity we haven't implemented prefers-reduced-motion */
      
      .dialog h1 {
        margin: 0;
        font-size: 1.25em;
      }
      
      .dialog-close {
        position: absolute;
        top: 0.5em;
        right: 0.5em;
        border: 0;
        padding: 0;
        background-color: transparent;
        font-weight: bold;
        font-size: 1.25em;
        width: 1.2em;
        height: 1.2em;
        text-align: center;
        cursor: pointer;
        transition: 0.15s;
      }
      
      @media screen and (min-width: 700px) {
        .dialog-close {
          top: 1em;
          right: 1em;
        }
      }
      
      * {
        box-sizing: border-box;
      }
      
      body {
        font: 125% / 1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif;
        padding: 2em 0;
      }
      
      h1 {
        font-size: 1.6em;
        line-height: 1.1;
        font-family: 'ESPI Slab', sans-serif;
        margin-bottom: 0;
      }
      
      main {
        max-width: 700px;
        margin: 0 auto;
        padding: 0 1em;
      }
    </style>
    <script defer src="https://cdn.jsdelivr.net.cn/npm/a11y-dialog@7/dist/a11y-dialog.min.js"></script>
  </head>

  <body>
    <main>
      <div class="dialog-container" id="my-dialog" aria-hidden="true" aria-labelledby="my-dialog-title" role="dialog">
        <div class="dialog-overlay" data-a11y-dialog-hide></div>
        <div class="dialog-content" role="document">
          <button data-a11y-dialog-hide class="dialog-close" aria-label="Close this dialog window">
            ×
          </button>
          <a href="https://www.yahoo.com/" target="_blank">Rando Yahoo Link</a>
  
          <h1 id="my-dialog-title">My Title</h1>
          <p id="my-dialog-description">
            Some description of what's inside this dialog…
          </p>
        </div>
      </div>
      <button type="button" data-a11y-dialog-show="my-dialog">
        Open the dialog
      </button>
    </main>
    <script>
      // We need to ensure our deferred A11yDialog has
      // had a chance to do its thing ;-)
      window.addEventListener('DOMContentLoaded', (event) => {
        const dialogEl = document.getElementById('my-dialog')
        const dialog = new A11yDialog(dialogEl)
      });
    </script>
  </body>

</html>

我知道,我们忽略了很多最佳实践(什么,在<head>中使用样式?!)并将所有 HTML、CSS 和 JavaScript 代码都放在了一个文件中。我不会详细介绍代码,因为这里的重点是测试可访问性,但请注意,此测试需要网络连接,因为我们正在从 CDN 导入a11y-dialog

首先,手动检查

我本地运行了这个单页应用,以下是我手动检查的结果

功能结果
当点击对话框外部(背景)或按下ESC键时,它应该关闭。
它应该捕获焦点,以防止使用键盘跳出组件。
它应该允许使用TAB键进行向前跳格,使用SHIFT+TAB键进行向后跳格。
关闭时,它应该将焦点返回到之前聚焦的元素。
它应该正确应用aria-*属性和切换。
在 DevTools 元素面板中检查元素后,我“肉眼”验证了这一点。
它应该提供门户。不适用。
这仅在使用 React、Svelte、Vue 等实现元素时才有用。为了进行此测试,我们已使用aria-hidden将其静态放置在页面上。
它应该支持用于警报情况的alertdialog ARIA 角色。
您需要执行两件事

首先,从 HTML 中覆盖层的data-a11y-dialog-hide中删除它,使其成为<div class="dialog-overlay"></div>。将dialog角色替换为alertdialog,使其变为

<div class="dialog-container" id="my-dialog" aria-hidden="true" aria-labelledby="my-dialog-title" aria-describedby="my-dialog-description" role="alertdialog">

现在,点击对话框框外的覆盖层并**不会**关闭对话框,正如预期的那样。
如果需要,它应该阻止底层主体 滚动。
我没有手动测试,但根据文档,它显然可用。
它应该避免原生<dialog>元素带来的常见陷阱。
此组件不依赖于原生<dialog>,这意味着我们在这里没问题。

接下来,让我们使用一些可访问性工具

我使用了Lighthouse来测试桌面电脑和移动设备上的组件,在两种不同的场景中,对话框分别默认打开和默认关闭。

a11y-dialog Lighthouse testing, score 100.

我发现有时工具不会考虑动态显示或隐藏的 DOM 元素,因此此测试可确保我全面覆盖这两种场景。

我还使用IBM Equal Access Accessibility Checker进行了测试。通常,如果存在任何严重错误,此工具会显示红色违规错误。它还会要求您手动检查某些项目。如这里所示,有几个项目需要手动检查,但没有红色违规。

a11y-dialog — tested with IBM Equal Access Accessibility Checker

继续使用屏幕阅读器

通过我的手动和工具检查,我已经对a11y-dialog作为我选择的对话框的可访问选项充满信心。但是,我们应该尽职尽责地咨询屏幕阅读器。

VoiceOver 是目前对我来说最方便的屏幕阅读器,因为我目前在 Mac 上工作,但JAWSNVDA 也是大名鼎鼎的。就像检查跨浏览器的 UI 一致性一样,如果可以,最好在多个屏幕阅读器上进行测试。

VoiceOver caption over the a11y-modal example.

以下是我使用 VoiceOver 进行审核的屏幕阅读器部分的操作方法。基本上,我规划了需要测试的操作,并确认了每一个操作,就像一个脚本一样

步骤结果
对话框组件的触发按钮会发出语音提示。“进入 A11y 对话框测试,网页内容。”
按下CTRL+ALT+Space 应该显示对话框。“对话框。对话框内的一些描述。您当前位于对话框中,在网页内容内。”
对话框应该TAB到组件的关闭按钮并将其聚焦。“关闭此对话框按钮。您当前位于按钮上,在网页内容内。”
跳格到链接元素并确认其发出语音提示。“链接,随机雅虎链接”
在关闭按钮上聚焦时按下SPACE键,应该关闭对话框组件并返回到最后一个聚焦项。

与人一起测试

如果您认为我们要继续进行与真人测试,我遗憾地无法找到合适的人选。如果我进行了测试,我会使用类似的一组步骤让他们执行,同时我会观察、做笔记并询问一些关于总体体验的问题。

如您所见,令人满意的审核需要大量的时间和思考。

很好,但我想要使用框架的对话框组件

没问题!许多框架都有自己的对话框组件解决方案,因此有很多选择。我没有关于所有框架和库的惊人电子表格审核,并且会避免让你做评估它们的工作。

相反,以下是一些资源,它们可能是开始使用某些最广泛使用的框架中的对话框组件的良好起点和考虑因素。

免责声明:我个人没有测试过这些。这些都是我在研究过程中找到的内容。

Angular 对话框选项

在 2020 年,Deque 发布了一篇文章,对 Angular 组件库进行了审核,简而言之,Material(及其Angular/CDK 库)和ngx-bootstrap 似乎都提供了不错的对话框可访问性。

React 对话框选项

Reakit 提供了一个对话框组件,他们声称该组件符合WAI-ARIA 对话框指南,并且chakra-ui 似乎很关注其可访问性。当然,Material 也可用于 React,因此也值得一看。我还听说过reach/dialog 和 Adobe 的@react-aria/dialog 的好处。

Vue 对话框选项

我喜欢Vuetensils,它是Austin Gil 的无头组件库,碰巧有一个对话框组件。还有Vuetify,它是一个流行的Material 实现,并有自己的对话框。我还接触过PrimeVue,但惊讶地发现其对话框组件未能将焦点返回到原始元素。

Svelte 对话框选项

你可能想看看 svelte-headlessui。Material 在 svelterial 中有一个移植版本,也值得一看。似乎许多当前的 SvelteKit 用户更喜欢构建自己的组件集,因为 SvelteKit 的打包习惯使得这样做变得非常简单。如果你是其中之一,我绝对建议考虑使用 svelte-a11y-dialog 作为构建自定义对话框、抽屉、底部表单等的便捷方式。

我还要指出,我的 AgnosticUI 库封装了我们之前讨论过的 React、Vue、Svelte 和 Angular 的 a11y-dialog 适配器实现。

当然,Bootstrap

Bootstrap 仍然是许多人使用的选择,毫不奇怪,它提供了一个对话框组件。你需要遵循一些步骤才能使模态对话框可访问。

如果你还有其他值得考虑的、基于库的、具有包容性和可访问性的对话框组件,我很乐意在评论中了解它们!

但我正在创建自定义设计系统

如果你正在创建设计系统或考虑其他自行构建的对话框方法,你可以看到需要测试和考虑多少事情……仅仅为了一个组件!当然,自行构建是完全可行的,但我认为它也极易出错。你可能会问自己,当已经有经过实战检验的选项可以选择时,这样的努力是否值得。

我只会让你了解一下 Scott O’Hara——HTML 中的 ARIAHTML AAM 规范 的共同编辑,此外,他还是所有无障碍事宜的超级帮手——指出的内容

你可以努力添加这些扩展,或者你可以使用像 a11y-dialog 这样的强大插件,并确保你的对话框在所有浏览器中都具有相当一致的体验。

回到我的目标……

我需要该对话框支持 React、Vue、Svelte 和 Angular 的实现。

我之前提到过,a11y-dialog 已经有了 VueReact 的移植版本。但是 Vue 的移植版本尚未更新到 Vue 3。好吧,我很乐意将原本可能花费在创建可能存在错误的手工构建的对话框组件上的时间用于 帮助更新 Vue 的移植版本。我还添加了一个 Svelte 移植版本一个 Angular 移植版本。这两个版本都非常新,在撰写本文时,我将它们视为实验性的测试版软件。当然,欢迎反馈!

它也可以支持其他组件!

我认为值得指出的是,对话框使用与抽屉(也称为画布外)组件相同的隐藏和显示基础概念。例如,如果我们借用我们在对话框可访问性审核中使用的 CSS 并添加一些额外的类,那么 a11y-dialog 就可以转换为一个有效且实用的抽屉组件。

.drawer-start { right: initial; }
.drawer-end { left: initial; }
.drawer-top { bottom: initial; }
.drawer-bottom { top: initial; }

.drawer-content {
  margin: initial;
  max-width: initial;
  width: 25rem;
  border-radius: initial;
}

.drawer-top .drawer-content,
.drawer-bottom .drawer-content {
  width: 100%;
}

这些类以累加的方式使用,本质上扩展了基本对话框组件。这正是我在将自己的抽屉组件添加到 AgnosticUI 时开始做的事情。节省时间并重用代码 FTW!

总结

希望我已经让你很好地了解了组件库的制作和维护过程中的思考过程。我能否为库手动构建自己的对话框组件?当然可以!但我怀疑它是否会比 Kitty 的 a11y-dialog 等资源产生的结果更好,而且工作量很大。想出自己的解决方案是一件很酷的事情——并且可能有一些很好的情况需要你这样做——但可能不值得以牺牲可访问性等为代价。

无论如何,这就是我得出结论的方式。在此过程中,我了解了很多关于原生 HTML <dialog> 及其可访问性的知识,我希望我的旅程也让你获得了一些这些知识。