无障碍与键盘规格
无障碍在 XiHan.UI 中是判据:每个组件都有一份机读的键盘规格表,它同时是测试的分母。
键盘规格表
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 个组件上。
规格表由三方共同消费:
- 测试:一致性用例通过
covers字段反查行 id,缺一行即套件失败; - 文档:组件参考中每个组件的键盘表由它渲染;
- 校验:
source字段指向规格出处(W3C APG 模式、WAI-ARIA、HTML 标准或 WCAG 技术),修改行为时可以追溯依据。
分母外化
让键盘测试通过只能编写用例,不能修改分母。规格表是先行确立的契约,不是事后补充的记录。
ARIA 上的几处明确选择
组件源码中反复出现以下处理,都是经过权衡的:
aria-disabled而非disabled:禁用项仍需可聚焦时使用。原生disabled会使元素失去焦点,读屏用户无法 Tab 到它,也读不到不可用的原因。手风琴的禁用条目、加载中的按钮都采用这种方式。- 不该使用 roving tabindex 的地方不使用:手风琴的每个触发器都是独立的 Tab 停靠点,这是 APG 对该模式的规定。组件不输出
tabindex是有意的。 - 方向键的轴向与书写方向:垂直列表不响应左右键;RTL 下左右键语义互换。这两条收在
navIntentFromKey一处,所有列表型组件共享同一份判断。 - 不由导航处理的按键不
preventDefault,否则会吞掉输入法、浏览器快捷键和默认行为。 - 输入法组合期不误判:
isComposingEvent()识别输入法组合中的按键,避免把确认候选词的Enter当作提交。
背景失活与焦点
模态浮层打开时按固定顺序装配:
- 注册层,压入层栈;
- 建立消隐层(Esc、点击外部、焦点移出);
- 建立焦点域(捕获、回绕、归还);
- 锁定滚动;
- 推迟一帧为背景添加
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 没有布局,对比度、目标尺寸、翻面与避让都无法表现。
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:扫描必须拦截跨文档跳转,否则测试宿主会被导航离开;而用例断言的正是不拦截,同一文档内无法兼得。
