无障碍(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 | 含义 | 处理 |
|---|---|---|
keyboard | Tab/方向键到达 | 显示焦点环 |
mouse | 鼠标点击 | 通常不显示环 |
touch | 触屏 | 同上 |
program | element.focus() 调用 | 视调用方意图,默认同 keyboard |
null | 失焦 | 隐藏环 |
monitor 的第二个参数 checkChildren 表示是否把子元素的焦点视为该元素的焦点(宿主模式)。它同时会给元素打上 cdk-focused 与 cdk-keyboard-focused 等 class——所以很多场景根本不用自己订阅,直接用这两个 class 写 CSS 就够了,这也是 :focus-visible 的等价实现。
配套的还有指令形态:cdkMonitorElementFocus/cdkMonitorSubtreeFocus,以及 FocusTrap 的 focusVia(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-visible或cdk-keyboard-focused样式) - 打开任何浮层后 Tab 是否被圈住、关闭后焦点是否回家
- 动态更新的状态(计数、错误、结果)是否有 aria live 播报
- 自定义列表/组合框是否支持方向键与字母跳转(下一篇 ListKeyManager 专治这个)
常见坑
- FocusMonitor 不 stopMonitoring:组件销毁后监听仍在,既是泄漏也会误判
- autoCapture 用在嵌套陷阱:两个陷阱抢焦点会导致焦点抖动,浮层叠浮层时要手动规划焦点归还链
- announce 频繁调用:读屏器会排队朗读,合并播报内容、降低频率
- 只写 aria 属性不做焦点管理:读屏器用户”听得见”对话框,键盘用户却进不去——aria 与焦点是两件事
- 在 Zoneless 下用 focus() 后指望立即渲染:焦点管理是纯 DOM 操作,不涉及变更检测,属性绑定记得用信号驱动
系列导航
- 上一篇:CDK 22+ 教程 44:Table 与 DataSource
- 下一篇:CDK 22+ 教程 46:ListKeyManager 键盘导航
- 相关阅读:ListKeyManager 键盘导航(无障碍姊妹篇) · Overlay——浮层的基石 · Portal——内容投射