后台与工具型界面的可访问性检查点

0阅读13分钟

一个让我重新审视后台可访问性的需求单

去年我负责的一个运营后台,上线两年跑了无数个迭代,功能稳定,团队内部用得很顺手。直到某天产品甩来一个需求单:某合作机构的运营人员使用屏幕阅读器,要求后台支持无障碍访问,否则合同签不了。

我当时的第一反应是——这是个内部工具,用户都有培训,不至于需要无障碍吧?

测试了一圈,结果打脸:键盘 Tab 进去弹窗就出不来;数据表格用 div 套 flex 写的,屏幕阅读器读到一片混乱;错误提示只靠红色边框,色弱同事根本找不到哪个字段出了问题;图表只有一张图,完全没有文字替代。

这些问题在鼠标用户眼里不存在,但对键盘用户和屏幕阅读器用户来说,每一个都是任务中断。本文是我在这一轮整改中积累的检查点和代码模式,覆盖键盘路径、语义结构、颜色对比度、错误提示、焦点管理和图表无障碍。不涉及高深理论,都是直接能落到代码里的东西。

顺带说一句背景:WCAG 2.2 在 2023 年 10 月成为 W3C 正式推荐标准,2025 年又被采纳为 ISO/IEC 40500 国际标准。欧盟的《欧洲无障碍法案》(EAA)在 2025 年 6 月生效,要求所有面向消费者的数字服务满足 WCAG 2.1 AA 级。如果你的产品有海外客户,无障碍合规已经不是可选项。


工具型界面的无障碍,和 C 端有什么不同

W3C 的 WCAG 2.2 将无障碍原则归纳为四个维度:可感知(Perceivable)、可操作(Operable)、可理解(Understandable)、健壮(Robust),简称 POUR[@wcag22]。C 端产品通常更关注感知层——图片 alt、视频字幕、颜色对比度。工具型界面的问题更多集中在操作层和理解层:键盘能不能走完所有路径、焦点状态清不清晰、表单错误能不能定位。

WCAG 2.2 在 2.1 基础上新增了 9 条成功准则,其中多条与工具型界面直接相关[@wcag22-new]:

准则编号名称级别与后台界面的关联
2.4.11焦点不被遮挡(最小)AA侧边栏固定定位遮挡焦点环
2.4.13焦点外观AAA焦点指示器需要足够清晰、高对比
2.5.7拖拽操作AA看板拖拽必须有替代操作方式
2.5.8目标尺寸(最小)AA操作按钮至少 24×24 CSS 像素
3.2.6一致的帮助A帮助入口在整站位置一致
3.3.7冗余输入A同一次任务中不重复要求用户输入同一信息
3.3.8无障碍认证(最小)AA登录不依赖纯验证码

2.5.8 的目标尺寸要求尤其容易在后台界面里踩坑——很多紧凑布局里的图标按钮只有 16×16 像素,远低于 24×24 的最低门槛[@target-size]。


案例一:键盘路径断裂——弹窗里的死胡同

场景

运营后台有一个「导出数据」弹窗,里面是日期范围选择器、格式下拉框和确认按钮。鼠标用户用起来毫无问题。

翻车

键盘用户按 Tab 进入弹窗,焦点在弹窗内的三个控件之间正常移动。但到了最后一个按钮,继续按 Tab,焦点跳到了弹窗背后的页面元素上——弹窗没有做焦点捕获(Focus Trap)。更严重的是,按 Escape 无法关闭弹窗,关闭按钮本身也没有被键盘聚焦到,因为它用了 <div onclick> 而不是 <button>

// ❌ 错误做法:焦点会逃出弹窗,关闭按钮不可聚焦
function ExportModal({ onClose }) {
  return (
    <div className="modal-overlay">
      <div className="modal">
        <div className="modal-close" onClick={onClose}>×</div>
        <DateRangePicker />
        <FormatSelect />
        <button>确认导出</button>
      </div>
    </div>
  )
}

这里有两个问题:

  1. div.modal-close 不是原生交互元素,键盘无法聚焦,屏幕阅读器不知道它是按钮。
  2. 弹窗没有焦点捕获机制,Tab 键会让焦点逃逸到背后内容。

