Web中的焦点管理

发布于 大漠

A11Y中有一个非常重要的点就是关于Web页面或应用上焦点的管理。焦点的管理涉及到多个部分,比如焦点的顺序,焦点的样式等等。这些看上去细微的地方,对于Web的可访问性是非常的重要,特别是对于重度依赖于键盘操作的用户群体来说更为重要,因为焦点的顺序能更这些用户带来更好的体验,另外焦点的样式也能更好的告诉用户现在所处的位置,能有效指导用户在Web上的导航位置。今天和大家一起聊聊Web中有关于焦点方面的知识,如果您感兴趣的话,欢迎继续往下阅读。

什么是焦点

当用户与一个元素进行交互时,浏览器通常会显示一个指示器来表示该元素具有“焦点”。有时也会称之为“焦点环(Focus Ring)”,因为浏览器通常会在焦点元素周置设置一个实心或虚线的边框环。

焦点环向用户发出信号,表明哪个元素将接收键盘事件。如果用户正在浏览一个表单,焦点环会指示他们可以在哪个输入框中输入,或者如果用户已经在一个提交按钮上获得焦点,用户可以直接按键盘的“Enter”或“Space”键盘激活该按钮。

在HTML中,有很多元素称为 可聚焦元素 ,即用户操作Tab键可以让元素获得焦点,带有焦点环效果。

比如上面演示的Facebook的登录界面,其中inputbutton都被称为是可聚焦元素,默认情况之下,客户端会在元素得到焦点的时候给其一个额外的样式(焦点环)样式:

如果你用浏览器的开发者检测器查看代码的话,你会发现,元素在得到焦点时可以使用CSS的:focus给焦点元素设置焦点状态下的样式风格(即,焦点环样式):

Chrome 86引入了两个新功能,在使用焦点时,改善了用户和开发者的体验。比如,新增:focus-visible伪类选择器,它允许开发人员给可聚焦元素设置焦点样式,但只允许用户在使用键盘操作时才显示焦点的元素的焦点环样式。另外还新增了“快速聚焦高亮”的用户首选项,它可以使当前聚焦元素显示一个焦点指示器两秒钟。即使开发者使用CSS禁用了焦点样式,快速焦点高亮也会始终显示。不管用户与页面交互的输入设备是什么,它还会使所有CSS焦点样式相匹配。

焦点问题

对于依赖键盘或其他辅助技术访问Web页面的用户来说,焦点环就像他们的鼠标指针(鼠标指示器)一样。这也是用户知道自己在与什么交互的方式。

不幸的是,许多Web开发者在开Web页面或Web应用时使用CSS样式将可聚焦元素的焦点环都隐藏了。比如:

*:focus {
    outline: none;
}

开发者这样做的原因是因为焦点的底层行为可能很难理解,而对焦点进行样式设计可能会产生令人惊讶的后果。

这样做,虽然满足了设计上的需求(和设计稿效果相匹配),但同时也破坏了依赖键盘访问页面的用户的体验。如前所述,对于依赖键盘访问页面的用户来说,焦点环充当了他们的鼠标指针。因此,去掉焦点环的CSS(不提供替代方案)就相当于隐藏了鼠标指针。

为了改善这种情况,开发人员需要一种更好的方式来处理焦点元素的焦点样式,即一种符合他们对焦点应该如何工作,并且符合用户的期望,也不会给用户带来破坏体验的风险。同时,用户需要在体验中拥有最终发言权,并且应该能够选择何时以及如何看到焦点。这就是:focus-visible和“快速获得高亮”的使用所在。

开发者控制焦点样式的方法

浏览器对于焦点元素都会有一个默认样式,比如拿我们熟悉的<a><button>来说:

我们可以使用开发者工具来查看到它们在获得焦点状态下(:focus)下的样式:

:focus {
    outline: -webkit-focus-ring-color auto 1px;
}

a:-webkit-any-link:focus {
    outline-offset: 1px;
}

也就是说,Web开发者可以显式地使用:focus给可聚焦元素设置焦点状态下的样式(焦点环样式),除此之外,还新增了:focus-visible:focus-within。你在开发者工具中也可以发现这两个新增的伪类选择器:

:focus方式

Web开发者可以使用CSS伪类选择器:focus给可聚焦元素设置焦点样式。当用户点击或触摸元素或通过键盘的Tab键选择它时会被触发。

比如<button>元素:

