React 组件无障碍(a11y)模式实战指南:ARIA、键盘导航与焦点管理全解析
2026/9/10 21:40:01 网站建设 项目流程

React 组件无障碍(a11y)模式实战指南:ARIA、键盘导航与焦点管理全解析

【免费下载链接】agentsMulti-harness agentic plugin marketplace for Claude Code, Codex, Cursor, OpenCode, GitHub Copilot, and Google Antigravity项目地址: https://gitcode.com/GitHub_Trending/agents24/agents

本指南以ui-design插件中 web-component-design 技能 的 无障碍模式参考文档 为核心,系统讲解在 React 组件库中落地 WCAG 无障碍所需的六大核心能力:ARIA 语义、焦点管理、键盘导航、表单可访问性、动态内容播报与颜色对比度校验。读完本文,你将获得一套可以直接复制到项目中的可访问组件实现方案,并能用仓库提供的 无障碍审计命令 验证成果。

一、无障碍先行:为什么组件库必须内置 a11y

在 web-component-design 技能文档的最佳实践中,"Accessible by Default"(默认无障碍)被列为组件设计的第一原则,同时指出常见问题之一是 "Accessibility Gaps"(无障碍缺口)。也就是说,可访问性不能靠事后修补,而应从组件 API 设计阶段就内建 ARIA 属性与键盘支持。

仓库中的 accessibility-expert Agent 将这项工作归纳为三个维度:

  • ARIA 实现:为自定义组件补充角色(role)、状态(state)与属性(property);
  • 键盘导航与焦点管理:Tab 顺序、焦点陷阱(focus trap)、跳转链接、roving tabindex;
  • 颜色与视觉无障碍:WCAG AA(4.5:1)与 AAA(7:1)对比度、非颜色信息传达、焦点可见性。

下面的章节逐一给出这些能力的可运行代码模式。

二、ARIA 模式:四个高频组件的完整实现

无障碍模式参考文档 提供了四个核心组件的完整实现:模态对话框、下拉菜单、组合框(自动完成)与表单校验。它们共同覆盖了 ARIA 的三大核心角色族:dialog、menu、combobox。

2.1 模态对话框(Modal Dialog):role="dialog"+ 焦点陷阱

模态对话框是无障碍组件中最容易出错的类型,因为它同时涉及三个问题:焦点必须被"困"在对话框内、关闭后焦点必须归还给触发元素、背景内容不能被屏幕阅读器访问。

