构建网站的主导航

本教程介绍了如何构建可访问的网站主导航。您将学习语义 HTML、无障碍功能,以及使用 ARIA 属性有时弊大于利的原因。

Manuel Matuzović
Manuel Matuzović

在样式、功能以及底层标记和语义信息方面,构建网站主导航的方式有很多种。如果实现过于简约,则对大多数人来说都能用,但用户体验 (UX) 可能不太好。如果过度设计,则可能会让用户感到困惑,甚至阻碍他们完全访问。

对于大多数网站,您都希望构建既不太简单也不太复杂的东西。

逐层构建

在本教程中,您将从基本设置开始,逐层添加功能,直到提供足够的信息、样式和功能来满足大多数用户。为了实现这一目标,您将使用渐进增强原则,该原则规定您从最基本、最稳健的解决方案开始,并逐步添加功能层。如果某一层由于某种原因无法工作,导航仍然可以工作,因为它会优雅地回退到底层。

基本结构

对于基本导航,您需要两样东西:<a> 元素和几行 CSS 代码来改进链接的默认样式和布局。

<a href="/home">Home</a>
<a href="/about-us">About us</a>
<a href="/pricing">Pricing</a>
<a href="/contact">Contact</a>
/* Define variables for your colors */
:root {
  --color-shades-dark: rgb(25, 25, 25);
}

/* Use the alternative box model
Details: <https://webdev.ac.cn/learn/css/box-model/> */
*{
  box-sizing: border-box;
}

/* Basic font styling */
body {
  font-family: Segoe UI, system-ui, -apple-system, sans-serif;
  font-size: 1.6rem;
}

/* Link styling */
a {
  --text-color: var(--color-shades-dark);
  border-block-end: 3px solid var(--border-color, transparent);
  color: var(--text-color);
  display: inline-block;
  margin-block-end: 0.5rem; /* See note at the bottom of this chapter */
  margin-inline-end: 0.5rem;
  padding: 0.1rem;
  text-decoration: none;
}

/* Change the border-color on :hover and :focus */
a:where(:hover, :focus) {
  --border-color: var(--text-color);
}
查看 CodePen 上的“步骤 1:基本 HTML 和 CSS”

无论用户如何访问网站,这对于大多数用户来说都效果良好。导航可以通过鼠标、键盘、触摸设备或屏幕阅读器访问,但仍有改进空间。您可以通过使用其他功能和信息扩展此基本模式来增强体验。

以下是您可以执行的操作

  • 突出显示活动页面。
  • 向屏幕阅读器用户播报项目数量。
  • 添加地标,并允许屏幕阅读器用户使用快捷方式直接访问导航。
  • 在窄视口中隐藏导航。
  • 改进焦点样式。

突出显示活动页面

要突出显示活动页面,您可以向相应的链接添加一个类。

<a href="/about-us" class="active-page">About us</a>

这种方法的问题在于,它纯粹以视觉方式传达哪个链接处于活动状态的信息。盲人屏幕阅读器用户无法分辨活动页面和其他页面之间的区别。幸运的是,无障碍富互联网应用程序 (ARIA) 标准提供了一种以语义方式传达此信息的方法。使用 aria-current="page" 属性和值来代替类。