button:focus {
    outline: 2px dotted #959595;
    outline-offset: 2px;
    box-shadow: 0px 1px 1px #e4e4e4;
}

当用户使用鼠标点击按钮或使用键盘的Tab键选中按钮时,button的样式会产生变化:

也就是说,使用:focus给可聚焦元素设置了样式之后,会告诉浏览器忽略自己给可聚焦元素设置的默认样式,并始终显示你在CSS中:focus状态下的样式。对于某些情况来说,这可能会打破用户的预期,导致混乱的体验。

使用:focus给可聚焦元素设置焦点样式,它有一个副作用,很多人都不喜欢。这意味着当你使用鼠标点击一个可聚焦元素时,会看到焦点样式。但对于使用鼠标的用户来说,他们是不太需要这种反馈(焦点样式),因为你只是把光标移到那里,然后点击一下。不管你怎么想,这些年来,它让很多人很恼火,以至于他们完全删除了焦点样式,这对于Web可访问性来说是一个巨大的损失。

如果我们可以只在使用键盘来聚焦某样东西的时候才应用焦点样式,而不用鼠标呢?是不是给用户能带来更好的一种体验。

:focus-visible

按理说,每个可聚焦元素在元素获得焦点时,浏览器都会给可聚焦元素一个焦点样式。不知道你是否发现,如果我们未显式使用:focus给可聚焦元素设置样式时,我们使用鼠标点击可聚焦元素时,并不会有焦点样式,比如下面这个<button>元素:

但用户使用键盘的Tab键,让<button>元素得到焦点时,会有对应的焦点样式:

但当你使用:focus显式设置焦点元素焦点样式:

button:focus {
    outline: 2px dotted #f36;
    outline-offset: 3px;
}

这个时候,用户不管是使用鼠标点击按钮还是使用键盘的Tab键让<button>获得焦点时,都会有:focus中设置的焦点样式效果:

这样做却失去了浏览器默认状态下可聚焦元素在焦点状态下的指示器。

正如上面提到的,在现代浏览器中,用户使用鼠标点击可聚焦元素,比如<button><a>时不会有焦点样式,但用户按Tab键时,可聚焦元素可以得到焦点样式!

为此,我们可以使用CSS的另一个伪类选择器:focus-visible,这个选择器可以有效地根据用户的输入方式(键盘)展示不同形式的焦点样式。

button:focus-visible {
    outline: 2px solid #f36;
    outline-offset: 2px;
}

这个时候你使用键盘的Tab键,让<button>元素得到焦点时,它的效果如下:

你使用鼠标让<button>得到焦点时,并不会有相应的焦点样式。

我们可以通过将:focus:focus-visible结合起来,可以进一步根据用户的输入设备提供不同的焦点样式:

button:focus {
    outline: 2px dotted #09f;
    outline-offset: 2px;
}

button:focus-visible {
    outline: 2px solid #f36;
    outline-offset: 2px;
}

这个时候不同方式让<button>得到焦点,对应的焦点样式会有所不同,比如下图中左侧是用户按键盘Tab键时,<button>得到焦点时的焦点样式,右侧是用户使用鼠标时,<button>得到焦点时的焦点样式:

不过,:focus:focus-visible也会涉及到选择器权重的问题,就上面的示例来说,如果我们把:focus选择器对应的样式放置到:focus-visible之后:

button:focus-visible {
    outline: 2px solid #f36;
    outline-offset: 2px;
}

button:focus {
    outline: 2px dotted #09f;
    outline-offset: 2px;
}

这个时候,你会发现不管用户使用键盘Tab键还是鼠标让<button>获得焦点时,焦点样式都会采用:focus对应的样式:

如果我们要让:focus:focus-visible可以有独自的样式,可以借助CSS选择器中的:not()来处理:

button:focus:not(:focus-visible) {
    outline: 2px dotted #416dea;
    outline-offset: 2px;
    box-shadow: 0px 1px 1px #416dea;
}

button:focus-visible {
    outline: 2px solid #416dea;
    outline-offset: 2px;
    box-shadow: 0px 1px 1px #416dea;
}

尝试在上面示例中使用键盘的Tab键和鼠标让<button>获得焦点:

:focus-visible启动方式

