我们如何改进单页应用程序菜单的可访问性

Avatar of Luke Denton
Luke Denton

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费积分!

我最近开始与我的团队一起为客户开发一个渐进式 Web 应用程序 (PWA)。我们使用 React 以及通过 React Router 进行的客户端路由,我们制作的首批元素之一是主菜单。菜单是任何网站或应用程序的关键组成部分。这确实是人们四处走动的方式,因此,使它易于访问对团队来说是一个非常高的优先级。

但在这个过程中,我们发现,在 PWA 中创建可访问的主菜单并不像听起来那么容易。我认为我会与您分享一些这些经验教训,以及我们如何克服这些挑战。

就要求而言,我们希望用户不仅可以使用鼠标,还可以使用键盘导航菜单,验收标准是用户应该能够通过顶级菜单项进行制表符,以及通常只有当使用鼠标悬停在顶级菜单项上时才可见的子菜单项。当然,我们希望焦点环跟随具有焦点的元素。

我们首先要做的是更新现有的 CSS,该 CSS 已设置为在将鼠标悬停在顶级菜单项上时显示子菜单。我们之前使用的是 visibility 属性,在父容器的悬停状态下在 visiblehidden 之间切换。这对鼠标用户来说很好用,但对于键盘用户来说,焦点不会自动移动到设置为 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 专家,只是尽我们所能,并乐于学习有关该主题的更多知识。