本章概述
可访问性(Accessibility,简称 a11y)是现代 Web 开发中不可忽视的重要方面。本章将深入探讨如何使用 Bootstrap 创建无障碍的 Web 应用,确保所有用户,包括残障人士,都能平等地访问和使用我们的网站。
学习目标
- 理解 Web 可访问性的重要性和基本原则
- 掌握 Bootstrap 中的可访问性特性
- 学会使用 ARIA 标签和属性
- 实现键盘导航和屏幕阅读器支持
- 掌握颜色对比度和视觉设计的可访问性
- 学会测试和验证可访问性
Web 可访问性基础
1. 可访问性原则 (WCAG 2.1)
Web 内容可访问性指南(WCAG)定义了四个基本原则:
POUR 原则
可感知(Perceivable)
- 信息和用户界面组件必须以用户能够感知的方式呈现
- 提供替代文本、字幕、音频描述等
可操作(Operable)
- 用户界面组件和导航必须是可操作的
- 支持键盘导航、避免引起癫痫的内容
可理解(Understandable)
- 信息和用户界面的操作必须是可理解的
- 使用清晰的语言、提供帮助信息
健壮(Robust)
- 内容必须足够健壮,能够被各种用户代理解释
- 使用有效的 HTML、支持辅助技术
2. 常见的可访问性障碍
<!-- 可访问性问题示例 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>可访问性问题示例</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
/* 问题:颜色对比度不足 */
.low-contrast {
color: #999;
background-color: #ccc;
}
/* 问题:仅依赖颜色传达信息 */
.error-text {
color: red;
}
/* 问题:字体太小 */
.tiny-text {
font-size: 10px;
}
</style>
</head>
<body>
<div class="container mt-4">
<!-- 问题:缺少语义化标签 -->
<div class="header">
<div class="title">网站标题</div>
<div class="nav">
<div class="nav-item">首页</div>
<div class="nav-item">关于</div>
<div class="nav-item">联系</div>
</div>
</div>
<!-- 问题:缺少替代文本 -->
<img src="important-chart.png" class="img-fluid">
<!-- 问题:不可访问的表单 -->
<form>
<input type="text" placeholder="请输入姓名">
<input type="email" placeholder="请输入邮箱">
<button type="submit">提交</button>
</form>
<!-- 问题:不可访问的模态框 -->
<button class="btn btn-primary" onclick="showModal()">打开模态框</button>
<div id="myModal" style="display: none;">
<div>模态框内容</div>
<button onclick="hideModal()">关闭</button>
</div>
<!-- 问题:低对比度文本 -->
<p class="low-contrast">这段文字的对比度不足,难以阅读。</p>
<!-- 问题:仅用颜色表示错误 -->
<p class="error-text">这是错误信息</p>
<!-- 问题:字体过小 -->
<p class="tiny-text">这段文字太小了</p>
</div>
<script>
function showModal() {
document.getElementById('myModal').style.display = 'block';
}
function hideModal() {
document.getElementById('myModal').style.display = 'none';
}
</script>
</body>
</html>
Bootstrap 可访问性特性
1. 语义化 HTML 结构
Bootstrap 鼓励使用语义化的 HTML 元素:
<!-- 正确的语义化结构 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>可访问的网站结构</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<!-- 使用 header 元素 -->
<header class="bg-primary text-white">
<div class="container">
<nav class="navbar navbar-expand-lg navbar-dark">
<a class="navbar-brand" href="#">网站名称</a>
<button class="navbar-toggler" type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="切换导航">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">首页</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">关于我们</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">服务</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">联系我们</a>
</li>
</ul>
</div>
</nav>
</div>
</header>
<!-- 使用 main 元素 -->
<main class="container my-5">
<!-- 使用 section 和 article 元素 -->
<section aria-labelledby="welcome-heading">
<h1 id="welcome-heading">欢迎来到我们的网站</h1>
<p class="lead">这是一个注重可访问性的网站示例。</p>
</section>
<section aria-labelledby="services-heading">
<h2 id="services-heading">我们的服务</h2>
<div class="row">
<article class="col-md-4">
<div class="card h-100">
<img src="service1.jpg" class="card-img-top" alt="网站开发服务图片">
<div class="card-body">
<h3 class="card-title">网站开发</h3>
<p class="card-text">专业的网站开发服务,注重用户体验和可访问性。</p>
</div>
</div>
</article>
<article class="col-md-4">
<div class="card h-100">
<img src="service2.jpg" class="card-img-top" alt="移动应用开发服务图片">
<div class="card-body">
<h3 class="card-title">移动应用</h3>
<p class="card-text">跨平台移动应用开发,支持 iOS 和 Android。</p>
</div>
</div>
</article>
<article class="col-md-4">
<div class="card h-100">
<img src="service3.jpg" class="card-img-top" alt="UI/UX设计服务图片">
<div class="card-body">
<h3 class="card-title">UI/UX 设计</h3>
<p class="card-text">用户界面和用户体验设计,注重可用性和美观性。</p>
</div>
</div>
</article>
</div>
</section>
</main>
<!-- 使用 footer 元素 -->
<footer class="bg-dark text-white py-4">
<div class="container">
<div class="row">
<div class="col-md-6">
<h4>联系信息</h4>
<address>
<p>地址:北京市朝阳区某某街道123号</p>
<p>电话:<a href="tel:+8610-12345678" class="text-white">010-12345678</a></p>
<p>邮箱:<a href="mailto:info@example.com" class="text-white">info@example.com</a></p>
</address>
</div>
<div class="col-md-6">
<h4>快速链接</h4>
<ul class="list-unstyled">
<li><a href="#" class="text-white">隐私政策</a></li>
<li><a href="#" class="text-white">服务条款</a></li>
<li><a href="#" class="text-white">网站地图</a></li>
</ul>
</div>
</div>
<hr class="my-3">
<div class="text-center">
<p>© 2024 公司名称. 保留所有权利。</p>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
2. 内置的 ARIA 支持
Bootstrap 组件内置了许多 ARIA 属性:
<!-- Bootstrap 组件的 ARIA 支持 -->
<div class="container mt-4">
<!-- 折叠面板 (Accordion) -->
<div class="accordion" id="accessibleAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button" type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseOne"
aria-expanded="true"
aria-controls="collapseOne">
第一个折叠项
</button>
</h2>
<div id="collapseOne"
class="accordion-collapse collapse show"
aria-labelledby="headingOne"
data-bs-parent="#accessibleAccordion">
<div class="accordion-body">
这是第一个折叠项的内容。Bootstrap 自动添加了适当的 ARIA 属性。
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingTwo">
<button class="accordion-button collapsed" type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseTwo"
aria-expanded="false"
aria-controls="collapseTwo">
第二个折叠项
</button>
</h2>
<div id="collapseTwo"
class="accordion-collapse collapse"
aria-labelledby="headingTwo"
data-bs-parent="#accessibleAccordion">
<div class="accordion-body">
这是第二个折叠项的内容。
</div>
</div>
</div>
</div>
<!-- 标签页 (Tabs) -->
<div class="mt-5">
<ul class="nav nav-tabs" id="accessibleTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active"
id="home-tab"
data-bs-toggle="tab"
data-bs-target="#home"
type="button"
role="tab"
aria-controls="home"
aria-selected="true">
首页
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link"
id="profile-tab"
data-bs-toggle="tab"
data-bs-target="#profile"
type="button"
role="tab"
aria-controls="profile"
aria-selected="false">
个人资料
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link"
id="contact-tab"
data-bs-toggle="tab"
data-bs-target="#contact"
type="button"
role="tab"
aria-controls="contact"
aria-selected="false">
联系方式
</button>
</li>
</ul>
<div class="tab-content" id="accessibleTabsContent">
<div class="tab-pane fade show active"
id="home"
role="tabpanel"
aria-labelledby="home-tab">
<div class="p-3">
<h3>首页内容</h3>
<p>这是首页标签的内容。</p>
</div>
</div>
<div class="tab-pane fade"
id="profile"
role="tabpanel"
aria-labelledby="profile-tab">
<div class="p-3">
<h3>个人资料</h3>
<p>这是个人资料标签的内容。</p>
</div>
</div>
<div class="tab-pane fade"
id="contact"
role="tabpanel"
aria-labelledby="contact-tab">
<div class="p-3">
<h3>联系方式</h3>
<p>这是联系方式标签的内容。</p>
</div>
</div>
</div>
</div>
<!-- 模态框 (Modal) -->
<div class="mt-5">
<button type="button"
class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#accessibleModal">
打开可访问的模态框
</button>
<div class="modal fade"
id="accessibleModal"
tabindex="-1"
aria-labelledby="accessibleModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="accessibleModalLabel">可访问的模态框</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="关闭">
</button>
</div>
<div class="modal-body">
<p>这是一个可访问的模态框示例。它包含了适当的 ARIA 属性和键盘导航支持。</p>
<form>
<div class="mb-3">
<label for="modalInput" class="form-label">输入内容</label>
<input type="text" class="form-control" id="modalInput">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary">保存</button>
</div>
</div>
</div>
</div>
</div>
</div>
ARIA 标签和属性详解
1. 常用 ARIA 属性
<!-- ARIA 属性详解 -->
<div class="container mt-4">
<!-- aria-label: 为元素提供可访问的名称 -->
<button class="btn btn-primary" aria-label="关闭对话框">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<!-- aria-labelledby: 引用其他元素作为标签 -->
<div class="card">
<div class="card-header">
<h3 id="card-title">用户信息</h3>
</div>
<div class="card-body" aria-labelledby="card-title">
<p>这里是用户的详细信息。</p>
</div>
</div>
<!-- aria-describedby: 引用描述性文本 -->
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password"
class="form-control"
id="password"
aria-describedby="passwordHelp">
<div id="passwordHelp" class="form-text">
密码必须包含至少8个字符,包括大小写字母和数字。
</div>
</div>
<!-- aria-expanded: 表示可折叠元素的状态 -->
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle"
type="button"
id="dropdownMenuButton"
data-bs-toggle="dropdown"
aria-expanded="false">
下拉菜单
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
<li><a class="dropdown-item" href="#">操作1</a></li>
<li><a class="dropdown-item" href="#">操作2</a></li>
<li><a class="dropdown-item" href="#">操作3</a></li>
</ul>
</div>
<!-- aria-hidden: 隐藏装饰性元素 -->
<p>
<i class="fas fa-star" aria-hidden="true"></i>
重要信息
</p>
<!-- aria-live: 动态内容更新通知 -->
<div class="alert alert-info" role="alert" aria-live="polite" id="statusMessage">
状态信息将在这里显示
</div>
<!-- aria-invalid: 表示表单字段验证状态 -->
<div class="mb-3">
<label for="email" class="form-label">邮箱地址</label>
<input type="email"
class="form-control is-invalid"
id="email"
aria-invalid="true"
aria-describedby="emailError">
<div id="emailError" class="invalid-feedback">
请输入有效的邮箱地址。
</div>
</div>
<!-- role 属性: 定义元素的语义角色 -->
<div role="alert" class="alert alert-danger">
这是一个重要的错误消息。
</div>
<nav role="navigation" aria-label="面包屑导航">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#">首页</a></li>
<li class="breadcrumb-item"><a href="#">产品</a></li>
<li class="breadcrumb-item active" aria-current="page">详情</li>
</ol>
</nav>
</div>
<script>
// 动态更新 aria-live 区域
function updateStatus(message) {
const statusElement = document.getElementById('statusMessage');
statusElement.textContent = message;
}
// 示例:3秒后更新状态
setTimeout(() => {
updateStatus('操作已成功完成!');
}, 3000);
</script>
2. 复杂组件的 ARIA 实现
<!-- 复杂组件的可访问性实现 -->
<div class="container mt-4">
<!-- 可访问的数据表格 -->
<div class="table-responsive">
<table class="table table-striped"
role="table"
aria-label="用户数据表">
<caption class="visually-hidden">
用户数据表,包含姓名、邮箱、角色和操作列
</caption>
<thead>
<tr role="row">
<th scope="col" role="columnheader">
<button class="btn btn-link p-0 text-start"
aria-label="按姓名排序">
姓名
<i class="fas fa-sort" aria-hidden="true"></i>
</button>
</th>
<th scope="col" role="columnheader">
<button class="btn btn-link p-0 text-start"
aria-label="按邮箱排序">
邮箱
<i class="fas fa-sort" aria-hidden="true"></i>
</button>
</th>
<th scope="col" role="columnheader">角色</th>
<th scope="col" role="columnheader">操作</th>
</tr>
</thead>
<tbody>
<tr role="row">
<td role="cell">张三</td>
<td role="cell">zhangsan@example.com</td>
<td role="cell">
<span class="badge bg-primary">管理员</span>
</td>
<td role="cell">
<div class="btn-group" role="group" aria-label="用户操作">
<button class="btn btn-sm btn-outline-primary"
aria-label="编辑张三的信息">
<i class="fas fa-edit" aria-hidden="true"></i>
编辑
</button>
<button class="btn btn-sm btn-outline-danger"
aria-label="删除张三">
<i class="fas fa-trash" aria-hidden="true"></i>
删除
</button>
</div>
</td>
</tr>
<tr role="row">
<td role="cell">李四</td>
<td role="cell">lisi@example.com</td>
<td role="cell">
<span class="badge bg-secondary">用户</span>
</td>
<td role="cell">
<div class="btn-group" role="group" aria-label="用户操作">
<button class="btn btn-sm btn-outline-primary"
aria-label="编辑李四的信息">
<i class="fas fa-edit" aria-hidden="true"></i>
编辑
</button>
<button class="btn btn-sm btn-outline-danger"
aria-label="删除李四">
<i class="fas fa-trash" aria-hidden="true"></i>
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 可访问的分页组件 -->
<nav aria-label="页面导航">
<ul class="pagination justify-content-center">
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1" aria-disabled="true">
<span aria-hidden="true">«</span>
<span class="visually-hidden">上一页</span>
</a>
</li>
<li class="page-item active" aria-current="page">
<a class="page-link" href="#">
1
<span class="visually-hidden">(当前页)</span>
</a>
</li>
<li class="page-item">
<a class="page-link" href="#" aria-label="转到第2页">2</a>
</li>
<li class="page-item">
<a class="page-link" href="#" aria-label="转到第3页">3</a>
</li>
<li class="page-item">
<a class="page-link" href="#" aria-label="下一页">
<span aria-hidden="true">»</span>
<span class="visually-hidden">下一页</span>
</a>
</li>
</ul>
</nav>
<!-- 可访问的进度条 -->
<div class="mb-3">
<label for="file-upload-progress" class="form-label">文件上传进度</label>
<div class="progress" role="progressbar"
aria-valuenow="75"
aria-valuemin="0"
aria-valuemax="100"
aria-labelledby="file-upload-progress">
<div class="progress-bar" style="width: 75%">
<span class="visually-hidden">75% 完成</span>
</div>
</div>
<div class="form-text">已上传 75%</div>
</div>
<!-- 可访问的警告框 -->
<div class="alert alert-warning alert-dismissible fade show"
role="alert"
aria-live="assertive">
<strong>注意!</strong> 这是一个重要的警告信息。
<button type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="关闭警告">
</button>
</div>
</div>
键盘导航支持
1. 键盘导航基础
<!-- 键盘导航支持 -->
<div class="container mt-4">
<!-- 跳转链接 -->
<a href="#main-content" class="visually-hidden-focusable">跳转到主要内容</a>
<a href="#navigation" class="visually-hidden-focusable">跳转到导航</a>
<nav id="navigation" class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">网站名称</a>
<div class="navbar-nav">
<a class="nav-link" href="#" tabindex="0">首页</a>
<a class="nav-link" href="#" tabindex="0">关于</a>
<a class="nav-link" href="#" tabindex="0">服务</a>
<a class="nav-link" href="#" tabindex="0">联系</a>
</div>
</div>
</nav>
<main id="main-content" class="mt-4">
<!-- 可键盘操作的卡片组 -->
<div class="row">
<div class="col-md-4">
<div class="card keyboard-card"
tabindex="0"
role="button"
aria-label="产品A详情">
<img src="product-a.jpg" class="card-img-top" alt="产品A图片">
<div class="card-body">
<h5 class="card-title">产品A</h5>
<p class="card-text">这是产品A的描述信息。</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card keyboard-card"
tabindex="0"
role="button"
aria-label="产品B详情">
<img src="product-b.jpg" class="card-img-top" alt="产品B图片">
<div class="card-body">
<h5 class="card-title">产品B</h5>
<p class="card-text">这是产品B的描述信息。</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card keyboard-card"
tabindex="0"
role="button"
aria-label="产品C详情">
<img src="product-c.jpg" class="card-img-top" alt="产品C图片">
<div class="card-body">
<h5 class="card-title">产品C</h5>
<p class="card-text">这是产品C的描述信息。</p>
</div>
</div>
</div>
</div>
<!-- 可键盘操作的表单 -->
<form class="mt-5" novalidate>
<fieldset>
<legend>联系表单</legend>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="firstName" class="form-label">姓名 *</label>
<input type="text"
class="form-control"
id="firstName"
required
aria-required="true">
<div class="invalid-feedback">
请输入您的姓名。
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="email" class="form-label">邮箱 *</label>
<input type="email"
class="form-control"
id="email"
required
aria-required="true">
<div class="invalid-feedback">
请输入有效的邮箱地址。
</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="subject" class="form-label">主题</label>
<select class="form-select" id="subject">
<option value="">请选择主题</option>
<option value="general">一般咨询</option>
<option value="support">技术支持</option>
<option value="sales">销售咨询</option>
</select>
</div>
<div class="mb-3">
<label for="message" class="form-label">消息</label>
<textarea class="form-control"
id="message"
rows="4"
aria-describedby="messageHelp"></textarea>
<div id="messageHelp" class="form-text">
请详细描述您的问题或需求。
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input"
type="checkbox"
id="newsletter"
value="yes">
<label class="form-check-label" for="newsletter">
订阅我们的新闻通讯
</label>
</div>
</div>
<div class="mb-3">
<fieldset>
<legend class="form-label">联系方式偏好</legend>
<div class="form-check">
<input class="form-check-input"
type="radio"
name="contactPreference"
id="preferEmail"
value="email"
checked>
<label class="form-check-label" for="preferEmail">
邮箱
</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
name="contactPreference"
id="preferPhone"
value="phone">
<label class="form-check-label" for="preferPhone">
电话
</label>
</div>
</fieldset>
</div>
<button type="submit" class="btn btn-primary">提交</button>
<button type="reset" class="btn btn-secondary ms-2">重置</button>
</fieldset>
</form>
</main>
</div>
<style>
/* 键盘焦点样式 */
.keyboard-card:focus {
outline: 2px solid #0d6efd;
outline-offset: 2px;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* 跳转链接样式 */
.visually-hidden-focusable:not(:focus):not(:focus-within) {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.visually-hidden-focusable:focus {
position: static !important;
width: auto !important;
height: auto !important;
padding: 0.25rem 0.5rem !important;
margin: 0 !important;
overflow: visible !important;
clip: auto !important;
white-space: normal !important;
background-color: #0d6efd;
color: white;
text-decoration: none;
border-radius: 0.25rem;
}
</style>
<script>
// 键盘导航支持
document.addEventListener('DOMContentLoaded', function() {
// 为卡片添加键盘事件
const cards = document.querySelectorAll('.keyboard-card');
cards.forEach(card => {
card.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
// 模拟点击事件
console.log('卡片被激活:', this.getAttribute('aria-label'));
// 这里可以添加实际的点击处理逻辑
}
});
});
// 表单验证
const form = document.querySelector('form');
form.addEventListener('submit', function(e) {
e.preventDefault();
const firstName = document.getElementById('firstName');
const email = document.getElementById('email');
let isValid = true;
// 验证姓名
if (!firstName.value.trim()) {
firstName.classList.add('is-invalid');
firstName.setAttribute('aria-invalid', 'true');
isValid = false;
} else {
firstName.classList.remove('is-invalid');
firstName.setAttribute('aria-invalid', 'false');
}
// 验证邮箱
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email.value.trim() || !emailPattern.test(email.value)) {
email.classList.add('is-invalid');
email.setAttribute('aria-invalid', 'true');
isValid = false;
} else {
email.classList.remove('is-invalid');
email.setAttribute('aria-invalid', 'false');
}
if (isValid) {
alert('表单提交成功!');
} else {
// 将焦点移到第一个错误字段
const firstError = form.querySelector('.is-invalid');
if (firstError) {
firstError.focus();
}
}
});
});
</script>
2. 高级键盘导航模式
// advanced-keyboard-navigation.js
// 高级键盘导航管理器
class AdvancedKeyboardNavigation {
constructor() {
this.focusableElements = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]'
].join(', ');
this.init();
}
// 初始化键盘导航
init() {
this.setupFocusManagement();
this.setupArrowKeyNavigation();
this.setupEscapeKeyHandling();
this.setupFocusTrapping();
}
// 设置焦点管理
setupFocusManagement() {
// 为所有可聚焦元素添加焦点指示器
document.addEventListener('focusin', (e) => {
this.showFocusIndicator(e.target);
});
document.addEventListener('focusout', (e) => {
this.hideFocusIndicator(e.target);
});
// 检测键盘导航
document.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
document.body.classList.add('keyboard-navigation');
}
});
document.addEventListener('mousedown', () => {
document.body.classList.remove('keyboard-navigation');
});
}
// 设置箭头键导航
setupArrowKeyNavigation() {
// 为网格布局添加箭头键导航
document.addEventListener('keydown', (e) => {
const target = e.target;
if (target.hasAttribute('data-grid-navigation')) {
this.handleGridNavigation(e, target);
}
if (target.hasAttribute('data-list-navigation')) {
this.handleListNavigation(e, target);
}
});
}
// 处理网格导航
handleGridNavigation(e, target) {
const container = target.closest('[data-grid-container]');
if (!container) return;
const items = Array.from(container.querySelectorAll('[data-grid-navigation]'));
const currentIndex = items.indexOf(target);
const columns = parseInt(container.getAttribute('data-grid-columns')) || 3;
let newIndex = currentIndex;
switch (e.key) {
case 'ArrowRight':
newIndex = Math.min(currentIndex + 1, items.length - 1);
break;
case 'ArrowLeft':
newIndex = Math.max(currentIndex - 1, 0);
break;
case 'ArrowDown':
newIndex = Math.min(currentIndex + columns, items.length - 1);
break;
case 'ArrowUp':
newIndex = Math.max(currentIndex - columns, 0);
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = items.length - 1;
break;
default:
return;
}
if (newIndex !== currentIndex) {
e.preventDefault();
items[newIndex].focus();
}
}
// 处理列表导航
handleListNavigation(e, target) {
const container = target.closest('[data-list-container]');
if (!container) return;
const items = Array.from(container.querySelectorAll('[data-list-navigation]'));
const currentIndex = items.indexOf(target);
let newIndex = currentIndex;
switch (e.key) {
case 'ArrowDown':
newIndex = (currentIndex + 1) % items.length;
break;
case 'ArrowUp':
newIndex = (currentIndex - 1 + items.length) % items.length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = items.length - 1;
break;
default:
return;
}
if (newIndex !== currentIndex) {
e.preventDefault();
items[newIndex].focus();
}
}
// 设置 Escape 键处理
setupEscapeKeyHandling() {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.handleEscapeKey(e);
}
});
}
// 处理 Escape 键
handleEscapeKey(e) {
// 关闭模态框
const openModal = document.querySelector('.modal.show');
if (openModal) {
const closeButton = openModal.querySelector('[data-bs-dismiss="modal"]');
if (closeButton) {
closeButton.click();
}
return;
}
// 关闭下拉菜单
const openDropdown = document.querySelector('.dropdown-menu.show');
if (openDropdown) {
const toggle = document.querySelector('[data-bs-toggle="dropdown"][aria-expanded="true"]');
if (toggle) {
toggle.click();
}
return;
}
// 退出搜索模式
const searchInput = document.querySelector('input[type="search"]:focus');
if (searchInput && searchInput.value) {
searchInput.value = '';
searchInput.dispatchEvent(new Event('input'));
return;
}
}
// 设置焦点陷阱
setupFocusTrapping() {
// 为模态框设置焦点陷阱
document.addEventListener('shown.bs.modal', (e) => {
this.trapFocus(e.target);
});
document.addEventListener('hidden.bs.modal', (e) => {
this.releaseFocus(e.target);
});
}
// 陷阱焦点
trapFocus(container) {
const focusableElements = container.querySelectorAll(this.focusableElements);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// 设置初始焦点
if (firstElement) {
firstElement.focus();
}
// 添加键盘事件监听器
const trapHandler = (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
container.addEventListener('keydown', trapHandler);
container._focusTrapHandler = trapHandler;
}
// 释放焦点
releaseFocus(container) {
if (container._focusTrapHandler) {
container.removeEventListener('keydown', container._focusTrapHandler);
delete container._focusTrapHandler;
}
// 恢复之前的焦点
const trigger = document.querySelector('[data-bs-target="#' + container.id + '"]');
if (trigger) {
trigger.focus();
}
}
// 显示焦点指示器
showFocusIndicator(element) {
if (document.body.classList.contains('keyboard-navigation')) {
element.classList.add('keyboard-focus');
}
}
// 隐藏焦点指示器
hideFocusIndicator(element) {
element.classList.remove('keyboard-focus');
}
// 获取所有可聚焦元素
getFocusableElements(container = document) {
return Array.from(container.querySelectorAll(this.focusableElements))
.filter(element => {
return element.offsetWidth > 0 &&
element.offsetHeight > 0 &&
!element.disabled &&
element.tabIndex !== -1;
});
}
// 移动焦点到下一个元素
focusNext(currentElement) {
const focusableElements = this.getFocusableElements();
const currentIndex = focusableElements.indexOf(currentElement);
const nextIndex = (currentIndex + 1) % focusableElements.length;
focusableElements[nextIndex].focus();
}
// 移动焦点到上一个元素
focusPrevious(currentElement) {
const focusableElements = this.getFocusableElements();
const currentIndex = focusableElements.indexOf(currentElement);
const previousIndex = (currentIndex - 1 + focusableElements.length) % focusableElements.length;
focusableElements[previousIndex].focus();
}
// 移动焦点到第一个元素
focusFirst(container = document) {
const focusableElements = this.getFocusableElements(container);
if (focusableElements.length > 0) {
focusableElements[0].focus();
}
}
// 移动焦点到最后一个元素
focusLast(container = document) {
const focusableElements = this.getFocusableElements(container);
if (focusableElements.length > 0) {
focusableElements[focusableElements.length - 1].focus();
}
}
}
// 创建全局实例
const keyboardNavigation = new AdvancedKeyboardNavigation();
// 导出
export default keyboardNavigation;
颜色对比度与视觉设计
1. 颜色对比度标准
<!-- 颜色对比度示例 -->
<div class="container mt-4">
<h2>颜色对比度示例</h2>
<!-- 良好的对比度 -->
<div class="row mb-4">
<div class="col-md-6">
<h3>良好的对比度 (WCAG AA 级别)</h3>
<div class="p-3 mb-3" style="background-color: #ffffff; color: #212529;">
<strong>对比度比例: 16.75:1</strong><br>
这是黑色文字在白色背景上的示例,具有极佳的对比度。
</div>
<div class="p-3 mb-3" style="background-color: #0d6efd; color: #ffffff;">
<strong>对比度比例: 5.9:1</strong><br>
这是白色文字在 Bootstrap 主色调背景上的示例。
</div>
<div class="p-3 mb-3" style="background-color: #198754; color: #ffffff;">
<strong>对比度比例: 4.7:1</strong><br>
这是白色文字在绿色背景上的示例。
</div>
</div>
<div class="col-md-6">
<h3>对比度不足的示例 (避免使用)</h3>
<div class="p-3 mb-3" style="background-color: #f8f9fa; color: #adb5bd;">
<strong>对比度比例: 2.3:1 ❌</strong><br>
这个对比度太低,难以阅读。
</div>
<div class="p-3 mb-3" style="background-color: #ffc107; color: #ffffff;">
<strong>对比度比例: 1.8:1 ❌</strong><br>
黄色背景上的白色文字对比度不足。
</div>
<div class="p-3 mb-3" style="background-color: #20c997; color: #28a745;">
<strong>对比度比例: 1.2:1 ❌</strong><br>
相似颜色之间的对比度极低。
</div>
</div>
</div>
<!-- 改进的颜色方案 -->
<div class="row">
<div class="col-12">
<h3>改进的颜色方案</h3>
<div class="alert alert-success" role="alert">
<strong>成功!</strong> 操作已成功完成。
<button type="button" class="btn btn-outline-success btn-sm ms-2">查看详情</button>
</div>
<div class="alert alert-warning" role="alert">
<strong>警告!</strong> 请注意以下事项。
<button type="button" class="btn btn-outline-dark btn-sm ms-2">了解更多</button>
</div>
<div class="alert alert-danger" role="alert">
<strong>错误!</strong> 操作失败,请重试。
<button type="button" class="btn btn-outline-danger btn-sm ms-2">重试</button>
</div>
</div>
</div>
</div>
2. 可访问的颜色系统
// accessible-colors.scss
// 可访问的颜色系统
// 基础颜色定义
$white: #ffffff;
$black: #000000;
$gray-100: #f8f9fa;
$gray-200: #e9ecef;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #6c757d;
$gray-700: #495057;
$gray-800: #343a40;
$gray-900: #212529;
// 主题颜色 (符合 WCAG AA 标准)
$primary: #0d6efd;
$primary-dark: #0a58ca; // 用于白色文字
$secondary: #6c757d;
$success: #198754;
$info: #0dcaf0;
$info-dark: #087990; // 用于白色文字
$warning: #fd7e14; // 改进的警告色
$warning-dark: #e55100; // 用于白色文字
$danger: #dc3545;
$light: #f8f9fa;
$dark: #212529;
// 对比度检查函数
@function contrast-ratio($color1, $color2) {
$l1: luminance($color1);
$l2: luminance($color2);
@if $l1 > $l2 {
@return ($l1 + 0.05) / ($l2 + 0.05);
} @else {
@return ($l2 + 0.05) / ($l1 + 0.05);
}
}
@function luminance($color) {
$red: red($color) / 255;
$green: green($color) / 255;
$blue: blue($color) / 255;
$red: if($red <= 0.03928, $red / 12.92, pow(($red + 0.055) / 1.055, 2.4));
$green: if($green <= 0.03928, $green / 12.92, pow(($green + 0.055) / 1.055, 2.4));
$blue: if($blue <= 0.03928, $blue / 12.92, pow(($blue + 0.055) / 1.055, 2.4));
@return 0.2126 * $red + 0.7152 * $green + 0.0722 * $blue;
}
// 自动选择文字颜色
@function auto-text-color($bg-color, $light-color: $white, $dark-color: $black) {
$light-contrast: contrast-ratio($bg-color, $light-color);
$dark-contrast: contrast-ratio($bg-color, $dark-color);
@if $light-contrast > $dark-contrast {
@return $light-color;
} @else {
@return $dark-color;
}
}
// 可访问的按钮变体
@mixin accessible-button-variant($bg-color, $border-color: $bg-color) {
$text-color: auto-text-color($bg-color);
$hover-bg: darken($bg-color, 7.5%);
$hover-border: darken($border-color, 10%);
$active-bg: darken($bg-color, 10%);
$active-border: darken($border-color, 12.5%);
color: $text-color;
background-color: $bg-color;
border-color: $border-color;
&:hover {
color: auto-text-color($hover-bg);
background-color: $hover-bg;
border-color: $hover-border;
}
&:focus,
&.focus {
color: auto-text-color($hover-bg);
background-color: $hover-bg;
border-color: $hover-border;
box-shadow: 0 0 0 0.2rem rgba($bg-color, 0.5);
}
&:active,
&.active {
color: auto-text-color($active-bg);
background-color: $active-bg;
border-color: $active-border;
}
&:disabled,
&.disabled {
opacity: 0.65;
}
}
// 可访问的警告框变体
@mixin accessible-alert-variant($bg-color, $border-color, $text-color) {
$contrast-ratio: contrast-ratio($bg-color, $text-color);
@if $contrast-ratio < 4.5 {
@warn "Warning: Contrast ratio #{$contrast-ratio} is below WCAG AA standard (4.5:1)";
}
background-color: $bg-color;
border-color: $border-color;
color: $text-color;
.alert-link {
color: darken($text-color, 10%);
&:hover {
color: darken($text-color, 20%);
}
}
}
// 可访问的表单控件变体
@mixin accessible-form-control-variant($border-color, $focus-color) {
border-color: $border-color;
&:focus {
border-color: $focus-color;
box-shadow: 0 0 0 0.2rem rgba($focus-color, 0.25);
outline: 2px solid transparent;
outline-offset: 2px;
}
&.is-valid {
border-color: $success;
&:focus {
border-color: $success;
box-shadow: 0 0 0 0.2rem rgba($success, 0.25);
}
}
&.is-invalid {
border-color: $danger;
&:focus {
border-color: $danger;
box-shadow: 0 0 0 0.2rem rgba($danger, 0.25);
}
}
}
// 使用示例
.btn-accessible-primary {
@include accessible-button-variant($primary);
}
.btn-accessible-warning {
@include accessible-button-variant($warning-dark);
}
.alert-accessible-info {
@include accessible-alert-variant(lighten($info, 45%), $info, $info-dark);
}
.form-control-accessible {
@include accessible-form-control-variant($gray-300, $primary);
}
3. 视觉设计的可访问性原则
<!-- 视觉设计可访问性示例 -->
<div class="container mt-4">
<h2>视觉设计可访问性</h2>
<!-- 字体大小和行高 -->
<section class="mb-5">
<h3>字体大小和行高</h3>
<div class="row">
<div class="col-md-6">
<h4>推荐的字体大小</h4>
<div class="p-3 border">
<p style="font-size: 16px; line-height: 1.5;">正文文字 (16px, 行高 1.5)</p>
<p style="font-size: 14px; line-height: 1.4;">小号文字 (14px, 行高 1.4)</p>
<p style="font-size: 12px; line-height: 1.3;">最小文字 (12px, 行高 1.3)</p>
</div>
</div>
<div class="col-md-6">
<h4>避免的字体大小</h4>
<div class="p-3 border bg-light">
<p style="font-size: 10px; line-height: 1.2;" class="text-muted">过小的文字 (10px) - 难以阅读</p>
<p style="font-size: 16px; line-height: 1.0;" class="text-muted">行高过小 (1.0) - 行间距不足</p>
</div>
</div>
</div>
</section>
<!-- 图标和文字组合 -->
<section class="mb-5">
<h3>图标和文字组合</h3>
<div class="row">
<div class="col-md-6">
<h4>良好的图标使用</h4>
<div class="list-group">
<div class="list-group-item">
<i class="fas fa-check-circle text-success me-2" aria-hidden="true"></i>
<span>成功状态 (图标 + 文字)</span>
</div>
<div class="list-group-item">
<i class="fas fa-exclamation-triangle text-warning me-2" aria-hidden="true"></i>
<span>警告状态 (图标 + 文字)</span>
</div>
<div class="list-group-item">
<i class="fas fa-times-circle text-danger me-2" aria-hidden="true"></i>
<span>错误状态 (图标 + 文字)</span>
</div>
</div>
</div>
<div class="col-md-6">
<h4>避免仅使用图标</h4>
<div class="list-group">
<div class="list-group-item bg-light">
<i class="fas fa-check-circle text-success" aria-hidden="true"></i>
<span class="text-muted ms-2">(仅图标,含义不明确)</span>
</div>
<div class="list-group-item bg-light">
<i class="fas fa-exclamation-triangle text-warning" aria-hidden="true"></i>
<span class="text-muted ms-2">(仅图标,含义不明确)</span>
</div>
<div class="list-group-item bg-light">
<i class="fas fa-times-circle text-danger" aria-hidden="true"></i>
<span class="text-muted ms-2">(仅图标,含义不明确)</span>
</div>
</div>
</div>
</div>
</section>
<!-- 焦点指示器 -->
<section class="mb-5">
<h3>焦点指示器</h3>
<div class="row">
<div class="col-md-6">
<h4>清晰的焦点指示器</h4>
<div class="d-grid gap-2">
<button class="btn btn-primary focus-visible-example">主要按钮</button>
<button class="btn btn-outline-secondary focus-visible-example">次要按钮</button>
<a href="#" class="btn btn-link focus-visible-example">链接按钮</a>
</div>
</div>
<div class="col-md-6">
<h4>表单控件焦点</h4>
<form>
<div class="mb-3">
<label for="focusInput1" class="form-label">文本输入</label>
<input type="text" class="form-control focus-visible-example" id="focusInput1">
</div>
<div class="mb-3">
<label for="focusSelect1" class="form-label">选择框</label>
<select class="form-select focus-visible-example" id="focusSelect1">
<option>选项 1</option>
<option>选项 2</option>
</select>
</div>
<div class="form-check">
<input class="form-check-input focus-visible-example" type="checkbox" id="focusCheck1">
<label class="form-check-label" for="focusCheck1">
复选框
</label>
</div>
</form>
</div>
</div>
</section>
<!-- 间距和布局 -->
<section class="mb-5">
<h3>间距和布局</h3>
<div class="row">
<div class="col-md-6">
<h4>适当的间距</h4>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">卡片标题</h5>
<p class="card-text mb-3">这是卡片的内容,具有适当的间距。</p>
<div class="d-grid gap-2 d-md-flex">
<button class="btn btn-primary me-md-2">主要操作</button>
<button class="btn btn-outline-secondary">次要操作</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<h4>触摸目标大小</h4>
<div class="d-grid gap-3">
<button class="btn btn-lg btn-primary">大按钮 (推荐移动端)</button>
<button class="btn btn-primary">标准按钮</button>
<button class="btn btn-sm btn-primary">小按钮 (避免在移动端使用)</button>
</div>
<div class="mt-3">
<small class="text-muted">
移动端触摸目标应至少为 44x44 像素
</small>
</div>
</div>
</div>
</section>
</div>
<style>
/* 焦点指示器样式 */
.focus-visible-example:focus {
outline: 2px solid #0d6efd;
outline-offset: 2px;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
.focus-visible-example:focus {
outline: 3px solid;
outline-offset: 2px;
}
}
/* 减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
</style>
屏幕阅读器支持
1. 屏幕阅读器优化
<!-- 屏幕阅读器优化示例 -->
<div class="container mt-4">
<h2>屏幕阅读器支持</h2>
<!-- 跳转链接 -->
<div class="skip-links">
<a href="#main-content" class="skip-link">跳转到主要内容</a>
<a href="#navigation" class="skip-link">跳转到导航</a>
<a href="#sidebar" class="skip-link">跳转到侧边栏</a>
</div>
<!-- 页面结构 -->
<header>
<nav id="navigation" class="navbar navbar-expand-lg navbar-dark bg-primary" aria-label="主导航">
<div class="container">
<a class="navbar-brand" href="#">
<img src="logo.png" alt="公司标志" width="30" height="30">
公司名称
</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="切换导航菜单">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link active"
aria-current="page"
href="#">首页</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
href="#"
id="servicesDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
服务
</a>
<ul class="dropdown-menu" aria-labelledby="servicesDropdown">
<li><a class="dropdown-item" href="#">网站开发</a></li>
<li><a class="dropdown-item" href="#">移动应用</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">咨询服务</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="#">关于我们</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">联系我们</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="row mt-4">
<aside id="sidebar" class="col-md-3">
<nav aria-label="侧边栏导航">
<h3>快速导航</h3>
<ul class="list-unstyled">
<li><a href="#section1">第一部分</a></li>
<li><a href="#section2">第二部分</a></li>
<li><a href="#section3">第三部分</a></li>
</ul>
</nav>
<!-- 搜索功能 -->
<div class="mt-4">
<h3>搜索</h3>
<form role="search">
<div class="input-group">
<label for="search-input" class="visually-hidden">搜索内容</label>
<input type="search"
class="form-control"
id="search-input"
placeholder="搜索..."
aria-describedby="search-help">
<button class="btn btn-outline-secondary"
type="submit"
aria-label="执行搜索">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
</div>
<div id="search-help" class="form-text">
输入关键词搜索相关内容
</div>
</form>
</div>
</aside>
<main id="main-content" class="col-md-9">
<h1>页面主标题</h1>
<!-- 面包屑导航 -->
<nav aria-label="面包屑导航">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#">首页</a></li>
<li class="breadcrumb-item"><a href="#">产品</a></li>
<li class="breadcrumb-item active" aria-current="page">详情页</li>
</ol>
</nav>
<!-- 内容区域 -->
<section id="section1" aria-labelledby="section1-heading">
<h2 id="section1-heading">第一部分</h2>
<p>这是第一部分的内容。屏幕阅读器会读取这些文字。</p>
<!-- 数据表格 -->
<table class="table table-striped" aria-label="产品信息表">
<caption>产品价格和库存信息</caption>
<thead>
<tr>
<th scope="col">产品名称</th>
<th scope="col">价格</th>
<th scope="col">库存</th>
<th scope="col">状态</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">笔记本电脑</th>
<td>¥5,999</td>
<td>15</td>
<td>
<span class="badge bg-success">有库存</span>
<span class="visually-hidden">,库存充足</span>
</td>
</tr>
<tr>
<th scope="row">智能手机</th>
<td>¥2,999</td>
<td>3</td>
<td>
<span class="badge bg-warning text-dark">库存不足</span>
<span class="visually-hidden">,仅剩3件</span>
</td>
</tr>
<tr>
<th scope="row">平板电脑</th>
<td>¥1,999</td>
<td>0</td>
<td>
<span class="badge bg-danger">缺货</span>
<span class="visually-hidden">,暂时缺货</span>
</td>
</tr>
</tbody>
</table>
</section>
<section id="section2" aria-labelledby="section2-heading">
<h2 id="section2-heading">第二部分</h2>
<!-- 图片和替代文本 -->
<figure>
<img src="chart.png"
class="img-fluid"
alt="2024年销售数据图表,显示第一季度销售额为100万,第二季度为120万,第三季度为150万,第四季度预计为180万">
<figcaption>2024年销售数据趋势图</figcaption>
</figure>
<!-- 复杂图表的文字描述 -->
<div class="visually-hidden">
<h3>图表详细描述</h3>
<p>该图表显示了2024年各季度的销售数据:</p>
<ul>
<li>第一季度:100万元</li>
<li>第二季度:120万元,增长20%</li>
<li>第三季度:150万元,增长25%</li>
<li>第四季度:预计180万元,预期增长20%</li>
</ul>
<p>总体趋势显示销售额稳步增长。</p>
</div>
</section>
<section id="section3" aria-labelledby="section3-heading">
<h2 id="section3-heading">第三部分</h2>
<!-- 交互式内容 -->
<div class="accordion" id="accessibleAccordion">
<div class="accordion-item">
<h3 class="accordion-header" id="headingOne">
<button class="accordion-button"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseOne"
aria-expanded="true"
aria-controls="collapseOne">
常见问题 1
</button>
</h3>
<div id="collapseOne"
class="accordion-collapse collapse show"
aria-labelledby="headingOne"
data-bs-parent="#accessibleAccordion">
<div class="accordion-body">
这是第一个常见问题的答案。屏幕阅读器用户可以通过键盘导航来展开和折叠这些内容。
</div>
</div>
</div>
<div class="accordion-item">
<h3 class="accordion-header" id="headingTwo">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseTwo"
aria-expanded="false"
aria-controls="collapseTwo">
常见问题 2
</button>
</h3>
<div id="collapseTwo"
class="accordion-collapse collapse"
aria-labelledby="headingTwo"
data-bs-parent="#accessibleAccordion">
<div class="accordion-body">
这是第二个常见问题的答案。
</div>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
<style>
/* 跳转链接样式 */
.skip-links {
position: absolute;
top: -40px;
left: 6px;
z-index: 1000;
}
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
border-radius: 0 0 4px 4px;
transition: top 0.3s;
}
.skip-link:focus {
top: 0;
color: #fff;
}
/* 屏幕阅读器专用内容 */
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* 仅在获得焦点时显示 */
.visually-hidden-focusable:not(:focus):not(:focus-within) {
@extend .visually-hidden;
}
</style>
2. 动态内容更新
// screen-reader-support.js
// 屏幕阅读器支持管理器
class ScreenReaderSupport {
constructor() {
this.liveRegions = new Map();
this.init();
}
// 初始化屏幕阅读器支持
init() {
this.createLiveRegions();
this.setupFormValidation();
this.setupDynamicContent();
this.setupProgressAnnouncements();
}
// 创建实时区域
createLiveRegions() {
// 创建礼貌的实时区域
const politeRegion = document.createElement('div');
politeRegion.id = 'polite-announcements';
politeRegion.setAttribute('aria-live', 'polite');
politeRegion.setAttribute('aria-atomic', 'true');
politeRegion.className = 'visually-hidden';
document.body.appendChild(politeRegion);
this.liveRegions.set('polite', politeRegion);
// 创建断言的实时区域
const assertiveRegion = document.createElement('div');
assertiveRegion.id = 'assertive-announcements';
assertiveRegion.setAttribute('aria-live', 'assertive');
assertiveRegion.setAttribute('aria-atomic', 'true');
assertiveRegion.className = 'visually-hidden';
document.body.appendChild(assertiveRegion);
this.liveRegions.set('assertive', assertiveRegion);
// 创建状态区域
const statusRegion = document.createElement('div');
statusRegion.id = 'status-announcements';
statusRegion.setAttribute('role', 'status');
statusRegion.setAttribute('aria-atomic', 'true');
statusRegion.className = 'visually-hidden';
document.body.appendChild(statusRegion);
this.liveRegions.set('status', statusRegion);
}
// 宣布消息
announce(message, priority = 'polite', delay = 100) {
const region = this.liveRegions.get(priority);
if (!region) {
console.warn(`Live region '${priority}' not found`);
return;
}
// 清除之前的内容
region.textContent = '';
// 延迟添加新内容,确保屏幕阅读器能够检测到变化
setTimeout(() => {
region.textContent = message;
}, delay);
// 自动清除消息
setTimeout(() => {
if (region.textContent === message) {
region.textContent = '';
}
}, 5000);
}
// 设置表单验证
setupFormValidation() {
document.addEventListener('invalid', (e) => {
const field = e.target;
const label = this.getFieldLabel(field);
const errorMessage = this.getValidationMessage(field);
// 设置 aria-invalid
field.setAttribute('aria-invalid', 'true');
// 宣布错误
this.announce(`${label}: ${errorMessage}`, 'assertive');
// 添加错误描述
this.addErrorDescription(field, errorMessage);
}, true);
document.addEventListener('input', (e) => {
const field = e.target;
if (field.hasAttribute('aria-invalid') && field.checkValidity()) {
field.setAttribute('aria-invalid', 'false');
this.removeErrorDescription(field);
}
});
}
// 获取字段标签
getFieldLabel(field) {
const label = document.querySelector(`label[for="${field.id}"]`);
if (label) {
return label.textContent.trim();
}
const ariaLabel = field.getAttribute('aria-label');
if (ariaLabel) {
return ariaLabel;
}
const placeholder = field.getAttribute('placeholder');
if (placeholder) {
return placeholder;
}
return '字段';
}
// 获取验证消息
getValidationMessage(field) {
if (field.validity.valueMissing) {
return '此字段为必填项';
}
if (field.validity.typeMismatch) {
return '请输入有效的格式';
}
if (field.validity.patternMismatch) {
return '输入格式不正确';
}
if (field.validity.tooShort) {
return `至少需要 ${field.minLength} 个字符`;
}
if (field.validity.tooLong) {
return `最多允许 ${field.maxLength} 个字符`;
}
if (field.validity.rangeUnderflow) {
return `值必须大于或等于 ${field.min}`;
}
if (field.validity.rangeOverflow) {
return `值必须小于或等于 ${field.max}`;
}
return field.validationMessage || '输入无效';
}
// 添加错误描述
addErrorDescription(field, message) {
let errorId = field.getAttribute('aria-describedby');
let errorElement = errorId ? document.getElementById(errorId) : null;
if (!errorElement) {
errorId = `${field.id}-error`;
errorElement = document.createElement('div');
errorElement.id = errorId;
errorElement.className = 'invalid-feedback';
errorElement.setAttribute('role', 'alert');
field.parentNode.appendChild(errorElement);
field.setAttribute('aria-describedby', errorId);
}
errorElement.textContent = message;
errorElement.style.display = 'block';
}
// 移除错误描述
removeErrorDescription(field) {
const errorId = field.getAttribute('aria-describedby');
if (errorId) {
const errorElement = document.getElementById(errorId);
if (errorElement && errorElement.classList.contains('invalid-feedback')) {
errorElement.style.display = 'none';
}
}
}
// 设置动态内容
setupDynamicContent() {
// 监听 Bootstrap 组件事件
document.addEventListener('shown.bs.modal', (e) => {
const modal = e.target;
const title = modal.querySelector('.modal-title');
if (title) {
this.announce(`对话框已打开: ${title.textContent}`, 'polite');
}
});
document.addEventListener('hidden.bs.modal', (e) => {
this.announce('对话框已关闭', 'polite');
});
document.addEventListener('shown.bs.collapse', (e) => {
const trigger = document.querySelector(`[data-bs-target="#${e.target.id}"]`);
if (trigger) {
this.announce(`${trigger.textContent} 已展开`, 'polite');
}
});
document.addEventListener('hidden.bs.collapse', (e) => {
const trigger = document.querySelector(`[data-bs-target="#${e.target.id}"]`);
if (trigger) {
this.announce(`${trigger.textContent} 已折叠`, 'polite');
}
});
// 监听标签页切换
document.addEventListener('shown.bs.tab', (e) => {
const tab = e.target;
this.announce(`已切换到 ${tab.textContent} 标签页`, 'polite');
});
}
// 设置进度宣布
setupProgressAnnouncements() {
// 监听进度条更新
const progressBars = document.querySelectorAll('.progress-bar');
progressBars.forEach(bar => {
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'aria-valuenow') {
const value = bar.getAttribute('aria-valuenow');
const max = bar.getAttribute('aria-valuemax') || 100;
const percentage = Math.round((value / max) * 100);
this.announce(`进度: ${percentage}%`, 'polite');
}
});
});
observer.observe(bar, {
attributes: true,
attributeFilter: ['aria-valuenow']
});
});
}
// 宣布页面变化
announcePageChange(title, description) {
this.announce(`页面已更改: ${title}. ${description}`, 'polite');
}
// 宣布搜索结果
announceSearchResults(count, query) {
if (count === 0) {
this.announce(`没有找到 "${query}" 的搜索结果`, 'polite');
} else {
this.announce(`找到 ${count} 个 "${query}" 的搜索结果`, 'polite');
}
}
// 宣布加载状态
announceLoading(message = '正在加载') {
this.announce(message, 'polite');
}
// 宣布完成状态
announceComplete(message = '加载完成') {
this.announce(message, 'polite');
}
// 宣布错误
announceError(message) {
this.announce(`错误: ${message}`, 'assertive');
}
// 宣布成功
announceSuccess(message) {
this.announce(`成功: ${message}`, 'polite');
}
}
// 创建全局实例
const screenReaderSupport = new ScreenReaderSupport();
// 导出
export default screenReaderSupport;
3. 可访问的表单验证
<!-- 可访问的表单验证示例 -->
<div class="container mt-4">
<h2>可访问的表单验证</h2>
<form id="accessibleForm" novalidate>
<fieldset>
<legend>用户注册表单</legend>
<!-- 用户名字段 -->
<div class="mb-3">
<label for="username" class="form-label">
用户名 <span class="text-danger" aria-label="必填">*</span>
</label>
<input type="text"
class="form-control"
id="username"
name="username"
required
minlength="3"
maxlength="20"
pattern="[a-zA-Z0-9_]+"
aria-required="true"
aria-describedby="username-help username-error">
<div id="username-help" class="form-text">
用户名必须为3-20个字符,只能包含字母、数字和下划线
</div>
<div id="username-error" class="invalid-feedback" role="alert" style="display: none;"></div>
</div>
<!-- 邮箱字段 -->
<div class="mb-3">
<label for="email" class="form-label">
邮箱地址 <span class="text-danger" aria-label="必填">*</span>
</label>
<input type="email"
class="form-control"
id="email"
name="email"
required
aria-required="true"
aria-describedby="email-help email-error">
<div id="email-help" class="form-text">
请输入有效的邮箱地址
</div>
<div id="email-error" class="invalid-feedback" role="alert" style="display: none;"></div>
</div>
<!-- 密码字段 -->
<div class="mb-3">
<label for="password" class="form-label">
密码 <span class="text-danger" aria-label="必填">*</span>
</label>
<div class="input-group">
<input type="password"
class="form-control"
id="password"
name="password"
required
minlength="8"
aria-required="true"
aria-describedby="password-help password-error">
<button class="btn btn-outline-secondary"
type="button"
id="togglePassword"
aria-label="显示密码">
<i class="fas fa-eye" aria-hidden="true"></i>
</button>
</div>
<div id="password-help" class="form-text">
密码必须至少8个字符,包含大小写字母、数字和特殊字符
</div>
<div id="password-error" class="invalid-feedback" role="alert" style="display: none;"></div>
<!-- 密码强度指示器 -->
<div class="mt-2">
<div class="progress" style="height: 5px;">
<div id="password-strength"
class="progress-bar"
role="progressbar"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100"
aria-label="密码强度">
</div>
</div>
<small id="password-strength-text" class="form-text">密码强度: 无</small>
</div>
</div>
<!-- 确认密码字段 -->
<div class="mb-3">
<label for="confirmPassword" class="form-label">
确认密码 <span class="text-danger" aria-label="必填">*</span>
</label>
<input type="password"
class="form-control"
id="confirmPassword"
name="confirmPassword"
required
aria-required="true"
aria-describedby="confirm-password-help confirm-password-error">
<div id="confirm-password-help" class="form-text">
请再次输入密码以确认
</div>
<div id="confirm-password-error" class="invalid-feedback" role="alert" style="display: none;"></div>
</div>
<!-- 生日字段 -->
<div class="mb-3">
<label for="birthdate" class="form-label">出生日期</label>
<input type="date"
class="form-control"
id="birthdate"
name="birthdate"
max="2006-01-01"
aria-describedby="birthdate-help birthdate-error">
<div id="birthdate-help" class="form-text">
必须年满18岁才能注册
</div>
<div id="birthdate-error" class="invalid-feedback" role="alert" style="display: none;"></div>
</div>
<!-- 性别选择 -->
<fieldset class="mb-3">
<legend class="form-label">性别</legend>
<div class="form-check">
<input class="form-check-input"
type="radio"
name="gender"
id="gender-male"
value="male">
<label class="form-check-label" for="gender-male">
男性
</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
name="gender"
id="gender-female"
value="female">
<label class="form-check-label" for="gender-female">
女性
</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="radio"
name="gender"
id="gender-other"
value="other">
<label class="form-check-label" for="gender-other">
其他
</label>
</div>
</fieldset>
<!-- 兴趣爱好 -->
<fieldset class="mb-3">
<legend class="form-label">兴趣爱好 (可多选)</legend>
<div class="form-check">
<input class="form-check-input"
type="checkbox"
id="interest-tech"
name="interests"
value="technology">
<label class="form-check-label" for="interest-tech">
科技
</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="checkbox"
id="interest-sports"
name="interests"
value="sports">
<label class="form-check-label" for="interest-sports">
体育
</label>
</div>
<div class="form-check">
<input class="form-check-input"
type="checkbox"
id="interest-music"
name="interests"
value="music">
<label class="form-check-label" for="interest-music">
音乐
</label>
</div>
</fieldset>
<!-- 同意条款 -->
<div class="mb-3">
<div class="form-check">
<input class="form-check-input"
type="checkbox"
id="terms"
name="terms"
required
aria-required="true"
aria-describedby="terms-error">
<label class="form-check-label" for="terms">
我同意 <a href="#" target="_blank">服务条款</a> 和 <a href="#" target="_blank">隐私政策</a>
<span class="text-danger" aria-label="必填">*</span>
</label>
<div id="terms-error" class="invalid-feedback" role="alert" style="display: none;"></div>
</div>
</div>
<!-- 提交按钮 -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="reset" class="btn btn-outline-secondary me-md-2">重置</button>
<button type="submit" class="btn btn-primary">
<span id="submit-text">注册</span>
<span id="submit-loading" class="spinner-border spinner-border-sm ms-2"
role="status" aria-hidden="true" style="display: none;"></span>
</button>
</div>
</fieldset>
</form>
<!-- 提交结果 -->
<div id="form-result" class="mt-3" role="alert" aria-live="polite" style="display: none;"></div>
</div>
<script>
// 可访问的表单验证脚本
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('accessibleForm');
const passwordField = document.getElementById('password');
const confirmPasswordField = document.getElementById('confirmPassword');
const togglePasswordBtn = document.getElementById('togglePassword');
const passwordStrengthBar = document.getElementById('password-strength');
const passwordStrengthText = document.getElementById('password-strength-text');
// 密码显示/隐藏切换
togglePasswordBtn.addEventListener('click', function() {
const type = passwordField.getAttribute('type') === 'password' ? 'text' : 'password';
passwordField.setAttribute('type', type);
const icon = this.querySelector('i');
const label = type === 'password' ? '显示密码' : '隐藏密码';
icon.className = type === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash';
this.setAttribute('aria-label', label);
// 宣布状态变化
screenReaderSupport.announce(label, 'polite');
});
// 密码强度检测
passwordField.addEventListener('input', function() {
const password = this.value;
const strength = calculatePasswordStrength(password);
updatePasswordStrength(strength);
});
// 确认密码验证
confirmPasswordField.addEventListener('input', function() {
validatePasswordMatch();
});
passwordField.addEventListener('input', function() {
if (confirmPasswordField.value) {
validatePasswordMatch();
}
});
// 表单提交
form.addEventListener('submit', function(e) {
e.preventDefault();
if (this.checkValidity() && validatePasswordMatch()) {
submitForm();
} else {
// 聚焦到第一个错误字段
const firstError = this.querySelector('.is-invalid, :invalid');
if (firstError) {
firstError.focus();
screenReaderSupport.announce('表单包含错误,请检查并修正', 'assertive');
}
}
});
// 计算密码强度
function calculatePasswordStrength(password) {
let strength = 0;
if (password.length >= 8) strength += 25;
if (/[a-z]/.test(password)) strength += 25;
if (/[A-Z]/.test(password)) strength += 25;
if (/[0-9]/.test(password)) strength += 15;
if (/[^a-zA-Z0-9]/.test(password)) strength += 10;
return Math.min(strength, 100);
}
// 更新密码强度显示
function updatePasswordStrength(strength) {
passwordStrengthBar.style.width = strength + '%';
passwordStrengthBar.setAttribute('aria-valuenow', strength);
let strengthText = '无';
let strengthClass = '';
if (strength >= 80) {
strengthText = '强';
strengthClass = 'bg-success';
} else if (strength >= 60) {
strengthText = '中等';
strengthClass = 'bg-info';
} else if (strength >= 40) {
strengthText = '弱';
strengthClass = 'bg-warning';
} else if (strength > 0) {
strengthText = '很弱';
strengthClass = 'bg-danger';
}
passwordStrengthBar.className = `progress-bar ${strengthClass}`;
passwordStrengthText.textContent = `密码强度: ${strengthText}`;
}
// 验证密码匹配
function validatePasswordMatch() {
const password = passwordField.value;
const confirmPassword = confirmPasswordField.value;
if (confirmPassword && password !== confirmPassword) {
confirmPasswordField.setCustomValidity('密码不匹配');
confirmPasswordField.classList.add('is-invalid');
confirmPasswordField.setAttribute('aria-invalid', 'true');
const errorElement = document.getElementById('confirm-password-error');
errorElement.textContent = '密码不匹配';
errorElement.style.display = 'block';
return false;
} else {
confirmPasswordField.setCustomValidity('');
confirmPasswordField.classList.remove('is-invalid');
confirmPasswordField.setAttribute('aria-invalid', 'false');
const errorElement = document.getElementById('confirm-password-error');
errorElement.style.display = 'none';
return true;
}
}
// 提交表单
function submitForm() {
const submitBtn = form.querySelector('button[type="submit"]');
const submitText = document.getElementById('submit-text');
const submitLoading = document.getElementById('submit-loading');
const resultDiv = document.getElementById('form-result');
// 显示加载状态
submitBtn.disabled = true;
submitText.textContent = '提交中...';
submitLoading.style.display = 'inline-block';
screenReaderSupport.announceLoading('正在提交表单');
// 模拟提交过程
setTimeout(() => {
// 恢复按钮状态
submitBtn.disabled = false;
submitText.textContent = '注册';
submitLoading.style.display = 'none';
// 显示成功消息
resultDiv.className = 'alert alert-success mt-3';
resultDiv.textContent = '注册成功!欢迎加入我们。';
resultDiv.style.display = 'block';
screenReaderSupport.announceSuccess('注册成功');
// 重置表单
form.reset();
updatePasswordStrength(0);
}, 2000);
}
});
</script>