了解浏览器对焦点指示器的启动方式将有助于我们更好的了解:focus-visible和何时使用:focus-visible。不幸运的是,该启动方式从未被指定,因此在每个浏览器中的行为都有微妙的不同。:focus-visible规范根据浏览器目前渲染行为提出了一种可能的启动主式。

  • 用户是否表示喜欢总是看到焦点指示器:如果用户表示他们总是希望看到焦点指示器,那么:focus-visible将总是在焦点元素上匹配,就像:focus一样
  • 该元素是否需要输入文本:当一个需要文本输入的元素(比如,<input type="text">)被聚焦时,:focus-visible将始终匹配。了解一个元素是否可能需要文本输入的快速方法是问自己:“如果我使用移动设备点击这个元素,我是否希望看到一个虚拟键盘?”如果答案是“是”,那么该元素将匹配:focus-visible
  • 使用的是什么输入设备:如果用户使用键盘来浏览页面,那么:focus-visible将匹配任何成为焦点的交互式元素(包括任何带有tabindex的元素);如果用户使用的是鼠标或触摸屏,那么只有当焦点元素需要输入文本时,它才会匹配
  • 焦点是通过脚本移动的吗:如果焦点是通过调用JavaScript的focus()事件来移动,那么新的焦点元素只有在之前的焦点元素与之匹配时才会匹配:focus-visible。例如,如果用户按下一个物理键,事件处理程序(脚本)打开一个菜单,并将焦点移动到第一个菜单项,那么:focus-visible仍然会匹配,并且菜单项会有一个焦点样式

如果不易于理解,我们可以写一个小Demo来体验。在HTML中,常见的可聚焦的元素主要有链接<a>,按钮<button>,表单控件,比如<input><textarea><select>等,带有tabindex属性的非聚焦元素,比如div。你会发现,在这些可聚焦元素上,你使用鼠标Tab键时,它们都可以得到焦点,默认都会有一个焦点环样式:

就上面示例中,除了<a>链接和<button>按钮之外,用户使用鼠标也可以让其有焦点样式,比如<input>元素:

尝试着在这结可聚集元素上使用:focus:focus-visible添加焦点样式:

从上图的测试示例结果来看,表单中除type="checkbox"type="radio"之外的<input><select>可聚焦元素在用户使用鼠标时,焦点元素样式会是:focus-visible指定的焦点样式。

聚焦状态下的快速高亮

:focus-visible让开发者更容易给焦点元素设置焦点样式,并避免了现有的:focus给焦点样式带来的缺陷。虽然这对于可聚焦元素设置焦点样式有一个很好的被充,但对于一部分用户来说,特别是那些有认知障碍的用户,总是看到焦点指示器是很有帮助的,而且他们可能会发现,当焦点指示器由于:focus-visible的选择性样式而减滗出现的次数时,他们会感到很痛苦。

对于这些用户,Chrome 86新增了一个名为“焦点快速高亮(Quick Focus Highlight)”的功能设置。开启了该设置,会快速突出显示当前聚焦元素的焦点样式,并使:focus-visible始终匹配。

高光是从焦点元素开始的,以避免干扰该元素现有的焦点样式或阴影。高光将在两秒后淡出,以避免遮挡页面内容,如文本。

如果想看到上图的效果,需要确保你的Chrome浏览器开启了这个功能。如果没有开启可以按下面的方式来开启。在Chrome浏览器的地址栏中输入chrome://settings,并在“高级(Advanced)”中的“可访问性(Accessibility)”开启“短暂地突出显示焦点对象”:

:focus-within

前面介绍的:focus:focus-visible都是给可聚焦元素设置焦点状态下的样式,而:focus-within这个伪类和他们都有所不同。它表示一个元素获得焦点,或,该元素的后代元素获得焦点。这也意味着,它或它的后代获得焦点,都可以触发:focus-within

这个属性有点类似于JavaScript的事件冒泡,从可获得焦点元素开始一直冒泡到HTML的根元素<html>,都可以接收触发:focus-within事件。比如:

.box:focus-within,
.container:focus-within {
    box-shadow: 0 0 5px 3px rgba(255, 255, 255, 0.65);
}

body:focus-within {
    background-color: #2a0f5bde;
}

html:focus-within {
    border: 5px solid #09f;
}

聚焦HTML中的焦点

一直以来,HTML中的焦点行为一直不够规范,而且由于焦点方法,UA特定行为、tabindex属性以及Shadow DOM等之间的关系都非常的微妙,而且有各种微妙的差异。这些因素的结合在一起,HTML中焦点元素的行为让开发者感到困惑。

焦点类型

