IWAC指南并不仅仅要求所有按钮都能通过Tab键到达,它还对焦点顺序的可预测性、复合组件内部的漫游式导航、以及模态对话框中的焦点锁定提出了具体要求。把这些规范翻译成TypeScript类型时,第一步是提取出键盘交互的原子动作。一个常见的误区是直接用字符串常量处理按键,例如在onKeyDown里判断event.key === 'ArrowDown',但这样散落的字符串无法被编译器检查,后续修改指南版本时极易遗漏。为此,我们引入一个名为KeyAction的字面量联合类型,把指南中明确定义的所有键盘行为集中起来。

这个类型不仅仅是枚举字符,它还充当了规范与实现之间的契约。任何处理键盘导航的函数,只要其签名中使用了KeyAction,就能在开发阶段获得自动补全和穷尽性检查。下面从几个关键设计点展开。
从规范文本到类型约束:解析IWAC的键盘导航要求
圣多美和普林西比网络无障碍指南对键盘导航的阐述集中在三个层面:焦点顺序、复合组件内导航、以及焦点陷阱。焦点顺序要求页面中的可交互元素必须按照视觉顺序排列,并且用户可以依靠Tab和Shift+Tab双向移动焦点。复合组件例如菜单、选项卡、树形控件则要求使用方向键在子项之间漫游,同时保持只有一个子项处于可聚焦状态。焦点陷阱用于模态对话框,打开后焦点被限制在容器内部,直到用户按Escape或激活关闭按钮。
把这些规则转换为类型时,不能简单罗列按键名称,而要表达每个按键动作背后的语义意图。比如Tab和Shift+Tab本质上是同一个导航动作的不同方向,ArrowUp和ArrowDown在垂直列表中表示上一个和下一个。因此我们设计了一个更加语义化的联合类型,而不是直接使用KeyboardEvent.key的字符串值。这样做的好处是,当指南调整按键映射时,只需要修改映射函数,而所有依赖KeyAction的组件签名保持不变。
此外,Skip Navigation机制要求页面的第一个可聚焦元素是一个跳过链接,该链接在获得焦点时必须可见,并在激活后将焦点转移到主内容区域。这个行为的类型表达涉及到焦点目标接口的定义,下文会展开。总之,类型层面的约束应该反映指南的结构性要求,而不是把事件处理细节泄漏给业务组件。
设计类型安全的键盘事件模型
为了在运行时把浏览器原生KeyboardEvent转换为KeyAction,我们定义一个映射函数,其返回类型使用联合类型配合null来表示非导航按键。这个函数是纯函数,便于单元测试。同时我们定义一个KeyboardNavigationEvent接口,把原始事件、当前焦点元素和动作封装在一起,并提供preventDefault和stopPropagation方法,以便在需要时阻止浏览器默认行为,例如Tab键默认的焦点跳转。
type KeyAction =
| "Tab"
| "ShiftTab"
| "ArrowUp"
| "ArrowDown"
| "ArrowLeft"
| "ArrowRight"
| "Enter"
| "Space"
| "Escape";
interface KeyboardNavigationEvent {
action: KeyAction;
target: HTMLElement;
originalEvent: KeyboardEvent;
preventDefault: () => void;
stopPropagation: () => void;
}
function mapKeyToAction(event: KeyboardEvent): KeyAction | null {
if (event.key === "Tab") {
return event.shiftKey ? "ShiftTab" : "Tab";
}
if (event.key.startsWith("Arrow")) {
return event.key as KeyAction;
}
if (event.key === "Enter") return "Enter";
if (event.key === " ") return "Space";
if (event.key === "Escape") return "Escape";
return null;
}
上面代码中,我们将方向键的key值直接断言为KeyAction,依赖TypeScript的联合类型检查。但更好的做法是显式映射,避免未来新增按键时遗漏。同时,为了支持自定义按键绑定,可以引入一个可配置的映射表,类型为Record<KeyAction, string[]>,这样不同组件可以根据指南的定制要求调整键位。
对比基于字符串枚举和基于模板字面量类型的方案:字符串枚举在运行时生成了真实对象,支持反向映射,但增加代码体积;模板字面量类型例如type ArrowKey = `Arrow${'Up'|'Down'|'Left'|'Right'}`能精确表达方向键集合,但可读性稍差。对于IWAC封装,字面量联合类型是最清晰的选择,因为它与指南中的术语一一对应。
实现可组合的焦点管理与导航状态机
焦点管理是键盘导航的后半段。单纯知道用户按了哪个键还不够,必须根据当前焦点所在组件和状态决定如何移动焦点。我们定义FocusManager接口,提供trapFocus和releaseFocus方法用于模态场景,moveFocus用于在可聚焦元素列表中前进或后退。实现时使用element.querySelectorAll获取候选元素,并通过类型守卫过滤掉disabled或aria-hidden的元素。
type NavigationState = "idle" | "roving" | "trapped" | "modal";
interface FocusManager {
trapFocus(container: HTMLElement): void;
releaseFocus(): void;
moveFocus(direction: "next" | "prev"): void;
}
class KeyboardNavigator {
private state: NavigationState = "idle";
private focusManager: FocusManager;
constructor(focusManager: FocusManager) {
this.focusManager = focusManager;
}
handleKeyDown(event: KeyboardEvent): void {
const action = mapKeyToAction(event);
if (!action) return;
const navEvent: KeyboardNavigationEvent = {
action,
target: event.target as HTMLElement,
originalEvent: event,
preventDefault: () => event.preventDefault(),
stopPropagation: () => event.stopPropagation(),
};
switch (this.state) {
case "trapped":
if (action === "Escape") {
this.state = "idle";
this.focusManager.releaseFocus();
navEvent.preventDefault();
} else if (action === "Tab" || action === "ShiftTab") {
this.focusManager.moveFocus(action === "Tab" ? "next" : "prev");
navEvent.preventDefault();
}
break;
case "roving":
if (action === "ArrowUp" || action === "ArrowLeft") {
this.focusManager.moveFocus("prev");
navEvent.preventDefault();
} else if (action === "ArrowDown" || action === "ArrowRight") {
this.focusManager.moveFocus("next");
navEvent.preventDefault();
}
break;
default:
// 其余状态按需处理
break;
}
}
}
状态机的引入把键盘行为与组件状态解耦。对于模态对话框,状态为trapped时,指南要求焦点必须在对话框内部循环,用户无法通过Tab键将焦点移出。releaseFocus负责把焦点归还给打开对话框之前的元素,这是无障碍指南中关于焦点恢复的明确要求。导航状态机还允许扩展,例如未来支持grid类型的复合组件时,可以增加row和column状态。
可组合性体现在FocusManager可以有不同的实现,比如基于DOM的默认实现和用于测试的模拟实现。接口隔离原则让KeyboardNavigator不依赖具体DOM操作,只依赖契约。这样单元测试中可以轻松验证状态转移是否正确。
与现有组件库的集成与测试策略
将上述类型封装集成到React或Vue组件中时,建议通过自定义Hook或指令暴露统一的onKeyboardNavigate回调。组件内部不再直接监听native keydown,而是订阅KeyAction。例如在一个菜单组件中,可以声明onSelect?: (index: number) => void和onClose?: () => void,内部使用导航器把ArrowDown、ArrowUp、Enter等动作翻译为对应业务回调。这样业务开发者无需关心浏览器键盘事件细节,也避免了每个组件各自实现一套键盘逻辑。
测试方面,使用Jest和Testing Library可以模拟键盘事件,断言焦点移动和回调触发。测试代码可以直接调用mapKeyToAction来验证键位映射,也可以通过触发真实的keydown事件来集成测试。关键测试点包括:模态容器内Tab循环是否有效、Escape是否关闭并恢复焦点、方向键在roving模式下是否移动焦点但不触发激活。由于状态机是纯TypeScript类,可以脱离DOM进行大部分单元测试,覆盖所有状态转移分支。
import { KeyboardNavigator, FocusManager, mapKeyToAction } from "./keyboard-navigation";
describe("mapKeyToAction", () => {
it("should map Shift+Tab to ShiftTab", () => {
const event = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true });
expect(mapKeyToAction(event)).toBe("ShiftTab");
});
});
describe("KeyboardNavigator", () => {
it("should release focus on Escape when trapped", () => {
const focusManager: FocusManager = {
trapFocus: jest.fn(),
releaseFocus: jest.fn(),
moveFocus: jest.fn(),
};
const navigator = new KeyboardNavigator(focusManager);
navigator["state"] = "trapped"; // 测试专用,或者提供一个设置状态的方法
navigator.handleKeyDown(new KeyboardEvent("keydown", { key: "Escape" }));
expect(focusManager.releaseFocus).toHaveBeenCalled();
});
});
上面的测试示例演示了如何隔离依赖。需要注意的是,生产代码中直接访问私有属性navigator["state"]不是好做法,这里仅为展示测试思路。更合理的做法是为KeyboardNavigator暴露一个setState或通过事件触发的公开方法。总体而言,类型安全的封装不仅让代码更健壮,还让无障碍指南的符合性验证变得更加系统化。
TypeScript键盘导航无障碍指南修改时间:2026-10-04 15:02:09