根本原因是语义缺失与键盘逻辑未补全:原生标签自带语义、焦点、键盘响应和状态同步能力,而用div封装会主动放弃这些,需手动重建整条链路。

自定义插件无法被屏幕阅读器识别,根本原因不是缺 ARIA,而是语义缺失 + 键盘逻辑没补全。 你加了 role="button" 却没处理 Space 和 Enter,或者用了 aria-live 却把更新塞进 innerHTML 替换里——这些都会让读屏器静默或误报。
为什么原生标签不能直接套在自定义插件上
原生标签(如 <button></button>、<select></select>)自带语义、焦点管理、键盘响应和状态同步能力。一旦你用 div 封装整个插件结构,就等于主动放弃这些能力,必须手动重建整条链路。
- 比如一个日历选择器,若外层是
<div class="calendar">,那它默认不可聚焦、不响应空格、不朗读“日历”,也不支持方向键导航日期 <li>强行给这个 <code>div加role="application"是高风险操作:它会关闭读屏器的大部分浏览模式,用户必须切换到“应用模式”才能操作,且极易迷失上下文 - 更稳妥的做法是只对插件中真正需要交互的子元素补语义,例如日期单元格用
role="gridcell"+tabindex="0"+ 方向键事件,而外层容器保持无role或仅用role="region"+aria-labelledby - 用
element.innerHTML = newHTML会销毁旧节点并新建,读屏器可能错过变化,尤其当新内容没有可访问名称时 - 动态加载列表项时,应使用
element.appendChild(item)或element.append(...items),确保新增节点真实进入 DOM 树 -
aria-live区域必须始终存在于 DOM 中(不能随插件销毁/重建),且不能被aria-hidden="true"父级遮蔽——否则即使内容更新,读屏器也完全忽略 - 首次打开时,用
element.focus()主动聚焦第一个可操作子元素(如首项button或搜索框input),而不是插件根容器 - Tab 导航需循环限制在插件内部:监听
focusin,发现焦点移出插件范围时,立即event.preventDefault()并切回第一个/最后一个可聚焦元素 - 关闭插件后,焦点必须回到触发它的元素(如点击的
button),可用data-trigger-id记录或闭包保存引用;别依赖document.activeElement,它可能已被其他脚本篡改 - 避免用
aria-label="搜索商品"硬编码——当插件复用于不同场景(如“搜索客户”“搜索订单”),文案就得动态注入 - 优先用
aria-labelledby="plugin-title plugin-input",把插件标题 ID 和输入框 ID 同时关联,读屏器会拼接朗读:“商品搜索,搜索商品,编辑文本” - 若插件内含多个字段(如日期范围的“开始”“结束”),每个
input都要单独配aria-labelledby,指向各自对应的标签容器,不能共用一个 ID
aria-live 更新失败的三个常见原因
aria-live="polite" 看似简单,但在插件中常因 DOM 操作方式失效。它依赖“插入新节点”而非“替换整个内容”来触发播报。
文章转信息图。将文章/笔记转化为手机可读的 HTML 信息图,自动匹配视觉风格。触发场景:文章转图、笔记转图、信息图、转小红书图、做张图、可视化这篇文章、文生图。
如何让键盘用户不卡在插件里
插件一旦打开(比如下拉菜单、模态筛选器),焦点必须可控进出。这不是加个 tabindex="0" 就完事的。
label 和描述文本不能靠视觉位置猜
插件里的输入控件(如搜索框、筛选条件)如果只是视觉上挨着标题,屏幕阅读器根本不知道它们是一组。必须显式绑定。
最易被忽略的是:插件初始化完成后的可访问性检查,不能只看属性是否存在。必须用 NVDA 或 VoiceOver 实际走一遍 Tab 流程、触发开关、监听 live 区域播报——因为很多问题只在真实辅助技术上下文中才暴露出来。