你可能知道,用户在与Web进行交互时,可以通过鼠标的点击键盘的Tab调用JavaScript的focus()事件来让HTML元素聚焦。@Domenic和@Mu-An一起整理了HTML中可交互的元素列表,展示了它们对不同的聚焦方法的反应,并且可以从不同的浏览器中进行测试,看到它们之间的差异。

为了反映出规范中不同UA中焦点的不同行为,我们将焦点行为分为三种类型:可编程聚焦(Programmatically Focusable)可点击聚焦(Click Focusable)可顺序聚焦(Sequentially Focusable)

可编程聚焦

如果一个元素是可编程聚焦(Programmatically Focusable),那么使用JavaScript脚本的focus()方法或在元素上显式设置autofocus属性,它会得到焦点。在所有平台中,所有可点击聚焦或“顺序聚焦(Sequentially Focusable)”的元素也都是可编程聚焦的,所以“可编程聚焦”与“可聚焦”是可以互换的。

可点击聚焦

如果一个元素是可点击聚焦(Click focusable),那么当元素被点击时,这个元素就会被聚焦。这与大多数用户体验(UAs/平台中的)“可编程聚焦”具有相同的元素集。一个明显的例外是Safari浏览器,其中不可编程的表单控件(复选框等)默认情况下是不可点击聚焦的。

可顺序聚焦

如果一个元素是可顺序聚焦(Sequentially Focusable),那么该元素可以通过键盘的Tab键来聚焦。这意味着按键盘的Tab键、Tab+Shift(在Safari中,也可以按Option + Tab键)。

之前的规范并没有明确区分“程序上可聚焦”可聚焦和“可顺序聚焦”,甚至没有提到“可点击聚焦”,并且使用了tabindex属性的概念。

tabindex

在HTML中,像<button><input><a>这些可聚焦元素都有一个“默认”的焦点样式。当tabindex属性获取器运行在一个tabindex属性尚未明确设置的元素上时,它有时候会返回0,有时也会返回-1。之前,规范说如果元素默认是“可聚焦”,则返回0,否则返回-1。但这并没有在任何地方实现,因为在不同的UA中,哪些元素默认是可聚焦的可能存在差异。

那么tabindex到底有什么用呢?你可以在一个元素上显式设置tabindex的值为一个整数:

  • tabindex="-1":设置了tabindex-1的元素,该元素不会被置于页面的Tab键序列中,但可以通过JavaScript的focus()获取,允许脚本设置元素的焦点。当你需要将焦点转移到通过脚本或用户操作之外更新的内容时,该设置非常方便
  • tabindex="0":可以让非常聚焦元素按自然顺序出现在Tab键序列中
  • tabindex="1":不要设置tabindex="1"或任何大于0的值

有关于tabindex更详细的介绍可以阅读《使用tabindex的正确姿势》一文。

autofocus