import { useEffect, useRef, type ReactNode } from "react"; import { createPortal } from "react-dom"; interface ModalProps { isOpen: boolean; onClose: () => void; title: string; children: ReactNode; } export function Modal({ isOpen, onClose, title, children }: ModalProps) { const dialogRef = useRef<HTMLDivElement>(null); const previousActiveElement = useRef<Element | null>(null); useEffect(() => { if (isOpen) { previousActiveElement.current = document.activeElement; dialogRef.current?.focus(); document.body.style.overflow = "hidden"; } else { document.body.style.overflow = ""; (previousActiveElement.current as HTMLElement)?.focus(); } return () => { document.body.style.overflow = ""; }; }, [isOpen]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "Tab") trapFocus(e, dialogRef.current); }; if (isOpen) { document.addEventListener("keydown", handleKeyDown); } return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, onClose]); if (!isOpen) return null; return createPortal( <div className="fixed inset-0 z-50 flex items-center justify-center" aria-hidden={!isOpen} > {/* Backdrop */} <div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" /> {/* Dialog */} <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl" > <h2 id="modal-title" className="text-lg font-semibold"> {title} </h2> <button onClick={onClose} aria-label="Close dialog" className="absolute right-4 top-4 p-1" > <XIcon aria-hidden="true" /> </button> <div className="mt-4">{children}</div> </div> </div>, document.body, ); } function trapFocus(e: KeyboardEvent, container: HTMLElement | null) { if (!container) return; const focusableElements = container.querySelectorAll<HTMLElement>( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (e.shiftKey && document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } else if (!e.shiftKey && document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } }

该实现中的关键点:

  • role="dialog"+aria-modal="true":向辅助技术声明这是一个模态对话框,背景内容不可交互;
  • aria-labelledby="modal-title":将<h2>标题关联为对话框的可访问名称;
  • tabIndex={-1}:使对话框容器可以接收程序化焦点(.focus()),从而在打开瞬间将焦点移入;
  • 打开前保存document.activeElement,关闭后归还焦点:这是 WCAG 2.1.2「无键盘陷阱」与焦点顺序(2.4.3)的落地;
  • trapFocus循环:当焦点到达第一个/最后一个可聚焦元素时按 Tab / Shift+Tab 反向循环,防止焦点逃逸到背景页面;
  • createPortal(..., document.body):将对话框渲染到document.body,避免被祖先容器的overflowz-index上下文裁剪。

这个焦点循环选择器'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'与 web-component-design 中useFocusTrap的实现完全一致,可复用到任何需要陷阱的组件。仓库中 aria-patterns.md 还给出了role="alertdialog"的变体,用于需要强制用户确认的危险操作。

2.2 下拉菜单(Dropdown Menu):aria-haspopup+ 完整方向键协议

菜单组件遵循 WAI-ARIA Authoring Practices 的 menu 模式:触发按钮声明aria-haspopup="menu"aria-expanded,菜单容器使用role="menu",每一项使用role="menuitem",并通过方向键实现 roving focus(焦点漫游)。

import { useState, useRef, useEffect, type ReactNode } from "react"; interface DropdownProps { trigger: ReactNode; children: ReactNode; label: string; } export function Dropdown({ trigger, children, label }: DropdownProps) { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const handleKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case "Escape": setIsOpen(false); triggerRef.current?.focus(); break; case "ArrowDown": e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { focusNextItem(menuRef.current, 1); } break; case "ArrowUp": e.preventDefault(); if (isOpen) { focusNextItem(menuRef.current, -1); } break; case "Home": e.preventDefault(); focusFirstItem(menuRef.current); break; case "End": e.preventDefault(); focusLastItem(menuRef.current); break; } }; return ( <div ref={containerRef} className="relative" onKeyDown={handleKeyDown}> <button ref={triggerRef} aria-haspopup="menu" aria-expanded={isOpen} aria-label={label} onClick={() => setIsOpen(!isOpen)} className="flex items-center gap-2 px-3 py-2" > {trigger} <ChevronDownIcon aria-hidden="true" className={`transition-transform ${isOpen ? "rotate-180" : ""}`} /> </button> {isOpen && ( <div ref={menuRef} role="menu" aria-orientation="vertical" className="absolute left-0 mt-1 min-w-48 rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5" > {children} </div> )} </div> ); } interface MenuItemProps { children: ReactNode; onClick?: () => void; disabled?: boolean; } export function MenuItem({ children, onClick, disabled }: MenuItemProps) { return ( <button role="menuitem" disabled={disabled} onClick={onClick} className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 disabled:opacity-50" tabIndex={-1} > {children} </button> ); } function focusNextItem(menu: HTMLElement | null, direction: 1 | -1) { if (!menu) return; const items = menu.querySelectorAll<HTMLElement>( '[role="menuitem"]:not([disabled])', ); const currentIndex = Array.from(items).indexOf( document.activeElement as HTMLElement, ); const nextIndex = (currentIndex + direction + items.length) % items.length; items[nextIndex]?.focus(); } function focusFirstItem(menu: HTMLElement | null) { menu ?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])') ?.focus(); } function focusLastItem(menu: HTMLElement | null) { const items = menu?.querySelectorAll<HTMLElement>( '[role="menuitem"]:not([disabled])', ); items?.[items.length - 1]?.focus(); }

这里需要注意两个细节:

  • tabIndex={-1}放在每个menuitem:这正是 roving tabindex 模式——整个菜单在 Tab 序列中只占一个位置(触发按钮),菜单项只能通过方向键在内部移动焦点。方向键按(currentIndex + direction + items.length) % items.length取模,实现首尾循环;
  • Home/End跳转到首/末项,Escape关闭并把焦点还给触发按钮:这是菜单键盘协议(对应 ARIA APG)的标准行为,也符合 WCAG 2.1.1 键盘可操作与 2.4.7 焦点可见的要求。

2.3 组合框(Combobox / Autocomplete):aria-activedescendant联动

组合框是最复杂的 ARIA 模式之一,需要把输入框与列表联动起来。参考文档的实现使用了aria-activedescendant方案:焦点始终停留在输入框上,通过aria-activedescendant指向当前高亮的选项,从而避免在列表项上做真实的 DOM 焦点切换。

import { useState, useRef, useId, type ChangeEvent, type KeyboardEvent, } from "react"; interface Option { value: string; label: string; } interface ComboboxProps { options: Option[]; value: string; onChange: (value: string) => void; label: string; placeholder?: string; } export function Combobox({ options, value, onChange, label, placeholder, }: ComboboxProps) { const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(""); const [activeIndex, setActiveIndex] = useState(-1); const inputRef = useRef<HTMLInputElement>(null); const listboxRef = useRef<HTMLUListElement>(null); const inputId = useId(); const listboxId = useId(); const filteredOptions = options.filter((option) => option.label.toLowerCase().includes(inputValue.toLowerCase()), ); const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { setInputValue(e.target.value); setIsOpen(true); setActiveIndex(-1); }; const handleSelect = (option: Option) => { onChange(option.value); setInputValue(option.label); setIsOpen(false); inputRef.current?.focus(); }; const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); if (!isOpen) { setIsOpen(true); } else { setActiveIndex((prev) => prev < filteredOptions.length - 1 ? prev + 1 : prev, ); } break; case "ArrowUp": e.preventDefault(); setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev)); break; case "Enter": e.preventDefault(); if (activeIndex >= 0 && filteredOptions[activeIndex]) { handleSelect(filteredOptions[activeIndex]); } break; case "Escape": setIsOpen(false); break; } }; return ( <div className="relative"> <label htmlFor={inputId} className="block text-sm font-medium mb-1"> {label} </label> <input ref={inputRef} id={inputId} type="text" role="combobox" aria-expanded={isOpen} aria-autocomplete="list" aria-controls={listboxId} aria-activedescendant={ activeIndex >= 0 ? `option-${activeIndex}` : undefined } value={inputValue} placeholder={placeholder} onChange={handleInputChange} onKeyDown={handleKeyDown} onFocus={() => setIsOpen(true)} onBlur={() => setTimeout(() => setIsOpen(false), 200)} className="w-full rounded-md border px-3 py-2" /> {isOpen && filteredOptions.length > 0 && ( <ul ref={listboxRef} id={listboxId} role="listbox" aria-label={label} className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5" > {filteredOptions.map((option, index) => ( <li key={option.value} id={`option-${index}`} role="option" aria-selected={activeIndex === index} onClick={() => handleSelect(option)} className={`cursor-pointer px-3 py-2 ${ activeIndex === index ? "bg-blue-100" : "hover:bg-gray-100" } ${value === option.value ? "font-medium" : ""}`} > {option.label} </li> ))} </ul> )} {isOpen && filteredOptions.length === 0 && ( <div className="absolute z-10 mt-1 w-full rounded-md bg-white px-3 py-2 shadow-lg"> No results found </div> )} </div> ); }

本实现完整覆盖了 combobox 模式的命名关系:

属性作用
role="combobox"输入框声明组合框角色
aria-expandedisOpen告知列表展开状态
aria-autocomplete="list"固定声明自动完成类型为列表建议
aria-controls={listboxId}列表 id建立输入框与列表的控制关系
aria-activedescendantoption-{index}指向当前高亮选项 id,替代真实焦点移动
role="option"+aria-selected每个列表项声明选项角色与选中状态

两个工程细节值得注意:其一,useId()生成inputIdlistboxId,避免硬编码 id 在 SSR 或多实例场景下冲突;其二,onBlur中用setTimeout(..., 200)延迟关闭,给鼠标点击选项留下触发onClick的时间窗口,这是"点击外部关闭"与"点击列表项选择"两者不冲突的经典处理。

2.4 表单校验(Form Validation):aria-invalid+aria-describedby+role="alert"

表单的可访问性核心是:每个输入都必须有标签(Label)、错误必须与输入建立关联、错误出现时必须被屏幕阅读器播报。参考文档通过一个FormField渲染属性组件统一封装了这三件事。

import { useId, type FormEvent } from "react"; interface FormFieldProps { label: string; error?: string; required?: boolean; children: (props: { id: string; "aria-describedby": string | undefined; "aria-invalid": boolean; }) => ReactNode; } export function FormField({ label, error, required, children, }: FormFieldProps) { const id = useId(); const errorId = `${id}-error`; return ( <div className="space-y-1"> <label htmlFor={id} className="block text-sm font-medium"> {label} {required && ( <span aria-hidden="true" className="ml-1 text-red-500"> * </span> )} </label> {children({ id, "aria-describedby": error ? errorId : undefined, "aria-invalid": !!error, })} {error && ( <p id={errorId} role="alert" className="text-sm text-red-600"> {error} </p> )} </div> ); } // Usage function ContactForm() { const [errors, setErrors] = useState<Record<string, string>>({}); const handleSubmit = (e: FormEvent) => { e.preventDefault(); // Validation logic... }; return ( <form onSubmit={handleSubmit} noValidate> <FormField label="Email" error={errors.email} required> {(props) => ( <input {...props} type="email" required className={`w-full rounded border px-3 py-2 ${ props["aria-invalid"] ? "border-red-500" : "border-gray-300" }`} /> )} </FormField> <button type="submit" className="mt-4 px-4 py-2 bg-blue-600 text-white rounded" > Submit </button> </form> ); }

实现要点:

  • useId()生成 id 并把 label 关联到输入框htmlFor={id}),满足 WCAG 1.3.1 信息与关系、3.3.2 标签或说明;
  • aria-invalid={!!error}告知辅助技术该输入当前无效(对应 3.3.1 错误标识);
  • aria-describedby={errorId}把错误文案与输入框建立描述关系,屏幕阅读器聚焦输入框时会读出错误;
  • 错误文案使用role="alert",其隐式语义为aria-live="assertive",错误出现时会立即打断播报,这正是 aria-patterns.md 中"重要错误应使用 assertive 播报"建议的实践;
  • 必填星号用aria-hidden="true"包裹,避免读屏器播报无意义的*,同时保留视觉提示;
  • noValidate+ 自管校验:禁用浏览器原生气泡,保证错误提示样式与播报行为一致可控。

该模式与仓库中 wcag-guidelines.md 的 3.3.1 示例(FormField使用aria-invalid+role="alert")如出一辙,是经过 WCAG 标准对齐后的推荐写法。

三、跳转链接(Skip Links):绕过重复导航

跳转链接对应 WCAG 2.4.1「绕过重复内容」(Level A),让键盘与屏幕阅读器用户能直接跳到主内容,而不用反复 Tab 过整个导航。参考文档的实现用 Tailwind 的sr-only+focus-within:not-sr-only实现"平时隐藏、聚焦时显示":

export function SkipLinks() { return ( <div className="sr-only focus-within:not-sr-only"> <a href="#main-content" className="absolute left-4 top-4 z-50 rounded bg-blue-600 px-4 py-2 text-white focus:outline-none focus:ring-2" > Skip to main content </a> <a href="#main-navigation" className="absolute left-4 top-16 z-50 rounded bg-blue-600 px-4 py-2 text-white focus:outline-none focus:ring-2" > Skip to navigation </a> </div> ); }

工程要点:

  • 跳转目标(#main-content)应放置tabIndex={-1},否则部分浏览器(如 Safari)不会把焦点移入目标容器;
  • 跳转链接必须位于页面 DOM 的最前面,确保它是键盘 Tab 序列的第一个元素;
  • 链接自身需要有高对比度背景与明显焦点样式(focus:ring-2),满足 2.4.7 焦点可见。

在 wcag-guidelines.md 的 2.4.1 示例中可以看到完全相同的"跳过主内容 + 跳过导航"双链接结构,属于 WCAG 官方推荐模式。

四、动态区域(Live Regions):让屏幕阅读器"听到"变化

SPA 中异步加载、搜索过滤等动态内容不会自动被屏幕阅读器感知,必须通过aria-live区域主动播报。参考文档提供了一个通用LiveAnnouncer组件,并封装了"先清空、再延迟 100ms 设置"的技巧:

import { useState, useEffect } from "react"; interface LiveAnnouncerProps { message: string; politeness?: "polite" | "assertive"; } export function LiveAnnouncer({ message, politeness = "polite", }: LiveAnnouncerProps) { const [announcement, setAnnouncement] = useState(""); useEffect(() => { // Clear first, then set - ensures screen readers pick up the change setAnnouncement(""); const timer = setTimeout(() => setAnnouncement(message), 100); return () => clearTimeout(timer); }, [message]); return ( <div role="status" aria-live={politeness} aria-atomic="true" className="sr-only" > {announcement} </div> ); } // Usage in a search component function SearchResults({ results, loading, }: { results: Item[]; loading: boolean; }) { const message = loading ? "Loading results..." : `${results.length} results found`; return ( <> <LiveAnnouncer message={message} /> <ul>{/* results */}</ul> </> ); }

关键设计解读:

  • role="status"隐式等价于aria-live="polite",而politeness参数允许切换到"assertive"用于必须打断的紧急信息;
  • aria-atomic="true":告知辅助技术整块替换播报内容,而不是逐字比较差异;
  • "先清空再延迟设置":如果新消息与旧消息相同,屏幕阅读器可能忽略 DOM 无变化的更新;通过先置空再在 100ms 后写入,强制触发一次完整的播报。这正是代码注释所强调的 "Clear first, then set" 技巧;
  • className="sr-only":区域在视觉上隐藏、但对读屏器可见——这就是"视觉隐藏但仍在无障碍树中"的正确用法。对比 aria-patterns.md 中列出的常见错误:用display:none的内容不会被播报,二者不可混用。

在 aria-patterns.md 的 Live Regions 一节还给出了两种更细粒度的变体:进度提示(role="status"+ "Loading: X% complete")与聊天日志(role="log"+aria-relevant="additions"),后者只播报新增消息,适合高频追加内容的场景。

五、焦点管理工具:useFocusReturn 与 useFocusTrap

将上文散落在各个组件里的焦点逻辑提取成可复用的 Hooks,是组件库工程化的关键。参考文档给出了两个 Hook:

// useFocusReturn - restore focus after closing function useFocusReturn() { const previousElement = useRef<Element | null>(null); const saveFocus = () => { previousElement.current = document.activeElement; }; const restoreFocus = () => { (previousElement.current as HTMLElement)?.focus(); }; return { saveFocus, restoreFocus }; } // useFocusTrap - keep focus within container function useFocusTrap(containerRef: RefObject<HTMLElement>, isActive: boolean) { useEffect(() => { if (!isActive || !containerRef.current) return; const container = containerRef.current; const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== "Tab") return; const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector); const first = focusableElements[0]; const last = focusableElements[focusableElements.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; container.addEventListener("keydown", handleKeyDown); return () => container.removeEventListener("keydown", handleKeyDown); }, [containerRef, isActive]); }
  • useFocusReturn:在组件打开时调用saveFocus()记录document.activeElement,关闭时调用restoreFocus()归还焦点——这正是模态框、弹层、抽屉等"瞬时层"组件防止焦点丢失的标准做法(对应 2.4.3 焦点顺序);
  • useFocusTrap:把 2.1 节模态框中的trapFocus逻辑参数化为"容器 + 激活标志"。当isActive为 false 或容器未挂载时,useEffect直接返回,不注册监听,避免内存泄漏与无效监听;
  • 两个 Hook 与 web-component-design 强调的"Forward Refs(转发 ref 让父组件访问 DOM 节点)"协作良好:容器 ref 通常来自forwardRef转发的节点。

六、颜色对比度工具:用 WCAG 公式在代码中做校验

视觉无障碍的最后一道关卡是颜色对比度。参考文档给出了一段自包含的对比度计算与 WCAG 判定函数,可在开发期(如 CI 或 Storybook 检查)直接复用:

// Check if colors meet WCAG requirements function getContrastRatio(fg: string, bg: string): number { const getLuminance = (hex: string): number => { const rgb = parseInt(hex.slice(1), 16); const r = (rgb >> 16) & 0xff; const g = (rgb >> 8) & 0xff; const b = rgb & 0xff; const [rs, gs, bs] = [r, g, b].map((c) => { c = c / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }); return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; }; const l1 = getLuminance(fg); const l2 = getLuminance(bg); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } function meetsWCAG( fg: string, bg: string, level: "AA" | "AAA" = "AA", ): boolean { const ratio = getContrastRatio(fg, bg); return level === "AAA" ? ratio >= 7 : ratio >= 4.5; }

这段代码实现的是 WCAG 2.x 官方的相对亮度与对比度公式:

  1. #RRGGBB拆成 RGB 三个通道;
  2. 对每个通道做gamma 校正c/255 ≤ 0.03928时线性映射为c/12.92,否则使用((c + 0.055) / 1.055)^2.4进行 sRGB 非线性变换;
  3. 0.2126 R + 0.7152 G + 0.0722 B加权得到相对亮度(人眼对绿色最敏感,因此绿色权重最高);
  4. 对比度 =(较亮 + 0.05) / (较暗 + 0.05)
  5. meetsWCAG默认按AA 级 4.5:1(普通文本)判定,AAA 级要求7:1

需要说明的适用范围(对应 WCAG 1.4.3 对比度最小化):

  • 普通文本:AA 4.5:1、AAA 7:1;
  • 大号文本(18pt 以上或 14pt 加粗以上):AA 3:1、AAA 4.5:1;
  • UI 组件与图形(1.4.11 非文本对比度):3:1。

在 wcag-guidelines.md 的 1.4.3 一节中可以找到这些阈值的 CSS 注释原文,本工具函数即与之对应。仓库的 无障碍审计命令 也内置了同样的对比度计算流程:从设计令牌或 CSS 中提取文本/背景颜色组合,逐对计算并标记不达标项。

七、在项目中验证:接入无障碍审计与测试

模式写完之后,需要可验证的闭环。ui-design 插件为此提供了两条路径:

7.1 使用无障碍审计命令

在 Claude Code 中安装插件后,可执行 accessibility-audit 命令:

/plugin install ui-design /ui-design:accessibility-audit --file src/components/Modal.tsx --level AA

命令会做四类检查,正好覆盖本文的模式:

  1. 静态代码分析:按 WCAG 四大原则(Perceivable / Operable / Understandable / Robust)逐条勾选,例如图片是否有alt、交互元素是否可键盘操作、是否存在键盘陷阱、跳转链接是否存在、表单是否有关联标签、错误提示是否可被读屏器感知;
  2. 反模式正则检测:内置一组正则,如<img缺少altonClick没有onKeyDown<div>/<span>挂 click 处理器、tabIndex={[1-9]}正值、autoFocus等;
  3. 颜色对比度分析:提取颜色组合并按 4.5:1 / 7:1 / 3:1 阈值判定(与第六节工具函数同一套公式);
  4. ARIA 校验:验证 role 有效性、必填属性是否齐全、是否存在冗余 ARIA(如role="button"加在<button>上)。

审计结果会生成在.ui-design/audits/{audit_id}.md,按 Critical / Serious / Moderate / Minor 四级严重度分类,每条问题附带 WCAG 准则编号、影响分析与逐步修复建议。

7.2 接入自动化测试

审计命令生成的报告会附带测试建议,例如用 jest-axe 做回归拦截:

import { axe, toHaveNoViolations } from "jest-axe"; expect.extend(toHaveNoViolations); test("component has no accessibility violations", async () => { const { container } = render(<Component />); const results = await axe(container); expect(results).toHaveNoViolations(); });

此外,accessibility-compliance 技能 还给出了一套手工测试清单,可作为发布前的最终关卡:

  • 全程仅用键盘完成页面导航;
  • 用 VoiceOver / NVDA 实际走查一遍屏幕阅读器体验;
  • 200% 缩放下验证可用性;
  • 高对比度模式下验证可读性;
  • 确认焦点指示器始终可见;
  • prefers-reduced-motion验证动画可关闭。

八、常见陷阱自查清单

综合参考文档与仓库中 aria-patterns.md 的 "Common Mistakes" 章节,以下反模式最容易在组件开发中反复出现:

陷阱错误示例正确做法
冗余 ARIA<button role="button">aria-label与可见文本重复优先使用原生语义元素
无效 ARIA在无角色元素上用aria-selected必须搭配role="option"等正确角色
断开的控制关系aria-expanded没有配套aria-controlsaria-controls指向被控制元素 id
隐藏内容仍被播报视觉隐藏但未从无障碍树移除display:none/hidden彻底移除,或用aria-hidden="true"显式隐藏装饰内容
焦点丢失关闭弹层后焦点留在 bodyuseFocusReturn归还焦点给触发元素
键盘陷阱弹层内 Tab 焦点逃逸到背景useFocusTrap循环锁定焦点
只靠颜色传达信息仅红色边框表示校验失败叠加aria-invalid、图标与文字错误提示
缺失跳转链接长导航无法跳过页面顶部放置SkipLinks

九、模式之间的协作关系

最后把本文的组件放在一起看它们如何形成体系:SkipLinks解决"进入页面"的导航效率,Dropdown/Combobox解决"复杂交互"的键盘协议,ModaluseFocusTrap/useFocusReturn解决"焦点闭环",FormField解决"输入与错误"的语义关联,LiveAnnouncer解决"动态更新"的播报,getContrastRatio/meetsWCAG解决"视觉呈现"的合规校验。这六类能力共同支撑起 web-component-design 中 "Accessible by Default" 的组件库目标,也与 accessibility-compliance 技能 的 WCAG 2.2 合规路线(Level A / AA / AAA)完全对齐。

每个模式都可作为独立的组件规格沉淀到组件库中,并配合 accessibility-audit 命令 在 CI 中持续校验,最终形成"先设计、后实现、再验证"的无障碍工程闭环。

【免费下载链接】agentsMulti-harness agentic plugin marketplace for Claude Code, Codex, Cursor, OpenCode, GitHub Copilot, and Google Antigravity项目地址: https://gitcode.com/GitHub_Trending/agents24/agents

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询