CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-212 · Angular 高级教程

CDK 22+ 教程 46:ListKeyManager 键盘导航

原生 <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()(未提供时用 idlabel 输入),中文列表的 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 为空,提前创建会拿不到项

系列导航

← CDK 22+ 教程 45:Accessibility——FocusTrap 与焦点管理 目录 CDK 22+ 教程 47:Layout 断点与 Observers 观察者 →
← 返回文章列表