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

# Accessibility and keyboard

In XiHan.UI accessibility is a criterion: every component has a machine-readable keyboard specification, and it doubles as the test denominator.

## Keyboard specifications

```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: "expand / collapse the content of this item",
    },
    {
      id: "accordion.kbd.next",
      keys: ["ArrowDown", "ArrowRight"],
      when: "focus in trigger, key on the orientation axis (left and right swap under dir=rtl)",
      does: "move focus to the next trigger, without wrapping at the last item",
    },
    // …
  ],
};
```

Every row has a stable `id`, the trigger keys, a precondition and an effect. Each component has its own table.

The specification is consumed by three parties:

1. Tests: conformance cases point back to row ids through their `covers` field, and a suite missing one row fails;
2. Documentation: the keyboard table of every component in the [component reference](../components/) is rendered from it;
3. Review: the `source` field points at the origin of the specification (a W3C APG pattern, WAI-ARIA, the HTML standard or a WCAG technique), so behavior changes can be traced back to their basis.

::: tip An external denominator
Making keyboard tests pass can only be done by writing cases, never by changing the denominator. The specification is a contract established up front, not a record added afterwards.
:::

## Deliberate choices about ARIA

These treatments appear again and again in the component sources, and each is a considered trade-off:

- `aria-disabled` instead of `disabled` when a disabled item must stay focusable. Native `disabled` removes the element from focus, so screen reader users can neither Tab to it nor hear why it is unavailable. Disabled accordion items and loading buttons work this way.
- No roving tabindex where it does not belong: every accordion trigger is its own Tab stop, as the APG prescribes for that pattern. Components not outputting `tabindex` there is intentional.
- Arrow key axes and writing direction: vertical lists ignore left and right; under RTL, left and right swap meaning. Both rules live in one place, `navIntentFromKey`, shared by every list-like component.
- Keys not handled by navigation are not `preventDefault`-ed; otherwise they would swallow input methods, browser shortcuts and default behavior.
- No misreading during IME composition: `isComposingEvent()` recognizes keys pressed while composing, so the `Enter` that confirms a candidate is not taken as a submit.

## Background inertness and focus

A modal overlay is assembled in a fixed order when it opens:

1. Register the layer and push it onto the layer stack;
2. Set up dismissal (Esc, outside clicks, focus leaving);
3. Set up the focus scope (trapping, wrapping, restoring);
4. Lock scrolling;
5. After the host commits the overlay nodes, add `aria-hidden` to the background; layers without a backdrop also get a transparent plate inside the positioning layer that blocks the pointer.

The order cannot be swapped.

## Visible focus

The focus ring is a single solid line drawn by one shared skin for the whole library: `outline`, width `--xh-ring-width` (2px), color `--xh-_ring-color` (`--xh-ring-focus` by default), offset `--xh-ring-offset` (minus one ring width). The ring is drawn inside the element's own box, its outer edge on the outer border edge; an outline takes no layout space, so the footprint and the silhouette are the same before and after focus.

The ring never leaves the box: whatever the element's size and whatever clipping container it sits in (a scroll area, a card with `overflow: hidden`, rows and cells laid end to end), the ring stays whole and never covers a neighbor, so applications need not reserve room for it. It does not follow the tone, appears instantly and never transitions.

WCAG 2.2 non-text contrast (SC 1.4.11) asks for at least 3:1 against adjacent colors. The ring's outer side touches the host surface: the library ring `--xh-ring-focus` reaches 3:1 against the page, the canvas, the surface and every step of the subtle ladder. Its inner side touches the element's own face. Where that face is solid or an arbitrary color (solid buttons and tags, checked boxes, a checked switch track, the selected calendar cell, swatches, color picker thumbs, heatmap cells) the ring can match the face, so these states line the inside of the ring with a 1px gap in the background color (`--xh-ring-gap`, the host surface color, also at least 3:1 against the library ring). Both sides of the ring are then background, and it reads on a face of any color. Children of colored areas switch the ring to ink through [ink domains](/design/colors). Forced colors drop box-shadow, the gap goes with it, and only the ring remains.

### The ring color does not follow the face

Skins do not recolor the ring. When a skin assigns `--xh-_ring-color` or `outline-color`, or writes the `outline` shorthand in a keyboard focus rule, the value must resolve to one of the two library rings (`--xh-ring-focus`, `--xh-ring-invalid`), optionally through a component override slot or the skin's own private slot; `currentColor`, foreground or background tokens and `transparent` all fail. To keep the ring readable on a solid face, a skin turns on the gap instead: it writes `--xh-_ring-gap: var(--xh-ring-gap)` in the solid state, and no other value is allowed. The library ring tokens (including `--xh-ring-gap`) and every name on their resolution chain (such as `--xh-color-brand-600`) must never be assigned in skins.

### Small controls whose border matches the ring

The thumbs of Slider and ColorSlider and the ColorPicker area thumb are bounded by a 2px border, which a ring drawn on top would coincide with. These pull the ring inside the border (`calc(var(--xh-ring-offset) - var(--xh-stroke-thick))`), still within the thumb's own box; dragging draws the same ring.

### Whether overlay shells take focus

Most overlay panels are only fallback landing spots of the focus trap (`tabindex=-1`); the ring sits on the controls inside, so the skins turn off the panel's own ring and register in the `ringless` partition who draws focus instead; the panel keeps its own shadow. The tour bubble is the exception: Enter / Space on it advances to the next step, so it is an actionable target (WCAG 2.4.7) and needs visible focus; after programmatic focus it uses the default ring. The browser criterion `focus-ring-geometry` guarantees this.

### Field shells

