CHARLIE SAYS

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

CDK 22+ 教程 45:Accessibility——FocusTrap 与焦点管理

无障碍(a11y)不是”锦上添花”,而是键盘用户、屏幕阅读器用户能否使用你产品的分水岭,也是越来越多法域的合规要求。CDK 的 a11y 模块沉淀了 Material 全系组件的焦点管理经验:FocusMonitor 追踪焦点来源,FocusTrap 圈住焦点,LiveAnnouncer 向读屏器播报。v22 起 @angular/aria 包也正式 GA,提供了更多无障碍原语。

Web 无障碍速览:焦点是键盘用户的”光标”

视力正常的用户用鼠标点击任意元素,键盘用户只能靠 Tab 在可聚焦元素间线性移动。浏览器用 :focus 表示当前目标,CDK 关注的核心问题有三个:

  • 焦点从哪来:鼠标点的不该显示键盘焦点环(focus ring),键盘 Tab 的必须显示——CSS :focus-visible 之前,需要 JS 判断
  • 焦点在哪:对话框打开时焦点必须进入对话框、关闭时必须还给触发按钮,否则键盘用户会”迷路”到被遮罩的背景里
  • 变化如何被听见:屏幕阅读器感知不到 DOM 里静默更新的文本,需要 aria live region 主动播报

FocusMonitor:追踪焦点来源

FocusMonitor 服务监听元素焦点变化,并标注来源(FocusOrigin):

import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';

@Component({ selector: 'app-menu-item', template: `<button type="button">删除</button>` })
export class MenuItemComponent implements OnDestroy {
  private readonly focusMonitor = inject(FocusMonitor);
  private readonly host = inject(ElementRef<HTMLElement>);

  constructor() {
    this.focusMonitor.monitor(this.host, true).subscribe((origin: FocusOrigin) => {
      // origin: 'keyboard' | 'mouse' | 'touch' | 'program' | null(失焦)
      this.showFocusRing.set(origin === 'keyboard');
    });
  }

  ngOnDestroy(): void {
    // monitor 注册了全局监听,注销是强制的
    this.focusMonitor.stopMonitoring(this.host);
  }

  showFocusRing = signal(false);
}

FocusOrigin 的语义与典型处理:

origin含义处理
keyboardTab/方向键到达显示焦点环
mouse鼠标点击通常不显示环
touch触屏同上
programelement.focus() 调用视调用方意图,默认同 keyboard
null失焦隐藏环

monitor 的第二个参数 checkChildren 表示是否把子元素的焦点视为该元素的焦点(宿主模式)。它同时会给元素打上 cdk-focusedcdk-keyboard-focused 等 class——所以很多场景根本不用自己订阅,直接用这两个 class 写 CSS 就够了,这也是 :focus-visible 的等价实现。

配套的还有指令形态:cdkMonitorElementFocus/cdkMonitorSubtreeFocus,以及 FocusTrapfocusVia(el, 'program') 可以带来源地设置焦点。

FocusTrap:对话框的”陷阱”

对话框打开期间,Tab 焦点不允许跑出对话框边界——这就是焦点陷阱(focus trap)。两种用法:

指令(CDKTrapFocus)——适合模板内的对话框:

<div class="dialog" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
  <input placeholder="名称" />
  <button type="button">取消</button>
  <button type="button">确定</button>
</div>

工厂(FocusTrapFactory)——适合动态创建的浮层:

private readonly trapFactory = inject(FocusTrapFactory);

private attachTrap(container: HTMLElement): FocusTrap {
  const trap = this.trapFactory.create(container);
  trap.focusFirstTabbableElement(); // 打开时把焦点送进对话框
  return trap;
}
  • cdkTrapFocus(属性写法即激活)创建陷阱;Tab/Shift+Tab 在容器内首尾元素间循环
  • autoCapture(或 trap.focusFirstTabbableElement())在激活时自动把焦点移入容器;销毁指令时焦点自动还原到之前的元素
  • FocusTrap 实例的 focusInitialContainerWhenAttached()focusLastTabbableElement() 用于细节控制

WAI-ARIA 的 APG(Authoring Practices Guide)对 dialog 还有 role="dialog"aria-modal="true"、Escape 关闭等要求,下面完整示例一并落实。

LiveAnnouncer:让读屏器听见

import { LiveAnnouncer } from '@angular/cdk/a11y';

private readonly announcer = inject(LiveAnnouncer);

// 操作完成后播报,polite 等待读屏器空闲;assertive 立即打断
this.announcer.announce('已删除 3 个文件', 'assertive');

实现上它维护了一个 aria-live="polite" 的隐藏区域,写入文本后读屏器会朗读。用于”删除成功""已加载 20 条结果”这类视觉之外的反馈。注意播报内容应该是结论而非过程(“已排序,共 120 项”好于”排序中”)。

AriaDescriber 与 @angular/aria

