今天来和大家分享一个在我多年的无障碍工作实践中遇到最多的问题:如何在网页上创建一个读屏软件可访问的模态对话框。
很多人可能会觉得,弹出对话框不是一个很简单很常见的功能吗,点击按钮后出现弹窗,再点击关闭按钮或点击背景关闭,没什么复杂的。然而,在无障碍测试过程中,我发现绝大多数网页中的弹窗对话框都存在各种问题,尤其是对依赖辅助技术的用户来说,几乎是没法儿用的。
在没有考虑到无障碍特性的情况下,弹窗对话框会出现以下普遍问题:
- 读屏软件无法识别弹窗:由于缺少适当的 ARIA 属性,读屏软件无法识别弹窗的存在,更无法朗读它的内容。
- 焦点顺序混乱:弹窗弹出时,焦点没有跳转到弹窗内部的可交互元素,导致用户可能继续与底层页面元素交互,甚至完全不知道弹窗已经打开。
- 键盘导航受限:没有实现 Tab键循环,键盘用户在使用Tab键导航时,可能会跳出弹窗范围,聚焦到底层被覆盖的元素,导致操作混乱。
- 关闭方式单一:有些弹窗只能通过鼠标点击关闭按钮来关闭,忽视了仅依赖键盘操作的用户。
- 视觉提示不足:弹窗没有明确的视觉层次,比如缺少遮罩层、焦点样式等,用户可能无法区分弹窗与其他页面内容。
- 缺少动态提示:对于读屏软件用户,弹窗的出现或关闭可能没有触发提示,用户根本感知不到发生了什么。
以上这些问题不仅严重影响了依赖辅助技术的用户,也让许多键盘用户、低视力用户和触摸屏用户的体验变得糟糕。一个简单的弹出对话框,在设计上如果不够细致,就会成为用户操作中的“拦路虎”。
为什么无障碍设计很重要?
无障碍设计的核心思想是人人可用。对于弹出对话框这样的交互组件,它往往承担着重要的功能,比如表单填写、确认操作、错误提示等。如果没有进行无障碍优化,障碍用户将完全无法使用这些功能。
所以,如何创建一个无障碍的弹出对话框,成为了每个前端开发者都需要掌握的基础技能。接下来,我将结合我自己设计的一个简单弹窗示例——messageBox 函数,从无障碍的角度带大家一步步拆解如何实现一个对所有用户都友好的弹出对话框。
弹出对话框存在的常见无障碍问题
1.1 读屏软件无法识别弹窗
弹出对话框如果没有添加适当的 ARIA 属性,读屏软件用户完全不知道页面上发生了什么。例如,在弹窗弹出后,如果没有语义化的标识(如 role="dialog" 或 role="alertdialog"),读屏软件将不会识别理解这是一个弹窗,用户无法理解页面的结构。
解决方法:
通过添加适当的 ARIA 属性,可以让读屏软件识别弹窗。例如:
- role="dialog"或- role="alertdialog":用于标识弹出对话框。
- aria-labelledby:指定弹窗的标题。
- aria-describedby:指定弹窗的描述内容。
- aria-modal="true":标识弹窗是模态的,即弹窗打开时,页面的其他内容不应该被访问到,读屏软件借助这个属性就可以屏蔽弹窗之外的内容。
在 messageBox 中实现如下:
modal.setAttribute('role', 'alertdialog'); // 标识弹窗为模态对话框
modal.setAttribute('aria-labelledby', uniqueId + '-title'); // 标题
modal.setAttribute('aria-describedby', uniqueId + '-description'); // 描述
modal.setAttribute('aria-modal', 'true'); // 标识模态行为
1.2 焦点管理混乱
当弹窗打开时,如果焦点没有移动到弹窗内部,用户可能继续操作弹窗之外被覆盖的底层内容,而完全不知道弹窗已经打开。此外,关闭弹窗时,如果没有恢复焦点,用户可能迷失在页面中,不知道当前应该操作什么。
解决方法:
- 弹窗打开时:将焦点移动到弹窗的第一个可操作元素(如标题或按钮)。
- 弹窗关闭时:将焦点恢复到弹窗打开前的元素。
messageBox 的实现:
const previouslyFocusedElement = document.activeElement; // 保存打开前的焦点
modal.focus(); // 将焦点移动到弹窗
function closeModal() {
    previouslyFocusedElement.focus(); // 恢复焦点
    modal.style.display = 'none';
    document.body.removeChild(modal);
}
1.3 键盘导航受限
没有实现 Tab 键循环的弹窗会导致键盘用户在导航时跳出弹窗,进入弹窗之外被覆盖的底层内容,影响用户体验。
解决方法:
通过监听 keydown 事件,捕获 Tab 键的按下,并在用户尝试跳出弹窗时将焦点聚焦到弹窗的第一个或最后一个元素,实现循环浏览,限定在弹窗中的效果。
实现代码如下:
modal.addEventListener('keydown', function(e) {
    const isTabPressed = e.key === 'Tab' || e.keyCode === 9;
    if (!isTabPressed) return;
    const focusableElements = modal.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
    const firstFocusableElement = focusableElements[0];
    const lastFocusableElement = focusableElements[focusableElements.length - 1];
    if (e.shiftKey) { // 如果是 Shift + Tab
        if (document.activeElement === firstFocusableElement) {
            lastFocusableElement.focus();
            e.preventDefault(); // 阻止默认行为
        }
    } else { // 如果是 Tab
        if (document.activeElement === lastFocusableElement) {
            firstFocusableElement.focus();
            e.preventDefault();
        }
    }
});
如何优化一个无障碍弹出对话框?
2.1 增加视觉提示
为了让低视力用户和普通用户能够清楚地看到弹窗的存在,需要添加遮罩层和视觉层次。例如,使用半透明背景来区分弹窗和弹窗之外被覆盖的底层内容。
const style = document.createElement('style');
style.textContent = `
.modal {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
}
.modal-content {
    background: #fff;
    padding: 20px;
    border-radius: 8px;
    width: 400px;
    text-align: center;
}
`;
document.head.appendChild(style);
2.2 支持键盘关闭
为弹窗添加键盘关闭功能,使用户可以通过 Escape 键关闭弹窗。
window.addEventListener('keydown', function(e) {
    if (e.key === 'Escape') {
        closeModal();
    }
});
2.3 提供快捷键操作
为弹窗按钮设置快捷键,用户可以通过组合键快速选择按钮。
confirmBtn.accessKey = 'o'; // 确认按钮快捷键为 Alt+O
cancelBtn.accessKey = 'c'; // 取消按钮快捷键为 Alt+C
完整的 messageBox 函数实现
以下是最终实现的 messageBox 函数,结合了以上所有优化点:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>无障碍弹窗演示</title>
</head>
<body>
<p>测试弹窗</p>
<p>这是弹窗外面的文字,点击下面的按钮可以打开弹窗</p>
<p><a href="https://www.bing.com/">这是弹窗前面的链接(bing)</a></p>
<button onclick="messageBox('提醒','您要访问 prc.cx 网站吗?',function () {window.open('https://prc.cx/','_blank');},function () {})">打开弹窗</button>
<p><a href="https://www.google.com/">这是弹窗后面的链接(google)</a></p>
<p>这是底部文字,也是弹窗外面的</p>
<script>
function messageBox(title, message, onConfirm, onCancel) {
const uniqueId = 'modal-' + Math.random().toString(36).substr(2, 9);
const previouslyFocusedElement = document.activeElement;
const modal = document.createElement('div');
modal.classList.add('modal');
modal.setAttribute('role', 'alertdialog');
modal.setAttribute('aria-labelledby', uniqueId + '-title');
modal.setAttribute('aria-describedby', uniqueId + '-description');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('tabindex', '-1');
modal.style.display = 'block';
const modalContent = document.createElement('div');
modalContent.classList.add('modal-content');
const titleElement = document.createElement('h1');
titleElement.id = uniqueId + '-title';
titleElement.textContent = title || 'Message';
modalContent.appendChild(titleElement);
const description = document.createElement('div');
description.id = uniqueId + '-description';
description.tabIndex = 0;
description.textContent = message;
modalContent.appendChild(description);
const confirmBtn = document.createElement('button');
confirmBtn.id = uniqueId + '-confirm';
confirmBtn.textContent = '确定 (Ok)';
confirmBtn.accessKey = 'o';
modalContent.appendChild(confirmBtn);
const cancelBtn = document.createElement('button');
cancelBtn.id = uniqueId + '-cancel';
cancelBtn.textContent = '取消 (Cancel)';
cancelBtn.accessKey = 'c';
modalContent.appendChild(cancelBtn);
modal.appendChild(modalContent);
document.body.appendChild(modal);
modal.focus();
confirmBtn.addEventListener('click', function() {
closeModal();
onConfirm();
});
cancelBtn.addEventListener('click', function() {
closeModal();
onCancel();
});
window.addEventListener('keydown', function(e) {
if (e.key === 'Escape' || e.keyCode === 27) {
closeModal();
}
});
function closeModal() {
modal.style.display = 'none';
modal.remove();
previouslyFocusedElement.focus();
}
modal.addEventListener('keydown', function(e) {
const isTabPressed = e.key === 'Tab' || e.keyCode === 9;
if (!isTabPressed) return;
const focusableElements = modal.querySelectorAll('button, [tabindex]:not([tabindex="-1"])');
const firstFocusableElement = focusableElements[0];
const lastFocusableElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstFocusableElement) {
lastFocusableElement.focus();
e.preventDefault();
}
} else {
if (document.activeElement === lastFocusableElement) {
firstFocusableElement.focus();
e.preventDefault();
}
}
});
const style = document.createElement('style');
style.textContent = `
.modal {
display: none;
position: fixed;
z-index: 1;
left: 0; top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.modal-content button {
margin: 5px;
}
.focus-visible {
outline: 2px solid blue;
}
`;
document.head.appendChild(style);
}
</script>
</body>
</html>
进一步优化
4.1 支持读屏软件的动态更新
利用 ARIA live 属性动态提示用户弹窗状态。
4.2 适配不同场景
比如复杂表单的弹窗,内容滚动的对话框等,需要更多优化。
总结
一个无障碍的弹出对话框,不仅要满足视觉和交互的要求,更要兼顾依赖辅助技术的用户需求。希望这篇文章能帮助大家更好地理解无障碍设计,让我们共同为“人人可用”的互联网环境努力!
知乎: @张赐荣
赐荣博客: www.prc.cx
 
                     
                    
                 
                    
                 
                
 
 
         
                
            
         浙公网安备 33010602011771号
浙公网安备 33010602011771号