1. 1. 设计,结构和语义
    1. 1.1. 定义HTML结构和语义
    2. 1.2. 用CSS创建样式和交互
      1. 1.2.1. 必要的样式
      2. 1.2.2. 美化
  2. 2. 用JavaScript让组件“活”起来
    1. 2.1. 为什么不起作用?
    2. 2.2. 让工作轻松些
    3. 2.3. 建立事件回调
    4. 2.4. 处理组件的值
  3. 3. 让组件变得无障碍
    1. 3.1. role特性
    2. 3.2. aria-selected特性
  4. 4. 结论

在许多情况下,可用的HTML表单组件是不够的。若你想在诸如<select>元素的组件上应用高级样式、或者想定制组件的行为,你就只能选择创建自己的表单组件。

我们将通过本文学习如何构建一个表单组件。为达到目的,我们选择重构<select>元素作为例子。

注意:我们会专注于构建组件,但不会关注如何保证代码的通用和可重用。构建组件时会涉及到一些特殊的JavaScript代码和未知上下文中的DOM操作,而这些内容已经超出了本文的讨论范围。

设计,结构和语义

在构建一个定制组件前,应先从明确你想要达到的效果开始,这会节省你宝贵的时间。具体来讲,清晰地定义组件的所有状态是很重要的。要做到这点,最好从一个已经存在的、状态和行为已经为人所熟知的组件开始,这样你就只需尽可能地模仿该组件即可。

在我们的例子中,我们会重构<select>元素。下面是我们期望达到的结果:

上面的截屏展示了我们组件的三个主要状态:普通状态(左)、激活状态(中)和打开状态(右)。

至于组件的行为,我们希望可以像其他原生组件一样,通过鼠标和键盘来操控它。先从定义组件如何到达各个状态开始:

组件变为普通状态:

  • 页面加载
  • 组件激活且用户点击了组件外任意地方
  • 组件激活且用户用键盘把焦点移动到别的组件

注意:在页面上移动焦点通常是通过敲tab键来实现的,但不是所有地方都遵循这个惯例。比如Safari上默认是用Option+Tab组合键来实现在页面上移动焦点。

组件变为激活状态:

  • 用户点击了组件
  • 用户按tab键且组件获得了焦点
  • 组件处于打开状态且用户点击了组件

组件变为打开状态:

  • 组件处于其他非打开状态且用户点击了它

在知道如何改变状态后,定义组件的值如何被改变也是很重要的:

组件的值改变:

  • 在组件处于打开状态时,用户点击了一个选项
  • 在组件处于激活状态时,用户按了上下方向键

最后我们来定义下组件选项的行为:

  • 当组件处于打开状态时,被选中的选项会高亮
  • 当鼠标移到一个选项上,该选项会高亮且原先高亮状态的选项会恢复到普通状态

考虑例子的演示目的,我们的分析就到此为止;然而如果你认真读过上文,会发现我们漏了一些效果。比如,当组件处于打开状态时,如果用户按了tab键会发生什么呢?答案是–什么都不会发生。正确的效果虽然显而易见(译注:参考select原生组件,也是什么都不会发生),但事实是我们没有在上述说明中定义它,这个效果很容易就会被忽视。在团队协作中,如果设计组件的人和实现它的人不同,这是特别容易出现的问题。

另一个有趣的问题是:组件处于打开状态时,用户按上下方向键会发生什么?要回答它,需要一点技巧。若考虑激活状态和打开状态是完全不相干的,那答案就还是“什么都不会发生”,因为我们并未给打开状态定义任何键盘交互。另一方面,如果考虑激活状态和打开状态有部分重叠,那答案就是:值可能会改变但选项也因此不会被高亮(译注:大概因为组件已经处于激活状态了吧),这也是因为当组件处于打开状态时,我们并未给选项未定义任何键盘交互(只是定义了组件打开时应该发生什么,却没定义打开后要干嘛)。

