#无障碍访问# 弹窗组件

3,234 阅读14分钟

对话框 (dialog) 是覆盖在主窗口或另一个对话框窗口上的窗口。模态对话框下的窗口通常是处于不活跃的状态的。也就是说,用户不能与活动对话框窗口之外的内容进行交互。这些不可用的内容通常在视觉上被模糊或变暗以弱化它们的视觉呈现。在某些场景中,尝试与这些不可用内容交互会触发对话框的关闭。-- WAI-ARIA Authoring Practices 1.2

对话框其实是有模态非模态两种状况,不是本文的重点,大家可以下来自行查阅。

背景

最近在研究做一个 C 测使用的能定制 UI 的 Low level 弹窗组件。

在弹窗自身的逻辑中,无障碍访问是我目前觉得最复杂但也最容易被人忽视的点。

这边我大概总结了以下几个需要考虑的障碍

  1. 键盘访问
  2. 屏幕阅读器
  3. 鼠标访问
  4. 屏幕适配
  5. 无 CSS
  6. 无 JS

扫除了这些障碍,我们也就做到了弹窗组件的无障碍访问

一、键盘访问

键盘访问是弹窗里面最复杂的一个逻辑,W3C 官方也有非常详细的内容给大家介绍,这里大概按照自己的理解给大家介绍。

W3C 文档

W3C英文原文

键盘可访问元素:是指任何 tabindex 值为 0 或更大的元素「 强烈不建议使用大于 0 的值 」。

  • 当对话框打开时,需要让对话框内的某一个元素获取焦点。
  • Tab:
    • 将焦点移动到对话框内的下一个可访问元素;
    • 如果焦点在对话框内的最后一个可访问元素上,则将焦点移动到对话框内的第一个可选元素上;
  • Shift + Tab:
    • 将焦点移动到对话框内前一个可访问元素。
    • 如果焦点在对话框内的第一个访问元素上,则将焦点移动到对话框内的最后一个可选元素上。
  • Escape: 关闭对话框

打开弹窗时,第一个需要获取焦点的元素的规则如下:

  1. 在所有情况下弹窗打开之后,焦点都只能是对话框中的元素;
  2. 除非特殊指定,否则默认将焦点设置在第一个可访问元素上;
  3. 如果容器内容足够大,第一个可以访问的元素如果位置靠下,可能会导致弹窗顶部区域移出视区。建议把第一个可以访问的元素设定在弹窗顶部区域,如标题,或者正文的第一段,添加 tabindex = -1
  4. 如果对话框处于某个流程中不容易逆转的最后一步,比如数据删除或和金钱相关的不可逆操作。建议将焦点放在破坏性最小的操作上,就是放到取消或者关闭按钮上。Alert弹窗一般就是用于处理这种情况的;
  5. 如果对话框仅限于提供附加信息或需要继续处理的交互,则建议将焦点设置为到常用的元素上,例如 OK 或 Continue 按钮。

关闭弹窗时,应该默认将焦点回退到弹窗显示之前相关的位置「 比如触发这个弹窗显示的按钮 」。但是有以下几个状况需要特殊考虑:

没有触发弹窗显示的相关元素,那就选中能保证逻辑流程可以持续进行的元素。

如果工作流中有如下状况,可以特殊考虑

  • 非常不希望用户再次唤起这个弹窗;
  • 这个弹窗操作完成之后,需要用户进入到下一个流程;

例如,在一个网格中有一个包含添加行按钮相关联的工具栏。添加行按钮打开一个提示输入添加行数的对话框。当这个对话框关闭后,焦点应该放在第一个新行的第一个单元格中,而不是回到那个添加按钮上。

强烈建议所有对话框的制表符序列中包含一个带有 role="button"可见的元素,且点击这个元素可以关闭弹窗。例如关闭图标或取消按钮「 不然无法通过键盘关闭弹窗 」。

实现