修复

// ✅ 正确做法:原生 button + 焦点捕获 + Escape 关闭
function ExportModal({ onClose }) {
  const modalRef = useRef<HTMLDivElement>(null)
 
  useEffect(() => {
    // 弹窗打开时焦点进入容器
    modalRef.current?.focus()
 
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose()
        return
      }
      if (e.key === 'Tab') {
        // 焦点捕获:在弹窗内的可聚焦元素之间循环
        const focusable = modalRef.current?.querySelectorAll(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        )
        if (!focusable?.length) return
        const first = focusable[0] as HTMLElement
        const last = focusable[focusable.length - 1] as HTMLElement
        if (e.shiftKey && document.activeElement === first) {
          e.preventDefault()
          last.focus()
        } else if (!e.shiftKey && document.activeElement === last) {
          e.preventDefault()
          first.focus()
        }
      }
    }
 
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [onClose])
 
  return (
    <div
      className="modal-overlay"
      role="dialog"
      aria-modal="true"
      aria-labelledby="export-modal-title"
    >
      <div className="modal" ref={modalRef} tabIndex={-1}>
        <h2 id="export-modal-title">导出数据</h2>
        {/* ✅ 原生 button,键盘天然可聚焦 */}
        <button
          className="modal-close"
          onClick={onClose}
          aria-label="关闭导出弹窗"
        >
          ×
        </button>
        <DateRangePicker />
        <FormatSelect />
        <button>确认导出</button>
      </div>
    </div>
  )
}

关键差异:

  • role="dialog" + aria-modal="true" 告诉屏幕阅读器这是一个模态对话框,背后内容被屏蔽。
  • aria-labelledby 关联标题,屏幕阅读器打开弹窗时会读出标题内容。
  • 关闭按钮改为原生 &lt;button&gt;,自带键盘聚焦和 Enter/Space 响应。
  • useEffect 中的焦点捕获逻辑确保 Tab 循环在弹窗内部。
  • Escape 键关闭弹窗,符合 WCAG 2.2 对键盘操作的基本要求。

实际项目中,如果不想手写焦点捕获逻辑,可以借助 @radix-ui/react-dialog&lt;dialog&gt; 元素,它们原生处理了焦点管理[@mdn-dialog]。


案例二:颜色对比度和图表可读性

场景

运营后台首页有一个折线图展示过去 30 天的订单量趋势,还有一个数据表格列出详细数据。图表用了蓝色折线搭配浅灰色网格背景,数据点用不同颜色区分渠道来源。

翻车

问题出在三个地方:

第一,图表中「其他渠道」的折线用了 #999999,画在 #F5F5F5 的背景上,对比度只有 1.9:1,远低于 WCAG AA 级要求的 3:1(图形对象最低标准)[@contrast-ui]。

第二,不同渠道只靠颜色区分——红、蓝、绿、橙——红绿色弱用户分不清其中两条线。

第三,图表是一个纯 SVG 图片,没有文字替代,屏幕阅读器只能看到一个 &lt;svg&gt; 标签,或者干脆跳过。

<!-- ❌ 错误做法:只靠颜色区分,没有文字替代,对比度不达标 -->
<svg width="600" height="300">
  <rect fill="#F5F5F5" />
  <!-- 灰色折线在浅灰背景上几乎不可见,对比度 1.9:1 -->
  <polyline points="..." stroke="#999999" fill="none" />
  <polyline points="..." stroke="#E74C3C" fill="none" />
  <polyline points="..." stroke="#27AE60" fill="none" />
  <text x="10" y="20">各渠道订单趋势</text>
</svg>

修复

分三层处理:颜色对比度、多通道区分、文字替代。

