aria-haspopup="listbox"必须配合role="combobox"使用,且下拉容器需为role="listbox"并含role="option"子项,同步设置aria-expanded、aria-controls和aria-activedescendant。

aria-haspopup="listbox" 必须搭配 role="combobox"
单独给按钮加 aria-haspopup="listbox" 是无效的。屏幕阅读器只认语义链:触发控件必须是 role="combobox"(不是 button 或 div),否则它会当成普通按钮,完全忽略下拉意图。
常见错误是把 aria-haspopup="listbox" 加在 <div> 或自定义 <code><span></span> 上——这些元素没语义基础,AT 直接跳过。正确结构只有两种:
- 原生
<input type="text">+role="combobox"(适合可输入+选择的场景) - 原生
<button></button>+role="combobox"(适合纯选择、无输入的下拉按钮)
二者都必须同步设置 aria-expanded 和 aria-controls,缺一不可。
下拉容器必须用 role="listbox",不能用 menu 或 div
aria-haspopup="listbox" 的“listbox”不是随便写的字符串,它强制要求弹出内容必须是 role="listbox" 容器,且内部子项必须是 role="option" —— 不能是 <li>、<div> 或 <code>role="menuitem"。
否则 JAWS/NVDA 会报错:“listbox 缺少必需子角色”,或直接静默跳过整个列表。
关键约束:
-
role="listbox"元素必须有明确 ID,与触发控件的aria-controls值严格一致 - 每个选项必须是
<div role="option"> 或 <code><li role="option">,不能套button或a - 选中项必须动态设
aria-selected="true",未选中为"false"(不是移除属性)
键盘交互不配 aria-activedescendant 就等于没做
只设 tabindex="-1" 给选项,再用 element.focus() 切焦点,屏幕阅读器依然不知道“当前高亮哪一项”。必须配合 aria-activedescendant 指向当前聚焦 option 的 ID。
这个属性要写在 role="listbox" 容器上,不是触发按钮上。每次方向键移动后,得手动更新:
listboxEl.setAttribute('aria-activedescendant', focusedOption.id);
容易漏的点:
- 初始状态没设
aria-activedescendant,首次按 ↓ 键时 AT 不播报 - 用
innerHTML = ...替换整个列表后,aria-activedescendant指向的 ID 已销毁,必须重置为空或指向新选项 - 收起菜单时没清空该属性,AT 下次打开仍读旧项
aria-expanded 和 aria-controls 动态不同步,AT 就当没这回事
aria-expanded 不是初始化一次就完事。它必须和下拉面板真实可见状态 1:1 同步——显示时为 "true",隐藏时立刻设为 "false"。
更隐蔽的问题是 aria-controls:多个下拉共用同一个 listbox ID 时,语音朗读会错乱;或者 JS 动态创建新列表但没更新该属性值,AT 会尝试聚焦一个不存在的元素,直接静音。
安全做法是在所有显示/隐藏路径里成对操作:
dropdownEl.style.display = 'block';<br>triggerEl.setAttribute('aria-expanded', 'true');<br>triggerEl.setAttribute('aria-controls', dropdownEl.id);
收起时同理,且必须确保 DOM 中对应 listbox 确实存在、ID 没拼错——这点在组件复用或动态渲染时最容易翻车。











