外观
无障碍框架
什么是无障碍框架
无障碍框架是系统化的工具集合和设计模式,用于在整个产品开发周期中系统性地实现和保障无障碍性。不同于零散的修复,框架从架构层面将无障碍集成到组件库、设计系统和开发流程中。
示意图:
传统方式: 开发 → 测试 → 发现无障碍问题 → 修复
框架方式: 设计系统 + 组件库 + 开发工具 → 无障碍内建 → 持续验证核心原理:通过标准化、自动化和系统集成,将无障碍从后期修复转变为前期预防,确保一致性并降低实施成本。
设计系统集成
无障碍设计令牌
在设计系统中定义无障碍相关的设计决策。
代码示例:
scss
// 无障碍设计令牌
:root {
// 色彩对比度保证
--color-text-primary: #333333;
--color-text-secondary: #666666;
--color-background: #ffffff;
--color-primary: #0056b3;
// 间距和尺寸标准
--spacing-focus-ring: 2px;
--size-touch-target: 44px;
--border-focus: 2px solid var(--color-primary);
// 动画和过渡
--duration-motion: 0.3s;
--timing-function: ease-in-out;
// 高对比度模式支持
@media (prefers-contrast: high) {
--color-primary: #000000;
--border-focus: 3px solid #000000;
}
// 减少动画偏好
@media (prefers-reduced-motion: reduce) {
--duration-motion: 0.01s;
}
}组件级无障碍规范
在设计系统中定义组件的无障碍要求。
代码示例 (设计系统文档):
markdown
## 按钮组件无障碍规范
### 必需属性
- `role="button"` (自定义按钮时)
- `tabindex="0"` (自定义按钮时)
- 可见的焦点指示器
- 最小44×44px触摸目标
### 键盘交互
- Enter/Space激活
- 支持程序化焦点管理
### 屏幕阅读器
- 有意义的可访问名称
- 状态变化通知 (aria-pressed等)
### 视觉设计
- 文本对比度 ≥ 4.5:1
- 非文本对比度 ≥ 3:1组件库实现
基础无障碍组件
创建具有内置无障碍支持的 React 组件。
代码示例 (React 按钮组件):
jsx
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
const AccessibleButton = React.forwardRef(({
children,
onClick,
disabled = false,
pressed = false,
ariaLabel,
className,
...props
}, ref) => {
const internalRef = useRef();
const buttonRef = ref || internalRef;
// 处理键盘交互
const handleKeyDown = (event) => {
if (disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (event.key === ' ') {
// 防止空格键滚动页面
event.preventDefault();
}
onClick?.(event);
}
};
// 程序化焦点管理
useEffect(() => {
if (props.autoFocus && buttonRef.current) {
buttonRef.current.focus();
}
}, [props.autoFocus, buttonRef]);
return (
<button
ref={buttonRef}
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
aria-pressed={pressed}
aria-label={ariaLabel}
onClick={disabled ? undefined : onClick}
onKeyDown={handleKeyDown}
className={`
accessible-button
${disabled ? 'accessible-button--disabled' : ''}
${className || ''}
`}
{...props}
>
{children}
</button>
);
});
AccessibleButton.propTypes = {
children: PropTypes.node.isRequired,
onClick: PropTypes.func,
disabled: PropTypes.bool,
pressed: PropTypes.bool,
ariaLabel: PropTypes.string,
className: PropTypes.string,
autoFocus: PropTypes.bool
};
export default AccessibleButton;复杂组件框架
实现完整的无障碍复合组件。
代码示例 (React 标签页组件):
jsx
import React, { useState, useEffect, useCallback } from 'react';
const TabContext = React.createContext();
export const TabList = ({ children, label, orientation = 'horizontal' }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const contextValue = {
selectedIndex,
setSelectedIndex,
orientation
};
return (
<div className="tab-list-container">
<TabContext.Provider value={contextValue}>
<div
role="tablist"
aria-label={label}
aria-orientation={orientation}
className="tab-list"
>
{children}
</div>
</TabContext.Provider>
</div>
);
};
export const Tab = ({ index, children, disabled = false }) => {
const { selectedIndex, setSelectedIndex, orientation } = React.useContext(TabContext);
const isSelected = index === selectedIndex;
const tabRef = React.useRef();
const handleClick = () => {
if (!disabled) {
setSelectedIndex(index);
}
};
const handleKeyDown = (event) => {
if (disabled) return;
const keyActions = {
ArrowRight: () => orientation === 'horizontal' && setSelectedIndex(prev => (prev + 1) % React.Children.count(children)),
ArrowLeft: () => orientation === 'horizontal' && setSelectedIndex(prev => (prev - 1 + React.Children.count(children)) % React.Children.count(children)),
ArrowDown: () => orientation === 'vertical' && setSelectedIndex(prev => (prev + 1) % React.Children.count(children)),
ArrowUp: () => orientation === 'vertical' && setSelectedIndex(prev => (prev - 1 + React.Children.count(children)) % React.Children.count(children)),
Home: () => setSelectedIndex(0),
End: () => setSelectedIndex(React.Children.count(children) - 1)
};
if (keyActions[event.key]) {
event.preventDefault();
keyActions[event.key]();
}
};
useEffect(() => {
if (isSelected && tabRef.current) {
tabRef.current.focus();
}
}, [isSelected]);
return (
<button
ref={tabRef}
role="tab"
aria-selected={isSelected}
aria-disabled={disabled}
tabIndex={isSelected ? 0 : -1}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={`tab ${isSelected ? 'tab--selected' : ''} ${disabled ? 'tab--disabled' : ''}`}
>
{children}
</button>
);
};
export const TabPanel = ({ index, children }) => {
const { selectedIndex } = React.useContext(TabContext);
const isSelected = index === selectedIndex;
return (
<div
role="tabpanel"
aria-hidden={!isSelected}
tabIndex={0}
className={`tab-panel ${isSelected ? 'tab-panel--active' : ''}`}
>
{isSelected && children}
</div>
);
};使用示例:
jsx
<TabList label="用户设置">
<Tab index={0}>个人信息</Tab>
<Tab index={1}>隐私设置</Tab>
<Tab index={2}>通知偏好</Tab>
</TabList>
<TabPanel index={0}>个人信息内容...</TabPanel>
<TabPanel index={1}>隐私设置内容...</TabPanel>
<TabPanel index={2}>通知偏好内容...</TabPanel>开发工具和 SDK
无障碍开发 SDK
提供开发时使用的工具函数和 Hooks。
代码示例 (React Hooks):
jsx
import { useEffect, useRef } from 'react';
// 焦点管理Hook
export const useFocusManagement = (autoFocus = false) => {
const ref = useRef();
useEffect(() => {
if (autoFocus && ref.current) {
ref.current.focus();
}
}, [autoFocus]);
return ref;
};
// 实时区域Hook
export const useLiveRegion = (politeness = 'polite') => {
const [message, setMessage] = useState('');
useEffect(() => {
if (message) {
const timer = setTimeout(() => setMessage(''), 5000);
return () => clearTimeout(timer);
}
}, [message]);
const announce = useCallback((newMessage) => {
setMessage(newMessage);
}, []);
const LiveRegion = () => (
<div
aria-live={politeness}
aria-atomic="true"
className="visually-hidden"
>
{message}
</div>
);
return { announce, LiveRegion };
};
// 键盘导航Hook
export const useKeyboardNavigation = (navigationMap) => {
const handleKeyDown = useCallback((event) => {
const handler = navigationMap[event.key];
if (handler) {
event.preventDefault();
handler(event);
}
}, [navigationMap]);
return { handleKeyDown };
};开发环境工具
集成到开发流程中的无障碍工具。
代码示例 (ESLint 插件配置):
javascript
// .eslintrc.js
module.exports = {
plugins: ['jsx-a11y'],
extends: [
'plugin:jsx-a11y/recommended'
],
rules: {
'jsx-a11y/alt-text': 'error',
'jsx-a11y/anchor-is-valid': 'error',
'jsx-a11y/aria-props': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/no-noninteractive-tabindex': 'warn'
}
};代码示例 (VS Code 代码片段):
json
{
"Accessible Button": {
"prefix": "a11y-button",
"body": [
"<button",
" type=\"button\"",
" aria-label=\"${1:button description}\"",
" onClick={${2:handleClick}}",
" onKeyDown={(e) => {",
" if (e.key === 'Enter' || e.key === ' ') {",
" e.preventDefault();",
" ${2:handleClick}(e);",
" }",
" }}",
">",
" ${3:button text}",
"</button>"
],
"description": "创建无障碍按钮"
}
}测试框架集成
单元测试工具
组件级的无障碍测试工具。
代码示例 (Jest + Testing Library):
javascript
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import AccessibleButton from './AccessibleButton';
expect.extend(toHaveNoViolations);
describe('AccessibleButton', () => {
test('通过无障碍测试', async () => {
const { container } = render(
<AccessibleButton onClick={jest.fn()}>
测试按钮
</AccessibleButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('支持键盘交互', async () => {
const handleClick = jest.fn();
render(
<AccessibleButton onClick={handleClick}>
测试按钮
</AccessibleButton>
);
const button = screen.getByRole('button', { name: '测试按钮' });
button.focus();
// 测试Enter键
await userEvent.keyboard('{Enter}');
expect(handleClick).toHaveBeenCalledTimes(1);
// 测试空格键
await userEvent.keyboard(' ');
expect(handleClick).toHaveBeenCalledTimes(2);
});
test('禁用状态正确', () => {
render(
<AccessibleButton disabled onClick={jest.fn()}>
禁用按钮
</AccessibleButton>
);
const button = screen.getByRole('button', { name: '禁用按钮' });
expect(button).toHaveAttribute('aria-disabled', 'true');
expect(button).toHaveAttribute('tabindex', '-1');
});
});端到端测试框架
集成到 E2E 测试中的无障碍检查。
代码示例 (Cypress 测试):
javascript
// cypress/plugins/index.js
const { injectAxe, checkA11y } = require('axe-cypress');
module.exports = (on, config) => {
on('task', {
log(message) {
console.log(message);
return null;
}
});
};
// cypress/support/commands.js
import 'axe-cypress/support';
Cypress.Commands.add('checkAccessibility', (context, options) => {
cy.injectAxe();
cy.checkA11y(context, options);
});
// cypress/integration/accessibility.spec.js
describe('页面无障碍测试', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('首页应通过无障碍测试', () => {
cy.checkA11y();
});
it('表单页面应通过无障碍测试', () => {
cy.visit('/form');
cy.checkA11y(null, {
rules: {
'color-contrast': { enabled: false } // 临时禁用颜色对比度规则
}
});
});
it('模态框应正确处理焦点', () => {
cy.get('[data-testid="open-modal"]').click();
cy.get('[role="dialog"]').should('be.visible');
cy.focused().should('have.attr', 'role', 'dialog');
// 测试焦点陷阱
cy.tab().tab().tab();
cy.focused().should('have.attr', 'data-testid', 'close-modal');
});
});构建时优化
静态分析集成
在构建过程中进行无障碍检查。
代码示例 (Webpack 插件):
javascript
// a11y-webpack-plugin.js
const { axe } = require('axe-core');
const { JSDOM } = require('jsdom');
class A11yWebpackPlugin {
apply(compiler) {
compiler.hooks.emit.tapPromise('A11yWebpackPlugin', async (compilation) => {
const a11yIssues = [];
for (const [filename, source] of Object.entries(compilation.assets)) {
if (filename.endsWith('.html')) {
const dom = new JSDOM(source.source());
const results = await axe.run(dom.window.document);
if (results.violations.length > 0) {
a11yIssues.push({
file: filename,
violations: results.violations
});
}
}
}
if (a11yIssues.length > 0) {
console.log('无障碍问题发现:');
a11yIssues.forEach(({ file, violations }) => {
console.log(`\n${file}:`);
violations.forEach(violation => {
console.log(`- ${violation.help}: ${violation.description}`);
});
});
// 在开发模式下警告,生产模式下报错
if (process.env.NODE_ENV === 'production') {
throw new Error('构建失败:存在无障碍问题');
}
}
});
}
}
module.exports = A11yWebpackPlugin;自动化修复工具
代码示例 (自动 ARIA 属性添加):
javascript
// a11y-auto-fix.js
const cheerio = require('cheerio');
function enhanceAccessibility(html) {
const $ = cheerio.load(html);
// 自动为自定义按钮添加角色
$('[onclick]:not(button):not(a):not(input)').each((i, elem) => {
const $elem = $(elem);
if (!$elem.attr('role')) {
$elem.attr('role', 'button');
$elem.attr('tabindex', '0');
}
});
// 为图片添加alt属性
$('img:not([alt])').each((i, elem) => {
const $elem = $(elem);
const isDecorative = $elem.css('display') === 'none' ||
$elem.attr('width') === '1' ||
$elem.attr('height') === '1';
$elem.attr('alt', isDecorative ? '' : '描述性文本需要手动添加');
});
return $.html();
}监控和分析
运行时监控
在生产环境中监控无障碍问题。
代码示例 (无障碍监控 SDK):
javascript
class A11yMonitor {
constructor() {
this.issues = [];
this.setupMonitoring();
}
setupMonitoring() {
// 监控焦点管理
document.addEventListener('focusin', this.handleFocusChange.bind(this));
// 监控键盘交互
document.addEventListener('keydown', this.handleKeyboardInteraction.bind(this));
// 监控动态内容变化
this.setupMutationObserver();
}
handleFocusChange(event) {
const target = event.target;
// 检查焦点指示器
const style = window.getComputedStyle(target);
if (style.outline === 'none' && style.boxShadow === 'none') {
this.reportIssue('MISSING_FOCUS_INDICATOR', {
element: target.tagName,
selector: this.getSelector(target)
});
}
}
handleKeyboardInteraction(event) {
// 检测可能的键盘陷阱
if (event.key === 'Tab') {
const focusableElements = this.getFocusableElements();
const focusedIndex = focusableElements.indexOf(document.activeElement);
if (focusedIndex === 0 && event.shiftKey) {
this.reportIssue('POSSIBLE_KEYBOARD_TRAP', {
selector: this.getSelector(document.activeElement)
});
}
}
}
setupMutationObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
mutation.attributeName === 'aria-live') {
this.validateLiveRegion(mutation.target);
}
});
});
observer.observe(document.body, {
attributes: true,
subtree: true,
attributeFilter: ['aria-live', 'aria-atomic']
});
}
validateLiveRegion(element) {
const liveValue = element.getAttribute('aria-live');
if (liveValue && !['polite', 'assertive'].includes(liveValue)) {
this.reportIssue('INVALID_ARIA_LIVE', {
value: liveValue,
selector: this.getSelector(element)
});
}
}
getFocusableElements() {
return Array.from(document.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
));
}
getSelector(element) {
if (element.id) return `#${element.id}`;
if (element.className) return `.${element.className.split(' ')[0]}`;
return element.tagName.toLowerCase();
}
reportIssue(type, data) {
const issue = {
type,
data,
timestamp: new Date().toISOString(),
url: window.location.href
};
this.issues.push(issue);
// 发送到监控服务
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/a11y-monitor', JSON.stringify(issue));
}
}
}
// 初始化监控
if (typeof window !== 'undefined') {
window.a11yMonitor = new A11yMonitor();
}数据分析仪表板
收集和分析无障碍指标。
代码示例 (无障碍指标 API):
javascript
// a11y-metrics-api.js
const express = require('express');
const router = express.Router();
// 存储无障碍指标
const a11yMetrics = {
totalPages: 0,
compliantPages: 0,
commonIssues: {},
complianceTrend: []
};
router.post('/report', (req, res) => {
const { pageUrl, violations, timestamp } = req.body;
// 更新指标
a11yMetrics.totalPages++;
if (violations.length === 0) {
a11yMetrics.compliantPages++;
}
// 统计常见问题
violations.forEach(violation => {
const issueId = violation.id;
a11yMetrics.commonIssues[issueId] =
(a11yMetrics.commonIssues[issueId] || 0) + 1;
});
// 计算合规率
const complianceRate = (a11yMetrics.compliantPages / a11yMetrics.totalPages) * 100;
a11yMetrics.complianceTrend.push({
timestamp,
rate: complianceRate
});
res.status(200).json({ success: true });
});
router.get('/metrics', (req, res) => {
res.json({
complianceRate: (a11yMetrics.compliantPages / a11yMetrics.totalPages) * 100,
totalScannedPages: a11yMetrics.totalPages,
commonIssues: Object.entries(a11yMetrics.commonIssues)
.sort(([,a], [,b]) => b - a)
.slice(0, 5)
.map(([issue, count]) => ({ issue, count })),
trend: a11yMetrics.complianceTrend.slice(-30) // 最近30个数据点
});
});
module.exports = router;框架集成模式
多框架适配器
为不同前端框架提供统一的无障碍接口。
代码示例 (框架适配器):
javascript
// a11y-adapter.js
class A11yAdapter {
static forReact(component) {
return {
...component,
a11yProps: this.generateA11yProps(component.props)
};
}
static forVue(component) {
return {
...component,
computed: {
...component.computed,
a11yAttributes() {
return this.generateA11yProps(this.$attrs);
}
}
};
}
static forAngular(directive) {
return {
...directive,
host: {
...directive.host,
'[attr.role]': 'role',
'[attr.aria-label]': 'ariaLabel',
'[attr.tabindex]': 'tabindex'
}
};
}
static generateA11yProps(props) {
const a11yProps = {};
if (props.role) {
a11yProps.role = props.role;
}
if (props.ariaLabel) {
a11yProps['aria-label'] = props.ariaLabel;
}
if (props.disabled) {
a11yProps['aria-disabled'] = true;
a11yProps.tabIndex = -1;
} else {
a11yProps.tabIndex = 0;
}
return a11yProps;
}
}微前端无障碍协调
在微前端架构中协调无障碍状态。
代码示例 (微前端无障碍管理器):
javascript
class MicroFrontendA11yManager {
constructor() {
this.applications = new Map();
this.globalLiveRegions = new Set();
}
registerApplication(appId, a11yConfig) {
this.applications.set(appId, {
config: a11yConfig,
status: 'active',
focusTraps: new Set()
});
}
setApplicationStatus(appId, status) {
const app = this.applications.get(appId);
if (app) {
app.status = status;
// 更新aria-hidden状态
this.updateAriaHiddenStates();
}
}
updateAriaHiddenStates() {
const activeApps = Array.from(this.applications.entries())
.filter(([_, app]) => app.status === 'active');
this.applications.forEach((app, appId) => {
const isActive = app.status === 'active';
const appRoot = document.querySelector(`[data-app="${appId}"]`);
if (appRoot) {
appRoot.setAttribute('aria-hidden', !isActive);
if (!isActive) {
// 清理非活跃应用的焦点陷阱
app.focusTraps.clear();
}
}
});
}
registerFocusTrap(appId, element) {
const app = this.applications.get(appId);
if (app && app.status === 'active') {
app.focusTraps.add(element);
}
}
// 协调多个应用间的实时区域
announce(message, priority = 'polite', appId = 'global') {
const liveRegion = this.getOrCreateLiveRegion(priority);
const announcement = document.createElement('div');
announcement.textContent = `[${appId}] ${message}`;
liveRegion.appendChild(announcement);
setTimeout(() => {
if (announcement.parentNode) {
announcement.parentNode.removeChild(announcement);
}
}, 5000);
}
getOrCreateLiveRegion(priority) {
const id = `global-live-region-${priority}`;
let liveRegion = document.getElementById(id);
if (!liveRegion) {
liveRegion = document.createElement('div');
liveRegion.id = id;
liveRegion.setAttribute('aria-live', priority);
liveRegion.setAttribute('aria-atomic', 'false');
liveRegion.className = 'visually-hidden';
document.body.appendChild(liveRegion);
this.globalLiveRegions.add(liveRegion);
}
return liveRegion;
}
}
// 全局管理器实例
window.microFrontendA11yManager = new MicroFrontendA11yManager();