如果你想在页面加载时将焦点设置在表单控件元素上,autofocus属性就很有用。但它也存在一些问题,比如:

  • autofocus属性只对某些表单控件元素有效,而对其他可聚焦的元素,比如<summary><div contenteditable="true"><span tabindex="0">无效(不可用)
  • 在使用片段标识符(如https://www.fedev.cn/#CSS)访问页面,这个时候autofocus也无法实现互动操作
  • 如果有多个自动聚焦元素(在多个元素上显式设置了autofocus),只有排在最前一个有效

使用JavaScript获取键盘焦点

在Web开发过程中,我们可以使用JavaScript通过键盘获得可聚焦的元素。在使用JavaScript获取可聚焦元素可以分为两个部分。

知道Web内容

如果开发者事先知道元素的内容,可以很容易地通过键盘找到可聚焦的元素。比如我们在一个登录面板,它有<input><button>元素:

我们可以使用querySelectorAll来获取可聚焦元素:

const loginForm = document.querySelector('.login-container')

const focusableElements = [...loginForm.querySelectorAll('input, button')]

console.log(focusableElements)

不知道Web内容

有时候我们事先并不知道Web的内容是什么,这个时候要找到Web中可聚焦的键盘的元素就难多了。

不过,事先我们可以知道,在HTML中通过键盘可聚焦的元素时常有:

  • <a>链接
  • <button>按钮
  • 具有不同type值的<input>
  • <textarea>
  • <select>
  • <details>
  • 显式设置了tabindex值为0的元素
  • 显式设置了tabindex值为正数的值,一般为1

这样一来,我们就可像下面这样使用querySelectorAll来获得所有键盘焦点元素:

const keyboardfocusableElements = document.querySelectorAll(
    'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
)

同样是上面的登录表单,按上面的方式输出的键盘可聚焦元素结果如下:

有的时候,可能会对一些元素禁用(即在元素上显式设置了disabled),比如在inputbutton显式设置了disabled。对于显式设置了disabled的元素而言,他就是一个不可聚焦的元素,为此我们可以在上面代码基础上通过数组的.filter()来过滤设置了disabled的可聚焦元素:

const keyboardfocusableElements = [...document.querySelectorAll(
    'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
)].filter(el => !el.hasAttribute('disabled'))

注意,[...document.querySelectorAll('a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])')]的返回值是一个数组。

如果你觉得上面的代码不好理解,我们可以把上面代码的功能封装成一个函数,比如getKeyboardFocusableElements()

function getKeyboardFocusableElements (element = document) {
    return [...element.querySelectorAll(
        'a, button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'
    )].filter(el => !el.hasAttribute('disabled'))
}

同样基于上面示例中的表单,使用getKeyboardFocusableElements()函数输出要聚焦元素:

console.log('getKeyboardFocusableElements function====>', getKeyboardFocusableElements())

使用非聚焦元素

时至今日,React和Vue这样的JavaScript框架是当下Web开发者开发Web应用的一种主流方式。很多时候会使用非聚焦元素来模拟一些可聚集元素或组件,比如使用<ul><li>,并且结合一些ARIA的特性构建一个ListBox

<ul role="listbox" tabindex="0">
    <li role="option" id="css">CSS</li>
    <li role="option" id="javascript">JavaScript</li>
    <li role="option" id="react">React</li>
    <li role="option" id="vue">Vue</li>
    <li role="option" id="svg">SVG</li>
    <li role="option" id="animation">Animation</li>
    <li role="option" id="canvas">Canvas</li>
    <li role="option" id="mobile">Mobile</li>
</ul>

针对这样的应用场景,同样需要为键盘用户和屏幕阅读器管理焦点。在WAI-ARIA规范中提供了两种方式对焦点进行管理:

  • 使用element.focus()tabindex
  • 使用aria-activedescendant

相对于focus()tabindex而言,估计很多Web开发者对aria-activedescendant更感到陌生。我们来一起看看aria-activedescendant

aria-activedescendant通常放在一个容器元素上,比如上面示例代码中的ul元素(它就是一个容器元素),它可以让屏幕阅读器识别到该元素是一个活动元素(Active Element)。但是要想让aria-activedescendant能正常工作,我们有四件事情需要做:

  • aria-activedescendant放在一个容器元素上,比如示例中的<ul>中,这个容器元素可以是一个复合的Widget。如果该元素不是复合Widget,它必须有一个文本框(role="textbox")、组(role="group")或应用程序(role="application")角色
  • 容器元素可聚焦,显式在该容器元素设置tabindex="0"
  • aria-activedescendant设置为活动项目的id
  • 给活动项目设置样式,让用户可以在视觉上看到不同

目前规范中定义的复合部件(Composite Widget)主要有comboboxgridlistboxmenumenubarradiogrouptablisttreetreegrid

根据这几个点的描述,重新改造上面的listbox的代码:

<ul role="listbox" tabindex="0" aria-activedescendant="css">
    <li role="option" id="css">CSS</li>
    <li role="option" id="javascript">JavaScript</li>
    <li role="option" id="react">React</li>
    <li role="option" id="vue">Vue</li>
    <li role="option" id="svg">SVG</li>
    <li role="option" id="animation">Animation</li>
    <li role="option" id="canvas">Canvas</li>
    <li role="option" id="mobile">Mobile</li>
</ul>

当用户在listbox中选择一个字符时,我们需要设置aria-activedescendant的值和列表中的id相匹配。比如,用户选择了listbox中的CSS选项,那么aria-activedescendant的值就是该选项对应的id值。

如果你希望被选中的列表项有另外的样式,可以在该选项上设置一个类名,在该类名下设置另一个样式规则,比如:

<ul role="listbox" tabindex="0" aria-activedescendant="css">
    <li role="option" id="css" class="active">CSS</li>
    <li role="option" id="javascript">JavaScript</li>
    <li role="option" id="react">React</li>
    <li role="option" id="vue">Vue</li>
    <li role="option" id="svg">SVG</li>
    <li role="option" id="animation">Animation</li>
    <li role="option" id="canvas">Canvas</li>
    <li role="option" id="mobile">Mobile</li>
</ul>

现在使用下面的JavaScript脚本允许用户点击来选择列表项,并且会在点击的列表项上添加active类名,同时点击的列表项的id值会赋值给aria-activedescendant

const listbox = document.querySelector('[role="listbox"]');
const characters = [...listbox.children];

listbox.addEventListener("click", (event) => {
    const option = event.target.closest("li");
    if (!option) return;

    listbox.setAttribute("aria-activedescendant", option.id);

    characters.forEach((element) => element.classList.remove("active"));
    option.classList.add("active");
});

打开iOS的“Voiceover”测试的结果如下:

如果我们使用键盘来访问该列表,我们无法使用键盘的方向键对列表进行操作:

为此,我们可以通过JavaScript给列表项添加相应的键盘事件keydown

listbox.addEventListener("keydown", (event) => {
    const { key } = event;
    if (key !== "ArrowDown" && key !== "ArrowUp") return;

    const activeElementID = listbox.getAttribute("aria-activedescendant");
    const activeElement = listbox.querySelector("#" + activeElementID);

    let selectedOption;
    if (key === "ArrowDown") selectedOption = activeElement.nextElementSibling;
    if (key === "ArrowUp") selectedOption = activeElement.previousElementSibling;

    if (selectedOption) {
        listbox.setAttribute("aria-activedescendant", selectedOption.id);

        characters.forEach((element) => element.classList.remove("active"));
        selectedOption.classList.add("active");
    }
});

这个时候我们可以通过键盘的向上()和向下()箭头键盘来选中列表项:

效果如下:

除此之外,不可以使用element.focus加上tabindex实现上面示例中同等的效果。使用element.focus来移动DOM焦点,而不是依赖于aria-activedescendant

这样一来,第个列表项li中的id和容器ul中的aria-activedescendant属性就可以移除:

<ul role="listbox">
    <li role="option">CSS</li>
    <li role="option">JavaScript</li>
    <li role="option">React</li>
    <li role="option">Vue</li>
    <li role="option">SVG</li>
    <li role="option">Animation</li>
    <li role="option">Canvas</li>
    <li role="option">Mobile</li>
</ul>

为了确保li元素可得到焦点,我们可以显式在li中设置tabindex的值为-1

<ul role="listbox">
    <li role="option" tabindex="-1">CSS</li>
    <li role="option" tabindex="-1">JavaScript</li>
    <li role="option" tabindex="-1">React</li>
    <li role="option" tabindex="-1">Vue</li>
    <li role="option" tabindex="-1">SVG</li>
    <li role="option" tabindex="-1">Animation</li>
    <li role="option" tabindex="-1">Canvas</li>
    <li role="option" tabindex="-1">Mobile</li>
</ul>

我们可以使用JavaScript的.focus()让选中的li得到焦点:

const listbox = document.querySelector('[role="listbox"]');

listbox.addEventListener("click", (event) => {
    const option = event.target.closest("li");
    if (!option) return;

    option.focus();
});

使用CSS的:focus伪类选择器设置焦点状态下li的样式:

li:focus {
    color: #f36;
    text-decoration: line-through;
    font-weight: 500;
}

li:focus::before {
    background: url("data:image/png;base64,i...") no-repeat center;
    background-size: 20px 20px;
}

当你点击li时,它会得到一个焦点,li:focus对应的样式会运用到li上:

iOS的“Voiceover”测试的结果如下:

上面的示例没有给其添加键盘事件。为此,我们给li添加keydown事件,在这种情况之下,先把第一个litabindex值设置为0,表示默认选中了第一个li

<ul role="listbox">
    <li role="option" class="active" tabindex="0">CSS</li>
    <li role="option" tabindex="-1">JavaScript</li>
    <li role="option" tabindex="-1">React</li>
    <li role="option" tabindex="-1">Vue</li>
    <li role="option" tabindex="-1">SVG</li>
    <li role="option" tabindex="-1">Animation</li>
    <li role="option" tabindex="-1">Canvas</li>
    <li role="option" tabindex="-1">Mobile</li>
</ul>

对应CSS样式也调整一下,使用CSS的属性选择器li[tabindex="0"]来表达选中的列表项样式

li[tabindex="0"],
li:focus {
    color: #f36;
    text-decoration: line-through;
    font-weight: 500;
}

li[tabindex="0"]::before,
li:focus::before {
    background: url("data:image/png;base64,i...") no-repeat center;
    background-size: 20px 20px;
}

加上相应的keydown脚本:

listbox.addEventListener("keydown", (event) => {
    const { key } = event;
    if (key !== "ArrowDown" && key !== "ArrowUp") return;
    event.preventDefault();
    const option = event.target;

    let selectedOption;
    if (key === "ArrowDown") selectedOption = option.nextElementSibling;
    if (key === "ArrowUp") selectedOption = option.previousElementSibling;

    if (selectedOption) {
        selectedOption.focus();
        document.querySelectorAll("li").forEach((element) => {
            element.setAttribute("tabindex", -1);
        });
        selectedOption.setAttribute("tabindex", 0);
    }
});

这个时候可以使用键盘的向上()和向下()键对列表项进行操作:

具体Demo如下:

如果你对这方面知识感兴趣,还可以阅读下面这几篇文章:

自定义焦点样式

从前面的内容中可以获知,浏览器对于可聚焦元素都会有相应的焦点环样式。为此,很多开发者有的时候会选择性的忽略给焦点元素设置焦点环样式(除全局设置*:focus{outline: none}之外)。采用浏览器默认的焦点样式虽然省事,但也有其利弊。先来看其好的一面。

用户对浏览器默认的焦点环样式比较熟悉。当我们看到某个元素周围有蓝色环(看上去像边框),就知道它处理焦点状态。

不好的是,浏览器默认的焦点环样式风格表现不一致:

另外,浏览器默认的焦点环样式在特定的情况下缺乏对比度。比如说:

在Firefox下,点线的轮廓效果在白色背景下缺乏对比度,几乎看不到,不过在深色的背景上,轮廓会从黑色切换到白色。对比度还不错:

Safari的蓝色轮廓在深色背景的页面上缺乏对比度:

要是蓝色背景就更难区分。

Chrome相比于Firefox和Safari在这方面要更好一些,在黑色背景状态,能看到一个白边的轮廓:

更多也是基于默认焦点环样式的不一致,甚至和自己Web应用设计风格不一致,导致开发者更多的禁用了焦点环样式。这样做对于Web的可访问性是致命的伤害,对于那些强度依赖于键盘访问的用户来说更是不友好的,因为焦点元素失去焦点环样式,会造成用户不知道自己浏览Web的时候处在什么位置。

如果你希望满足设计师对于焦点环样式的要求,我们可以通过CSS来为自己的应用定制焦点环样式。

改变元素背景颜色

对于可填充的元素,比如按钮,在焦点状态给其添加对比度较高的背景颜色是比较好。比如Codepen上有些地方就采用这种方式:

我们可以像下面这样写:

button:focus:not(:focus-visible) {
    outline: none;
    background-color: #3e4454;
    color: #fff;
}

button:focus-visible {
    outline: none;
    background-color: #3d4352;
    color: #fff;
}

上面的代码区分了鼠标点击和按键盘Tab两种方式下,button聚焦下焦点环样式,从代码上也能看出来,背景颜色略有差异。

可以尝试着按Tab键访问下面Demo中的按钮:

改变文本颜色

如果聚焦元素有文本,可以考虑在聚焦状态下改变其颜色。改变文本颜色需要注意的是,文本颜色的对比度。

a:focus:not(:focus-visible) {
    outline: none;
    color: #ffeb3b;
}

a:focus-visible {
    outline: none;
    color: #00bcd4;
}

对于<a>链接,我们还可以使用CSS的text-decoration来改为文本下划线的效果:

a:focus:not(:focus-visible) {
    outline: none;
    color: #ffeb3b;
    text-decoration-line: underline;
    text-underline-offset: 6px;
    text-decoration-color: #00bcd4;
}

a:focus-visible {
    outline: none;
    color: #00bcd4;
    text-decoration-style: wavy;
    text-underline-offset: 6px;
    text-decoration-color: #ffeb3b;
}

对于其他聚焦元素,比如<button>不建议只修改文本颜色,为了让用户能更直观知道元素聚焦了,还可以结合其他样式规则来设置焦点环样式。

而且我们在A11Y系列的《颜色对比度和Web可访问性》一文中也提到过:

颜色并不是传递信息、指示动作、提示和区分视觉元素的唯一手段!

使用box-shadow来模拟outline

outline常用来给聚焦元素设焦点环样式的属性,只不过他的功能有一定的局限性,因此灵活度不是太高。而CSS的box-shadow可以做与outline完全相同的事情,而且功能要更强,灵活性更大。比如说,使用box-shadow可以更好的控件轮廓的颜色、透明度、偏移、模糊等等,而且还不会影响盒子大小。

比如我们使用box-shadow<a><input><button>设置焦点环样式:

:focus:not(:focus-visible) {
    outline: none;
    box-shadow: 0 0 0 3px #03a9f4d9;
}

:focus-visible {
    outline: none;
    box-shadow: 0 0 0 3px #03f4e0b8;
}

尝试让上面Demo中元素获得焦点,你将看到下图这样的效果:

使用box-shadow有时候还可以模拟按钮按下的效果:

button:focus:not(:focus-visible) {
    outline: none;
    outline-offset: 2px;
    box-shadow: 0px 1px 1px #416dea;
}

button:focus-visible {
    outline: none;
    outline-offset: 2px;
    box-shadow: 0px 1px 1px #416dea;
}

改变焦点元素大小

当聚焦元素得到焦点时,使用transform轻微的改变焦点元素的尺寸大小。

:focus:not(:focus-visible) {
    outline: none;
    transform: scale(1.05);
}

:focus-visible {
    outline: none;
    transform: scale(1.1);
}

这里的关键就是“轻微”的调整焦点元素尺寸,如果尺寸调整过大可能会导致页面的回重排,会影响Web的性能。另外,在CSS中的盒模型属性都可以用来调整元素的尺寸,但这些属性的调整会影响Web的布局。因此,我们在示例中使用trasformscale()函数

使用动效来增强焦点环样式

Material的的表单组件中,当input获得焦点时,相应的label会有移动,其自身的边框也会有一个微妙的动效:

也就是说,我们在给聚焦元素设置样式时,除了上面提到的还可以通过一些动效来完成。比如下面这个示例:

除此之外,@Maurice Mahan创建了一个用于聚焦元素上焦点环的库 FocusOverlay。该库实现了焦点从一个元素到另一个元素时焦点环的动画效果:

其实,除了这个库之外,还有另外两个JavaScript也可以实现类似的功能:

焦点管理

许多形式的辅助技术都使用键盘导航来理解和操作屏幕内容。比如前面多次提到的,使用键盘的Tab键就是常见的一种方式。键盘的Tab键会按照可聚焦元素(可交互的元素)在DOM中显示的顺序跳转到它们。这就是为什么HTML的源代码顺序与你的设计的视觉层次结构相匹配是如此重要的原因之一。

因此,Web中的焦点管理一直令人感到头痛,也是开发者比较棘手的事情之一,但协调好Web中什么可以接收焦点对于使用Web应用可访问性尤其的重要。

一般情况下,只要你使用<button>来处理按钮,使用<a>来处理链接,使用像<input>这样的表单元素来处理表单。换句话,使用好具有语义化的HTML标签元素,可以让焦点很好的为你服务,而且你也不需要额外做过多的事情。在《编写HTML时要考虑可访问性》中也提到过这些方面的知识点。

当然,也会有些情况下,可能会将焦点运用于焦点顺序之外的东西,或者使用了不可聚焦的元素能得到焦点,比如《使用tabindex的正确姿势》文中提到的:

  • tabindex="-1":设置了tabindex-1的元素,该元素不会被置于页面的Tab键序列中,但可以通过JavaScript的.focus()获取,允许脚本设置元素的焦点。当你需要将焦点转移到通过脚本或用户操作之外更新的内容时,该设置非常方便
  • tabindex="0":可以让非常聚焦元素按自然顺序出现在Tab键序列中
  • tabindex="1":不要设置tabindex="1"或任何大于0的值

此外,请不要在非可聚焦元素上显式设置tabindex,避免它可以被辅助技术获取到焦点。

有的时候,Web布局虽然不会改变HTML的DOM源码顺序,但在视觉呈现上的顺序和HTML源码顺序会不一致,这个时候也会给焦点的顺序管理带来巨大的成本,也同样会给用户带来不好的体验。似乎到目前为止,还没有更好的方式来解决这方面的问题。

管理Web焦点环样式很容易,但要管理它的顺序,真的很难。有关于这方面的话题,希望后面有机会和大家一起来探讨。