// ✅ 正确做法:高对比色 + 形状区分 + 隐藏的数据表格
function OrderTrendChart({ data }: { data: TrendData[] }) {
  // 使用色盲友好调色板,所有颜色与白色背景对比度 ≥ 3:1
  const channelStyles = {
    '直营':  { color: '#005A9C', dash: 'none',   marker: 'circle' },
    '分销':  { color: '#D35400', dash: '5,5',    marker: 'square' },
    '其他':  { color: '#1A1A1A', dash: '2,2',    marker: 'triangle' },
  }
 
  return (
    <figure>
      <figcaption>
        过去 30 天各渠道订单趋势
        {/* 屏幕阅读器可见、视觉隐藏的详细数据 */}
        <span className="sr-only">
          {data.map(channel => (
            <span key={channel.name}>
              {channel.name}渠道共{channel.total}单,
              最高{channel.peak}单({channel.peakDate}),
              最低{channel.low}单({channel.lowDate})。
            </span>
          ))}
        </span>
      </figcaption>
 
      <svg role="img" aria-hidden="true" width="600" height="300">
        {data.map(channel => (
          <polyline
            key={channel.name}
            points={channel.points}
            stroke={channelStyles[channel.name].color}
            strokeDasharray={channelStyles[channel.name].dash}
            fill="none"
            strokeWidth="2"
          />
        ))}
      </svg>
 
      {/* 降级:完整的数据表格,屏幕阅读器和键盘用户可直接访问 */}
      <details>
        <summary>查看数据表格</summary>
        <table>
          <thead>
            <tr>
              <th scope="col">日期</th>
              {data.map(c => <th key={c.name} scope="col">{c.name}</th>)}
            </tr>
          </thead>
          <tbody>
            {/* 表格内容省略 */}
          </tbody>
        </table>
      </details>
    </figure>
  )
}

修复了三个问题:

问题错误做法修复方式WCAG 准则
颜色对比度不足灰色线条 #999#F5F5F5 背景上改用 #1A1A1A(对比度 >15:1)1.4.11 非文本对比度 ≥ 3:1
仅靠颜色区分红、蓝、绿、橙四条线颜色 + 虚线样式 + 标记形状三重编码1.4.1 非色觉依赖
图表无替代文本纯 SVG 无说明&lt;figcaption&gt; + &lt;details&gt; 数据表格1.1.1 非文本内容

.sr-only 是一个常见模式,让内容对视觉用户隐藏但对屏幕阅读器可读:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

W3C WAI 的教程明确指出,复杂图像需要提供结构化数据替代,屏幕阅读器无法解析空间布局,必须以线性方式传递相同信息[@w3c-complex-images]。对于 Dashboard 图表,最稳妥的替代方式就是一张数据表格。


案例三:语义结构混乱——数据表格变成一堆 div

场景

运营后台有一个订单列表,用 div + CSS Grid 实现,每个单元格是一个 div,行也是一个 div。视觉上看起来很整齐,CSS 控制力也强。

翻车

屏幕阅读器进入这个区域时,读到的是连续的文本流——「订单号 20260601001 状态 已完成 金额 ¥1,280 订单号 20260601002 状态 处理中 金额 ¥560」——没有行和列的概念。用户无法按列头跳转,无法知道「已完成」是属于「状态」这一列的。

<!-- ❌ 错误做法:div 模拟表格,丢失语义结构 -->
<div className="order-list">
  <div className="order-header">
    <div className="col">订单号</div>
    <div className="col">状态</div>
    <div className="col">金额</div>
    <div className="col">操作</div>
  </div>
  {orders.map(order => (
    <div className="order-row" key={order.id}>
      <div className="col">{order.id}</div>
      <div className="col">{order.status}</div>
      <div className="col">{order.amount}</div>
      <div className="col">
        <div onClick={() => handleEdit(order)}>编辑</div>
        <div onClick={() => handleDelete(order)}>删除</div>
      </div>
    </div>
  ))}
</div>

这里有四层语义丢失:

  1. 整个结构不是 &lt;table&gt;,屏幕阅读器不知道这是表格数据。
  2. 表头不是 &lt;th&gt;,无法用「跳到列头」命令导航。
  3. 单元格没有 scopearia 关联,数据和表头没有程序化关系。
  4. 操作列的按钮用 &lt;div&gt; 实现,键盘不可聚焦,没有按钮语义。

修复