AriaDescriber 服务把提示文本通过 aria-describedby 关联到目标元素,屏幕阅读器聚焦时朗读描述——错误提示、字段帮助文案的规范做法。v22 中独立的 @angular/aria 包已 GA,将常用的 aria 相关工具从 CDK 中独立成包,按需引入可以进一步减小包体;CDK a11y 仍是焦点管理的主力。

完整示例:可访问的确认对话框

把 Overlay(第 40 篇)+ Portal(第 41 篇)+ 本篇焦点管理拼起来,实现一个 60 行的可访问 ConfirmDialog:

import { Component, DestroyRef, Injector, InjectionToken, inject, signal } from '@angular/core';
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { A11yModule, LiveAnnouncer } from '@angular/cdk/a11y';

export const CONFIRM_DATA = new InjectionToken<{ title: string; message: string }>('CONFIRM_DATA');
export const CONFIRM_RESULT = new InjectionToken<() => void>('CONFIRM_RESULT');

@Service()
export class ConfirmService {
  private readonly overlay = inject(Overlay);
  private readonly injector = inject(Injector);

  open(title: string, message: string, onConfirm: () => void): void {
    const ref = this.overlay.create(new OverlayConfig({
      hasBackdrop: true,
      backdropClass: 'cdk-overlay-dark-backdrop',
      positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),
      scrollStrategy: this.overlay.scrollStrategies.block(),
    }));

    const dataInjector = Injector.create({
      parent: this.injector,
      providers: [
        { provide: CONFIRM_DATA, useValue: { title, message } },
        { provide: OverlayRef, useValue: ref },
        { provide: CONFIRM_RESULT, useValue: onConfirm },
      ],
    });
    ref.attach(new ComponentPortal(ConfirmDialog, undefined, dataInjector));
  }
}

@Component({
  selector: 'app-confirm-dialog',
  imports: [A11yModule],
  template: `
    <div class="dialog" role="dialog" aria-modal="true" [attr.aria-labelledby]="titleId"
         cdkTrapFocus cdkTrapFocusAutoCapture (keydown.escape)="close(false)">
      <h2 [id]="titleId">{{ data().title }}</h2>
      <p>{{ data().message }}</p>
      <div class="actions">
        <button type="button" (click)="close(false)">取消</button>
        <button type="button" (click)="close(true)">确定</button>
      </div>
    </div>
  `,
  styles: `.dialog { background: #fff; border-radius: 8px; padding: 24px; min-width: 320px; }`,
})
export class ConfirmDialog {
  private readonly overlayRef = inject(OverlayRef);
  private readonly announcer = inject(LiveAnnouncer);
  private readonly onConfirm = inject(CONFIRM_RESULT);

  readonly data = inject(CONFIRM_DATA);
  readonly titleId = 'confirm-title';

  close(confirmed: boolean): void {
    if (confirmed) {
      this.onConfirm();
      this.announcer.announce('操作已确认', 'polite');
    }
    this.overlayRef.dispose(); // 焦点随 FocusTrap 自动还原到触发按钮
  }
}

对照 APG 的 dialog 模式逐项检查:打开时焦点进入对话框(autoCapture)、Tab 不出圈(cdkTrapFocus)、Escape 可关闭(keydown.escape)、关闭后焦点还原(FocusTrap 销毁时自动处理)、role/aria-modal 齐备、结果有语音反馈(LiveAnnouncer)。这套骨架也正是 MatDialog 的内部实现思路。

focus-visible 与键盘导航清单

发布前用键盘过一遍核心流程:

  • 只用 Tab/Shift+Tab/Enter/Space/Escape/方向键能否完成主流程
  • 每个可交互元素聚焦时是否有可见指示(:focus-visiblecdk-keyboard-focused 样式)
  • 打开任何浮层后 Tab 是否被圈住、关闭后焦点是否回家
  • 动态更新的状态(计数、错误、结果)是否有 aria live 播报
  • 自定义列表/组合框是否支持方向键与字母跳转(下一篇 ListKeyManager 专治这个)

常见坑

  • FocusMonitor 不 stopMonitoring:组件销毁后监听仍在,既是泄漏也会误判
  • autoCapture 用在嵌套陷阱:两个陷阱抢焦点会导致焦点抖动,浮层叠浮层时要手动规划焦点归还链
  • announce 频繁调用:读屏器会排队朗读,合并播报内容、降低频率
  • 只写 aria 属性不做焦点管理:读屏器用户”听得见”对话框,键盘用户却进不去——aria 与焦点是两件事
  • 在 Zoneless 下用 focus() 后指望立即渲染:焦点管理是纯 DOM 操作,不涉及变更检测,属性绑定记得用信号驱动

系列导航

← 算法 045:头脑风暴题目 目录 CDK 22+ 教程 46:ListKeyManager 键盘导航 →
← 返回文章列表