来源：https://ui.docs.xihanfun.com/guide/a11y

# 无障碍与键盘规格

无障碍在 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. 文档：[组件参考](../components/)中每个组件的键盘表由它渲染；
3. 校验：`source` 字段指向规格出处（W3C APG 模式、WAI-ARIA、HTML 标准或 WCAG 技术），修改行为时可以追溯依据。

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

## ARIA 上的几处明确选择

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

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

## 背景失活与焦点

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

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

顺序不可调换，细节见[行为原语](./behavior)。

## 焦点可见

焦点环由一份公共皮肤绘制，全库共用：粗细 `--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`）在皮肤内一律不允许赋值：在子树上修改它们，库环在该子树上就不再是库环。

::: warning 这不是环随语气变化
四条口径需要一起理解：

- 环不随语气：`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` 环整体不可见，这不是对比不足，而是没有焦点提示。这类档位要么退回默认环，要么另给一支配对前景色。

判据是环压着的面，不是组件的语气。皮肤的写法见[皮肤与样式分层](./styling)。
:::

### 浮层壳是否接焦点

浮层的面板多数只是焦点陷阱的兜底落点（`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`：扫描必须拦截跨文档跳转，否则测试宿主会被导航离开；而用例断言的正是不拦截，同一文档内无法兼得。

## 相关

- [组件参考](../components/)：每个组件的完整键盘表
- [测试与质量门禁](./testing)：三套判据的运行方式
- [行为原语](./behavior)：焦点域与背景失活
- [皮肤与样式分层](./styling)：聚焦环在皮肤侧的写法