aria-current(状态)指示容器或一组相关元素中表示当前项目的元素。页面令牌用于指示一组分页链接中的链接,其中链接的视觉样式用于表示当前显示的页面。[无障碍富互联网应用程序 (WAI-ARIA) 1.1](https://www.w3.org/TR/wai-aria/#aria-current)

使用附加属性后,屏幕阅读器现在会播报类似“当前页面,链接,关于我们”的内容,而不仅仅是“链接,关于我们”。

<a href="/about-us" aria-current="page" class="active-page">About us</a>

一个方便的副作用是,您可以使用该属性在 CSS 中选择活动链接,从而使 active-page 类变得过时。

<a href="/home">Home</a>
<a href="/about-us" aria-current="page">About us</a>
<a href="/pricing">Pricing</a>
<a href="/contact">Contact</a>
/* Change border-color and color for the active page */
[aria-current="page"] {
  --border-color: var(--color-highlight);
  --text-color: var(--color-highlight);
}
查看 CodePen 上的“步骤 2:突出显示活动页面”

播报项目数量

通过查看导航,有视觉的用户可以知道它只包含四个链接。盲人屏幕阅读器用户无法如此快速地获得此信息。他们可能必须浏览整个链接列表。如果列表像本例中这样短,这可能不是问题,但如果它包含 40 个链接,则此任务可能会很麻烦。如果屏幕阅读器用户预先知道导航包含大量链接,他们可能会决定使用其他更有效的导航方式,例如网站搜索。
一种预先传达项目数量的好方法是将每个链接包装在列表项 (<li>) 中,嵌套在无序列表 (<ul>) 中。

<ul>
  <li>
     <a href="/home">Home</a>
  </li>
  <li>
    <a href="/about-us" aria-current="page">About us</a>
  </li>
  <li>
    <a href="/pricing">Pricing</a>
  </li>
  <li>
    <a href="/contact">Contact</a>
  </li>
</ul>

当屏幕阅读器用户找到列表时,他们的软件会播报类似“列表,4 个项目”的内容。

这是一个在 Windows 上与屏幕阅读器 NVDA 一起使用的导航演示。

现在您必须调整样式以使其看起来与以前相同。

/* Remove the default list styling and create a flexible layout for the list */
ul {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Basic link styling */
a {
  --text-color: var(--color-shades-dark);

  border-block-end: 3px solid var(--border-color, transparent);
  color: var(--text-color);
  padding: 0.1rem;
  text-decoration: none;
}

使用列表对屏幕阅读器用户有很多优势

  • 他们可以在与项目交互之前获得项目总数。
  • 他们可以使用快捷方式从一个列表项跳转到另一个列表项。
  • 他们可以使用快捷方式从一个列表跳转到另一个列表。
  • 屏幕阅读器可能会播报当前项目的索引(例如,“列表项,四分之二”)。

最重要的是,如果页面在没有 CSS 的情况下呈现,列表会将链接显示为一组连贯的项目,而不仅仅是一堆链接。

关于 Safari 中的 VoiceOver 的一个值得注意的细节是,当您设置 list-style: none 时,您将失去所有这些优势。这是设计使然。WebKit 团队决定在列表看起来不像列表时删除列表语义。根据您的导航的复杂性,这可能是也可能不是问题。一方面,导航仍然可用,并且仅影响 Safari 中的 VoiceOver。带有 Chrome 或 Firefox 的 VoiceOver 仍然会播报项目数量,其他屏幕阅读器(如 NVDA)也是如此。另一方面,语义信息在某些情况下可能非常有用。为了做出该决定,您应该使用实际的屏幕阅读器用户测试导航并获得他们的反馈。如果您确定需要 Safari 中的 VoiceOver 的行为方式与其他所有屏幕阅读器相同,则可以通过在 <ul> 上显式设置 ARIA 列表角色来解决此问题。这会将行为恢复到您删除列表样式之前的状态。在视觉上,列表看起来仍然相同。

<ul role="list">
  <li>
     <a href="/home">Home</a>
  </li>
  ...
</ul>
查看 CodePen 上的“步骤 3:播报项目数量”

添加地标

只需稍作努力,您就为屏幕阅读器用户做了很大的改进,但您还可以做一件事。导航在语义上仍然只是一个链接列表,很难判断此特定列表是您网站的主导航。您可以通过将 <ul> 包装在 <nav> 元素中,将此普通列表转换为导航列表。

使用 <nav> 元素有几个优点。值得注意的是,当用户与之交互时,屏幕阅读器会播报类似“导航”的内容,并且它会向页面添加地标。地标是页面上的特殊区域,例如 <header><footer><main>,屏幕阅读器可以跳转到这些区域。在页面上设置地标可能很有用,因为它允许屏幕阅读器用户直接访问页面上的重要区域,而无需与页面的其余部分进行交互。例如,您可以通过按 NVDA 中的 D 键从一个地标跳转到另一个地标。在 Voice Over 中,您可以使用转子通过按 VO + U 列出页面上的所有地标。

A list of four landmarks: banner, navigation, main, content information.
VoiceOver 中的转子列出了页面上的所有地标。

在此列表中,您可以看到 4 个地标:banner,它是 <header> 元素,navigation<nav>main<main> 元素,而 content information<footer>。此列表不应太长,您真正只想将 UI 的关键部分标记为地标,例如网站搜索、本地导航或分页。

如果您有一个全站导航、页面的本地导航以及单个页面上的分页,您也可能有 3 个 <nav> 元素。这没问题,但现在有三个导航地标,并且在语义上它们看起来都相同。除非您非常了解页面的结构,否则很难区分它们。

Image showing three landmarks that all say 'navigation'.
VoiceOver 中的转子列出了三个未标记的导航地标。

为了使它们可区分,您应该使用 aria-labelledbyaria-label 标记它们。

<nav aria-label="Main">
    <ul>
      <li>
         <a href="/home">Home</a>
      </li>
      ...
  </ul>
</nav>
...
<nav aria-label="Select page">
    <ul>
      <li>
         <a href="/page-1">1</a>
      </li>
      ...
    </ul>
</nav>

如果您选择的标签已存在于页面中的某个位置,则可以使用 aria-labelledby 代替,并使用 id 属性引用现有标签。

<nav aria-labelledby="pagination_heading">
  <h2 id="pagination_heading">Select a page</h2>
  <ul>
    <li>
       <a href="/page-1">1</a>
    </li>
    ...
  </ul>
</nav>

简洁的标签就足够了,不要太冗长。省略诸如“navigation”或“menu”之类的表达,因为屏幕阅读器已经为用户提供了此信息。

Landmarks
VoiceOver 列出了地标“banner”、“main navigation”、“main”、“page navigation”、“select page navigation”和“content information”。
查看 CodePen 上的“步骤 4:添加地标”

在窄视口中隐藏导航

就我个人而言,我不太喜欢在窄视口中隐藏主导航,但如果链接列表太长,那就别无选择了。如果是这种情况,用户看到的不是列表,而是一个标记为“菜单”的按钮或汉堡图标或组合。单击该按钮可显示和隐藏列表。如果您了解基本的 JavaScript 和 CSS,这是一项可行的任务,但在 UX 和无障碍功能方面,您需要注意以下几点。

  • 您必须以可访问的方式隐藏列表。
  • 导航必须是键盘可访问的。
  • 导航必须传达它是否可见。

添加汉堡按钮

由于您遵循渐进增强原则,因此您要确保即使在 JavaScript 关闭的情况下,您的导航仍然可以工作并且有意义。
您的导航首先需要一个汉堡按钮。您在 HTML 中创建一个模板元素,在 JavaScript 中克隆它,并将其添加到导航中。

A page displaying a burger button.
结果:在窄视口中,导航显示一个汉堡按钮而不是链接。
<nav id="mainnav">
  ...
</nav>

<template id="burger-template">
  <button type="button" aria-expanded="false" aria-label="Menu" aria-controls="mainnav">
    <svg width="24" height="24" aria-hidden="true">
      <path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z">
    </svg>
  </button>
</template>
  1. aria-expanded 属性告诉屏幕阅读器软件按钮控制的元素是否已展开。
  2. aria-label 为按钮提供所谓的无障碍名称,即汉堡图标的文本替代方案。
  3. 您使用 aria-hidden 从辅助技术隐藏 <svg>,因为它已经具有 aria-label 提供的文本标签。
  4. aria-controls 告诉支持该属性的辅助技术(例如 JAWS)按钮控制哪个元素。
const nav = document.querySelector('#mainnav')
const list = nav.querySelector('ul');
const burgerClone = document.querySelector('#burger-template').content.cloneNode(true);
const button = burgerClone.querySelector('button');

// Toggle aria-expanded attribute
button.addEventListener('click', e => {
  // aria-expanded="true" signals that the menu is currently open
  const isOpen = button.getAttribute('aria-expanded') === "true"
  button.setAttribute('aria-expanded', !isOpen);
});

// Hide list on keydown Escape
nav.addEventListener('keyup', e => {
  if (e.code === 'Escape') {
    button.setAttribute('aria-expanded', false);
  }
});

// Add the button to the page
nav.insertBefore(burgerClone, list);
  1. 用户能够随时关闭导航(例如通过按 Escape 键)是很方便的。
  2. 重要的是使用 insertBefore 而不是 appendChild,因为按钮应该是导航中的第一个元素。如果键盘或屏幕阅读器用户在单击按钮后按 Tab 键,他们希望将焦点放在列表中的第一个项目上。如果按钮在列表之后,情况就不是这样了。

接下来,您重置按钮的默认样式,并确保它仅在窄视口中可见。

@media (min-width: 48em) {
  nav {
    --nav-button-display: none;
  }
}

/* Reset button styling */
button {
  all: unset;
  display: var(--nav-button-display, flex);
}
查看 CodePen 上的“步骤 5:添加汉堡按钮”

隐藏列表

在隐藏列表之前,定位和设置导航和列表的样式,以便布局针对窄视口进行了优化,但在较大屏幕上看起来仍然不错。
首先,从页面的自然流中删除 <nav>,并将其放置在视口的顶端角。

@media (min-width: 48em) {
  nav {
    --nav-button-display: none;
    --nav-position: static;
  }
}

nav {
  position: var(--nav-position, fixed);
  inset-block-start: 1rem;
  inset-inline-end: 1rem;
}

接下来,通过添加新的自定义属性 (—-nav-list-layout) 来更改窄视口上的布局。默认情况下布局为列,并在较大屏幕上切换为行。

@media (min-width: 48em) {
  nav {
    --nav-button-display: none;
    --nav-position: static;
  }

  ul {
    --nav-list-layout: row;
  }
}

ul {
  display: flex;
  flex-direction: var(--nav-list-layout, column);
  flex-wrap: wrap;
  gap: 1rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

您的导航在窄视口上应如下所示。

The page showing the navigation list and the burger button.
汉堡按钮和列表都放置在视口的顶端角。

列表显然需要一些 CSS。我们将它向上移动到顶端角,使其垂直填充整个屏幕,应用 background-colorbox-shadow

@media (min-width: 48em) {
  nav {
    --nav-button-display: none;
    --nav-position: static;
  }
  
  ul {
    --nav-list-layout: row;
    --nav-list-position: static;
    --nav-list-padding: 0;
    --nav-list-height: auto;
    --nav-list-width: 100%;
    --nav-list-shadow: none;
  }
}

ul {
  background: rgb(255, 255, 255);
  box-shadow: var(--nav-list-shadow, -5px 0 11px 0 rgb(0 0 0 / 0.2));
  display: flex;
  flex-direction: var(--nav-list-layout, column);
  flex-wrap: wrap;
  gap: 1rem;
  height: var(--nav-list-height, 100vh);
  list-style: none;
  margin: 0;
  padding: var(--nav-list-padding, 2rem);
  position: var(--nav-list-position, fixed);
  inset-block-start: 0; /* Logical property. Equivalent to top: 0; */
  inset-inline-end: 0; /* Logical property. Equivalent to right: 0; */
  width: var(--nav-list-width, min(22rem, 100vw));
}

button {
  all: unset;
  display: var(--nav-button-display, flex);
  position: relative;
  z-index: 1;
}

列表在窄视口上应如下所示,更像是侧边栏而不是简单列表。

The navigation list open.

最后,隐藏列表,仅在用户单击按钮一次时显示它,并在他们再次单击时隐藏它。重要的是仅隐藏列表,而不是整个导航,因为隐藏导航也意味着隐藏重要的地标。

之前,您向按钮添加了单击事件以切换 aria-expanded 属性的值。您可以使用该信息作为在 CSS 中显示和隐藏列表的条件。

@media (min-width: 48em) {
  ul {
    --nav-list-visibility: visible;
  }
}

ul {
  visibility: var(--nav-list-visibility, visible);
}

/* Hide the list on narrow viewports, if it comes after an element with
   aria-expanded set to "false". */
[aria-expanded="false"] + ul {
  visibility: var(--nav-list-visibility, hidden);
}

重要的是使用属性声明(如 visibility: hiddendisplay: none)而不是 opacity: 0translateX(100%) 来隐藏列表。这些属性确保在导航隐藏时链接不可聚焦。使用 opacitytranslate 将在视觉上删除内容,因此链接将不可见,但仍然可以使用键盘访问,这会让人感到困惑和沮丧。使用 visibilitydisplay 会在视觉上隐藏它并使其无法访问,因此对所有用户都隐藏它。

查看 CodePen 上的“步骤 6:隐藏列表”

动画列表

如果您想知道为什么使用 visibility: hidden; 而不是 display: none;,那是因为您可以为可见性设置动画。它只有两种状态,hiddenvisible,但您可以将其与另一个属性(如 transformopacity)结合使用,以创建滑动或淡入效果。这不适用于 display: none,因为 display 属性不可动画。

以下 CSS 过渡 opacity 以创建淡入和淡出效果。

ul {
  transition: opacity 0.6s linear, visibility 0.3s linear;
  visibility: var(--nav-list-visibility, visible);
}

[aria-expanded="false"] + ul {
  opacity: 0;
  visibility: var(--nav-list-visibility, hidden);
}

如果您想为运动设置动画,则应考虑将 transition 属性包装在 prefers-reduced-motion 媒体查询中,因为动画可能会在某些用户中引发恶心、头晕和头痛

ul {
  visibility: var(--nav-list-visibility, visible);
}

@media (prefers-reduced-motion: no-preference) {
  ul {
    transition: transform 0.6s cubic-bezier(.68,-0.55,.27,1.55), visibility 0.3s linear;
  }
}

[aria-expanded="false"] + ul {
  transform: var(--nav-list-transform, translateX(100%));
  visibility: var(--nav-list-visibility, hidden);
}

这确保只有对减少运动没有偏好的人才会看到动画。

查看 CodePen 上的“步骤 7:动画列表”

改进焦点样式

键盘用户依赖于元素的焦点样式来在页面上定向和导航。默认焦点样式比没有焦点样式(如果您设置 outline: none,则会发生这种情况)更好,但具有更清晰可见的自定义焦点样式可以改善用户体验。

以下是 Chrome 103 中链接上的默认焦点样式的外观。

A blue 2px outline around a focused link in Chrome 103.

您可以通过使用您自己的颜色提供您自己的样式来改进这一点。通过使用 :focus-visible 而不是 :focus,您可以让浏览器决定何时适合显示焦点样式。:focus 样式对所有人(鼠标、键盘和触摸用户)都可见,无论他们是否需要它们。使用 :focus-visible,浏览器使用内部启发式方法来决定是仅向键盘用户显示它们还是向所有人显示它们。

/* Remove the default :focus outline */
*:focus {
  outline: none;
}

/* Show a custom outline on :focus-visible */
*:focus-visible {
  outline: 2px solid var(--color-shades-dark);
  outline-offset: 4px;
}

:focus-visible 的浏览器支持

浏览器支持

  • Chrome: 86.
  • Edge: 86.
  • Firefox: 85.
  • Safari: 15.4.

来源

Clearly visible dark 2px outline with spacing inside.

在项目获得焦点时,有多种突出显示项目的方法。建议使用 outline 属性,因为它不会破坏布局(使用 border 可能会发生这种情况),并且它与 Windows 上的高对比度模式 配合良好。效果不佳的属性是 background-colorbox-shadow,因为它们可能根本不会以自定义对比度设置显示。

A site with a dark background with the focus highlighted in purple.
查看 CodePen 上的“步骤 8:改进焦点样式”

恭喜!您已经构建了一个渐进增强、语义丰富、可访问且移动设备友好的主导航。

总有一些可以改进的地方,例如

  • 您可以考虑捕获焦点在导航内部或使页面的其余部分在窄视口上变为惰性
  • 您可以在页面顶部添加跳过链接,以允许键盘用户跳过导航。

如果您还记得本文的开头,其目的是解决方案应“既不太简单也不太复杂”,那么我们现在就处于这种状态。但是,过度设计导航是可能的。

导航和菜单之间存在明显的区别。导航是用于导航相关文档的链接集合。菜单是在文档中执行的操作集合。有时这些任务会重叠。您可能有一个导航,其中还包含一个执行操作的按钮,例如打开模态窗口,或者您可能有一个菜单,其中一个操作是导航到另一个页面,例如帮助页面。如果是这种情况,重要的是您不要混用 ARIA 角色,而是确定组件的主要用途并相应地选择标记和角色。

<nav> 元素具有导航的隐式 ARIA 角色,这足以传达该元素是导航,但通常您也会看到网站也使用 menu、menubar 和 menuitem。由于我们有时可以互换使用这些术语,因此认为将它们组合起来以改善屏幕阅读器用户的体验可能是有意义的。在我们了解为什么通常不是这种情况之前,让我们先看一下这些角色的官方定义。

导航角色

用于导航文档或相关文档的导航元素(通常是链接)的集合。

navigation(角色)WAI-ARIA 1.1

菜单角色

菜单通常是用户可以调用的常用操作或功能列表。当菜单项列表以类似于桌面应用程序上的菜单的方式呈现时,菜单角色是合适的。

menu(角色)WAI-ARIA 1.1

菜单栏角色

菜单的呈现,通常保持可见并且通常水平呈现。菜单栏角色用于创建类似于 Windows、Mac 和 Gnome 桌面应用程序中找到的菜单栏。菜单栏用于创建一组常用命令。作者确保菜单栏交互类似于桌面图形用户界面中的典型菜单栏交互。

menubar(角色)WAI-ARIA 1.1

菜单项角色

菜单菜单栏包含的一组选择中的一个选项。

menuitem(角色)WAI-ARIA 1.1

规范在这里非常清楚,将 navigation 用于导航文档或相关文档,而 menu 仅用于类似于桌面应用程序中菜单的操作或功能列表。如果您不是在构建下一个 Google Docs,则主导航可能不需要任何菜单角色。

何时适合使用菜单?

菜单项的主要用途不是导航,而是执行操作。假设您有一个数据列表或表格,用户可以对列表中的每个项目执行某些操作。您可以向每一行添加一个按钮,并在用户单击该按钮时显示操作。

<ul>
  <li>
    Product 1

    <button aria-expanded="false" aria-controls="options1">Edit</button>

    <div role="menu" id="options1">
      <button role="menuitem">
        Duplicate
      </button>
      <button role="menuitem">
        Delete
      </button>
      <button role="menuitem">
        Disable
      </button>
    </div>
  </li>
  <li>
    Product 2
    ...
  </li>
</ul>

使用菜单角色的含义

明智地使用这些菜单角色非常重要,因为可能会出现很多问题。

菜单需要特定的 DOM 结构。menuitem 必须是 menu 的直接子项。以下代码可能会破坏语义行为

 <!-- Wrong, don't do this -->
<ul role="menu">
  <li>
    <a href="#" role="menuitem">Item 1</a>
  </li>
</ul>

精通的用户希望某些键盘快捷键可以与菜单和菜单栏一起使用。根据ARIA 创作实践指南 (APG),这包括

  • EnterSpace 选择菜单项。
  • 所有方向的箭头键在项目之间导航。
  • HomeEnd 键将焦点分别移动到第一个或最后一个项目。
  • a-z 将焦点移动到标签以键入字符开头的下一个菜单项。
  • Esc 关闭菜单。

如果屏幕阅读器检测到菜单,该软件可能会自动更改浏览模式,从而可以使用前面提到的快捷键。没有经验的屏幕阅读器用户可能无法使用菜单,因为他们不知道这些快捷键或如何使用它们。

对于可能期望可以使用 ShiftShift + Tab 的键盘用户来说,情况也是如此。

当您创建菜单和菜单栏时,有很多需要考虑的事情,首先要考虑的是在什么情况下适合使用它们。当您构建典型的网站时,您只需要带有列表和链接的 nav 元素。这也包括单页应用程序 (SPA) 或 Web 应用程序。底层堆栈无关紧要。除非您要构建非常接近桌面应用程序的东西,否则请避免使用菜单角色。

其他资源

英雄图片由 Mick Haupt 提供