首页
/ Front-End-Checklist accessible-notifications:用 ARIA Live Regions 构建可被读屏软件感知的通知系统

Front-End-Checklist accessible-notifications:用 ARIA Live Regions 构建可被读屏软件感知的通知系统

2026-09-04 11:18:16作者:魏侃纯Zoe

在 Front-End-Checklist 仓库中,skills/accessible-notifications 是一条高优先级的 HTML/组件类检查规则,聚焦 Toast 通知、Alert 与实时状态更新如何让屏幕阅读器用户"听得到"。本文基于该 Skill 文档及其引用规则 references/rule.md 完整展开:从 aria-live 语义选择、可复制的 React Toast 组件实现,到展示时长策略、样式约束与验证清单,并结合仓库中真实的屏幕阅读器播报工具 screen-reader.ts 剖析底层实现。读完后,你可以独立编写一条符合该规则的通知组件,并知道如何在代码评审中判断实现是否违规。

规则定位与元数据

SKILL.md 的 frontmatter 定义了这条规则的完整元信息,这也是 Agent 检索和使用该规则时的入口:

字段 取值 含义
category html 归属于 HTML 分类(而非纯 CSS 或性能)
priority high 高优先级,属于应优先修复项
difficulty intermediate 需要理解 ARIA 语义,而非简单加属性
estimatedTime 25 预估修复/落实耗时 25 分钟
source / url frontendchecklist.io 规则溯源至前端检查清单站点的规则页

SKILL.md 把执行过程拆成了四个可复用的提示词段落,这也是该仓库所有 Skill 的统一结构:

  • Check:验证通知使用了 aria-live 区域、恰当的 rolealertstatus),且展示时间足够被读完;
  • Fix:以 role='alert'role='status'aria-live='polite''assertive' 及足够展示时长实现通知;
  • Explain:向使用者解释为什么无障碍通知能让所有用户不论交互方式都能获知重要更新;
  • Code Review:评审模板、服务端渲染 HTML 与共享组件中输出的最终面向浏览器的标记,标记出具体元素、属性与路由,而不只是指出源码框架层的抽象。

最后一点是这条规则区别于普通"组件规范"的关键:审查对象是最终渲染出的 HTML,而不是源码里的组件抽象。同一条规则的完整内容同时维护在 accessible-notifications.mdx,其 frontmatter 还声明了相关规则(如 aria-live-regionsaccessible-tooltipscarousel-accessibility)与权威来源(MDN、WHATWG HTML Living Standard)。

核心问题:没有 ARIA 属性的通知等于"隐形"

规则文档给出的动机(Why It Matters)一句话概括了问题本质:

Without proper ARIA attributes, screen reader users miss critical notifications like form errors, success messages, and real-time updates—leaving them unaware of important page changes.

翻译过来就是:一个普通的 <div> Toast 在 DOM 中出现了、消失了,屏幕阅读器对此毫无感知——视障用户既不知道表单提交失败,也不知道保存成功。原因在于屏幕阅读器只播报"焦点所在位置"或"被标记为会动态变化的区域",而动态注入的内容若不在 live region 内,变更就完全被忽略。这正是 aria-live-regions 规则 与本规则交叉的原因:前者负责"动态内容变更的播报机制",本规则负责"通知这类具体场景的落地实现"。

ARIA Live Region 类型选择

这是全规则最核心的决策表,决定了每条通知的"打断程度":

属性 行为 适用场景
aria-live="polite" 等待用户停顿后再播报 状态更新、非紧急信息
aria-live="assertive" 立即打断当前播报 错误、时间敏感型告警
role="status" 隐式等同于 polite 进度、成功消息
role="alert" 隐式等同于 assertive 错误、警告

role="status"role="alert" 的价值在于隐式语义role="alert" 隐含 aria-live="assertive"role="status" 隐含 aria-live="polite",可以减少重复声明,也让语义意图更清晰。但规则文档在结尾特别警告(原样保留这条告诫):

Don't Overuse Assertive — 把 aria-live="assertive" 留给真正紧急的消息。滥用它会通过不断打断屏幕阅读器输出而破坏用户体验。

最小 HTML 实现

规则文档给出的三个基础范式,覆盖了"静态可见通知"、"紧急错误通知"和"纯屏幕阅读器播报容器"三种形态:

<!-- Status notification (polite) -->
<div role="status" aria-live="polite" class="notification">
  Your changes have been saved.
</div>

<!-- Alert notification (assertive) -->
<div role="alert" aria-live="assertive" class="notification notification--error">
  Error: Please fill in all required fields.
</div>

<!-- Live region container (content injected dynamically) -->
<div
  id="notifications"
  aria-live="polite"
  aria-atomic="true"
  class="sr-only"
></div>

