跳转到内容

无障碍与键盘规格 ​

无障碍在 XiHan.UI 中是判据:每个组件都有一份机读的键盘规格表,它同时是测试的分母。

键盘规格表 ​

ts
export const accordionKeyboard: KeyboardTable = {
  component: "accordion",
  source: "https://www.w3.org/WAI/ARIA/apg/patterns/accordion/#keyboardinteraction",
  rows: [
    {
      id: "accordion.kbd.toggle",
      keys: ["Space", "Enter"],
      when: "focus in trigger, not disabled",
      does: "展开/收起该条目的 content",
    },
    {
      id: "accordion.kbd.next",
      keys: ["ArrowDown", "ArrowRight"],
      when: "focus in trigger, 按键与 orientation 同轴(dir=rtl 时左右键语义互换)",
      does: "焦点移到下一个 trigger,末条不回绕",
    },
    // …
  ],
};

每一行有稳定的 id、触发按键、生效前置条件与效果。全库共 695 条,分布在 136 个组件上。

规格表由三方共同消费:

  1. 测试:一致性用例通过 covers 字段反查行 id,缺一行即套件失败;
  2. 文档:组件参考中每个组件的键盘表由它渲染;
  3. 校验:source 字段指向规格出处(W3C APG 模式、WAI-ARIA、HTML 标准或 WCAG 技术),修改行为时可以追溯依据。

分母外化

让键盘测试通过只能编写用例,不能修改分母。规格表是先行确立的契约,不是事后补充的记录。

ARIA 上的几处明确选择 ​

组件源码中反复出现以下处理,都是经过权衡的:

  • aria-disabled 而非 disabled:禁用项仍需可聚焦时使用。原生 disabled 会使元素失去焦点,读屏用户无法 Tab 到它,也读不到不可用的原因。手风琴的禁用条目、加载中的按钮都采用这种方式。
  • 不该使用 roving tabindex 的地方不使用:手风琴的每个触发器都是独立的 Tab 停靠点,这是 APG 对该模式的规定。组件不输出 tabindex 是有意的。
  • 方向键的轴向与书写方向:垂直列表不响应左右键;RTL 下左右键语义互换。这两条收在 navIntentFromKey 一处,所有列表型组件共享同一份判断。
  • 不由导航处理的按键不 preventDefault,否则会吞掉输入法、浏览器快捷键和默认行为。
  • 输入法组合期不误判:isComposingEvent() 识别输入法组合中的按键,避免把确认候选词的 Enter 当作提交。

背景失活与焦点 ​

模态浮层打开时按固定顺序装配:

  1. 注册层,压入层栈;
  2. 建立消隐层(Esc、点击外部、焦点移出);
  3. 建立焦点域(捕获、回绕、归还);
  4. 锁定滚动;
  5. 推迟一帧为背景添加 inert。

顺序不可调换,细节见行为原语。

焦点可见 ​

焦点环由一份公共皮肤绘制,全库共用:粗细 --xh-ring-width、颜色 --xh-_ring-color(默认 --xh-ring-focus)、偏移 --xh-ring-offset。偏移是负的一个环宽:环向元素内收,外沿与元素边框外沿重合,聚焦前后占位一致,不会挤开周围内容。

WCAG 2.2 的非文本对比(SC 1.4.11)要求焦点指示器对相邻颜色至少 3:1。环内收之后,相邻的是两块颜色:

  • 内侧:元素自己的面。这是判定基准。
  • 外侧:元素所在的背景。它随使用者放置组件的位置而变化,库无法决定,因此只测量不判定。

实心面上环取 currentColor ​

面为实心的档位(实心按钮、选中的开关轨道、选中的日历格、拖拽中的滑块等),环色与面同族,对比度最低可到 1.00:1。这些档位在各自皮肤的 :focus-visible 规则中把环色槽设为 currentColor,环改取该面配对的前景色。该前景色本就要在这块面上保证文字可读(与实心底 4.5:1),作为环色自然超过 3:1。