在我们的例子中,缺失的特性还是比较明显的,所以我们还能处理得了它;但当面对来自外部的新组件时,由于没人知道正确的行为是什么,这时就会造成真正的麻烦。因此,花些时间在设计阶段是很有必要的,如果你此时定义了一个不佳的交互,或忘记了去定义,后续在用户使用了该交互时再去重定义是很困难的。若(处理交互时)你有疑问,应积极寻求他人的帮助;而若你心中有数,则应毫不犹豫地进行用户测试。上面讨论的过程,可称之为UX(译注:用户体验)设计。如果你想了解更多这方面的内容,可以参考下面这些资源:

注意:在多数系统中,还有有一种方法可以打开<select>元素以查看所有可用的选项(这和用鼠标点击<select>元素是一样的)。这个方法在Windows下是用Alt+下方向键来实现的,我们的例子中并未实现它–但要这样做也很简单,因为整个操作的机制已经被用于实现click事件了。

定义HTML结构和语义

上面我们确定了组建的基本功能,现在可以来构建我们的组件了。第一步我们要定义其HMLT结构,并为其添加基本的语义。下面是我们重构<select>元素所需的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 这是组件的主要容器.
tabindex 特性用于让用户能聚焦到该组件。
用JavaScript来设置它是一个更好的办法 -->
<div class="select" tabindex="0">
<!-- 这个容器用于展示组件的当前值 -->
<span class="value">Cherry</span>
<!-- 这个容器会包含组件里的所有可用选项,因为选项是一个列表,所有采用ul元素更加合适 -->
<ul class="optList">
<!-- 每个选项只会包含要展示的内容,稍后我们会了解如何处理其真实值,用来和表单数据一起发出去 -->
<li class="option">Cherry</li>
<li class="option">Lemon</li>
<li class="option">Banana</li>
<li class="option">Strawberry</li>
<li class="option">Apple</li>
</ul>
</div>

要注意此处class名的使用;这些class标记了每个相关的元素,而不需要依赖其实际使用的HTML元素。这么做能确保我们不会把CSS和JavaScript与HTML结构作强关联,从而做到改变后续的组件代码实现时,不破坏使用该组件的代码。比如你想实现一个同样的<optgroup>元素时,可用直接用相同的代码来调用。

用CSS创建样式和交互

现在我们已经有了组件的结构了,接下来要来设计组件了。创建这个自定义组件的目的,是为了用我们想要的形式来给该组件添加样式。要做到这点,我们要把CSS的编码工作拆为两部分:第一部分是让我们组件和<select>元素看起来一致的必要CSS规则,第二部分是用来让组件变成我们想要的样子的样式。

必要的样式

必要的样式是用来处理我们组件的三个状态的。

1
2
3
4
5
6
7
.select {
/* 给选项列表创建一个定位上下文 */
position: relative;
/* 让我们的组件成为文本流的一部分,并使之可伸缩 */
display : inline-block;
}

我们需要一个额外类名active,来定义组件处于激活状态时的外观。因为我们的组件是可以获得操作焦点的,所以还要将相同的样式用于:focus伪类,保证激活和获得焦点时的行为一致。

1
2
3
4
5
6
7
.select.active,
.select:focus {
outline: none;
/* box-shadow 属性不是必要的,但它可以作为默认值保证激活状态可见,去掉它也是可以的。 */
box-shadow: 0 0 3px 1px #227755;
}

接下来处理选项列表:

1
2
3
4
5
6
7
/* 这里的 .select 选择器,用来确保后面选择器匹配的元素就是我们组件中那个 */
.select .optList {
/* 下面样式确保选项列表会展示在当前值下面、并在HTML文档流之外 */
position : absolute;
top : 100%;
left : 0;
}

我们需要一个额外的class来处理选项列表的隐藏状态。为了管理激活和展开两个不同的状态,这么做是很有必要的。

1
2
3
4
5
.select .optList.hidden {
/* 下面是一个以无障碍方式来隐藏列表的简单方法,我们会在文末讨论更多关于无障碍访问的内容。 */
max-height: 0;
visibility: hidden;
}

美化

