第二十九章:无障碍访问(A11y)
博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)
第二十九章:无障碍访问(A11y)
1. 为什么关注无障碍
全球约有 15% 的人口有某种形式的残障:
| 障碍类型 | 人口比例 | 对应需求 | |---------|---------|---------| | 视力障碍 | 2.2B | 屏幕阅读器、高对比度、字体缩放 | | 听力障碍 | 430M | 字幕、视觉提示 | | 运动障碍 | 1B | 键盘导航、大点击区域 | | 认知障碍 | 不定 | 清晰文案、一致性布局 |
无障碍 = 更好的用户体验 —— 对所有人都如此。
2. 语义化 HTML
2.1 使用正确元素
// ❌ 错误:全部使用 div
div { class: "nav",
div { class: "nav-item", "首页" }
div { class: "nav-item", "文章" }
}
div { class: "main",
div { class: "article", ... }
}
// ✅ 正确:使用语义化元素
nav { class: "main-nav",
ul {
li { a { href: "/", "首页" } }
li { a { href: "/blog", "文章" } }
}
}
main {
article { ... }
}
2.2 语义元素速查
| 元素 | 用途 | 屏幕阅读器行为 |
|------|------|---------------|
| <nav> | 导航区域 | 快速跳转到导航 |
| <main> | 主内容 | 直接跳转主内容 |
| <article> | 独立文章 | 识别文章边界 |
| <aside> | 侧边栏/补充 | 标记补充内容 |
| <header> | 页眉/标题组 | 识别区域起始 |
| <footer> | 页脚 | 识别区域结束 |
| <section> | 主题分组 | 语义化分区 |
| <button> | 可操作按钮 | 可聚焦、可点击 |
| <a href> | 导航链接 | 可聚焦、跳转 |
| <label> | 表单标签 | 关联输入框 |
3. ARIA 属性
3.1 基础 ARIA
// 按钮(非 button 元素时)
div {
role: "button",
tabindex: "0",
aria_label: "提交表单",
onclick: move |_| submit(),
onkeydown: move |e| if e.key() == Key::Enter { submit() },
"提交"
}
// 导航栏
nav {
aria_label: "主导航",
ul { ... }
}
// 进度条
div {
role: "progressbar",
aria_valuenow: "{progress}",
aria_valuemin: "0",
aria_valuemax: "100",
aria_label: "文章加载进度",
div { class: "h-full bg-blue-500", style: "width: {progress}%;" }
}
// 警告
div {
role: "alert",
aria_live: "assertive",
"表单提交失败,请重试"
}
3.2 动态内容更新
// 告诉屏幕阅读器区域内容会动态更新
#[component]
fn LiveRegion() -> Element {
let messages = use_signal(|| Vec::<String>::new());
rsx! {
div {
// polite: 阅读器完成当前朗读后再通知
// assertive: 立即中断当前朗读
aria_live: "polite",
aria_atomic: "true", // 将区域视为整体
for msg in messages.read().iter() {
p { "{msg}" }
}
}
}
}
3.3 展开/折叠
#[component]
fn Accordion(title: String, children: Element) -> Element {
let expanded = use_signal(|| false);
let panel_id = use_signal(|| format!("panel-{}", rand_id()));
rsx! {
div {
// 标题按钮
button {
aria_expanded: "{expanded}",
aria_controls: "{panel_id}",
onclick: move |_| expanded.toggle(),
"{title}"
span { "{if expanded() { \"▲\" } else { \"▼\" }}" }
}
// 内容面板
div {
id: "{panel_id}",
role: "region",
aria_labelledby: "...",
class: "transition-all",
style: "display: {if expanded() { \"block\" } else { \"none\" }};",
{children}
}
}
}
}
4. 键盘导航
4.1 可聚焦元素
// 确保所有可交互元素都能通过键盘访问
#[component]
fn CustomButton(
label: String,
onclick: EventHandler,
disabled: Option<bool>,
) -> Element {
let is_disabled = disabled.unwrap_or(false);
rsx! {
div {
class: "custom-btn {if is_disabled { \"opacity-50\" } else { \"cursor-pointer\" }}",
role: "button",
tabindex: if is_disabled { "-1" } else { "0" },
aria_disabled: "{is_disabled}",
onclick: move |_| if !is_disabled { onclick.call(()) },
onkeydown: move |e| {
if !is_disabled && (e.key() == Key::Enter || e.key() == Key::Space) {
e.prevent_default();
onclick.call(());
}
},
"{label}"
}
}
}
4.2 焦点管理
// 使用 autofocus 和 ref 管理焦点
#[component]
fn SearchModal(open: Signal<bool>) -> Element {
let search_ref = use_node_ref();
use_effect(move || {
if open() {
// 打开时聚焦到搜索框
if let Some(el) = search_ref.get() {
let _ = el.cast::<web_sys::HtmlInputElement>()
.map(|input| input.focus());
}
}
});
rsx! {
div {
role: "dialog",
aria_label: "搜索文章",
aria_modal: "true",
// 焦点陷阱:Tab 循环在弹窗内
input {
class: "...",
placeholder: "输入关键词搜索...",
onkeydown: move |e| {
if e.key() == Key::Escape {
open.set(false);
}
},
}
}
}
}
// 焦点跳转链接 —— 跳过导航直接到主内容
a {
class: "skip-link sr-only focus:not-sr-only",
href: "#main-content",
"跳转到主内容"
}
main { id: "main-content", ... }
4.3 Tab 键顺序
// 用 tabindex 控制 Tab 顺序
input { tabindex: "1", placeholder: "用户名" }
input { tabindex: "2", r#type: "password", placeholder: "密码" }
button { tabindex: "3", "登录" }
// tabindex="0" 按照文档顺序
// tabindex="-1" 不可 Tab 聚焦,但可 JS 聚焦
5. 色彩与对比度
5.1 对比度要求
WCAG 2.1 要求:
| 级别 | AA | AAA | |------|----|-----| | 普通文本 | ≥ 4.5:1 | ≥ 7:1 | | 大文本 (≥18px/14px bold) | ≥ 3:1 | ≥ 4.5:1 | | UI 组件 | ≥ 3:1 | ≥ 3:1 |
5.2 CSS 变量中的对比度
:root {
--text: #4c4f69; /* 与背景 #eff1f5 对比度约 7.2:1 ✅ AA/AAA */
--tertiary: #9ca0b0; /* 与背景 #eff1f5 对比度约 3.5:1 ✅ AA(大文本) */
--border: #ccd0da; /* 装饰性,非必需 */
}
[data-theme="dark"] {
--text: #cdd6f4; /* 与背景 #1e1e2e 对比度约 9.5:1 ✅ */
--tertiary: #6c7086; /* 与背景 #1e1e2e 对比度约 4.2:1 ✅ AA */
}
5.3 不只是颜色
// ❌ 仅用颜色区分状态
span { class: "text-red-500", "错误" }
// ✅ 颜色 + 图标 + 文字
span { class: "text-red-500 flex items-center gap-1",
span { aria_hidden: "true", "✗" }
"错误:邮箱格式不正确"
}
// 链接不仅靠颜色区分
a {
class: "underline decoration-1",
style: "color: var(--primary);",
"了解更多"
}
6. 屏幕阅读器支持
6.1 仅屏幕阅读器可见的内容
/* 在 main.css 中 */
.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;
}
// 使用
span { class: "sr-only", "当前页面:首页" }
// 图标的文字替代
button {
aria_label: "切换到暗夜模式",
span { aria_hidden: "true", "🌙" }
span { class: "sr-only", "暗夜模式" }
}
6.2 文章朗读优化
#[component]
fn ArticleContent(html: String) -> Element {
rsx! {
article {
// 文章结构让阅读器更好朗读
header {
h1 { "文章标题" }
div { class: "sr-only",
"发布时间:2024年1月15日"
"作者:Rabit Logic"
"阅读时间:5分钟"
}
}
div {
class: "prose-content",
// 为阅读器添加更清晰的段落标记
role: "article",
dangerous_inner_html: "{html}",
}
}
}
}
7. 表单无障碍
#[component]
fn AccessibleForm() -> Element {
rsx! {
form {
// 表单整体描述
aria_label: "登录表单",
// 方式一:label 关联(推荐)
div { class: "mb-4",
label {
r#for: "username-input",
class: "block text-sm font-medium mb-1",
"用户名"
}
input {
id: "username-input",
r#type: "text",
required: true,
aria_required: "true",
aria_describedby: "username-hint",
}
p { id: "username-hint", class: "text-xs text-gray-500",
"3-20个字符,只能包含字母和数字"
}
}
// 方式二:aria-label(无可见标签时)
input {
aria_label: "搜索文章",
placeholder: "搜索...",
}
// 错误提示
div {
role: "alert",
aria_live: "assertive",
// 错误信息列表
}
}
}
}
8. 动画与运动偏好
// 尊重用户的运动偏好
#[component]
fn AnimatedElement(children: Element) -> Element {
let prefers_reduced = use_signal(|| false);
use_effect(move || {
// 检测 prefers-reduced-motion
let query = web_sys::window()
.unwrap()
.match_media("(prefers-reduced-motion: reduce)")
.ok()
.flatten();
if let Some(mq) = query {
prefers_reduced.set(mq.matches());
// 监听变化
}
});
rsx! {
div {
class: "transition-all",
style: "
transition-duration: {if prefers_reduced() { \"0ms\" } else { \"300ms\" }};
transform: {if prefers_reduced() { \"none\" } else { \"...\" }};
",
{children}
}
}
}
CSS 中处理:
/* 用户要求减少动画时禁用 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
9. 自动化检查
9.1 axe-core 集成
// 在 E2E 测试中
const { injectAxe, checkA11y } = require('axe-playwright');
test('首页无障碍检查', async ({ page }) => {
await page.goto('http://localhost:5051');
await injectAxe(page);
const results = await checkA11y(page);
expect(results.violations).toHaveLength(0);
});
9.2 CI 无障碍检查
# .github/workflows/a11y.yml
name: Accessibility Check
on: [pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run pa11y
run: |
npx pa11y-ci --sitemap http://localhost:5051/sitemap.xml
10. 无障碍检查清单
- [ ] 语义化 HTML 元素(nav, main, article, aside)
- [ ] 所有图片有
alt属性 - [ ] 表单有
label关联 - [ ] 颜色对比度 ≥ 4.5:1
- [ ] 所有交互元素可键盘操作
- [ ] 焦点顺序合理
- [ ] 弹窗有焦点陷阱
- [ ] 动态内容有
aria-live - [ ] 使用
sr-only提供额外上下文 - [ ] 错误提示关联到输入框
- [ ] 动画尊重
prefers-reduced-motion - [ ] 页面标题
<title>唯一且描述性 - [ ] 页面语言
<html lang="zh-CN">正确 - [ ] axe-core 检查无违规
11. 小结
- 无障碍不是功能,而是基本人权和质量指标
- 语义化 HTML 是无障碍的基础,正确使用
<nav>、<main>、<article>等 - ARIA 属性补充语义(
aria-label、aria-expanded、aria-live) - 键盘导航确保所有功能可通过 Tab + Enter 操作
- 颜色对比度满足 WCAG AA 标准(≥ 4.5:1)
sr-only类为屏幕阅读器提供额外信息- 使用
prefers-reduced-motion尊重用户运动偏好 - 自动化工具(axe-core、pa11y)在 CI 中拦截无障碍回归
dioxusa11yaccessibilityariakeyboardinclusive