<!-- ✅ 正确做法:原生 table + 语义化表头 + 原生按钮 -->
<table className="order-list" aria-label="订单列表">
  <thead>
    <tr>
      <th scope="col">订单号</th>
      <th scope="col">状态</th>
      <th scope="col">金额</th>
      <th scope="col">操作</th>
    </tr>
  </thead>
  <tbody>
    {orders.map(order => (
      <tr key={order.id}>
        <td>{order.id}</td>
        <td>
          {/* 状态不只是颜色,同时提供文字 */}
          <span
            className={`status-badge status-${order.statusType}`}
          >
            {order.statusLabel}
          </span>
        </td>
        <td>{order.amount}</td>
        <td>
          {/* 原生 button,键盘天然可用 */}
          <button onClick={() => handleEdit(order)}>编辑</button>
          <button onClick={() => handleDelete(order)}>删除</button>
        </td>
      </tr>
    ))}
  </tbody>
</table>

修复带来的语义层级:

维度div 实现table 实现
屏幕阅读器识别连续文本流表格结构,可听到行/列数
列头导航不支持th[scope="col"] 支持跳转列头
数据-表头关联scope 属性自动建立关系
操作按钮可聚焦不可聚焦原生 &lt;button&gt; 自动可聚焦
排序/分页扩展需要 ARIA grid原生 table + aria-sort 即可

如果确实需要可交互的单元格(比如单元格内嵌编辑框),可以用 role="grid" 配合 ARIA 属性,但 W3C APG 明确建议:能用原生 &lt;table&gt; 解决的场景,不要优先用 ARIA grid[@w3c-apg-table]。

后台表格经常有排序功能。排序状态需要通过 aria-sort 告诉屏幕阅读器:&lt;th scope="col" aria-sort="ascending"&gt;订单号&lt;/th&gt;。屏幕阅读器会读出「订单号列,升序排列」。图标-only 的排序箭头(↑↓)对屏幕阅读器毫无意义,必须同时提供文字或 ARIA 属性。


键盘导航到焦点管理的完整路径

上面三个案例分别涉及焦点捕获、焦点样式和焦点顺序。把这些问题放在一起看,一个 Dashboard 页面的焦点管理流程是这样的:

流程图画布 · 115%
Mermaid 流程图加载中...

这张图覆盖了 Dashboard 常见的焦点流转场景。几个容易忽略的节点:

弹窗关闭后焦点回到触发元素。 这是 WCAG 2.4.3 焦点顺序的要求,也是用户体验的基本预期。如果弹窗关闭后焦点跳回页面顶部,键盘用户需要重新 Tab 到原来的位置,在长页面中这是不可接受的。

弹窗内的焦点捕获必须完整。 包括关闭按钮、所有表单控件、确认按钮,都要在捕获范围内。如果弹窗里有下拉菜单、日期选择器这类复合组件,它们的内部焦点管理也要兼容。

数据表格的导航模式不同于普通内容。 普通内容用 Tab 逐个聚焦,但数据表格(特别是可编辑表格)更适合方向键导航,每个单元格是一个 tab stop 或者使用 roving tabindex 模式[@w3c-apg-table]。

具体来说,roving tabindex 的做法是:当前获得焦点的单元格设置 tabindex="0",其他所有单元格设置 tabindex="-1"。用户按方向键时,JavaScript 把 tabindex="0" 移到目标单元格并调用 focus()。这样 Tab 键进入表格后,方向键可以在单元格间自由移动,再按一次 Tab 键跳出表格。这种方式既保证了键盘可达性,又避免了在几百个单元格中按上百次 Tab。

Dashboard 还有一个容易忽略的场景:侧边栏折叠。当侧边栏处于展开状态时,导航菜单里的每个链接都是 Tab 可达的。侧边栏折叠成图标状态后,如果菜单项仍然在 DOM 中且可聚焦,键盘用户会碰到幽灵焦点——他们感知不到焦点停在了哪里。解决方案是折叠状态下把菜单项的 tabindex 设为 -1,或者直接用 display: none 隐藏(隐藏的元素自动不可聚焦)。


表单错误提示:不只是红色边框

工具型界面里,表单是最频繁交互的组件。WCAG 2.2 对表单错误有三条直接相关的准则:

准则要求常见违反场景
3.3.1 错误识别错误要被文字描述,不只是颜色只用红色边框标识错误字段
3.3.3 错误建议告诉用户如何修正只说「格式错误」不说应该什么格式
4.1.3 状态变化动态状态变化要被程序化感知错误信息出现后屏幕阅读器没有播报

最后一条需要用到 aria-live 区域。错误信息不能只是 DOM 里多了一行文字——屏幕阅读器需要被主动通知这个变化。

// ❌ 错误做法:只显示红色边框和模糊错误信息
function EmailField() {
  const [error, setError] = useState(false)
  return (
    <div>
      <input
        className={error ? 'border-red' : ''}
        placeholder="邮箱"
      />
      {error && <span className="error-text">输入有误</span>}
    </div>
  )
}
 
// ✅ 正确做法:关联 label、明确错误信息、aria-live 播报
function EmailField() {
  const [error, setError] = useState<string | null>(null)
  const id = 'email'
  const errorId = `${id}-error`
 
  const validate = (value: string) => {
    if (!value) {
      setError('请输入邮箱地址')
    } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      setError('邮箱格式不正确,示例:[email protected]')
    } else {
      setError(null)
    }
  }
 
  return (
    <div>
      {/* ✅ label 通过 htmlFor 关联 input */}
      <label htmlFor={id}>邮箱地址</label>
      <input
        id={id}
        type="email"
        // ✅ 错误时建立 aria-describedby 关联
        aria-describedby={error ? errorId : undefined}
        aria-invalid={!!error}
        onBlur={(e) => validate(e.target.value)}
      />
      {/* ✅ aria-live="polite" 让屏幕阅读器在空闲时播报错误 */}
      {error && (
        <div id={errorId} role="alert" aria-live="polite" className="error-text">
          {error}
        </div>
      )}
    </div>
  )
}

对比两组做法的差别:

维度错误做法正确做法
错误标识红色边框文字错误信息 + aria-invalid
错误描述「输入有误」「邮箱格式不正确,示例:[email protected]
字段关联无 label&lt;label htmlFor&gt; 程序化关联
屏幕阅读器感知不播报错误role="alert" + aria-live 主动播报
修正指引给出正确格式示例

aria-describedbyaria-invalid 配合使用,屏幕阅读器在聚焦输入框时会读出关联的错误信息。role="alert" 等同于 aria-live="assertive",会立即打断当前播报读出错误内容,适合提交时的错误汇总。逐字段验证时用 aria-live="polite" 更合适,等用户停止输入后再播报,不会干扰输入过程。

表单提交时的错误汇总也值得注意。很多后台系统在表单顶部放一个错误列表,列出所有出错字段。这个汇总区域本身需要一个 role="alert"aria-live="assertive",让屏幕阅读器在提交后立即播报:「表单有 3 个错误」。同时,焦点应该移到错误汇总区域或者第一个出错字段,而不是留在提交按钮上——否则用户不知道发生了什么。


对比度不只是文字的事

后台系统常有深色模式。颜色对比度的要求在 WCAG 2.2 中分三档[@contrast-ratio]:

元素类型AA 级最低对比度常见不达标场景
正文文本(< 18pt / < 14pt bold)4.5:1浅灰色说明文字在白底上
大号文本(≥ 18pt / ≥ 14pt bold)3:1标题用浅色搭配深色背景
UI 组件和图标3:1灰色边框、浅色图标按钮
焦点指示器3:1(与相邻颜色)蓝色焦点环在白底上

最后一条是 WCAG 2.2 新增的 2.4.13 焦点外观要求。焦点环的对比度需要单独验证——很多系统默认的蓝色焦点环 #4A90D9 在白色背景上对比度只有 2.8:1,不达标。

深色模式下的对比度问题更隐蔽。深色背景上的浅色文字看起来对比度足够,但灰色辅助文字 #888888#1A1A1A 背景上的对比度只有 3.4:1,低于正文要求的 4.5:1。另一个常见陷阱是禁用态按钮——灰色文字配灰色背景,对比度经常不到 2:1。WCAG 允许禁用态不满足对比度要求(因为禁用态本身不可交互),但前提是用户能明确感知到「这个按钮被禁用了」。如果禁用态和正常态只差了一点点颜色深浅,用户可能会误以为它是可点击的。