对于把焦点固定锁定在某个元素之内,这边有个组件可以推荐给大家:

  1. focus-trap
  2. focus-trap-react

这个组件能帮你实现上面约 90% 焦点相关的逻辑。不过对于页面打开,第一个需要聚焦的元素,和关闭弹窗之后需要回退焦点的问题,这个还是得结合实际的场景进行定制

<div>不能被获取焦点</div>
<div tabindex="-1">可以获取焦点,但不能被键盘访问</div>
<div tabindex="0">可以获取焦点,也能被键盘访问</div>

这边还需要特别注意的点是,如果一个元素tabindex="-1"它虽然可以获取焦点,但是却无法被键盘访问。所以在做无障碍访问的时候,标签语意化就显得比较的重要。

<dialog>
    <button typef="button">遮罩</button>
    <section tabindex="-1">
       <!-- 弹窗内容 -->
       <button type="button">关闭</button>
    </section>
</dialog>

所以这里提供大家一个人认为比较好的排布方式。

  1. 我们第一个可访问元素是遮罩,能保证在没有特别指定焦点的情况下,如果用户只是手抖打开了弹窗,默认会让遮罩获取较淡,然后可以直接回车把弹窗关掉;
  2. 中间的主体容器采用tabindex="-1",可以保证在没有遮罩和关闭按钮的弹窗内,我们至少有一个可以获取焦点的元素。但是当用户在用键盘的 tab 键切换焦点的时候,又可以忽略掉这个元素;
  3. 最后一个可访问元素是关闭按钮,这样在用户在用键盘的 tab 键扫完整个弹窗的最后如果还没有操作,用户可以关闭弹窗。这样就比较好的形成了一个轮回;
  4. 遮罩和关闭我们都采用了默认具有可访问性的元素,减少代码也增加可访问性;
<button type="button">按钮1</button>
<button type="button" style="visibility:hidden;">按钮2</button>
<button type="button" style="opacity: 0;">按钮3</button>
<button type="button" style="pointer-events: none;">按钮4</button>

还有一个问题就是,以上按钮中只有 1,3,4可以被键盘访问到。

这个在做弹窗动画的时候特别要注意,因为往往弹窗动画结束的时候,我们的元素才处于 visibility:visible 的状态。动画还没有结束就绑定焦点监听事件是会失效的。

二、屏幕阅读

W3C英文原文

这里虽然归类在了屏幕阅读下,其实是除开上面提到的键盘访问之外的其它弹窗无障碍访问的官方细则。包括弹窗的角色,状态,属性...

  1. 被作为弹窗的容器应该具有 role="dialog" 属性;
  2. 其它所有可以操作这个弹窗的元素都应该是这个弹窗的后代元素;
  3. 弹窗容器应该具有 aria-modal="true" 属性
  4. 同时这个弹窗容器还应该:
    • aria-labelledby="[id]"属性,这个 id 指向这个弹窗内可见的标题元素;
    • 通过 aria-label 属性添加 label;
  5. 可选属性,aria-describedby="[id]", 这个 id 指向 能指示对话框中的哪个或哪些元素包含描述对话框的主要用途或消息的内容的元素。指定描述性元素使屏幕阅读器能够在对话框打开时,连同对话框标题和最初的焦点元素一起被朗读出来。
<dialog
    role="dialog"
    aria-modal="true"
    aria-describedby="alert-dialog-description" aria-labelledby="alert-dialog-title"
>
    <button typef="button" aria-label="遮罩"><button>
    <section tabindex="-1">
        <h2 id="alert-dialog-title">标题</h2>
        <p id="alert-dialog-description">正文</p>
        <button type="button" aria-label="关闭">&times;</button>
    </section>
</dialog>

这里的关闭按钮我们用了一个 HTML 字符 &tiems; 代替,如果这个按钮不设置 aria-label 就会直接读出这个字符。 当我们进入到屏幕阅读模式,首次打开弹窗,然后点击关闭按钮的时候,会听到这样的朗读:

标题 => 网页对话框 => 正文 => 关闭 => 按钮

在查看 bootstrapant design 的弹窗设计的时候发现,他们都在 section 这一层添加了 role="document" 这个属性。经自己测试有了这个属性之后。我们之前的朗读顺序会变成这样:

关闭 => 按钮 => 结尾 => 标题 => 网页对话框 => 正文

可以看到这里朗读的先后顺序发生了变化,会先朗读按钮信息再朗读弹窗信息。第二点是这里如果我们的按钮是弹窗里面的最后一个可访问元素,这里会同时朗读出 「结尾」这个单词。但是这种方式「 正文 」的内容不管有没有设定 aria-describedby 都不会朗读了。

material-ui 没有这个属性,与之相似的是使用了 role="presentation"role="none presentation" 这个属性。但是这个属性的朗读结果和第一种方式类似。并未发现有其它会特别朗读的特性。

因为 aria-describedby 是可选的,所以这两种状况都是可以的,大家可以结合自己实际项目去看。

三、鼠标访问

当用户把鼠标移入到上一节的提到的关闭按钮上的时候。

因为已经换成了图标,用户并不会知道这个按钮是干嘛用的。此时我们就可以给按钮添加 title="点击关闭对话框" 属性,当用户鼠标移入这个按钮时,就会看到带点击关闭对话框字样的气泡提示。

当然我们的title也是会被屏幕阅读器捕捉到的。上面两种方式会听到如下的朗读方式:

  1. 标题 => 网页对话框 => 正文 => 关闭 => 按钮 => 点击关闭对话框
  2. 关闭 => 按钮 => 结尾 => 标题 => 网页对话框 => 点击关闭对话框

同理我们也可以为遮罩添加 title 属性,以明确告知用户这个遮罩点击之后是可以关闭弹窗的「 有的弹窗点击遮罩的时候是不会关闭弹窗的 」。

四、无 CSS

对于无 CSS 这个点通常是说我们的 CSS 资源有可能加载失败。解决这个最简单粗暴的方式就是 CSS 样式内联进 HTML。

没有了 CSS 资源请求也就无所谓资源加载失败这一说。也是目前很多构建工具默认的一种方式。这也是一种优化我们网页加载体验的手段之一。

当然这边有一个冷知识。就是还真的是有没有 CSS 的弹窗的。那就是我们浏览器自带 UI 的一些默认弹窗。

  • window.alert('hello world!');
  • window.confirm('hello world!');
  • window.prompt("Please enter your name","");
  • window.createPopup(); 仅限IE
  • HTML dialog 对象 domDialog.show() 或者 domDialog.showModal()

在实际项目中,我们往往都会自定义样式去替换这些逻辑。不过对于我们自定义的弹窗接口的设计可以参考这些原生的语法。

或者我们对于 UI 没有要求的产品,用系统默认的弹窗其实也是一个不错的选择(除了视觉没有那么符合预期之外)。

五、无 JS

除了和 CSS 资源没有加载成功这个逻辑之外,还有可能是你的 JS 里面有错误,导致弹窗的事件并没有初始化或者触发弹窗显示的事件没有执行。

通常做无障碍访问处理 JS 的问题都是利用 CSS 的能力来做降级。对于 CSS 显示弹窗我知道的方式有两种。

1. chekckbox 开关

dialog{
  display:none;
}
.dialog-switch:checked ~ dialog{
  display:block;
}
<label for="dialogSwitch">显示弹窗</label>
<input id="dialogSwitch" type="checkbox" class="dialog-switch" />
<dialog>
    <label for="dialogSwitch">关闭弹窗</label>
    我是弹窗
<dialog>

Codepen 在线 Demo

2. Target 触发