改环色按求值判定,不按字面量判定:给 --xh-_ring-color 或 outline-color 赋值、或在键盘聚焦规则中写 outline 简写,值沿皮肤中的槽展开、再沿令牌链解到颜色,在任一主题 × 语气下与库的两支环(--xh-ring-focus、--xh-ring-invalid)都不相等即视为改环色;解不出的值(currentColor、使用者传入的色值、没有兜底的使用者令牌)同样视为改环色。因此写成 var(--xh-fg-on-brand) 等与面配对的前景色令牌(开关选中的轨道即如此,它是 <button>、皮肤没有给它 color)与写 currentColor 受同样约束;写回 var(--xh-ring-focus) 不算改环色;直接写 outline-color、把属性名写成大写、把规则包进 @supports 或 @media screen / @container 等静态无法判断何时不成立的条件块、写在裸 :focus 中、用 [data-variant] / [data-state] 等属性存在式选择器覆盖多档,都在判定范围内。库环两支令牌与它们解析链上经过的名称(如 --xh-color-brand-500)在皮肤内一律不允许赋值:在子树上修改它们,库环在该子树上就不再是库环。

这不是环随语气变化

四条口径需要一起理解:

  • 环不随语气:data-tone 切换的是颜色族,环不随之切换。语气色本体作为环色达不到 3:1(warning 对白底 2.70:1、success 3.04:1)。没有实心面的档位一律使用 --xh-ring-focus。
  • 实心面上取 currentColor:取的是该面配对的前景色,不是语气色。同一块 danger 实心底上,环是这块底上的文字色,不是 danger 本体。
  • 达标的淡色档不为统一而改环色:淡底、透空的面使用默认环即可(3.40:1 起),改为 currentColor 反而把品牌色环换成近黑近白,与同族组件分叉。currentColor 只在实心档使用,选择器的粒度与面的粒度对齐。
  • 失效档豁免对比度,但环不允许消失:WCAG 1.4.11 对失效控件不要求 3:1,库内照此不追求该数值;但 color: transparent 或置灰文字与置灰面同色会让 currentColor 环整体不可见,这不是对比不足,而是没有焦点提示。这类档位要么退回默认环,要么另给一支配对前景色。

判据是环压着的面,不是组件的语气。皮肤的写法见皮肤与样式分层。

浮层壳是否接焦点 ​

浮层的面板多数只是焦点陷阱的兜底落点(tabindex=-1),环位于内部的控件上,皮肤关闭面板自身的环,并在 ringless 分区中登记环由谁绘制。tour 的气泡是例外:Enter / Space 落在它上面会推进下一步,它是可操作目标(WCAG 2.4.7),必须有可见焦点;程序化聚焦后使用默认内收环,面是 --xh-bg-surface,默认环达标。浏览器判据 focus-ring-inset-grpring 保证这一条。

相关判据 ​

判据检查内容
check-focus-ring环的粗细、颜色、偏移写了字面量而不是令牌,主题与全局调整对它无效
check-focus-ring-surface静态解出每个可聚焦档的面与环色,面对环不到 3:1 却未改环色的判红;改环色的规则(--xh-_ring-color / outline-color / 聚焦规则中的 outline 简写,求值后落在库环之外)覆盖到非实心档、:focus-visible 中关闭环(outline: none / outline-width: 0)却未登记、绘制实心面却不接焦点也未登记的,同样判红;聚焦规则把环色写成透明直接判红,没有登记表。@supports 块内的规则按条件成立处理;@media 只认打印、高对比、减弱动效、粗指针、断点这几种真实媒体条件为条件块,其他写法(screen / all / 逗号并列 / not)与任何 @container 一律按成立处理;规则写了但档位未写取值的属性,存在式一律视为覆盖,写了取值的按是否有兄弟档认领判定
check-focus-outline-reset皮肤在 :focus:not(:focus-visible) 下复位 outline(含 outline-style / outline-width / outline-color)。UA 只在 :focus-visible 绘制环,这条复位是死代码,而 outline 简写会把 outline-color 复位成 currentColor,描边色一旦进过渡,焦点离开时就闪出一圈近黑描边
focus-ring-inset-grpring浏览器态:环内收后聚焦前后占位不变、全库只有一种偏移;改环色的每条规则(同样按求值判定;@media 按当前页面的媒体环境判断,@container 照常处理)逐条落焦测量 3:1;tour 气泡落焦有环
focus-ring-face-contrast浏览器态:从皮肤推出档位,在真实 Chromium 中逐档测量环与面的实测对比
focus-ring-disabled-ink浏览器态:失效档使用默认环、不取被置灰的前景色;未失效的实心档仍取面自己的前景色