我在实际项目中的做法是:把颜色对比度检查集成到 Design Token 的定义里。所有颜色对(前景/背景、图标/容器、边框/容器)在定义时就计算好对比度,不达标的直接不入库。这样从设计源头就避免了下游返工。

验证对比度不需要手工计算,可以用以下方式:

工具适用场景备注
Chrome DevTools 颜色选择器开发时随手检查打开取色器直接显示对比度
axe DevTools 浏览器插件整页自动扫描自动标记对比度不达标的元素
Figma A11y 插件设计阶段Stark、A11y - Color Contrast 等
@ctrl/tinycolor npm 包代码内程序化检查可在组件测试中断言对比度

工具型界面可访问性审查清单

以下清单按开发阶段分组,可以直接用作 PR 审查或迭代验收的 checklist。

结构阶段(写代码时)

  • 所有可交互元素使用原生 HTML 元素(&lt;button&gt;&lt;a&gt;&lt;input&gt;),不用 &lt;div&gt; 模拟
  • 弹窗和对话框有 role="dialog" + aria-modal="true" + aria-labelledby
  • 数据表格使用 &lt;table&gt; + &lt;thead&gt; + &lt;tbody&gt; + &lt;th scope&gt;
  • 表单每个字段都有 &lt;label htmlFor&gt; 关联,不依赖 placeholder 代替 label
  • 图标按钮有 aria-label 或包裹 &lt;span className="sr-only"&gt; 提供文字

键盘交互阶段

  • 所有功能可以通过纯键盘完成(Tab、Enter、Space、方向键、Escape)
  • 弹窗打开后焦点进入弹窗,关闭后焦点回到触发元素
  • 弹窗内焦点被捕获,Tab 不会逃逸到背后内容
  • 焦点指示器在所有主题(浅色/深色)下清晰可见,对比度 ≥ 3:1
  • 可交互元素的目标尺寸 ≥ 24×24 CSS 像素

视觉与感知阶段

  • 正文文本对比度 ≥ 4.5:1,大号文本和 UI 组件 ≥ 3:1
  • 图表不只靠颜色区分数据系列,同时使用形状、线型、文字标签
  • 图表有文字替代:简单图表用 alt/figcaption,复杂图表附带数据表格
  • 错误提示包含文字描述、具体原因和修正建议,不只用红色边框
  • 动态错误信息使用 aria-liverole="alert" 让屏幕阅读器播报

验证阶段(提交前)

  • 用 axe DevTools 或 Lighthouse 跑一次自动扫描,无 critical/serious 问题
  • 关掉鼠标,纯键盘走完核心流程至少一次
  • 在 macOS VoiceOver 或 Windows NVDA 下听一遍关键页面的主要操作
  • 深色模式下验证所有文字和 UI 组件的对比度

参考资料

  1. W3C. Web Content Accessibility Guidelines (WCAG) 2.2. https://www.w3.org/TR/WCAG22/
  2. W3C WAI. What's New in WCAG 2.2. https://tetralogical.com/blog/2023/10/05/whats-new-wcag-2.2/
  3. W3C WAI. Complex Images - Accessibility Tutorial. https://www.w3.org/WAI/tutorials/images/complex/
  4. W3C WAI. Table Pattern - ARIA Authoring Practices Guide. https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/table/
  5. MDN Web Docs. dialog role. https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/dialog_role
  6. W3C WAI. Understanding SC 1.4.3 Contrast (Minimum). https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html
  7. W3C WAI. Understanding SC 2.5.8 Target Size (Minimum). https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html
  8. A11y Collective. The Ultimate Checklist for Accessible Data Visualisations. https://www.a11y-collective.com/blog/accessible-charts/
  9. ForaSoft. The Accessible UI/UX Design Playbook for 2026. https://www.forasoft.com/blog/article/ai-accessibility-ui-ux-design

评论 0

0 / 1000