2

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

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

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

设计,结构和语义

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

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

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

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

组件变为普通状态:

  • 页面加载

  • 组件激活且用户点击了组件外任意地方

  • 组件激活且用户用键盘把焦点移动到别的组件

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

组件变为激活状态:

  • 用户点击了组件

  • 用户按tab键且组件获得了焦点

  • 组件处于打开状态且用户点击了组件

组件变为打开状态:

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

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

组件的值改变:

  • 在组件处于打开状态时,用户点击了一个选项

  • 在组件处于激活状态时,用户按了上下方向键

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

  • 当组件处于打开状态时,被选中的选项会高亮

  • 当鼠标移到一个选项上,该选项会高亮且原先高亮状态的选项会恢复到普通状态

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

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

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

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

定义HTML结构和语义

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

<!-- 这是组件的主要容器.
     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规则,第二部分是用来让组件变成我们想要的样子的样式。

必要的样式

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

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

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

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

接下来处理选项列表:

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

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

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

美化

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

.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类上用一个简单的背景图片来实现。

.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;
}

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

.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类来标明用户会选取(或已经选取)的值。

.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>元素。而为了能让自定义组件的数据和剩下的表单数据一起发送,这一步也是很有必要的。后边我们还会详细介绍。

<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代码会隐藏自定义组件。

.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>元素和自定义组件可见与否的切换。

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支持、让我们的工作轻松些,就像下面这样:

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

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

建立事件回调

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

/* 这个函数会在取消激活自定义组件时被使用
    需要一个参数:
    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');
};

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

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

/* 在文档加载出来后处理下事件绑定 */
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组件来作为回退;同步这个组件的值和自定义组件的值是很容易的:

// 该函数用于更新展示的值,并和原生组件作同步
// 需要两个参数:
// 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;
};

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

// 在文档加载出来后处理下事件绑定
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:

<!-- 给最外层元素指定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()函数:

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标准的多种浏览器下运行。


Levon
526 声望89 粉丝

大自然爱好者