用 aria-activedescendant 实现可访问的自动完成下拉框
【摘要】 本文用 aria-activedescendant 让焦点留在输入框,方向键更新高亮项,Enter 选中并同步 aria-expanded,Esc 关闭并清空高亮,给出最小可运行示例与边界处理。
问题场景
自动完成下拉框常见的实现是:用户输入后,把焦点移到第一个候选项,再用上下键在选项之间移动。这个方案对键盘操作看似可用,但对屏幕阅读器和开关控制用户不友好:焦点离开输入框后,用户可能失去当前输入上下文,读屏软件也会把正在输入的文本框切换成列表项,导致体验跳跃。WAI-ARIA 的 combobox 模式推荐另一种方式:焦点始终留在输入框,用 aria-activedescendant 指向列表中的活动项。方向键只更新活动项,不移动 DOM 焦点。
本文给出一个不依赖框架的最小实现:输入框保持焦点,列表用 listbox 语义,方向键更新高亮项,Enter 选中并关闭,Esc 关闭并清空高亮,同时维护 aria-expanded 与 aria-activedescendant。示例数据使用占位城市名,便于替换。
ARIA 约定
实现前先明确角色和属性:
- 输入框:
role=combobox、aria-autocomplete=list、aria-controls指向列表、aria-expanded表示列表是否展开、aria-activedescendant指向当前活动选项的 id;aria-haspopup=listbox可声明弹出内容类型。 - 列表:
role=listbox,每个选项role=option,并具有唯一 id。选项不要设置tabindex=0,否则可能被 Tab 键聚焦,违背焦点留在输入框的目标。 - 选中与活动项是两件事:
aria-activedescendant表示当前活动项,视觉高亮与aria-selected可用来辅助表达。本文的单选自动完成中,把当前高亮项设为aria-selected=true,其余为false。如果需要多选,应改用支持多选的模式。 aria-expanded必须在打开和关闭时同步更新为字符串true或false,不要只改视觉样式。
最小可运行示例
将下面代码保存为 autocomplete.html,直接用浏览器打开。无构建步骤和外部依赖。
<!doctype html>
<html lang='zh-CN'>
<head>
<meta charset='utf-8'>
<title>自动完成示例</title>
<style>
body { font-family: sans-serif; padding: 24px; }
.combo { position: relative; width: 260px; }
#city-input { width: 100%; box-sizing: border-box; padding: 8px; }
#city-listbox {
position: absolute;
left: 0;
right: 0;
margin: 4px 0 0;
padding: 4px 0;
list-style: none;
border: 1px solid #ccc;
background: #fff;
max-height: 180px;
overflow: auto;
}
#city-listbox [role=option] { padding: 8px 12px; cursor: pointer; }
#city-listbox [aria-selected=true] { background: #e8f0fe; }
</style>
</head>
<body>
<label for='city-input'>选择城市</label>
<div class='combo'>
<input id='city-input' type='text' role='combobox' aria-autocomplete='list' aria-expanded='false' aria-controls='city-listbox' aria-haspopup='listbox' autocomplete='off'>
<ul id='city-listbox' role='listbox' aria-label='城市建议' hidden></ul>
</div>
<script>
(() => {
const input = document.getElementById('city-input');
const listbox = document.getElementById('city-listbox');
const data = ['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '西安'];
let filtered = [];
let activeIndex = -1;
let composing = false;
function isOpen() {
return input.getAttribute('aria-expanded') === 'true';
}
function open() {
input.setAttribute('aria-expanded', 'true');
listbox.hidden = false;
}
function close() {
input.setAttribute('aria-expanded', 'false');
listbox.hidden = true;
clearActive();
}
function clearActive() {
activeIndex = -1;
input.removeAttribute('aria-activedescendant');
listbox.querySelectorAll('[aria-selected=true]').forEach((el) => {
el.setAttribute('aria-selected', 'false');
});
}
function setActive(index) {
if (!filtered.length) {
clearActive();
return;
}
activeIndex = Math.max(0, Math.min(index, filtered.length - 1));
const options = listbox.querySelectorAll('[role=option]');
options.forEach((el, i) => {
el.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
});
const el = options[activeIndex];
input.setAttribute('aria-activedescendant', el.id);
if (!isOpen()) open();
el.scrollIntoView({ block: 'nearest' });
}
function render(query) {
const q = query.trim().toLowerCase();
filtered = q ? data.filter((item) => item.toLowerCase().includes(q)) : data.slice();
listbox.textContent = '';
clearActive();
if (!filtered.length) {
close();
return;
}
filtered.forEach((item, index) => {
const li = document.createElement('li');
li.id = 'city-option-' + index;
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', 'false');
li.dataset.value = item;
li.textContent = item;
li.addEventListener('mousedown', (event) => event.preventDefault());
li.addEventListener('click', () => select(index));
listbox.appendChild(li);
});
open();
}
function select(index) {
if (index < 0 || index >= filtered.length) return;
input.value = filtered[index];
close();
input.focus();
}
input.addEventListener('compositionstart', () => {
composing = true;
});
input.addEventListener('compositionend', () => {
composing = false;
render(input.value);
});
input.addEventListener('input', () => {
if (composing) return;
render(input.value);
});
input.addEventListener('keydown', (event) => {
if (event.isComposing) return;
const key = event.key;
if (key === 'ArrowDown') {
event.preventDefault();
if (!isOpen()) render(input.value);
if (filtered.length) {
setActive(activeIndex < 0 ? 0 : Math.min(activeIndex + 1, filtered.length - 1));
}
return;
}
if (key === 'ArrowUp') {
event.preventDefault();
if (!isOpen()) render(input.value);
if (filtered.length) {
setActive(activeIndex < 0 ? filtered.length - 1 : Math.max(activeIndex - 1, 0));
}
return;
}
if (key === 'Enter') {
if (isOpen() && activeIndex >= 0) {
event.preventDefault();
select(activeIndex);
} else if (isOpen()) {
event.preventDefault();
close();
}
return;
}
if (key === 'Escape') {
if (isOpen()) {
event.preventDefault();
close();
}
return;
}
if (key === 'Tab') close();
});
document.addEventListener('click', (event) => {
if (!input.contains(event.target) && !listbox.contains(event.target)) close();
});
})();
</script>
</body>
</html>
代码中的关键点:
render负责按输入过滤并重建选项。每次重建后清空活动项,避免aria-activedescendant指向已删除的 id。setActive只更新 id、aria-selected和滚动位置,不调用focus()。- 选项的
mousedown事件调用preventDefault,防止鼠标点击时焦点从输入框转移到列表项;点击后再执行选中。 - 输入法组合期间用
compositionstart/compositionend标记,避免中文输入过程中因input事件频繁过滤或 Enter 误选。 - Enter 仅在列表展开且存在活动项时拦截默认行为并选中;列表关闭时不拦截,避免影响表单默认提交。Esc 仅在展开时拦截,关闭并清空高亮,输入框保持焦点。
- 方向键在列表关闭时会先渲染并打开列表,再设置第一项或最后一项;到达边界时使用 clamp,不循环。
运行方法
- 新建文本文件,粘贴上述 HTML,保存为
autocomplete.html。 - 用浏览器打开,点击输入框,输入“北”或“杭”等字符。
- 按 ArrowDown / ArrowUp 观察高亮项变化,焦点应始终在输入框。
- 按 Enter 选中当前高亮项,列表关闭,
aria-expanded变为 false。 - 按 Esc 关闭列表并清空高亮;清空输入后重新输入可再次打开。
可在开发者工具中选中输入框,观察 aria-expanded 和 aria-activedescendant 的变化,确认它们与视觉状态一致。
边界与限制
- 屏幕阅读器对
aria-activedescendant的支持和播报细节存在差异,发布前应使用目标读屏软件实际测试;ARIA 属性不能替代可理解的交互反馈。 aria-activedescendant的值必须是当前 DOM 中存在的元素 id。若使用虚拟滚动,活动项必须已经渲染;否则应先渲染活动项,或改用其他可访问方案。- 列表重建会删除旧选项,必须同步清空活动项 id,避免悬空引用。异步搜索时还要处理过期响应,保证高亮项属于当前结果集。
- 移动端软键盘不一定触发完整的 keydown 序列,触摸选择路径必须保留;点击选项后要恢复输入框焦点。
- Esc 的默认行为在不同上下文可能不同,本文只在列表展开时拦截。若页面有全局快捷键,需要避免冲突。
- 输入法组合期间不要更新
aria-activedescendant或响应 Enter,等组合结束后再过滤。 - 不要同时使用“移动焦点到选项”和
aria-activedescendant两套高亮机制,否则会出现双重高亮和焦点跳跃。 - 本文示例是单选自动完成,不处理分组、多选、异步加载状态和错误提示;这些场景需要扩展 listbox 结构和状态播报。
【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)