第二十九章:无障碍访问(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-labelaria-expandedaria-live
  • 键盘导航确保所有功能可通过 Tab + Enter 操作
  • 颜色对比度满足 WCAG AA 标准(≥ 4.5:1)
  • sr-only 类为屏幕阅读器提供额外信息
  • 使用 prefers-reduced-motion 尊重用户运动偏好
  • 自动化工具(axe-core、pa11y)在 CI 中拦截无障碍回归
dioxusa11yaccessibilityariakeyboardinclusive