在有了基本的功能之后,有趣的部分开始了。下面是一个可选的例子,效果和本文开头的那个截图一致。但是你也可以自由探索、看看你能实现怎样的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
.select {
/* 所有的大小值都会采用em值来保证无障碍访问
(保证组件在用户使用浏览器纯文字模式下的缩放时,还保留自适应的能力)。
在计算时,假设1em == 16px,这也是大多数浏览器的默认值。
如果你对px到em的转换感到困惑,可以访问:http://riddle.pl/emcalc/ */
font-size : 0.625em; /* this (10px) is the new font size context for em value in this context */
font-family : Verdana, Arial, sans-serif;
-moz-box-sizing : border-box;
box-sizing : border-box;
/* 需要额外的空间来添加向下箭头 */
padding : .1em 2.5em .2em .5em; /* 1px 25px 2px 5px */
width : 10em; /* 100px */
border : .2em solid #000; /* 2px */
border-radius : .4em; /* 4px */
box-shadow : 0 .1em .2em rgba(0,0,0,.45); /* 0 1px 2px */
/* 第一句声明用于不支持线性渐变的浏览器。
第二句声明是因为基于Webkit的浏览器对线性渐变属性还要加个前缀。
若你还想支持老旧浏览器,可参考http://www.colorzilla.com/gradient-editor/ */
background : #F0F0F0;
background : -webkit-linear-gradient(90deg, #E3E3E3, #fcfcfc 50%, #f0f0f0);
background : linear-gradient(0deg, #E3E3E3, #fcfcfc 50%, #f0f0f0);
}
.select .value {
/* 因为value元素可能会比组件还宽,所以我们得保障这不会改变组件的宽度 */
display : inline-block;
width : 100%;
overflow : hidden;
vertical-align: top;
/* 如果内容溢出了,最好能有省略号来替代。 */
white-space : nowrap;
text-overflow: ellipsis;
}

我们不需要额外的元素来设计向下箭头,而是使用:after伪元素。但其实这也能在select类上用一个简单的背景图片来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.select:after {
content : "▼"; /* 使用 unicode 字符 U+25BC;参见 http://www.utf8-chartable.de */
position: absolute;
z-index : 1; /* 用来保证箭头会叠在选项列表上面 */
top : 0;
right : 0;
-moz-box-sizing : border-box;
box-sizing : border-box;
height : 100%;
width : 2em; /* 20px */
padding-top : .1em; /* 1px */
border-left : .2em solid #000; /* 2px */
border-radius: 0 .1em .1em 0; /* 0 1px 1px 0 */
background-color : #000;
color : #FFF;
text-align : center;
}

接下来,给选项列表添加样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
.select .optList {
z-index : 2; /* 表明选项列表会始终叠在向下箭头之上 */
/* 重置ul元素的默认样式 */
list-style: none;
margin : 0;
padding: 0;
-moz-box-sizing : border-box;
box-sizing : border-box;
/* 确保即使值太少让选项列表小于组件主体,也能让选项列表会和组件主体一样大 */
min-width : 100%;
/* 如果列表太长了,其内容会在垂直方向上溢出(默认会自动添加一个垂直方向的滚动条),
但不会在水平方向上也这样(因为我们没有设置宽度,列表会有个自适应宽度,如果不能自适应,
内容就会被截断) */
max-height: 10em; /* 100px */
overflow-y: auto;
overflow-x: hidden;
border: .2em solid #000; /* 2px */
border-top-width : .1em; /* 1px */
border-radius: 0 0 .4em .4em; /* 0 0 4px 4px */
box-shadow: 0 .2em .4em rgba(0,0,0,.4); /* 0 2px 4px */
background: #f0f0f0;
}

对于选项,我们需要添加一个highlight类来标明用户会选取(或已经选取)的值。

1
2
3
4
5
6
7
8
.select .option {
padding: .2em .3em; /* 2px 3px */
}
.select .highlight {
background: #000;
color: #FFFFFF;
}

下面就是我们三个状态的实现效果了:
效果

用JavaScript让组件“活”起来

现在我们组件的结构和设计都已经做好,可以来写JavaScript代码让组件真正能运行起来了。

警告:下面的代码是教学代码,在实际编码时不能直接像下面一样使用。其中许多部分,并没有未来使用的保障、而且也不能在老旧浏览器上使用。此外,这些代码也有在生产环境中应该被优化掉的冗余部分。

注意:创建可复用的组件是很有技巧性的。W3C Web Component 草案是这个特定问题的一个解决方案。X-tag project是这一规范的实验性实现;我们鼓励你好好了解下它。

为什么不起作用?

在开始之前,我们需要知道JavaScript的一个严重问题:在浏览器里,它是一个不可靠的技术。当你在创建自定义组件的时候,你不得不依赖JavaScript,因为它是把所有东西维系在一起的绳索。但是,在许多情况下JavaScript并不能在浏览器中运行:

  • 用户禁用了JavaScript:这已经是个最不常见的情况了,现在很少有人会禁用JavaScript。
  • 脚本没有加载:这是最普遍的情况,特别是在网络不太可靠的移动端。
  • 脚本有bug:你要经常考虑这一可能性。
  • 脚本和第三方脚本冲突了:使用了追踪脚本或用户自用的书签时会发生这种情况。
  • 脚本和浏览器拓展(如火狐的NoScript拓展或Chrome的NoScripts拓展)发生冲突、或受到干扰。
  • 用户使用了老旧浏览器,并且你需要的一种特性不被支持:这通常发生在你用了很新的API时。

由于有这些风险,我们需要认真考虑下JavaScript不起作用时会发生什么。深入处理这个问题已经超出了本文的论述范围,因为这和你希望如何让脚本通用和可复用密切相关,我们不会在例子中考虑这点。

在本文的例子中,若JavaScript代码不能运行,我们会回退到展示标准的<select>元素。要做到这点,得先来做两件事。

首先,我们要在使用自定义组件之前,添加一个普通的<select>元素。而为了能让自定义组件的数据和剩下的表单数据一起发送,这一步也是很有必要的。后边我们还会详细介绍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<body class="no-widget">
<form>
<select name="myFruit">
<option>Cherry</option>
<option>Lemon</option>
<option>Banana</option>
<option>Strawberry</option>
<option>Apple</option>
</select>
<div class="select">
<span class="value">Cherry</span>
<ul class="optList hidden">
<li class="option">Cherry</li>
<li class="option">Lemon</li>
<li class="option">Banana</li>
<li class="option">Strawberry</li>
<li class="option">Apple</li>
</ul>
</div>
</form>
</body>

第二,我们还得添加两个新的类名,实现隐藏不需要的元素(即在脚本能运行时的<select>元素、或脚本不能运行时的自定义组件)。要注意的是在默认情况下,此处的HTML代码会隐藏自定义组件。

1
2
3
4
5
6
7
8
9
10
.widget select,
.no-widget .select {
/* 这个CSS选择器意思是:
- 要么body的类名被设为"widget",此处就要隐藏`<select>`元素
- 要么body的类名没有改变,仍是"no-widget",那么类名为"select"的元素就要被隐藏了 */
position : absolute;
left : -5000em;
height : 0;
overflow : hidden;
}

至此,我们只需要一个JavaScript开关来决定脚本是否能运行了。这个开关很简单:若页面加载了脚本并运行,就会移除no-widget类并添加widget类,实现对<select>元素和自定义组件可见与否的切换。

1
2
3
4
window.addEventListener("load", function () {
document.body.classList.remove("no-widget");
document.body.classList.add("widget");
});

效果

注意:若你真的想让你的组件变得通用和可复用,除了作类名的切换,更好的方法是(在脚本能执行时)只添加widget类名隐藏<select>元素,并在页面中的每个<select>元素后面指定自定义的组件、动态添加到DOM树中。

让工作轻松些

在将要创建的代码中,我们会使用标准的DOM API来完成工作。然而,尽管浏览器对DOM API的支持已经越来越好,但在老旧浏览器上仍存在一些问题(特别在很老的IE上)。

若你想避免老旧浏览器上的麻烦,有两种方法可以做到:使用诸如jQuery, $dom, prototype, Dojo, YUI之类的稳定框架;或者补充那些缺失的但你要用的特性(通过条件加载可以很容易做到这点,比如可以使用yepnope库)。

我们计划使用的特性如下(从风险最大到最安全排列):

  1. classList
  2. addEventListener
  3. forEach(不属于DOM但是现代JavaScript的特性)
  4. querySelectorquerySelectorAll

除了上述特性的可用性,在开发之前仍存在一个问题。querySelector()方法返回的是一个NodeList而不是数组。Array对象支持forEach方法、但NodeList不支持。因为NodeList看起来像数组、也因为forEach方法用起来很方便,所以我们可以很简单地就给NodeList添加forEach支持、让我们的工作轻松些,就像下面这样:

1
2
3
NodeList.prototype.forEach = function (callback) {
Array.prototype.forEach.call(this, callback);
}

我们说这很简单可不是瞎说的哦。

建立事件回调

前期工作已经做好了,我们现在可以来定义用户和我们的组件交互时要用到的所有函数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/* 这个函数会在取消激活自定义组件时被使用
需要一个参数:
select: 类名为`select`且要被取消激活的DOM节点
*/
function deactivateSelect(select) {
/* 若组件未被激活,则什么都不做 */
if (!select.classList.contains('active')) return;
/* 获取自定义组件的选项列表 */
var optList = select.querySelector('.optList');
/* 关闭选项列表 */
optList.classList.add('hidden');
/* 取消自定义组件的激活状态 */
select.classList.remove('active');
}
/* 该函数用于让用户(取消)激活组件
需要两个参数:
select:类名为`select`且要被激活的DOM节点
selectList:类名为`select`的所有DOM节点的列表
*/
function activeSelect(select, selectList) {
/* 若组件已经激活,则什么都不做 */
if (select.classList.contains('active')) return;
/* 所有自定义组件的激活状态都得取消,
因为deactivateSelect函数满足了作为forEach回调函数的要求,
所以我们会直接使用它而不是用一个中间的匿名函数
*/
selectList.forEach(deactivateSelect);
/* 开启该组件的激活状态 */
select.classList.add('active');
}
/* 该函数用于让用户打开和关闭选项列表
需要一个参数:
select:有一个列表要切换状态的DOM节点
*/
function toggleOptList(select) {
/* 选项列表可以从组件那获得 */
var optList = select.querySelector('.optList');
/* 改变列表的类名来展示和隐藏它 */
optList.classList.toggle('hidden');
}
/* 该函数用于高亮一个选项
需要两个参数:
select:类名为`select`且包含要被高亮选项的DOM节点
option:类名为`option`且要被高亮的DOM节点
*/
function highlightOption(select, option) {
/* 获得自定义select元素的所有可用选项 */
var optionList = select.querySelectorAll('.option');
/* 移除所有选项的高亮 */
optionList.forEach(function (other) {
other.classList.remove('highlight');
});
/* 高亮正确的选项 */
option.classList.add('highlight');
};

上面就是处理自定义组件的多个状态所需的所有函数。

接下来,我们把这些函数绑到合适的事件上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* 在文档加载出来后处理下事件绑定 */
window.addEventListener('load', function () {
var selectList = document.querySelectorAll('.select');
/* 每个自定义组件都要被初始化 */
selectList.forEach(function (select) {
/* 所有的`select`元素也要被初始化 */
var optionList = select.querySelectorAll('.option');
/* 用户把鼠标放到一个选项上时,高亮该选项 */
optionList.forEach(function (option) {
option.addEventListener('mouseover', function () {
/* 注意:在我们的函数调用内,`select`和`option`变量都是局部的 */
highlightOption(select, option);
});
});
/* 用户点击了自定义的select元素 */
select.addEventListener('click', function (event) {
/* 注意:在我们的函数调用内,`select`变量是局部的 */
/* 改变选项列表的可见状态 */
toggleOptList(select);
});
/* 组件获得焦点时
/* 用户点击组件或用tab键访问组件时,组件会获得焦点 */
select.addEventListener('focus', function (event) {
/* 注意:在我们的函数调用内,`select`和`selectList`变量都是局部的 */
/* 激活该组件 */
activeSelect(select, selectList);
});
/* 组件失去焦点时 */
select.addEventListener('blur', function (event) {
/* 注意:在我们的函数调用内,`select`变量是局部的 */
/* 取消激活该组件 */
deactivateSelect(select);
});
});
});

至此,组件已经能根据我们的设计来改变其状态了,但它的值目前还不会更新,接下来我们就会处理这点。

效果

处理组件的值

现在组件已经能用了,但我们还得加点代码,根据用户的输入更新它的值、并让其能随着表单数据一起发送它的值。

要做到这点,最简单的方式就是在私底下用一个原生组件。这样一来,自定义组件就会跟踪浏览器提供的内置控件的值,并和平时一样在表单提交时发送它的值。在浏览器已经为我们做好这一切时,没有必要来重新发明轮子了。

如前所示,出于可访问性的原因,我们已经用了一个原生的select组件来作为回退;同步这个组件的值和自定义组件的值是很容易的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 该函数用于更新展示的值,并和原生组件作同步
// 需要两个参数:
// select:类名为`select`且值要更新的DOM节点
// index:选定的值的索引
function updateValue(select, index) {
// 我们得为给定的自定义组件获取原生组件
// 本例中,原生组件是自定义组件的兄弟节点
var nativeWidget = select.previousElementSibling;
// 获得自定义组件的值容器
var value = select.querySelector('.value');
// 获得完整的选项列表
var optionList = select.querySelectorAll('.option');
// 设置选中索引为我们选择的选项的索引
nativeWidget.selectedIndex = index;
// 更新对应的值容器
value.innerHTML = optionList[index].innerHTML;
// 高亮自定义组件中关联的选项
highlightOption(select, optionList[index]);
};
// 该函数返回原生组件当前选中的索引
// 需要一个参数:
// select:类名为`select`且和原生组件关联的DOM节点
function getIndex(select) {
// 我们得为给定的自定义组件获取原生组件
// 本例中,原生组件是自定义组件的兄弟节点
var nativeWidget = select.previousElementSibling;
return nativeWidget.selectedIndex;
};

我们可以用上面这两个函数来绑定原生组件和自定义组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 在文档加载出来后处理下事件绑定
window.addEventListener('load', function () {
var selectList = document.querySelectorAll('.select');
// 每个自定义组件都要被初始化
selectList.forEach(function (select) {
var optionList = select.querySelectorAll('.option'),
selectedIndex = getIndex(select);
// 让自定义组件能聚焦
select.tabIndex = 0;
// 让原生组件不可聚焦
select.previousElementSibling.tabIndex = -1;
// 确保默认选择的值被正确展示
updateValue(select, selectedIndex);
// 用户点击选项时,更新对应的值
optionList.forEach(function (option, index) {
option.addEventListener('click', function (event) {
updateValue(select, index);
});
});
// 用户在聚焦的组件上按键盘时,更新对应的值
select.addEventListener('keyup', function (event) {
var length = optionList.length,
index = getIndex(select);
// 当用户按下箭头时,跳到后一选项
if (event.keyCode === 40 && index < length - 1) { index++; }
// 当用户按上箭头时,跳到前一选项
if (event.keyCode === 38 && index > 0) { index--; }
updateValue(select, index);
});
});
});

上面的代码里,要注意tabIndex属性的使用。该属性用来确保原生组件不会获得焦点,并确保自定义组件能在用户用键盘或鼠标访问时获得焦点。

通过上面的工作,我们已经完成任务了!下面就是结果:

效果

等等,我们真的完成了吗?

让组件变得无障碍

我们已经构建了一个可以运行的组件,虽然距离得到一个具有完整特性的选择框还很远,但它运行得还不错。然而,我们之前所做的只是在处理DOM而已,这个组件并不是真正语义化的,而且虽然它看起来像个选择框,但在浏览器的角度它却并不是这样,因此无障碍技术也不会认为它是个选择框。简而言之,它就是个无障碍性很差的漂亮选择框!

幸运的是,我们有个解决方案叫ARIA。ARIA表示“无障碍的富Internet应用”,它是个W3C规范,用来让web应用和自定义组件变得无障碍。基本上这个规范就是一系列拓展了HTML的特性,用这些特性,我们可以更好地描述角色、状态和属性,让我们刚才设计的元素变得像其尽力模仿的原生元素一样。使用这些特性很简单,下面我们来试试。

role特性

ARIA使用的关键特性是role。该特性会接收一个定义了元素用途的值,每个值都代表了元素的特点和行为。在本例中,我们会使用一个listbox作为role值,这个值是个“复合的role”,指定的元素可以包含多个特定role的子元素(本例中,至少有一个元素role值为option)。

值得注意的是,ARIA定义的role默认会自动用于标准的HTML标签中。比如说,<table>元素对应grid<ul>元素对应list。因为我们的组件使用了<ul>元素,所以得确保组件的listbox role能覆盖掉<ul>元素的list值。为此,可以使用presentation这个role值,该值用来指明一个没有特殊含义的元素,而且该元素只用来展示信息而已。这里我们会给<ul>应用presentation值。

要使用listbox这个role值,得像下面一样修改HTML:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 给最外层元素指定role="listbox" -->
<div class="select" role="listbox">
<span class="value">Cherry</span>
<!-- 给ul元素指定role="presentation" -->
<ul class="optList" role="presentation">
<!-- 给所有li元素指定role="presentation" -->
<li role="option" class="option">Cherry</li>
<li role="option" class="option">Lemon</li>
<li role="option" class="option">Banana</li>
<li role="option" class="option">Strawberry</li>
<li role="option" class="option">Apple</li>
</ul>
</div>

注意:如果你想兼容那些不支持CSS特性选择器的老旧浏览器,同时使用role特性和class特性这种做法是必须的。

aria-selected特性

仅使用role特性是不够的,ARIA本身也提供了很多许多状态和属性特性。对这些特性用得越多和越恰当,网页就越能被无障碍技术所理解。在我们的例子中,只会用到一个特性:aria-selected

aria-selected特性用于标记当前选中的选项,这样无障碍技术就能提示用户当前选中项是什么。我们会在JavaScript中动态地使用它,在用户选中一个选项时能标记该选中项。为此,得修改下updateValue()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function updateValue(select, index) {
var nativeWidget = select.previousElementSibling;
var value = select.querySelector('.value');
var optionList = select.querySelectorAll('.option');
// 确保所有的选项未被选中
optionList.forEach(function (other) {
other.setAttribute('aria-selected', 'false');
});
// 确保选择的那个选项被选中
optionList[index].setAttribute('aria-selected', 'true');
nativeWidget.selectedIndex = index;
value.innerHTML = optionList[index].innerHTML;
highlightOption(select, optionList[index]);
};

上述修改的最终效果如下(访问该组件时使用无障碍技术,譬如NVDAVoiceOver,会有更好的体验):

效果

结论

至此我们已经了解了创建定制表单组件的所有基本知识,但如你所见,这么做并不简单,如果使用第三方库的话会比自己从头写起更好、更简单(当然除非你是想构建这样一个库)。

下面是你在自己开发之前应该参考下的库:

若你想更进一步使用本例,为让其中的代码变得通用和可复用,还要对代码做一些改进。这个练习你可以自己尝试下,这里有两个提示:首先,所有函数的第一个参数都相同,这就意味着这些函数需要有同一个执行上下文,使用一个对象来共享执行上下文是很明智的。此外,代码还得保证兼容,即代码最好能在兼容不同Web标准的多种浏览器下运行。