无障碍与键盘规格
无障碍在 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、触发按键、生效前置条件与效果。每个组件各有一份。
规格表由三方共同消费:
- 测试:一致性用例通过
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、点击外部、焦点移出);
- 建立焦点域(捕获、回绕、归还);
- 锁定滚动;
- 宿主提交浮层节点后,为背景添加
aria-hidden;没有遮罩的层另在定位层里垫一块透明底板拦下指针。
顺序不可调换,细节见行为原语。
焦点可见
焦点环是一道实线,由一份公共皮肤绘制、全库共用:outline,粗细 --xh-ring-width(2px)、颜色 --xh-_ring-color(默认 --xh-ring-focus)、偏移 --xh-ring-offset(负一个环宽)。环画在元素自己的盒内,外沿与边框外沿重合;outline 不占布局,聚焦前后占位与外轮廓都一致。
环不出盒:元素多大、放进什么裁切容器里(滚动区、overflow: hidden 的卡片、首尾相接的行与格),环都完整,也压不到相邻元素,应用侧不必为它留位。环不随语气、即时出现、不进过渡。
WCAG 2.2 的非文本对比(SC 1.4.11)要求焦点指示器对相邻颜色至少 3:1。环外侧贴着元素所在的承载面:库环 --xh-ring-focus 对页面底、画布、承载面与淡底承载阶梯每一档都不低于 3:1。环内侧贴着元素自己的面,面是实心或任意颜色时(实心按钮与标签、勾中的方框、选中的开关轨道、日历选中格、色块、取色拇指、热力图格),环与面可能同色,这些档在环内侧衬一道 1px 的底色间隔线(--xh-ring-gap,取承载面底色,与库环之间同样不低于 3:1)。环两侧都是底色,压在什么颜色的面上都读得出来。彩色区块里的子元素由墨色域把环换成墨色。强制色档系统去掉 box-shadow,间隔线随之消失,只剩环。
环色不随面
皮肤不改环色。给 --xh-_ring-color 或 outline-color 赋值、或在键盘聚焦规则中写 outline 简写时,值只能解到库环两支(--xh-ring-focus、--xh-ring-invalid),可以经组件覆盖槽或本份皮肤的私有槽转一道;currentColor、前景色与底色令牌、transparent 一律判红。实心面要读得出环,改衬间隔线:皮肤在实心档写 --xh-_ring-gap: var(--xh-ring-gap),值只能是这一支。库环令牌(含 --xh-ring-gap)与它们解析链上经过的名称(如 --xh-color-brand-600)在皮肤内一律不允许赋值:在子树上修改它们,库环在该子树上就不再是库环。
描边与环同色的小控件
Slider、ColorSlider 的拇指与 ColorPicker 取色区的拇指,边界本身就是一道 2px 描边,环画在描边上会与它重合。这几处把环收进描边以内(calc(var(--xh-ring-offset) - var(--xh-stroke-thick))),仍在拇指自己的盒内;拖动中画同一道环。
浮层壳是否接焦点
浮层的面板多数只是焦点陷阱的兜底落点(tabindex=-1),环位于内部的控件上,皮肤关闭面板自身的环,并在 ringless 分区中登记环由谁绘制;面板自己的投影照旧。tour 的气泡是例外:Enter / Space 落在它上面会推进下一步,它是可操作目标(WCAG 2.4.7),必须有可见焦点;程序化聚焦后使用默认环。浏览器判据 focus-ring-geometry 保证这一条。
字段外壳
输入框壳、InputGroup 组壳这类字段外壳聚焦时,描边换成 --xh-border-control-focus(与 --xh-ring-focus 同色),描边内侧再补一道 inset 线,合起来一个环宽,就是画在盒内的焦点环,不另画 outline。校验失败的字段聚焦时聚焦态胜出。盒里的输入框与 field-inset 钮关掉自己的环,焦点由外壳的 :focus-within 标出。强制色档 inset 线被去掉,描边换成 Highlight,并补一道同宽的 Highlight 环。面板内嵌搜索(Command、Cascader、TreeSelect、Transfer、SideNav 的搜索框)没有外壳,聚焦时插入符就是焦点指示。这几处在焦点环门禁的 ringless 分区登记。
图表的数据标记
图表里可聚焦的柱、点、扇区由 focus-ring 部件沿标记外侧描一圈,隔一道表面间隙:散点只有几像素,环画不进标记里。这是盒内规则唯一登记的例外,环仍落在图表自己的绘图区内。
相关判据
| 判据 | 检查内容 |
|---|---|
check-focus-ring | 环的粗细、颜色、偏移写了字面量而不是令牌,主题与全局调整对它无效 |
check-focus-ring-surface | 改环色的声明解不到库环(currentColor、前景色、底色、透明)判红;--xh-_ring-gap 赋了 var(--xh-ring-gap) 以外的值判红;皮肤给库环令牌链上的名称赋值判红;键盘聚焦规则关了环(outline: none / outline-width: 0 / outline-style: none)却没在 ringless 中写明环由谁画、或登记了却已扫不到,同样判红。@media 只认打印、高对比、减弱动效、粗指针、断点这几种真实媒体条件为条件块,其他写法与任何 @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-geometry | 浏览器态:环的外沿落在边框外沿上、落焦前后占位不变;字段外壳描边换色并补 inset 线、合起来一个环宽、不画 outline;随库样式表里的偏移只有 --xh-ring-offset 与拇指收进描边以内的那一档;库环对每一档承载面、间隔线与库环之间都不低于 3:1;实心面落焦时衬着间隔线;tour 气泡落焦有环;强制色档环不消失、字段补同宽的系统色环 |
focus-ring-shared | 浏览器态:默认档、没写过聚焦规则的部件、实心动作控件(衬间隔线,悬停与按下时不让位)、实心面里的后代(不衬)、关环档与字段外壳逐一落焦核对 |
浏览器中的自动扫描
一致性套件的 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);combobox 的标签行排在输入框前、不在任何原生 disabled 的控件里,禁用时的标签文字同属这一档;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:扫描必须拦截跨文档跳转,否则测试宿主会被导航离开;而用例断言的正是不拦截,同一文档内无法兼得。