Field shells such as the input shell and the InputGroup shell switch their border to `--xh-border-control-focus` (the same color as `--xh-ring-focus`) on focus and add an inset line inside the border; together they make one ring width, the focus ring drawn inside the box, with no extra outline. An invalid field that takes focus shows the focus state. The input and field-inset buttons inside the box turn off their own ring; focus is marked by the shell's `:focus-within`. Forced colors drop the inset line, switch the border to `Highlight` and add a `Highlight` ring of the same width. Search boxes embedded in panels (Command, Cascader, TreeSelect, Transfer, SideNav) have no shell, and the caret is the focus indicator. These places are registered in the `ringless` partition of the focus ring gate.

### Chart data marks

Focusable bars, points and slices in charts get a focus-ring part traced around the outside of the mark with a surface gap: a scatter point is only a few pixels wide, too small to hold a ring inside. This is the one registered exception to the inside rule, and the ring still stays within the chart's own plot area.

### Related criteria

| Criterion | What it checks |
| --- | --- |
| `check-focus-ring` | The ring's width, color or offset is written as a literal instead of a token, so themes and global adjustments cannot reach it |
| `check-focus-ring-surface` | Fails ring color declarations that do not resolve to a library ring (`currentColor`, foreground or background colors, transparent); `--xh-_ring-gap` assigned anything other than `var(--xh-ring-gap)`; skins assigning a name on the library ring chain; and keyboard focus rules that turn the ring off (`outline: none` / `outline-width: 0` / `outline-style: none`) without being registered in `ringless` with a reason, or that are registered but no longer found. `@media` blocks count as conditional only for print, forced colors, reduced motion, coarse pointer and breakpoints; any other form and every `@container` block are treated as always true |
| `check-focus-outline-reset` | A skin resets `outline` (including `outline-style` / `outline-width` / `outline-color`) under `:focus:not(:focus-visible)`. The UA only draws the ring on `:focus-visible`, so that reset is dead code, and the `outline` shorthand resets `outline-color` to `currentColor`; once the border color transitions, a near-black outline flashes when focus leaves |
| `focus-ring-geometry` | Browser state: the ring's outer edge lies on the outer border edge and the footprint does not change on focus; field shells recolor the border and add an inset line, one ring width in total, without an outline; the shipped stylesheets use only `--xh-ring-offset` and the thumbs' inside-the-border offset; the library ring reaches 3:1 against every host surface and against the gap; solid faces line the ring with the gap on focus; the tour bubble shows a ring on focus; the ring survives forced colors and field shells get a system color ring of the same width |
| `focus-ring-shared` | Browser state: focuses the default case, a part with no focus rules of its own, a solid action control (lined with the gap, kept while hovered and pressed), a child of a solid face (not lined), a ring-off part and a field shell |

## Automated scans in the browser

The fixtures of the conformance suite are mounted in real Chromium, and axe runs on the initial state and the end state of every case. End states are deduplicated by their shape signature, so the same shape is not scanned twice.

Every fixture runs once with a light and once with a dark `data-theme` on the root, with `bg-canvas` / `fg-default` laid on the body as the base: when axe finds no ancestor painting a background it assumes white, and a dark run without a body background would compare dark text against white.

This has to run in a real browser: jsdom has no layout, so contrast, target size, flipping and collision avoidance cannot show up.

```bash
pnpm exec playwright install chromium # first time only
pnpm test:browser
```

## The registry of known violations

Known issues found by the scans are registered in `tooling/testing/src/a11y/known.ts`. A hit on a registered rule does not fail, but a registration that no longer hits anything is stale: after a fix it must be removed from the table, so no exemption outlives its reason.

There are six entries today. Five are shared by the three adapters: the disabled label text of `tag` uses the library-wide `fg-disabled` / `bg-muted` (2.36:1 in light, 1.93:1 in dark), which is exempt as a disabled control under 1.4.3, but the tag root has no disabled semantics axe recognizes, so it gets flagged; the removable tags of `select` outside the trigger are in the same position (tags inside the trigger sit in a natively disabled button that axe skips; the outer ones are role-less spans); the tag row of `combobox` comes before the input and is not inside any natively disabled control, so its disabled tag text belongs to the same case; when `file-upload` is disabled, the names and sizes of the file items also take `fg-disabled` (2.58:1 in light, 2.27:1 in dark) — the items are `role=listitem` and cannot carry `aria-disabled`, the three buttons are natively disabled and skipped by axe, so only these two texts are flagged; the input of `prompt-input` deliberately does not emit an unconditional `aria-label` (it would override the author's `<label for>`), and the conformance fixture deliberately provides no text to protect that contract, so the scan sees a textarea without a name, which authors name in real use. One belongs to the Web Components adapter alone: the required children of `steps` are written by the author and may lack the direct children their role requires. The global registry is empty. Registrations are kept per theme: hits in both the light and the dark run go into the shared table, hits in only one theme go into `knownByTheme`, both sides have stale checks, and a registration that is no longer found fails as well.

::: warning
This table is a faithful record of the current state, not a statement that it is acceptable. While it still has entries, assess the impact on your compliance requirements yourself.
:::

## Exemption from step replay

Only one component cannot be driven to the end state of its cases in a real browser, and both adapters share the registration:

- `breadcrumb`: the scan has to block cross-document navigation, otherwise the test host navigates away; yet the cases assert exactly that it is not blocked, and both cannot hold within one document.

## Related

- [Components](../components/): the keyboard behavior ids of every component
- [Design tokens and theming](./theme): the ring and surface tokens
- Testing, behavior primitives and skin layering, in Chinese: [测试与质量门禁](/guide/testing) · [行为原语](/guide/behavior) · [皮肤与样式分层](/guide/styling)
