我最近开始与我的团队一起为客户开发一个渐进式 Web 应用程序 (PWA)。我们使用 React 以及通过 React Router 进行的客户端路由,我们制作的首批元素之一是主菜单。菜单是任何网站或应用程序的关键组成部分。这确实是人们四处走动的方式,因此,使它易于访问对团队来说是一个非常高的优先级。
但在这个过程中,我们发现,在 PWA 中创建可访问的主菜单并不像听起来那么容易。我认为我会与您分享一些这些经验教训,以及我们如何克服这些挑战。
就要求而言,我们希望用户不仅可以使用鼠标,还可以使用键盘导航菜单,验收标准是用户应该能够通过顶级菜单项进行制表符,以及通常只有当使用鼠标悬停在顶级菜单项上时才可见的子菜单项。当然,我们希望焦点环跟随具有焦点的元素。
我们首先要做的是更新现有的 CSS,该 CSS 已设置为在将鼠标悬停在顶级菜单项上时显示子菜单。我们之前使用的是 visibility
属性,在父容器的悬停状态下在 visible
和 hidden
之间切换。这对鼠标用户来说很好用,但对于键盘用户来说,焦点不会自动移动到设置为 visibility: hidden
的元素(对于设置了 display: none
的元素也是如此)。因此,我们删除了 visibility
属性,而是使用了一个非常大的负位置值
.menu-item {
position: relative;
}
.sub-menu {
position: absolute
left: -100000px; /* Kicking off the page instead of hiding visiblity */
}
.menu-item:hover .sub-menu {
left: 0;
}
这对鼠标用户来说很好用。但对于键盘用户来说,即使焦点位于该子菜单中,子菜单仍然不可见!为了在子菜单中的元素具有焦点时使子菜单可见,我们需要利用 :focus
和 :focus-within
在父容器上
.menu-item {
position: relative;
}
.sub-menu {
position: absolute
left: -100000px;
}
.menu-item:hover .sub-menu,
.menu-item:focus .sub-menu,
.menu-item:focus-within .sub-menu {
left: 0;
}
此更新后的代码允许子菜单在该菜单中的每个链接获得焦点时出现。一旦焦点移动到下一个子菜单,第一个子菜单就会隐藏,第二个子菜单就会变为可见。完美!我们认为这项任务已经完成,因此创建了一个拉取请求并将其合并到主分支中。
但第二天,我们在暂存环境中使用菜单创建另一个页面时遇到了问题。在选择菜单项后(无论点击还是制表符),菜单本身都不会隐藏。鼠标用户必须单击侧面空白区域以清除焦点,而键盘用户则完全卡住了!他们无法按 esc 键以清除焦点,也无法按任何其他组合键。相反,键盘用户必须按 tab 键足够多次才能将焦点移动到菜单中并移动到另一个不会导致大型下拉菜单遮挡其视图的元素。
菜单保持可见的原因是,选定的菜单项保留了焦点。单页应用程序 (SPA) 中的客户端路由意味着只有页面的一部分会更新;没有完整的页面重新加载。
我们还注意到另一个问题:键盘用户很难使用我们的“跳到内容”链接。Web 用户通常期望按一次 tab 键会突出显示“跳到内容”链接,但我们的菜单实现破坏了这一点。我们必须想出一个模式来有效地复制浏览器在完全页面重新加载时免费提供的“焦点清除”。
我们尝试的第一个选项是最简单的:向 React Router 的 Link
组件添加一个 onClick
属性,在选择菜单中的链接时调用 document.activeElement.blur()
const Menu = () => {
const clearFocus = () => {
document.activeElement.blur();
}
return (
<ul className="menu">
<li className="menu-item">
<Link to="/" onClick={clearFocus}>Home</Link>
</li>
<li className="menu-item">
<Link to="/products" onClick={clearFocus}>Products</Link>
<ul className="sub-menu">
<li>
<Link to="/products/tops" onClick={clearFocus}>Tops</Link>
</li>
<li>
<Link to="/products/bottoms" onClick={clearFocus}>Bottoms</Link>
</li>
<li>
<Link to="/products/accessories" onClick={clearFocus}>Accessories</Link>
</li>
</ul>
</li>
</ul>
);
}
这种方法适用于在点击项目后“关闭”菜单。但是,如果键盘用户在选择菜单链接之一后按 tab 键,那么下一个链接将获得焦点。如前所述,在导航事件后按 tab 键,理想情况下会首先将焦点放在“跳到内容”链接上。
此时,我们知道我们必须以编程方式将焦点强制到另一个元素,最好是在 DOM 中较高的元素。这样,当用户在导航事件后开始按制表符时,他们会到达页面顶部或接近页面顶部,类似于完全页面重新加载,从而更容易访问跳转链接。
我们最初尝试将焦点强制到 <body>
元素本身,但这不起作用,因为主体不是用户可以与之交互的内容。它无法接收焦点。
下一个想法是将焦点强制到标题中的徽标,因为这本身只是一个指向主页的链接,并且可以接收焦点。但是,在这种特定情况下,徽标位于 DOM 中“跳到内容”链接下方,这意味着用户必须按 shift + tab 才能到达它。不好。
我们最终决定必须在 DOM 中渲染一个可交互元素,例如锚元素,该元素位于高于“跳到内容”链接的位置。此新的锚元素将被设置为不可见,并且用户无法使用“正常”Web 交互(即它已从正常的制表符流中移除)将焦点移到它。当用户选择菜单项时,焦点将以编程方式强制到此新的锚元素,这意味着再次按 tab 会直接将焦点放在“跳到内容”链接上。这也意味着一旦选择菜单项,子菜单将立即隐藏自身。
const App = () => {
const focusResetRef = React.useRef();
const handleResetFocus = () => {
focusResetRef.current.focus();
};
return (
<Fragment>
<a
ref={focusResetRef}
href="javascript:void(0)"
tabIndex="-1"
style={{ position: "fixed", top: "-10000px" }}
aria-hidden
>Focus Reset</a>
<a href="#main" className="jump-to-content-a11y-styles">Jump To Content</a>
<Menu onSelectMenuItem={handleResetFocus} />
...
</Fragment>
)
}
关于此新的“焦点重置”锚元素的一些说明
href
设置为javascript:void(0)
,以便如果用户设法与该元素交互,则不会实际发生任何事情。例如,如果用户在选择菜单项后立即按 return 键,则会触发交互。在这种情况下,我们不希望页面执行任何操作,也不希望 URL 更改。tabIndex
设置为-1
,以便用户无法“正常”将焦点移到此元素。这也意味着用户在加载页面时第一次按 tab 键时,此元素不会获得焦点,而是“跳到内容”链接。style
只会将元素移出视口。设置为position: fixed
可确保它从文档流中移除,因此不会为该元素分配任何垂直空间aria-hidden
告诉屏幕阅读器此元素不重要,因此不要向用户宣布它
但我们认为我们可以进一步改进这一点!假设我们有一个 超级菜单,并且该菜单不会在鼠标用户单击链接时自动隐藏。这会导致沮丧。用户必须精确地将鼠标移动到不包含菜单的页面部分才能清除 :hover
状态,从而使菜单关闭。
我们需要“强制清除”悬停状态。我们可以借助 React 和 clearHover
类来实现这一点
// Menu.jsx
const Menu = (props) => {
const { onSelectMenuItem } = props;
const [clearHover, setClearHover] = React.useState(false);
const closeMenu= () => {
onSelectMenuItem();
setClearHover(true);
}
React.useEffect(() => {
let timeout;
if (clearHover) {
timeout = setTimeout(() => {
setClearHover(false);
}, 0); // Adjust this timeout to suit the applications' needs
}
return () => clearTimeout(timeout);
}, [clearHover]);
return (
<ul className={`menu ${clearHover ? "clearHover" : ""}`}>
<li className="menu-item">
<Link to="/" onClick={closeMenu}>Home</Link>
</li>
<li className="menu-item">
<Link to="/products" onClick={closeMenu}>Products</Link>
<ul className="sub-menu">
{/* Sub Menu Items */}
</ul>
</li>
</ul>
);
}
此更新后的代码在单击菜单项时立即隐藏菜单。它还在键盘用户选择菜单项时立即隐藏。在选择导航链接后按 tab 键会将焦点移动到“跳到内容”链接。
此时,我们的团队已经将菜单组件更新到我们非常满意的程度。键盘和鼠标用户都获得了始终如一的体验,这种体验遵循浏览器为完全页面重新加载默认执行的操作。
我们实际的实现与这里示例略有不同,因此我们可以在其他项目中使用这种模式。我们将它放入 React 上下文中,将 Provider 设置为包装 Header 组件,并在 Provider 的 children
之前自动添加 Focus Reset 元素。这样,该元素就会在 DOM 层次结构中“跳到内容”链接之前放置。它还允许我们使用简单的钩子访问焦点重置函数,而不必进行道具传递。
我们创建了一个 Code Sandbox,允许您使用我们在这里介绍的三个不同解决方案进行操作。您一定会看到早期实现的痛点,然后看到最终结果的感觉有多好!
我们很乐意听到您对此实现的反馈!我们认为它会很好用,但它尚未发布到野外,因此我们没有确切的数据或用户反馈。我们当然不是 a11y 专家,只是尽我们所能,并乐于学习有关该主题的更多知识。
很高兴看到有关可访问的 SPA 导航的工作,谢谢!
我在 Firefox/NVDA 和 Safari/VoiceOver(Mac)中快速测试了您的 CodeSandBox。在路由链接激活后,焦点确实发生了变化(我使用 document.addEventListener('focusin', () => {console.log(document.activeElement);}); 跟踪了它),但由于您将焦点发送到 aria-hidden 元素,因此两个屏幕阅读器都保持静默。
但您走在将焦点发送到路由转换后的元素的正确道路上。Marcy Sutton 研究了类似的模式:https://www.gatsbyjs.com/blog/2019-07-11-user-testing-accessible-client-routing/
嘿,Marcus!
感谢您提供文章链接,我一定会阅读的。我们在实现菜单时很难在网上找到此类文章,因此很高兴知道有一些文章!
我对第一次使用 Android 11 智能手机与菜单的交互感到有些失望。如果我点击“产品”,子菜单只会在我手指放在屏幕上的时间内可见。一旦我抬起手指,我就会被带到“产品”页面。基本上,我永远无法点击子菜单,除非我做一些奇怪的操作,例如将一根手指放在“产品”上,同时用另一根手指将子菜单中的链接聚焦。
嘿,Sebastian,感谢您的反馈!
对于我们的应用程序,我们实际上没有在移动设备上使用此布局,我们会以不同的方式渲染菜单项,并以移动设备上预期的方式进行触摸交互。
此菜单适用于较大的设备和通常具有键盘输入的设备。
也许我们应该在文章底部添加该条款,感谢您指出这一点
为了提高能够按预期看到菜单的浏览器范围,您可能需要将 :focus-within 伪选择器与其他伪选择器分开。 这是因为如果浏览器不理解逗号分隔的选择器中的一个,它将忽略所有这些选择器。
对对对
绝对一针见血,Clint!
这是 Chris 在我们处理文章草稿时给出的第一条反馈。
为了简洁起见,我热衷于将它们分组,但绝对应该指出这种方法的注意事项,所以非常感谢您发布这条评论
太棒了!
您能分享一下您所使用的网站 URL 吗?
这对像我这样的学习者来说将非常有用。
提前感谢
我从这篇文章中得到了一些有用的收获,但我不赞同您关于将焦点发送到何处的决定。 让我分享一下我的经验。
当然,您可以在任何元素上添加 tabindex 属性,但获得焦点的任何元素都必须有一个可访问的标签。 将用户发送到“无底深渊”并期望他们再次按下 Tab 键以希望找到他们在页面中的位置并不理想,甚至不可接受。
为什么要模仿全页面导航? 在没有全页面加载的情况下,屏幕阅读器也不会以相同的方式起作用。 它不会像全页面加载那样再次读取页面标题。 用户必须以某种方式弄清楚发生了什么变化,将他们发送到页面的最顶部并不能帮助他们实现这个目标。 将他们发送到更新的内容。
为此,我发现 SPA 中有效的方法是针对跳过导航链接和任何路由导航的标签(假设在这种情况下,只有一个路由出口用于主要内容)。 您仍然需要在其上放置 tabindex 属性。 假设您的路由出口只是在这个标签的内部,那么主要元素是理想的选择,原因有二:
a) 它在静态状态下包装您的路由出口,因此您不必担心在发送焦点时出现异步竞争条件,以及
b) 它是一个地标元素,JAWS 会将其读作地标元素。 如果它是一个没有可访问标签的 div,则屏幕阅读器将开始读取其所有内容,这也是不理想的。
您可以在 main 标签上添加 aria-label 或 aria-describedby 属性以获得更多上下文,但我现在不记得这些确切的交互方式了。
那是应该说针对“main”标签。 看起来好像被吃掉了。
我也不知道为什么您想为依赖键盘导航的人模拟全页面刷新的感觉,尤其是在保持导航状态不变的情况下确实有好处。
试想一下在 https://twitter.com/ 中使用 Tab 键进行操作;您可以从通知切换到消息。 您的菜单保持不变。 这是一种更好的用户体验。
现在,对于依赖屏幕阅读器的用户来说,您需要做的是找到一种方法来宣布您已进入新的页面,以便屏幕阅读器可以识别。 这就需要使用 aria-live 来创建一个“播报器”。
Marcy Sutton 所做的上述研究在这篇文章中有所补充:https://www.gatsbyjs.com/blog/2020-02-10-accessible-client-side-routing-improvements/
避免使用
left: -100000px;
来将元素隐藏在屏幕之外是一种最佳做法。 对于可能依赖于页面自动翻译的国际用户来说,这可能会导致出现滚动条。例如,在您的代码中将
<html lang="en">
更改为<html lang="ur" dir="rtl">
,并注意出现的滚动条(读者也可以在浏览器的开发工具中执行此操作)。 即使您切换到 CSS 逻辑属性,这个问题仍然可能存在。为了以可访问的方式隐藏元素并避免国际化问题,可以参考 Kitty Giraudel 的 负责任地隐藏内容 或 Scott O’Hara 的 包含性隐藏。
在客户端导航中管理焦点的更健壮的解决方案是在
body
中添加tabindex="-1"
,并在路由更改后调用document.body.focus()
。tabindex="-1"
不会将元素添加到 Tab 键顺序中,但可以让它以编程方式获得焦点。