A11Y:构建可具包容性的价格
在开发电子商务网站或应用避免不了价格这个字段。事实上呢?很多电子商务网站的页面或应用的价格字段都没有提供一个较了的用户体验,或者说没有提供无障碍体验。而作为 Web 开发者,我们一些小小的改变就可以大大提升用户体验。接下来和大家一起聊聊,我们应该如何在自己的项目中为价格做出小小的改变来提高用户的体验。
当你走进一家实体店
虽然互联网给我们的生活带来了巨大的改变,也改变了我们的生活中衣食住行等方式,但总是难免会在线下走进实体店购物。想象一下,你有一天走进一家实体店,想买一件衣服。逛了一会,你找到了一件自己中意的衣服,想购买。你看了看价格标签,却发现有两个没有视觉差异的价格,比如 RMB399 和 RMB199。我想此时你肯定会感到困惑,这件衣服的价格到底是多少?
你决定找一名导购员咨询,想询问这件衣服的价格是多少?
此时估计你更会感到困惑,会继续询问:“对不起,也许我误解了。这件衣服的价格是多少”?要是导购员再次回答你:“RMB399,RMB199”。你可能会觉得自己的智商已经达到了零,或者会感觉到自己在语言的描述上有障碍,让对方没能理解自己需要表达的意思。或许你会尝试着再一次向导购描述你的疑惑,想再确认自己喜欢的物品价格。或许你再次得到相同的答案,或许会决定换过一个店购物。
你可能会说真实世界哪会有这样的客户体验!
虚拟世界的价格
可能正如你所说的,真实世界没有这样的客户体验(导购员没傻到那种程度)。那我们从线下回到线上(网格的虚拟世界)。
浏览线上一些主流的电子商务网站,商品售卖价格会有多种表达方式。最为常见的一种是一个是现价(当前售卖价)和一个是商品原价。在视觉上他们会有一定的差异:
- 当前售卖价会加粗
- 原价看上去被划掉
一般情况下,这两种价格都是紧挨在一起,加上一些视觉差异的风格,能给用户提供足够多的信息和背景,让用户知道这两个价格表达的是商品的 现价 和 折扣价。
对于没有障碍的用户而言,上面的一切都是正常的,也是能表述清楚的。但对于那些访问网页或应用有一定障碍的用户(比如依赖屏幕阅读器的用户)可能就没有想象的那么好了。换句话说,视觉上看上去一切都很好,但听觉上体验未必如此,甚至会令依赖屏幕阅读器的用户感到困惑和沮丧。有点类似上面实体店购物,导购员告诉你衣服价格是RMB399,RMB199一样。
我们用手机来体验一下真实的线上购物碰到价格的体验。
“京东生鲜”有一个较好的体验,会读出:
- ①:现价169.9元
- ②:原价299元
“手淘”的“浏览10秒送金币”的体验就比较差:
- ①:¥
- ②:57
- ③:.04
- ④:¥98.01
“拼多多万人团”的体验也是较差:
- ①:¥
- ②:49.8
- ③:¥
- ④:59.9
“考拉”的“精品超市”的体验如下:
- ①:¥
- ②:39
- ③:¥
- ④:29
“亚马逊”的体验如下:
- ①:¥84.29 ¥3206.43
我想大家有了一个切身的体验吧,除了“京东生鲜”有一个较好的体验,其他的应用在价格方面的体验都是差强人意,甚至令人沮丧。
这不是一个包容性的体验。
为什么会这样呢?
先来看视觉上的效果:
在价格视觉上,它们有着明显的特征:
- 现价的人民币符号
¥
比价格数字的字号要更小(有的价格的小数点位也和人民币符号有相同的字号) - 原价的人民币符号
¥
比价格数字的字号要更小
为了还原视觉效果,在构建 Web 页面 DOM结构的时候,可能会这样:
<!-- 手淘 -->
<div class="price">
<!-- 现价 -->
<div class="price__discount">
<span>¥</span>
<strong>57</strong>
<span>.04</span>
</div>
<!-- 原价 -->
<div class="price__original">¥98.01</div>
</div>
我们来看看线上的实际 DOM结构:
众所周知,构成一个 Web 页面主要由 HTML、CSS 和 JavaScript 构建,浏览器在解析页面的时候,会构建 DOM 树 和 CSSOM树,然合再构建渲染树,最后在屏幕上渲染出来。而屏幕阅读器在朗读一个 Web页面时,和浏览器解析页面类似,屏幕阅读器也会有自己的方式解析页面,他在解析页面的时候会在 DOM 树的基础上构建 可访问树(AOM) :
粗略地说,可访问树是DOM树的子集。它包括用户代理的用户接口对象和文档的对象。可访问对象是在可访问树中为每个应该暴露给辅助技术的DOM元素创建的,因为它可能触发一个可访问事件,或者因为它有一个需要暴露的属性、关系和特性。
有了这棵可访问树之后,辅助技术(Assistive Technology),比如屏幕阅读器,就会把可访问树中的内容朗读出来:
在浏览器开发者工具中,我们也可以查看到可访问树,比如“¥109.00”为了还原视觉效果,它可能会用三个DOM节点来构建:
<div class="price__discount">
<span>¥</span>
<strong>109</strong>
<span>.00</span>
</div>
相应的可访问树:
早前在 《技术有温,代码有爱: 如何让互动能说话?》和《A11Y 101:WAI-ARIA初探》简单提到过这方面的技术,但如果你想彻底的了解屏幕阅读器为什么这么朗读,建议你花点时间阅读:
创建一个更具包容性的价格体验
假设我们有这样的一个卡片需求:
请注意,我们的关注点是价格,价格,价格。即如何才能构建一个具有包容性的价格体验。我们来一起探讨一下一个可行性解决方案。
构建上面的卡片我们可能需要像下面这样的一段 HTML:
<div class="card">
<div class="card__badge">本周特推</div>
<div class="card__media">
<img src="https://picsum.photos/800/800?random=2" alt="六一儿童节幼儿园舞台眼影盘亮片表演出亮晶晶闪粉学生彩妆化妆" />
</div>
<div class="card__content">
<h3 class="card__heading">六一儿童节幼儿园舞台眼影盘亮片表演出亮晶晶闪粉学生彩妆化妆</h3>
<p class="card__body">购后返100金币</p>
<div class="card__footer">
<div class="card__price">
<div class="card__price--current">
<span>¥</span>
<strong>1</strong>
</div>
<div class="card__price--orgion">¥280.06</div>
</div>
<button class="card__button">1元抢</button>
</div>
</div>
</div>
满足上面的价格,编写HTML可能会像下面这样:
<div class="card__price">
<div class="card__price--current">
<span>¥</span>
<strong>1</strong>
</div>
<div class="card__price--orgion">¥280.06</div>
</div>
添加一些 CSS 可以得到我们想要的 UI 结果:
我想大多数 Web 开发者做到这里就结束了。
前面我们花了一些篇幅阐述过,虽然到这一步,我们是还原了一个UI的结果(你的需求方可能很满意),能明白“¥1”和“¥280.66”两个价格代表什么意思。但对于使用屏幕阅读器的用户,他的体验就非常不好了:
卡片中价格部分被屏幕阅读器读出来的是结果是:
- ①:¥
- ②:1
- ③:¥280.06
查看价格的 AOM 树:
此时,你或许会感到困惑。我们是为了 UI 效果,把“¥1”分在两个节点(span
和strong
)中,才会造成屏幕阅读器分两个节点(两次分开)朗读;而要让屏幕阅读器一次朗读出“¥1”,就需要把 span
和 strong
合在一起,比如:
<div class="card__price">
<div class="card__price--current">¥1</div>
<div class="card__price--orgion">¥280.06</div>
</div>
但这样做 UI 效果又不太好还原出。这就是矛盾,而我们接下来主要就是想办法解决这个矛盾,即:
"¥1"价格在 HTML 的 DOM 继续分成两个节点(为 UI 还原,或者说无障碍的用户服务),又要让屏幕阅读器朗读成一个节点(类似合并DOM节点,为有障碍的用户服务)!
如果你对 ARIA 有一定的了解的话,你肯定会想到像 aria-label
相关的标签。嗯,这的确是一种方案,但也有一定的局限性。换句话说,除了使用 ARIA 中的 aria-label
之外,还有别的技术方案,即 使用CSS的隐藏技术。
我们接下来分两种技术方案来构建具有包容性的价格体验。
使用 CSS 隐藏技术让屏幕阅读器读出想读的内容
先来看 CSS 隐藏技术,怎么让屏幕阅读器读出想要的读的内容,在本例中即:
- ①:¥1
- ②:¥280.06
在开始之前,我们先简单的了解一下,CSS中的隐藏相关的技术:
我们将采用上表中clip-path
来了隐藏元素:
.sr-only:not(:focus):not(:active) {
border: 0;
clip: rect(0 0 0 0);
clip-path: polygon(0px 0px, 0px 0px, 0px 0px);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
}
如果我们在某个元素上采用上面的样式,即元素上添加sr-only
类名,那么正常用户将无法看到,但屏幕阅读器是可以识别的。这样一来,如果我们要创建一个更具包容性的价格体验,就需要在 DOM 结构上做出一定的牺牲,即添加隐藏元素所需要的 DOM:
<!-- 未添加隐藏元素的 DOM 结构 -->
<div class="card__price">
<div class="card__price--current">
<span>¥</span>
<strong>1</strong>
</div>
<div class="card__price--orgion">¥280.06</div>
</div>
<!-- 添加了隐藏元素的DOM结构 -->
<div class="card__price">
<div class="card__price--current">
<span>¥</span>
<strong>1</strong>
<i class="sr-only">¥1</i>
</div>
<div class="card__price--orgion">¥280.06</div>
</div>
这个时候效果如下:
虽然 <i class="sr-only">
中的内容在视觉上看不到,但屏幕阅读器可以识别,可读出来的结果:
- ①:¥
- ②:1
- ③:¥1
- ④:¥280.06
并不完美对吧。虽然<i>
的内容“¥1”能很好朗读出来,但以前存在的问题依旧存在。我们需要继续解决这个问题。也就是说, 需要在屏幕阅读器中将<span>
和<strong>
中隐藏(类似在CSS中使用display:none
隐藏元素一样) 。 实现这个效果也很简单,可以借助 ARIA 中的aria-hidden
属性,该属性是一个布尔值,当:
- 当
aria-hidden
的值为true
时,屏幕阅读器不会朗读(类似display: none
) - 当
aria-hidden
的值为false
时,屏幕阅读器会朗读(类亿display
为非none
的值)
按这个描述,在需要隐藏的元素上(指的屏幕阅读器不需要朗读的元素)添加该属性 aria-hidden="true"
:
<div class="card__price">
<div class="card__price--current">
<span aria-hidden="true">¥</span>
<strong aria-hidden="true">1</strong>
<i class="sr-only">¥1</i>
</div>
<div class="card__price--orgion">¥280.06</div>
</div>
在浏览器查看 AOM 树,你会看到它们之间的差异:
加上aria-hidden="true"
的元素在 AOM 中的“generic”会是“Ignored”,并且在 ARIA 属性列会列出相应的属性和值:
屏幕阅读器的在朗读时也会发生相应变化:
此时屏幕阅读器朗读出来的结果是:
- ①:¥1
- ②:¥280.06
是不是比最初的效果好多了。但我们可以做得更好一些,比如可以让屏幕阅读器朗读出来的结果是:
- ①:抢购价1元
- ②:原价280.06元
实现这样的效果,只需要在 DOM 上稍作调整:
<div class="card__price">
<div class="card__price--current">
<span aria-hidden="true">¥</span>
<strong aria-hidden="true">1</strong>
<i class="sr-only">抢购价1元</i>
</div>
<div class="card__price--orgion" aria-hidden="true">¥280.06</div>
<div class="sr-only">原价280.06元</div>
</div>
屏幕阅读器朗读的效果如下:
是不是效果好多了,不管是谁都能很好的搞清楚“¥1”和“¥280.06”分别是抢购价和原价。最终效果如下:
从这个示例上来看,我们要构建一个更具包容性的价格也不是难事。作为开发者只需要稍些做一点就能给更多的用户有更好的体验。这也是我们开发应该努力去做的,更是应该具备的素养。
使用 ARIA 来构建更具包容性的价格
前面我们提到过,可以使用 ARIA 的 aria-label
来构建更具包容性的价格体验。但并不是 aira-label
加在任意的 HTML 标签元素上就能生效的(指的是被屏幕阅读器可以朗读出来)。 比如:
<div aria-label="抢购价1元">¥1</div>
屏幕阅读器只会朗读出“¥1”,并不会把 aria-label
的值“抢购价1元”朗读出来:
但我们要是在上面的代码中稍作修改,比如在div
上添加一个 role
值为 option
:
<div aria-label="抢购价1元" role="option">¥1</div>
这个时候屏幕阅读器会朗读出aria-label
中的“抢购价1元”,而忽略div
元素的文本内容“¥1”:
就上面的示例代码而言,div
是一个不可聚焦的元素,但显式在该元素上设置role
之后,它就变成了可聚焦元素。也就是说:
对于可聚焦元素或有
role
角色的元素,如果显式设置了aria-label
属性,那么aria-label
的值将会替代元素自身的文本内容;对于没有role
角色的元素,即使显式设置了aria-label
也将不会起任何作用,最终呈现给用户的是元素自身的内容。
那么回到我们的价格设计中来,我们是不是可以尝试着在价格“¥1”的元素父容器上设置role="option"
,并且设置aria-label
值,比如:
<div class="card__price">
<div class="card__price--current" role="option" aria-label="抢购价1元">
<span>¥</span>
<strong>1</strong>
</div>
<div class="card__price--orgion" aria-label="原价280.06元" role="option">¥280.06</div>
</div>
这个时候 AOM 的结构如下:
屏幕阅读器会朗读:
- ①:抢购价1元
- ②:原价280.06元
示例效果如下:
其实除了使用 aria-label
之外,还可以使用带有绑定关系的 aria-labelledby
和aria-describedby
,而且他们都需要用于可聚焦元素或带role
属性的元素上。并且它们可以同时用于同一个元素上,只不过:
aria-label
和aria-labelledby
将会替代元素自身的文本内容aria-label
和aria-labelledby
同时出现时,aria-labelledby
权重要更高aria-describedby
不会替代元素自身的文本内容,只是会在其基础上追加aria-labelledby
和aria-describedby
都可以同时绑定多个元素的id
,而且屏幕阅读器朗读的内容顺序和绑定的id
顺序有关
不过需要注意的是:简单地说,ARIA中的 aria-label
、aria-labelledby
和 aria-describedby
在不同的使用场景,或者说运用于不同的 role
以及在不同的用户代理或ATs技术中,它们的表现形式都会有所差异。
就上面的示例而言,如果我们不需要屏幕阅读器朗读:
- ①:抢购价1元
- ②:原价280.06元
只是朗读:
- ①:¥1
- ②:¥280.06
我们还可以使用 aria-labelledby
,比如:
<div class="card__price">
<div class="card__price--current" role="option" aria-labelledby="a11y1 a11y2">
<span id="a11y1">¥</span>
<strong id="a11y2">1</strong>
</div>
<div class="card__price--orgion">¥280.06</div>
</div>
屏幕阅读器朗读出来的效果如下:
相对而言,该效果并不是最好的效果,同样会让用户分不清楚“¥1”和“¥280.06”两价格的分别。但要让它更具区别性,也是可以的,只不过会更繁琐一些,比如:
<div class="card__price">
<div class="card__price--current" role="option" aria-labelledby="a11y1 a11y2 a11y3">
<i class="sr-only" id="a11y1">抢购价</i>
<span id="a11y2">¥</span>
<strong id="a11y3">1</strong>
</div>
<div class="card__price--orgion" role="option" aria-label="原价280.06元">¥280.06</div>
</div>
不过我不太推荐这样使用,相对而言还是喜欢aria-label
的方式。
这部分涉及到 ARIA 相关的知识,如果你感兴趣的话,可以阅读:
我们可以做得更好一点
前面的几个示例已经能帮助我们构建一个更具包容性的价格了,但我们可以做得更好一些。比如说,可以把原价格“¥280.06”的div
标签换成更具语义的<s>
标签或<del>
标签,这样做除了更具语义化之外,在视觉上的删除线我们也可以不用额外的CSS来添加:
<div class="card__price">
<div class="card__price--current" role="option" aria-label="抢购价1元">
<span>¥</span>
<strong>1</strong>
</div>
<s class="card__price--orgion" aria-label="原价280.06元" role="option">¥280.06</s>
</div>
除此之外,在使用屏蔽阅读器的朗读整个卡片的时候,你可能发现了当焦点在<img>
上时,会朗读出和 <h3>
标签上相同的内容,这主要是因为<img>
的alt
属性的值和<h3>
的文本内容等同:
<div class="card__media">
<img src="https://picsum.photos/800/800?random=2" alt="六一儿童节幼儿园舞台眼影盘亮片表演出亮晶晶闪粉学生彩妆化妆" />
</div>
<div class="card__content">
<h3 class="card__heading">六一儿童节幼儿园舞台眼影盘亮片表演出亮晶晶闪粉学生彩妆化妆</h3>
<!-- ... -->
</div>
如果你想避免这种重复性的内容发生的话,可以在类名为card__media
的<div>
上显式设置aria-hidden
的值为true
:
<div class="card__media" aria-hidden="true">
<img src="https://picsum.photos/800/800?random=2" alt="六一儿童节幼儿园舞台眼影盘亮片表演出亮晶晶闪粉学生彩妆化妆" />
</div>
这样一来,屏幕阅读器在朗读整个卡片时,效果会好得多:
最终效果如下:
对于整个卡片而言,特别是内容多,布局方式采用不合理时,会造成卡片内焦点元素的顺序混乱。这样也会给用户带来一个不好的体验。另外,在编码的时候,我们可以使用tabindex
来控制焦点。
焦点管理是件复杂而且蛋疼的事情,我把这个话题放到下一篇文章中来和大家讨论。另外,上面示例中没有采用 REM或VW布局适配方案,而是采用了一种新的适配方案,即《如何构建一个完美缩放的UI界面》一文所介绍的适配方案,你会发现有不同的终端访问文章中的示例都有一个较好的体验。
小结
在这篇文章中主要和大家探讨了当下很多电子商务网站上的价格在屏幕阅读器上给人带来很不好的体验,并且和大家一起探讨了如何通过技术手段构建一个更具包容性的价格,给用户提供更好的体验。事实上,文章中和大家探讨的一些技术,比如 ARIA 中的一些特性,在一些辅助技术上也和浏览器客户端非常的相似,存在兼容性等问题。另外,在构建可访问性应用,不管是使用哪种技术,都不能说最佳的,甚至没有一个完全正确的答案,我们还是需要以相关辅助技术最终呈现的效果为准。而且,所谓最佳的解决方案将取决于多种因素,包括你的受众和项目目标。即使如此,作为一名Web开发人员,我们都应该用自己掌握的技术,努力为用户创造最好的体验,哪怕有一顶点机会,我们都应该去努力,使其更加无障碍(更具可访问性)。你的一点点付出,会给更多的人带来幸福。