希腊网络无障碍指南(Greek Web Accessibility Guidelines)以WCAG为基础,对键盘可达性提出了明确要求:所有交互组件必须可以通过键盘完成操作,方向键应在复合组件中承担导航职责,焦点顺序必须符合逻辑且不能被陷阱式锁定(模态场景除外)。要把这些要求落实到一个组件库里,光靠文档约定是不够的,容易在迭代中被无意破坏。用TypeScript把这些规则固化到类型系统里,编译期就能拦住大部分违规写法,是更可靠的方案。

为什么需要为键盘导航单独设计类型层
直接使用浏览器提供的KeyboardEvent,你会发现event.key的类型被声明为宽泛的string。这意味着无论你写了多少if (e.key === 'ArrowUp')的判断,TypeScript都无法帮你检查拼写错误,比如把ArrowUp误写成arrowup或Arrowup,编译器一声不吭,运行时导航直接失效。这类bug在无障碍组件里特别隐蔽,鼠标用户完全感知不到,只有键盘用户会踩坑。
另一个常见问题是修饰键处理。希腊指南要求快捷键不能与系统级快捷键冲突,因此判断组合键时经常写出e.ctrlKey && e.key === 'k'这样的条件。这些逻辑散落在各个组件的事件处理器里,每次都要重新判断大小写、修饰键、是否阻止默认行为,重复且容易遗漏。把按键常量、修饰键组合、事件处理的签名统一收敛到类型层,组件代码只关心业务语义,才是可维护的做法。
此外,焦点管理相关的API(如focus()、activeElement)返回的都是HTMLElement级别的宽类型,调用其子类型特有的属性需要不断断言。为焦点可聚焦元素定义精确的类型别名,能让工具函数的输入输出契约更清晰。
定义按键常量类型与类型收窄
第一步是建立一个受约束的键类型。核心思路是:用const断言的对象作为唯一事实来源,再通过typeof推导出键的联合类型。这样常量与类型永远同步,新增按键只需改一处。
// 键盘按键常量,as const 保证推导出字面量类型
export const KEYS = {
Enter: 'Enter',
Space: ' ',
Escape: 'Escape',
Tab: 'Tab',
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
ArrowLeft: 'ArrowLeft',
ArrowRight: 'ArrowRight',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
} as const;
// 所有合法按键的联合类型:'Enter' | ' ' | 'Escape' | ...
export type Key = (typeof KEYS)[keyof typeof KEYS];
// 方向键子集,用于复合组件导航
export type NavigationKey =
| 'ArrowUp'
| 'ArrowDown'
| 'ArrowLeft'
| 'ArrowRight';有了Key类型之后,可以编写一个类型守卫函数,把KeyboardEvent的宽泛字符串收窄到受控集合内。任何组件里的按键判断都先经过这个守卫,拼错的键名会在编译期直接报错:
export function isKey<K extends Key>(
event: KeyboardEvent,
key: K
): event is KeyboardEvent & { key: K } {
return event.key === key;
}
// 使用示例:编译器能确认分支内 key 就是 'ArrowDown'
function handleListKeydown(e: KeyboardEvent) {
if (isKey(e, 'ArrowDown')) {
// e.key 在这里被收窄为字面量 'ArrowDown'
moveFocus('next');
}
}需要注意Space对应的值是空格字符' ',这是event.key规范里的实际值,不少封装库误写成'Spacebar'(旧版IE的值)。如果需要兼容极老的环境,可以在守卫函数内部做一层归一化映射,但对外暴露的类型保持不变。
封装方向键导航与焦点陷阱
希腊指南对列表、菜单、选项卡这类复合组件的建议是:使用方向键在子项间移动,起点终点是否循环可选,但必须保证焦点始终停留在某个子项上。可以把这套逻辑抽象成一个通用函数,通过泛型约束接受任何包含子项引用的容器:
import { KEYS, NavigationKey, isKey } from './keys';
export interface FocusNavigationOptions {
/** 到达末尾后是否循环到开头,默认 true */
loop?: boolean;
/** 是否允许 Home / End 跳转,默认 true */
allowHomeEnd?: boolean;
}
export function navigateFocus(
container: HTMLElement,
itemSelector: string,
event: KeyboardEvent
): boolean {
const items = Array.from(
container.querySelectorAll<HTMLElement>(itemSelector)
).filter(el => !el.hasAttribute('disabled'));
const currentIndex = items.indexOf(
document.activeElement as HTMLElement
);
let nextIndex: number | null = null;
if (isKey(event, 'ArrowDown') || isKey(event, 'ArrowRight')) {
nextIndex = currentIndex + 1;
} else if (isKey(event, 'ArrowUp') || isKey(event, 'ArrowLeft')) {
nextIndex = currentIndex - 1;
} else if (isKey(event, 'Home')) {
nextIndex = 0;
} else if (isKey(event, 'End')) {
nextIndex = items.length - 1;
}
if (nextIndex === null) return false;
const loop = true;
const count = items.length;
const target = ((nextIndex % count) + count) % count; // 处理循环
items[target].focus();
event.preventDefault();
return true;
}对于模态对话框,指南允许焦点陷阱存在,而且要求必须存在。实现陷阱的本质是拦截Tab键在首尾元素之间循环,这里同样借助类型化的按键判断保证逻辑只响应Tab,不受其他按键干扰:
export function trapFocus(
dialog: HTMLElement,
event: KeyboardEvent
): void {
if (!isKey(event, 'Tab')) return;
const focusable = dialog.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}修饰键组合的类型映射与自定义守卫
更进一步的封装是给组合键建立类型映射。用一个ModifierKey接口描述修饰键状态,再通过工厂函数生成只响应特定组合的处理器。这种方式把修饰键判断从业务代码里剥离,同时让处理器的依赖在类型上一目了然:
export interface ModifierState {
ctrl: boolean;
alt: boolean;
shift: boolean;
meta: boolean;
}
export function onKeyCombo(
key: Key,
modifier: Partial<ModifierState>,
handler: (e: KeyboardEvent) => void
): (e: KeyboardEvent) => void {
return (e) => {
if (e.key !== key) return;
if (Boolean(modifier.ctrl) !== e.ctrlKey) return;
if (Boolean(modifier.alt) !== e.altKey) return;
if (Boolean(modifier.shift) !== e.shiftKey) return;
if (Boolean(modifier.meta) !== e.metaKey) return;
e.preventDefault();
handler(e);
};
}
// 只有 Ctrl + / 且无其他修饰键时触发
const toggleHelp = onKeyCombo('/', { ctrl: true }, openHelpPanel);
document.addEventListener('keydown', toggleHelp);这套设计的收益在实践中很明显。首先,所有键名字符串集中在常量对象里,重构时不会遗漏;其次,isKey这类守卫函数把类型收窄能力带给了普通条件分支,组件内部不再需要as断言;最后,导航与陷阱逻辑与具体组件解耦,任何符合选择器的容器都能直接复用,符合指南对复合组件的统一要求。
落地时还有两点建议:一是为工具库补充单元测试,覆盖event.key的大小写变体和event.code的回退场景,避免不同键盘布局下的偏差;二是在CI中加入类型检查与无障碍lint(例如eslint-plugin-jsx-a11y),让类型系统和静态检查形成双保险,键盘可达性才能真正长期保持。TypeScript的类型层不能替代人工的无障碍测试,但它能大幅降低无意识回归的概率,是每个组件库都值得投入的基础设施。
TypeScript键盘导航无障碍修改时间:2026-09-03 12:05:19