Dawn 的无障碍写法:details 增强、焦点管理、状态播报与 combobox
本文基于 Dawn v15.3.0,源码固定到 ecb06c3(核验于 2026-10-01)。结论限于此提交;本篇为静态源码分析,未使用读屏软件实测。Dawn 上游已发布 v16.0.0,本库 Dawn 正文统一以 v15.3.0 为基线,升级前需重新核对。
各功能的无障碍差距已在对应篇目中逐项列出,例如购物车、页头导航、预测搜索。本文把其中可复用的写法抽出来,作为自己主题可以直接参考的清单。通用要求见无障碍合规基线。
一、页面骨架
- 跳转链接:
<body>的第一个元素是指向#MainContent的skip-to-content-link,平时视觉隐藏,获得焦点时显示(theme.liquid#L302-L304)。 - 主内容可接收焦点:
<main id="MainContent" tabindex="-1">(#L312),跳转后焦点真正进入主内容,而不只是滚动过去;focus-none类去掉这个容器的焦点轮廓。 - 共享描述文本:布局底部有一个隐藏列表,放着“选择后页面会整页刷新”“在新窗口打开”两条翻译后的提示(#L318-L321),排序下拉框、新窗口链接等通过
aria-describedby="a11y-refresh-page-message"引用。一条文案在多个控件间共用,不用为每个控件各写一个隐藏元素。
二、<details> 先能用,再增强
页头下拉菜单、筛选组、搜索弹层等展开收起控件都基于原生 <details>/<summary>。没有 JS 时,它们依然可以点击展开。JS 在此基础上补齐语义:
- global.js#L71-L85 给
id以Details-开头的summary加上role="button"、aria-expanded,并在下一个兄弟元素有id时补aria-controls;点击时同步aria-expanded。 onKeyUpEscape:Esc 关闭最近的details[open],并把焦点交回它的summary。DetailsDisclosure(下拉菜单)在焦点离开时自动关闭;DetailsModal(搜索弹层)则在打开时锁定焦点,Esc、关闭按钮或点击遮罩时关闭并把焦点还给触发者。
同一个原生元素,按交互强度分成三档:只补 ARIA、焦点离开即关闭、模态锁定焦点。
三、焦点锁定与返还
trapFocus(container, elementToFocus) 被抽屉、弹窗、搜索弹层、购物车改量后的焦点恢复共用。它有几处值得借鉴的细节:
- 只在边界上监听 Tab。
focusin时只有焦点落在容器、第一个或最后一个可聚焦元素上,才挂上keydown监听;焦点在中间元素时,不处理任何按键。 - 一次只锁一个。 开头先调用
removeTrapFocus(),处理函数存放在全局对象trapFocusHandlers中,重复调用不会叠加监听。 - 搜索框打开时选中已有文字,用户可以直接输入覆盖(#L126-L132)。
- 关闭时返还焦点:
removeTrapFocus(elementToFocus)由调用方传入触发元素,例如ModalDialog记录的openedBy。
另外,#L135-L182 是 :focus-visible 的回退:浏览器不支持该选择器时,用键盘与鼠标事件区分输入方式,只在键盘导航时给元素加 focused 类。
局限。 可聚焦元素列表只在调用时计算一次。锁定期间插入的新控件不在循环范围内,所以购物车每次替换内容后都要重新调用 trapFocus(cart.js#L202-L212)。背景也没有设为 inert,读屏软件的浏览模式仍能读到弹窗外的内容。
四、状态播报
异步操作的结果通过 role="status" 区域朗读,而不是移动焦点:
- 购物车改量后,
updateLiveRegions把行内错误写进对应行,并把整体状态区的aria-hidden置为false,1 秒后再隐藏; - 预测搜索在请求时播报“加载中”,结果返回后播报结果数量(predictive-search.js#L211-L239),同样用
aria-hidden在 1 秒后收起。
用 aria-hidden 开关状态区是 Dawn 的习惯写法。各读屏软件对“状态区先被隐藏、再显示并更新内容”的处理是否一致,本篇未验证;更稳妥的写法是让状态区始终可见,只更新文本。
五、选项与 combobox
- 不可售选项:色板与按钮的选项值都附带一段
visually-hidden文本,例如“已售罄或不可用”(product-variant-options.liquid#L55-L65),色板只靠颜色区分时,读屏用户也能知道选项名和状态。 - 预测搜索 combobox:输入框随结果开合设置
aria-expanded,方向键改变结果项的aria-selected,并把aria-activedescendant指向当前项(predictive-search.js#L121-L162)。焦点始终留在输入框里,用户可以继续输入。计算可选项时用offsetParent !== null过滤掉被隐藏的重复结果。方向键的默认行为在keydown中阻止(#L101-L106),避免光标在输入框里跳动。
六、减少动态
滚动显现、环境动画等全部写在 @media (prefers-reduced-motion: no-preference) 中(base.css#L3261),即默认不动,用户未要求减少动态时才启用;JS 驱动的缩放动画在开头检查同一个媒体查询后直接返回(animations.js#L43)。
读源码时发现的问题
均为静态阅读,未复现:
- 搜索词直接拼进正则。
updateSearchForTerm用new RegExp(previousTerm, 'g')匹配按钮文字,没有转义。上一次的搜索词含(、[等字符时,构造正则会抛出SyntaxError;由于它在getSearchResults之前执行,这次输入不会发出请求。正则匹配不到时,.match()返回null,读取.length同样会报错。 - 描述文本与实际行为不符。 集合页与搜索页的排序下拉框引用“选择后页面会整页刷新”的描述(facets.liquid#L396-L401),但启用 JS 后,排序通过局部刷新完成,页面并不刷新。
- 焦点集合不随内容更新(见第三节)。
迁移到自己主题时
- 展开收起控件优先基于
<details>,再按需要补 ARIA 和焦点逻辑。 - 焦点锁定在每次打开时重新计算可聚焦元素,或改用原生
<dialog>的showModal()与inert。 - 状态区保持可见,只更新文本。
- 拼接正则前转义用户输入;能用字符串方法时不用正则。
建议的验证范围
本篇均未执行:用 VoiceOver 与 NVDA 走一遍“跳转到主内容、打开搜索、方向键选结果、关闭”;在购物车改量后确认状态区是否被朗读;在预测搜索中依次输入 a、a(、a(b,确认第三次输入是否仍有结果;开启系统“减少动态”后浏览首页。