aria-haspopup 不该设为 true,而应明确指定弹出层类型,最常见的是 "menu";wai-aria 1.2 规范限定其值为 "menu"、"listbox"、"tree"、"grid"、"dialog" 等字符串字面量,且必须搭配 aria-expanded 使用以正确反映展开状态。

aria-haspopup 该设成 true 还是 menu
直接说结论:aria-haspopup 不该设为 true,而应明确指定弹出层类型,最常见的是 "menu"。设成 true 虽然不报错,但会丢失语义细节,屏幕阅读器可能只读“has popup”,无法区分是菜单、对话框还是树形选择器。
WAI-ARIA 1.2 规范已将允许值限定为字符串字面量:"menu"、"listbox"、"tree"、"grid"、"dialog" —— 没有 true 或 false。
实际使用中,90% 的下拉菜单场景都该用:
<button aria-haspopup="menu" aria-expanded="false">文件</button>
注意必须搭配 aria-expanded 才能正确反映展开状态。
为什么不能只靠 aria-haspopup 判断弹出层是否打开
aria-haspopup 描述的是“**有能力弹出**”,不是“**当前已弹出**”。它是个静态属性,类似“这扇门可以推开”,但推没推开得看 aria-expanded。
- 用户按空格/Enter 展开菜单时,只改
aria-expanded="true",aria-haspopup保持不变 - 若漏掉
aria-expanded,屏幕阅读器无法得知当前状态,键盘用户会困惑“点了但没反应” - 某些旧版 NVDA 在未设置
aria-expanded时,甚至不会把aria-haspopup="menu"当作可交互菜单处理
菜单弹出后,焦点和 aria-controls 怎么配
光有 aria-haspopup 和 aria-expanded 还不够。用户展开后,焦点必须落到弹出层第一个可聚焦项(如首个 <button></button> 或 <a></a>),否则键盘导航会断掉。
同时建议用 aria-controls 建立显式关联:
<button id="file-btn" aria-haspopup="menu" aria-expanded="false" aria-controls="file-menu">文件</button><br><div id="file-menu" role="menu"><!-- ... --></div>
这样屏幕阅读器能读出“文件,菜单,已折叠,受控于 file-menu”。不过要注意:
-
aria-controls的值必须是真实存在的 ID,拼错或 ID 不存在会导致辅助技术静默忽略 - 如果弹出层是动态插入 DOM(比如 Vue/React 渲染后才挂载),确保 ID 在插入后立刻可用,不要等动画结束再设
- Chrome + ChromeVox 目前对
aria-controls支持有限,但 JAWS 和 NVDA 都依赖它
用 role="menu" 就一定得模拟原生菜单行为
一旦用了 aria-haspopup="menu",就等于承诺这个弹出层遵循 WAI-ARIA Menu Pattern:上下键导航、Enter 激活、Esc 关闭、Home/End 跳首尾……用户和辅助技术都会按这套逻辑预期行为。
常见翻车点:
- 弹出层里用
<div onclick="..."> 而非 <code><button></button>或带role="menuitem"的元素 → 键盘无法聚焦或触发 - 没监听
Escape键关闭菜单 → 用户卡在弹出层里出不来 - 子菜单(二级菜单)没加
aria-haspopup="menu"和aria-expanded→ 屏幕阅读器读不出“有子菜单” - 菜单项 disabled 时只加了
disabled属性但没设aria-disabled="true"→ VoiceOver 可能仍把它当作可操作项朗读
真正难的不是写对 aria-haspopup,而是让整个交互链——从按钮点击、焦点移动、键盘响应到状态同步——全部符合模式。漏掉任意一环,对残障用户的破坏都是实质性的。