focus-ring-face-contrast 只测量库绘制的面:皮肤未绘制面、计算出的底色等于同标签裸元素 UA 底色的部件(菜单的触发器只绘制展开档,基础档露出的是原生 button 的 buttonface)不进入档位表,环内侧的颜色不属于库。明确不达标且更换环色无法解决的档位登记在 KNOWN 中,两侧反查:无法挂出该档、该档不再绘制环、或它已经达到 3:1,三种情况都判定登记过期。

两条判据的分母互相对账。静态算出的每个实心档,浏览器态都要能挂出、测量结果也是实心的、环是否撤销两侧一致;浏览器态测出的每个实心档,静态要么算出同一块面(同键或覆盖它的档,比值一致),要么在登记表的 declared 中说明为何算不出。两边的键先归一为同一形态再比较(:is() 展开、只写了 root 的祖先去掉),叠出同一份面的组合只测量一次、沿别名找到已测量的档。对不上的逐条登记在 focus-ring-face-contrast.reconcile.json 中写明理由,登记的必须仍然对不上,对不上的必须已登记。

静态与浏览器态各有盲区,因此都保留。静态判据运行快,交互后才出现的状态([data-dragging]、[data-state='picking'])同样能计算,但看不到面绘制在祖先上、改了环色却被另一条规则替换 color、同一档后面又有规则把环色写回默认等级联叠加;浏览器态测量的是实测值,覆盖更全,但 :hover / :active 叠加聚焦无法测量(真实指针移动会使浏览器退出键盘模态,:focus-visible 立即消失),::before 铺的底、真实媒体条件中的面、连接层内联进 style 的面(颜色选择器的色板格、裁剪器的图)也不在范围内。

浏览器中的自动扫描 ​

一致性套件的 fixture 挂载到真实 Chromium,对初始态与各用例终态运行 axe。终态按形态签名去重,避免同一形态重复扫描。

每套 fixture 按根元素的 data-theme 明暗各运行一遍,body 铺 bg-canvas / fg-default 作为底色:axe 沿祖先找不到绘制底色的元素时按白底计算,深色一遍不铺底色等于用深色文字对比白底。

必须在真实浏览器中运行:jsdom 没有布局,对比度、目标尺寸、翻面与避让都无法表现。

bash
pnpm exec playwright install chromium # 首次
pnpm test:browser

存量违规登记表 ​

扫描出的存量问题登记在 tooling/testing/src/a11y/known.ts。命中已登记的规则不判失败,但一条都不再命中时判定登记过期:修复后必须从表中删除,不保留已不成立的豁免。

当前共五条。三个适配器共用的四条:tag 的禁用标签文字使用全库统一的 fg-disabled / bg-muted(浅色 2.36:1、深色 1.93:1),按 1.4.3 的失效控件豁免不算违规,但 tag 的 root 没有 axe 识别的禁用语义,因此被扫出;select 位于触发器外的可删除标签属于同一档(触发器内的标签在原生 disabled 的按钮内,axe 跳过;外部的标签是没有角色的 span);file-upload 禁用时文件条目的名字与大小同走 fg-disabled(浅色 2.58:1、深色 2.27:1),条目是 role=listitem、不能写 aria-disabled,三颗按钮都是原生 disabled 被 axe 跳过,只有这两段文字被扫出;prompt-input 的输入框有意不无条件发出 aria-label(会覆盖作者的 <label for>),一致性夹具有意不提供文案以保证这条契约,扫描结果是一个没有名称的 textarea,真实用法中由作者命名。Web Components 适配器自身的一条:steps 的必需子节点由作者编写,可能缺少角色要求的直接子节点。全局登记为空。登记按主题记录:明暗两遍都命中的写在共用表,只在一个主题下成立的放在 knownByTheme,两侧都有过期反查,登记了却已扫不出的同样判失败。

WARNING

这份表是当前状态的如实记录,不代表可接受。表中仍有条目时,请自行评估对合规要求的影响。

步骤重放豁免 ​

只有一个组件在真实浏览器中无法推进到用例终态,两个适配器共用这条登记:

  • breadcrumb:扫描必须拦截跨文档跳转,否则测试宿主会被导航离开;而用例断言的正是不拦截,同一文档内无法兼得。

相关 ​

Released under The MIT License