三个要点:

  1. aria-atomic="true":区域内容被整体替换时,屏幕阅读器播报整个区域而非仅 diff 片段,适合通知这类"整句有意义"的内容;
  2. sr-only 容器:第三种形态是视觉上完全隐藏、仅读屏可见的"公告区",用于内容动态注入的场景(如上传进度、后台轮询结果);
  3. 区域必须先行存在:live region 必须在内容插入之前就存在于 DOM 中,之后动态添加的 aria-live 元素不被所有读屏器可靠识别——这一点在仓库源码实现中得到了印证(见[仓库源码实证](#仓库源码实证appsweb 中的屏幕阅读器播报工具)一节)。

React Toast 组件实现

规则文档给出了完整可运行的 Toast 组件,核心是根据消息类型在 alert/status 之间切换角色与优先级,并处理了自动消失与键盘可达性:

import { useEffect, useRef } from 'react'

type NotificationType = 'success' | 'error' | 'warning' | 'info'

interface ToastProps {
  message: string
  type: NotificationType
  duration?: number
  onDismiss: () => void
}

export function Toast({
  message,
  type,
  duration = 5000,
  onDismiss
}: ToastProps) {
  const toastRef = useRef<HTMLDivElement>(null)

  // Auto-dismiss after duration
  useEffect(() => {
    if (duration > 0) {
      const timer = setTimeout(onDismiss, duration)
      return () => clearTimeout(timer)
    }
  }, [duration, onDismiss])

  // Focus toast for keyboard users
  useEffect(() => {
    toastRef.current?.focus()
  }, [])

  const isError = type === 'error' || type === 'warning'

  return (
    <div
      ref={toastRef}
      role={isError ? 'alert' : 'status'}
      aria-live={isError ? 'assertive' : 'polite'}
      aria-atomic="true"
      tabIndex={-1}
      className={`toast toast--${type}`}
    >
      <span className="toast__icon" aria-hidden="true">
        {type === 'success' && '✓'}
        {type === 'error' && '✕'}
        {type === 'warning' && '⚠'}
        {type === 'info' && 'ℹ'}
      </span>

      <span className="toast__message">{message}</span>

      <button
        type="button"
        onClick={onDismiss}
        aria-label="Dismiss notification"
        className="toast__dismiss"
      >
        ×
      </button>
    </div>
  )
}

逐点解析实现中每个无障碍细节的意图:

  • role/aria-liveisError 二选一errorwarning 视为紧急,走 alert + assertive 立即打断;successinfostatus + polite 排队播报。这与选择表一一对应;
  • tabIndex={-1} + 挂载后 focus():Toast 进入程序化焦点,键盘用户能直接操作它的关闭按钮;
  • aria-hidden="true" 图标 这类装饰符号只服务视觉用户,必须对读屏隐藏,否则会播报出无意义的"勾"、"叉";
  • 关闭按钮必须有 aria-label="Dismiss notification":按钮内只有一个 × 字符,没有可读文本,程序化名称是键盘与读屏用户唯一的操作入口;
  • duration 默认 5000ms,duration <= 0 时不自动消失——为后续"错误不自动关闭"策略留了接口。

Toast 容器与 Provider

单条 Toast 之上,规则文档提供了带 Context 的容器方案。它同时渲染两样东西:可视的 Toast 列表和一个隐藏的读屏公告区

import { createContext, useContext, useState, useCallback } from 'react'

interface Notification {
  id: string
  message: string
  type: NotificationType
  duration?: number
}

interface ToastContextType {
  addToast: (notification: Omit<Notification, 'id'>) => void
  removeToast: (id: string) => void
}

const ToastContext = createContext<ToastContextType | null>(null)

export function ToastProvider({ children }: { children: React.ReactNode }) {
  const [toasts, setToasts] = useState<Notification[]>([])

  const addToast = useCallback((notification: Omit<Notification, 'id'>) => {
    const id = Math.random().toString(36).substr(2, 9)
    setToasts(prev => [...prev, { ...notification, id }])
  }, [])

  const removeToast = useCallback((id: string) => {
    setToasts(prev => prev.filter(t => t.id !== id))
  }, [])

  return (
    <ToastContext.Provider value={{ addToast, removeToast }}>
      {children}

      {/* Toast container with live region */}
      <div
        className="toast-container"
        aria-label="Notifications"
      >
        {toasts.map(toast => (
          <Toast
            key={toast.id}
            message={toast.message}
            type={toast.type}
            duration={toast.duration}
            onDismiss={() => removeToast(toast.id)}
          />
        ))}
      </div>

      {/* Screen reader announcement region */}
      <div
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {toasts.length > 0 && toasts[toasts.length - 1].message}
      </div>
    </ToastContext.Provider>
  )
}

export function useToast() {
  const context = useContext(ToastContext)
  if (!context) throw new Error('useToast must be used within ToastProvider')
  return context
}

这个结构值得注意的设计取舍:

  • 持久化的 sr-only 公告区与可见 Toast 分离:可见 Toast 自身已有 role="alert"/"status",而容器级公告区作为"兜底通道"始终存在于 DOM(对应前文的"区域必须先行存在"原则),且固定为 polite——即便最新一条是错误,兜底通道也不做 assertive 打断,避免双重打断;
  • useToast 在 Provider 外直接抛错:让"忘记包 Provider"成为立即暴露的运行时错误,而不是静默失效的读屏静默。

使用示例:成功与错误的时长策略

规则文档给出的 SaveButton 用法演示了两类消息的关键差异——错误不自动消失

function SaveButton() {
  const { addToast } = useToast()

  const handleSave = async () => {
    try {
      await saveData()
      addToast({
        message: 'Changes saved successfully',
        type: 'success',
        duration: 3000
      })
    } catch (error) {
      addToast({
        message: 'Failed to save changes. Please try again.',
        type: 'error',
        duration: 0 // Don't auto-dismiss errors
      })
    }
  }

  return <button onClick={handleSave}>Save</button>
}

错误消息 duration: 0 意味着用户有无限时间阅读并决定下一步;而成功消息 3 秒即走。这是"展示时间足够被读完"这条 Check 项的直接落地。

内联通知与进度通知

内联通知

内联(嵌入页面流、非浮层)通知的组件与 Toast 同构,角色切换逻辑一致,并支持可选标题与可关闭:

interface InlineNotificationProps {
  type: 'error' | 'warning' | 'success' | 'info'
  title?: string
  children: React.ReactNode
  dismissible?: boolean
  onDismiss?: () => void
}

export function InlineNotification({
  type,
  title,
  children,
  dismissible = false,
  onDismiss
}: InlineNotificationProps) {
  const isUrgent = type === 'error' || type === 'warning'

  return (
    <div
      role={isUrgent ? 'alert' : 'status'}
      aria-live={isUrgent ? 'assertive' : 'polite'}
      className={`notification notification--${type}`}
    >
      {title && (
        <strong className="notification__title">{title}</strong>
      )}
      <div className="notification__content">{children}</div>

      {dismissible && (
        <button
          type="button"
          onClick={onDismiss}
          aria-label="Dismiss"
          className="notification__dismiss"
        >
          ×
        </button>
      )}
    </div>
  )
}

进度通知

进度类消息用 role="status" + aria-live="polite" 播报语义化文本,视觉进度条则整体对读屏隐藏,并辅以 aria-busy 告知容器处于忙碌状态:

function UploadProgress({ progress, fileName }: { progress: number; fileName: string }) {
  return (
    <div
      role="status"
      aria-live="polite"
      aria-busy={progress < 100}
      className="upload-progress"
    >
      <span className="sr-only">
        Uploading {fileName}: {progress}% complete
      </span>

      <div aria-hidden="true">
        <span>{fileName}</span>
        <progress value={progress} max="100" />
        <span>{progress}%</span>
      </div>
    </div>
  )
}

注意 sr-only 里写的是人类可读的完整句子("Uploading report.pdf: 45% complete")而非裸露数字——这正是 aria-atomic 与完整语句配合的价值:每次更新读屏都能播报一个自洽的完整状态。

展示时长策略

规则文档给出的时长指导表:

通知类型 建议时长
成功消息 3–5 秒
信息/状态 5–7 秒
警告 8–10 秒或手动关闭
错误 不自动消失(仅手动关闭)

配套的常量映射:

const DURATION_MAP = {
  success: 3000,
  info: 5000,
  warning: 8000,
  error: 0, // No auto-dismiss
} as const

时长策略与 aria-live 优先级是两条正交但需协同的轴:assertive 消息若同时 3 秒消失,读屏用户刚被打断却读不完内容;错误消息"不自动消失 + 手动关闭"的组合保证了"通知持久化足够时间"这条 Quick Reference 项在最高严重级别上依然成立。

样式要点:sr-only 与动效偏好

规则文档的样式部分包含 Toast 容器布局、四类颜色变体(success/error/warning/info 各自的背景与左侧色条)与关闭按钮样式,其中对无障碍真正起决定作用的是两处:

/* Screen reader only */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
  .toast {
    animation: none;
  }
}
  • .sr-only 是"视觉隐藏 ≠ 读屏隐藏"的标准做法:元素被压缩到 1px 并裁剪,但依然保留在可访问性树中。切勿用 display: nonevisibility: hidden 隐藏公告区——那样读屏同样不可见;
  • prefers-reduced-motion: reduce 时禁用入场动画:入场动画(slideIn 0.3s)对前庭功能障碍用户可能不适,尊重系统级动效偏好是通知组件的基本义务。

容器布局与动画部分(供完整实现参考):

.toast-container {
  position: fixed;
  bottom: 1rem;
  right: 1rem;
  z-index: 1000;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.toast {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 1rem;
  border-radius: 0.5rem;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  animation: slideIn 0.3s ease-out;
}

.toast--success { background: #d4edda; border-left: 4px solid #28a745; }
.toast--error { background: #f8d7da; border-left: 4px solid #dc3545; }
.toast--warning { background: #fff3cd; border-left: 4px solid #ffc107; }
.toast--info { background: #d1ecf1; border-left: 4px solid #17a2b8; }

@keyframes slideIn {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}

仓库源码实证:apps/web 中的屏幕阅读器播报工具

规则讲"通知应如何声明",而本仓库自己在 apps/web/lib/accessibility/screen-reader.ts 中实现了两个与之直接对应的运行时工具,是规则落地到真实代码的印证。

announce():一次性临时播报

announce() 的实现在每次调用时动态创建一个 live region、插入消息、1 秒后移除

export function announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
  const announcement = document.createElement('div')
  announcement.setAttribute('role', 'status')
  announcement.setAttribute('aria-live', priority)
  announcement.setAttribute('aria-atomic', 'true')
  announcement.className = 'sr-only'
  announcement.textContent = message
  document.body.appendChild(announcement)

  setTimeout(() => {
    document.body.removeChild(announcement)
  }, 1000)
}

从源码结构看,它逐属性复现了规则的最小范式:role="status" + aria-live(默认 polite,可传 assertive)+ aria-atomic="true" + sr-only 类名。这也解释了一个常见疑问——为什么可以"动态创建" live region:region 必须先于内容变更存在,而这里正是先插入空壳(textContent = message 紧随其后)再读屏感知变化,符合时序要求。

createLiveRegion():持久区域与"重播同一条消息"的技巧

createLiveRegion() 创建一次、复用多次的持久公告区,返回 announce/destroy 两个句柄:

export function createLiveRegion(priority: 'polite' | 'assertive' = 'polite'): {
  announce: (message: string) => void
  destroy: () => void
} {
  const region = document.createElement('div')
  region.setAttribute('role', 'status')
  region.setAttribute('aria-live', priority)
  region.setAttribute('aria-atomic', 'true')
  region.className = 'sr-only'
  document.body.appendChild(region)

  return {
    announce: (message: string) => {
      region.textContent = ''
      void region.offsetHeight
      region.textContent = message
    },
    destroy: () => {
      document.body.removeChild(region)
    }
  }
}

其中 announce 的三行逻辑值得细看:先清空 → 强制同步 reflow(void region.offsetHeight)→ 再写入新消息。这是因为屏幕阅读器只在区域内容"发生变化"时播报,连续两次写入相同文本不会触发第二次播报;插入一次真实的 DOM 读取(offsetHeight 是经典的 reflow 触发器)确保"清空"被提交后,新内容被视为一次全新变更,从而可靠重播。这一技巧是规则文档"live region 容器"范式的运行时增强,两者可以互相参照阅读。

验证清单

规则文档定义的 Verification 六步,是上线前可执行的验收流程:

  1. 启用屏幕阅读器并触发各类通知;
  2. 确认播报发生在恰当的时机(polite 等待、assertive 立即);
  3. 测试键盘关闭(Escape 键);
  4. 确认通知不会消失过快;
  5. 验证关闭后的焦点管理(焦点不应"掉"进不可达状态);
  6. 在不同屏幕阅读器上测试(NVDA、VoiceOver、JAWS)。

自动化工具(Nu Html Checker 等,规则 frontmatter 中列出的 resource)能发现缺失 role/aria-live 的标记,但播报时机、打断感与焦点行为必须靠真实读屏器人工验证——这也是该仓库 screen-reader-testing 规则反复强调的分界线。

小结

accessible-notifications 规则给出的是一套完整闭环的决策链:语义分级(polite/assertive 与 status/alert 选择表)→ 标记落地aria-live + aria-atomic + sr-only 容器)→ 交互完整性(程序化焦点、带 aria-label 的关闭按钮、装饰图标 aria-hidden)→ 时长策略(错误不自动消失的 DURATION_MAP)→ 样式约束.sr-only 视觉隐藏而非可访问性树移除、prefers-reduced-motion 降级)→ 读屏器验证。仓库中的 screen-reader.ts 则从运行时侧印证了"区域先行存在"与"变更才播报"两个底层机制。代码评审时记住 SKILL.md 的最终要求:检查的是模板与服务端渲染出来的最终 HTML,并在问题报告中精确到元素、属性与路由。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384