状态变化(展开、选中、禁用)需要实时同步到无障碍树而非仅改变外观
别名: 状态同步 · aria-expanded · aria-checked · aria-disabled
概念解释
箭头转了 90 度、背景变成灰色、对勾画出来了——这些都还在绘制层。辅助技术要知道「展开了、禁用了、选中了」,必须把对应属性写进无障碍树:状态同步(accessibility state synchronization)。aria-expanded、aria-checked、aria-disabled、aria-pressed、aria-selected 以及原生元素自己的 disabled / checked,才是树上的状态。只改 class 和 CSS,树仍停在上一拍。
这不是「页面换了一块内容要喊一声」。那是动态播报的事。这里问的是同一个控件,它此刻处于哪一种状态,树有没有跟上外观。
机制
阅读器在焦点落到控件上、或状态属性变化时,把状态读出来:「已展开」「未选中」「灰色,不可用」。它读的是树节点上的属性,不读旋转角度。视觉层用过渡动画表示折叠,树如果仍是 expanded=true,语音会在动画结束后继续说展开。反过来,脚本把 aria-expanded 改了却没改画面,看见的人和听见的人各执一词。
第二层是时机。状态必须在用户动作的同一拍写入树,而不是等动画 transitionend。阅读器对属性变化的订阅是事件,不是每帧采样;晚一拍,用户已经按了下一次键,听到的是旧状态,下一次操作就建在错误前提上。禁用尤其容易只做「看起来灰」:指针事件被 CSS 挡住了,树上仍是可聚焦、可激活。
怎么研究
写一个手风琴或自定义复选框,分三档:只改外观;只改 ARIA;外观与 ARIA 一起改。每次切换后立刻让 NVDA / VoiceOver 读当前控件,并在无障碍检查器里看状态属性。再加一档:状态写在动画结束回调里,测用户连按两次时听到什么。
自变量:状态写入树的时机(同步 / 动画后 / 不写)、原生元素 vs 自定义。 因变量:语音中的状态词、检查器属性、第二次按键是否作用在用户以为的状态上。
边界
原生 <details>、<input type="checkbox"> 的开合与勾选由用户代理写入树,作者不必也不该再抄一份 aria-expanded。只读展示(静态的「已完成」徽章)若不是控件,没有「状态变化」这回事,不该被做成假的 aria-pressed。忙闲、加载这类瞬态更靠近播报,而不是控件状态;硬塞进 aria-busy 而不移开焦点,用户可能永远听不到。远程协作里「别人选中了」的状态,更新频率会把同步变成噪音,需要节流,但不能节流到用户自己的那一次点击。
怎么落地
- 每次折叠、勾选、禁用、切换,在改外观的同一函数里改树上的状态属性,不要放进动画回调。
- 禁用用真正的
disabled或aria-disabled并移出 Tab 序列或明确宣告不可用;不要只加灰色 class。 - 自定义开关同时维护
aria-checked或aria-pressed与可见的开合,两处用同一份数据。 - 验证:关掉显示器,对每个可切换控件操作一次,听状态词是否翻转;再打开检查器看属性是否在点击当下就变。画面动了、属性没动,或属性动了、语音仍说旧状态,就是同步只做了一半。