dialog{
  display:none;
}
dialog:target{
  display:block;
}
<a href="#dialog">显示弹窗</a>
<dialog id="dialog">
    <a href="##">关闭弹窗</a>
    我是弹窗
<dialog>

Codepen 在线 Demo

这两个种方式都可以利用纯 CSS 的形式来触发弹窗的显示逻辑。但是这两种方式对于 HTML 都是有侵入性的修改。所以这里只是提供该大家一种参考思路。

六、屏幕适配

用户设备千千万,不能围着他们转。

屏幕适配一旦开始去考虑是去兼容 PC 还是兼容 M 站或者用户的不同的设备就会显得十分的被动

我的经验是直接去兼容分辨率会更加的优雅。就是说在这个分辨率区间的用户都应该长这个样子,我不关心你是什么设备。

因为是适配分辨率,所以我们可以拆成两个方向去适配。

横向

<dialog open class="dialog-box">
    <section class="dialog">
       <!-- 弹窗内容 -->
    </section>
</dialog>
.dialog-box{ /*...*/ }
.dialog{
  background-color:#ffffff;
  padding: 16px;
  box-sizing:border-box;
  margin-left: auto;
  margin-right:auto;
  max-width:480px; /* 重点 */
  width:90%;
}

Codepen 在线 Demo

横向的适配比较简单,就是用 max-width 让我们的弹窗在水平方向上呈现一个流式的状态。这样我们的弹窗,在超过 480px / 90% 的屏幕上是居中的, 在小于这个尺寸的时候是占 90% 的。无所谓是什么设备。

纵向

和适配横向相同的是在浏览器窗口高于弹窗的时候弹窗应该居中。不同点在于当浏览器高度小于弹窗高度的时候,因为没有地方可以拓展,我们只能选择滚动条的方式。滚动条有内滚动外滚动两种方式。

  • 内滚动:在标题和底部的按钮区域滚动,有可能出现浏览器高度小于内滚动区域之外的内容;
  • 外滚动:整个弹窗区域滚动,会出现标题和按钮区被遮住的情况;

这个根据实际场景选择,这里以外滚动为例。

.dialog-box{
  /* 和横向相同的代码 */
  overflow:auto;
  display:flex;
}
.dialog{
  /* 和横向相同的代码*/
  margin:auto;
}

Codepen 在线 Demo

如果你的产品可用使用 flex 布局那么恭喜你,你只需要这一丢丢的代码就可以实现以上的逻辑。

如果你的产品不能是用 flex 那么就可能需要考虑一些奇技淫巧

/* 奇技淫巧 */
.dialog-box{
  text-align:center;
}
.dialog-box:after{
  content:'';
  display:inline-block;
  height:100%;
  border-left:1px solid red; /* 只是为了展示,实际不需要 */
  vertical-align:middle;
  margin-left:-8px;
}
.dialog{
  text-align:left;
  display:inline-block;
  vertical-align:middle;
}

Codepen 在线 Demo

采用这个你就能得到一个完美适配所有分辨率,且兼容性还不错的弹窗。

结语

可以看到小小弹窗几乎涵盖了,网页中几乎所有的需要考虑到的无障碍访问细节点。对于无障碍访问我想说的是,它是一个很难快速看出收益的功能。

一个网站,它一点无障碍都没有做,基本也是可以正常使用的。但是如果有一天你的鼠标突然没电,你又急于使用这个网站,你就会吐槽说这个网站的体验真差。

这边基于以上的理论写了一个可以自定义 UI 的 react dialog 组件 @_nu/react-dialog,因为刚刚写完,还没有测试过可能有bug,感兴趣的同学可以给我提 issue。

对于文中提到的项目技术方案和文档罗列如下:

  1. WAI-ARIA Authoring Practices 1.2
  2. @davidtheclark/focus-trap
  3. @davidtheclark/focus-trap-react
  4. @davidtheclark/react-aria-modal
  5. bootstrap-modal
  6. ant design-modal
  7. material-ui dialog