原生 <select> 天生支持键盘:上下方向键移动、字母键跳转、Home/End 首尾直达。但用 ul/li 自绘列表框、组合框、菜单时,这一切都要自己实现——漏掉键盘支持的”自定义下拉”是可访问性重灾区。CDK 的 ListKeyManager 把这套键盘交互提炼成了可复用的控制器,Material 的 Select、Autocomplete、Menu 全部基于它。
问题:自绘列表的键盘债
一个像样的列表框至少要覆盖这些按键:
| 按键 | 预期行为 |
|---|---|
| ArrowUp / ArrowDown | 上下移动活动项 |
| Home / End | 跳到首/尾项 |
| 字母/数字键 | 跳到以该字符开头的下一项(type-ahead) |
| Enter / Space | 选中活动项 |
徒手实现要处理重复按键、列表为空、禁用项跳过、与读屏器同步等一堆边角。ListKeyManager 统一接管:你把按键事件交给它,它维护”活动项下标”,并暴露变更流让你同步 UI。
两种管理器:焦点给谁
| 管理器 | 模式 | 适用 |
|---|---|---|
ActiveDescendantKeyManager | 活动项加高亮,容器持焦点,用 aria-activedescendant 同步读屏器 | 列表框、组合框弹出层 |
ArrowKeyManager | 每次把真实焦点移到目标项元素 | 菜单、工具栏、路由 tab |
区别只在焦点归属:前者”一个焦点 + 一个高亮”,后者”焦点即在各项间移动”。两者共享同一套基类 API。
基础用法:ActiveDescendantKeyManager
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
import { QueryList } from '@angular/core';
@Component({
selector: 'app-listbox',
template: `
<ul role="listbox" tabindex="0"
[attr.aria-activedescendant]="activeId()"
(keydown)="onKeydown($event)">
@for (option of options(); track option.value) {
<li role="option" [id]="optionDomId(option)"
[class.active]="keyManager?.activeItemIndex() === $index"
[attr.aria-selected]="selected() === option.value"
(click)="select(option.value)">
{{ option.label }}
</li>
}
</ul>
`,
})
export class ListboxComponent {
readonly options = signal<Option[]>([
{ value: 'apple', label: '苹果' },
{ value: 'banana', label: '香蕉' },
{ value: 'cherry', label: '樱桃' },
]);
readonly selected = signal<string | null>(null);
readonly activeId = signal<string | null>(null);
keyManager: ActiveDescendantKeyManager<ListboxItemDirective> | null = null;
ngAfterContentInit(): void {
// this.items: QueryList<ListboxItemDirective>(见下文包装指令)
this.keyManager = new ActiveDescendantKeyManager(this.items)
.withTypeAhead() // 启用字母跳转
.skipPredicate((item) => item.disabled); // 自动跳过禁用项
this.keyManager.change.subscribe((index) => {
const item = this.options()[index];
this.activeId.set(item ? this.optionDomId(item) : null);
});
}
onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
const item = this.options()[this.keyManager!.activeItemIndex() ?? -1];
if (item) this.select(item.value);
event.preventDefault();
return;
}
// 方向键/Home/End/字母全部交给管理器
this.keyManager?.onKeydown(event);
}
select(value: string): void {
this.selected.set(value);
}
optionDomId(option: Option): string {
return `opt-${option.value}`;
}
}
配套的包装指令负责把 disabled 与 DOM id 暴露给管理器——ListKeyManager 面向”实现了 ListKeyManagerOption 接口的项”工作:
@Directive({ selector: 'li[role="option"]' })
export class ListboxItemDirective implements ListKeyManagerOption {
disabled = false;
get id(): string {
return this.el.nativeElement.id;
}
private readonly el = inject(ElementRef<HTMLElement>);
}
aria 协议要点:容器 role="listbox" + tabindex="0"(容器持焦点),aria-activedescendant 指向当前活动项的 DOM id——读屏器据此朗读”第 N 项”而无需移动真实焦点。这就是 ActiveDescendant 模式的精髓。
键盘事件流转
sequenceDiagram
participant U as 用户
participant C as 容器(keydown)
participant K as KeyManager
participant L as change 订阅方
U->>C: ArrowDown / 'b' / Home
C->>K: onKeydown(event)
K->>K: 解析按键(跳过 skipPredicate 命中的项)
K->>L: change 发出新的活动下标
L->>C: 更新高亮 class 与 aria-activedescendant
Note over K: type-ahead 有输入缓冲:连续按键<br/>在延时窗口内拼接匹配
type-ahead:输入跳转
withTypeAhead() 启用后,按下 b 跳到下一个以 b 开头的项,快速连按 ba 则匹配 ba 开头的项——实现是管理器内部维护一个带延时窗口(默认 200ms,withTypeAheadDebounceInterval() 可调)的输入缓冲。两个细节:
- 匹配默认基于项的
getLabel()(未提供时用id或label输入),中文列表的 type-ahead 匹配的是拼音方案之外的原始字符,中文场景通常改用搜索框过滤而非字母跳转 skipPredicate同样作用于 type-ahead:禁用项不会被跳转目标选中
编程 API
除 onKeydown 外,管理器可完全用代码驱动:
| 方法/属性 | 作用 |
|---|---|
setActiveItem(index 或 item) | 直接设置活动项 |
activeItemIndex() | 当前活动下标(可能为 null) |
activeItem() | 当前活动项实例 |
onKeydown(event) | 分发键盘事件 |
change | 活动项变化的 Observable |
withHomeAndEnd() | 启用 Home/End 支持 |
withHorizontalOrientation('ltr') | 声明左右键语义(横排工具栏用) |
完整示例:键盘友好的 Combobox
组合框(input + 弹出建议列表)是 ListKeyManager 最经典的舞台。结合第 40 篇的 Overlay 弹出、本篇的键盘管理,核心逻辑如下:
@Component({
selector: 'app-combobox',
template: `
<input role="combobox" [attr.aria-expanded]="open()"
[attr.aria-activedescendant]="activeId()"
aria-autocomplete="list" aria-controls="cb-listbox"
[value]="query()" (input)="onInput($event)" (keydown)="onKeydown($event)" />
@if (open()) {
<ul id="cb-listbox" role="listbox" class="suggestions">
@for (city of suggestions(); track city.code) {
<li role="option" [id]="'city-' + city.code" [class.active]="isActive($index)"
(click)="pick(city)">{{ city.name }}</li>
}
</ul>
}
`,
})
export class ComboboxComponent {
readonly cities = CITIES; // { code, name }[]
readonly query = signal('');
readonly open = signal(false);
readonly activeId = signal<string | null>(null);
readonly suggestions = computed(() => {
const q = this.query().trim().toLowerCase();
return q ? this.cities.filter((c) => c.name.toLowerCase().includes(q)) : this.cities.slice(0, 8);
});
// 与 ListboxComponent 相同的接线:包装指令 + QueryList 在 ngAfterContentInit 中创建
private keyManager: ActiveDescendantKeyManager<ComboItem> | null = null;
ngAfterContentInit(): void {
this.keyManager = new ActiveDescendantKeyManager(this.comboItems)
.withTypeAhead()
.withHomeAndEnd()
.skipPredicate((item) => item.disabled);
}
onInput(event: Event): void {
this.query.set((event.target as HTMLInputElement).value);
this.open.set(true);
this.keyManager?.setActiveItem(0); // 输入变化后回到第一项
}
onKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowDown':
case 'ArrowUp':
case 'Home':
case 'End':
this.open.set(true);
this.keyManager?.onKeydown(event); // 方向键默认行为的拦截由管理器处理
this.syncActive();
break;
case 'Enter': {
const idx = this.keyManager?.activeItemIndex();
if (idx != null) this.pick(this.suggestions()[idx]);
break;
}
case 'Escape':
this.open.set(false);
break;
}
}
isActive(index: number): boolean {
return this.keyManager?.activeItemIndex() === index;
}
private syncActive(): void {
const idx = this.keyManager?.activeItemIndex();
const item = idx != null ? this.suggestions()[idx] : undefined;
this.activeId.set(item ? `city-${item.code}` : null);
}
pick(city: City): void {
this.query.set(city.name);
this.open.set(false);
}
}
对照 WAI-ARIA 的 combobox 模式:aria-expanded 反映弹出态、aria-activedescendant 指向活动项、Escape 关闭、Enter 选中、输入过滤——键盘用户全程不碰鼠标。生产实现还会把弹出层放进 Overlay(脱离 overflow 裁剪)、用 FocusTrap 管理焦点进出,这些在前两篇已有完整示范。
常见坑
- 管理器对旧数据残留活动下标:过滤后列表变短,先
setActiveItem(-1)或按需重置,否则 Enter 会选中越界项 - 忘记
event.preventDefault():Space 会触发页面滚动、ArrowDown 会滚动容器,管理器内部已处理多数默认行为,Enter/Escape 分支要自己拦 - 容器没有 tabindex:键盘事件根本到不了你的 keydown 处理器,ActiveDescendant 模式容器必须可聚焦
- aria-activedescendant 指向的 id 不在 DOM:过滤、虚拟滚动裁剪都会导致悬空引用,读屏器会沉默——高频更新场景先校验
- QueryList 未就绪就创建管理器:
ngAfterContentInit之前 QueryList 为空,提前创建会拿不到项