EN
Shopify 知识库 · 指南

Dawn 的无障碍写法:details 增强、焦点管理、状态播报与 combobox

基于 Dawn v15.3.0 源码,横向整理跳转链接、details 与 summary 的渐进增强、trapFocus 焦点循环与返还、共享描述文本、状态区播报、预测搜索 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 后,排序通过局部刷新完成,页面并不刷新。
  • 焦点集合不随内容更新(见第三节)。

迁移到自己主题时

  1. 展开收起控件优先基于 <details>,再按需要补 ARIA 和焦点逻辑。
  2. 焦点锁定在每次打开时重新计算可聚焦元素,或改用原生 <dialog> 的 showModal() 与 inert。
  3. 状态区保持可见,只更新文本。
  4. 拼接正则前转义用户输入;能用字符串方法时不用正则。

建议的验证范围

本篇均未执行:用 VoiceOver 与 NVDA 走一遍“跳转到主内容、打开搜索、方向键选结果、关闭”;在购物车改量后确认状态区是否被朗读;在预测搜索中依次输入 a、a(、a(b,确认第三次输入是否仍有结果;开启系统“减少动态”后浏览首页。

源码基线