Angular Material 18+ 高级教程 – CDK Accessibility の Focus
介绍
CDK Focus 是对原生 DOM focus 的上层封装和扩展。
Focus Origin
原生 DOM focus 我们只能知道 element 被 focus 了,但是无法知道它是怎么被 focus 的,但 CDK Focus 可以。
比如说,有一个 button,我们有三种方式可以 focus 它:
-
用 mouse 点击这个 button
-
用 keyboard tab
-
用 Script -- button.focus()
CDK Focus 在监听到 focus 后,会附带一个 origin 值,这个 origin 就阐明了 focus 的来源
touch (触屏) 和 mouse 代表用户点击了按钮导致了 focus。
keyboard 指的是用户按键 tab 导致了 focus。
program 表示是程序 Script 导致了 focus。
Descendant Focused
DOM focus 和 blur 不支持冒泡,所以无法监听子孙 element 的 focus 和 blur 事件。
通常我们会改用 focusin 和 focusout,因为它们支持冒泡。
或者用 focus + capture 提早捕获然后再自行判断。
这些繁琐的事情,CDK Focus 都替我们封装好了,只要告诉它我们是否要监听子孙 focus / blue 就可以了。
FocusMonitor
好,知道了它的用途,接着我们来看具体代码演示。
FocusMonitor 是一个 Root Level Provider,我们需要使用它来监听 focus 事件。
App Template
<button #button>click</button>
App 组件
export class AppComponent { // 1. query button element readonly button = viewChild.required('button', { read: ElementRef }); constructor() { // 2. inject FocusMonitor const focusMonitor = inject(FocusMonitor); afterNextRender(() => { // 3. 监听 focus 事件 focusMonitor.monitor(this.button().nativeElement).subscribe(origin => { if (origin !== null) { // 4. origin could be 'touch', 'mouse', 'keyboard', 'program' console.log('focused by: ', origin); } else { // 5. if origin null mean blur console.log('blur'); } }); }); } }
几个知识点:
-
DOM Manupulation
FocusMonitor.monitor 是 DOM Manupulation,它底层执行的是 element.addEventListenter('focus'),所以它需要 ElementRef 或者 HtmlElement。
最好是在组件 lifecycle AfterRenderHooks 阶段才监听。
-
runOutsideAngular
FocusMonitor.monitor 是 ngZone.runOutsideAngular 的,所以 focus 事件不会被 Zone.js 监听到。
如果我们在事件 callback 想让 Angular refreshView 需要自己手动 tick。
对 Change Detection 不熟悉的朋友,可以复习这篇 Change Detection。
如果你的项目是 Zoneless ChangeDetection 那可以忽略这第二部分。
-
RxJS Subject
FocusMonitor.monitor 返回的是 RxJS Subjectsubscribe 它就可以接受到 focus 和 blur 事件了。
origin 值如果是 null 表示它是一个 blur 事件,origin 值如果是 touch,mouse,keyboard,program 表示它是一个 focus 事件,origin 值表示它触发的方式。
提醒:它没有 Observable lazy execution 概念哦,并不是说等到 subscribe 了才会 addEventListenter 哦,只要调用 monitor 方法,它立马就会 addEventListenter 了。
Unsubscribe and remove listener
由于 FocusMonitor.monitor 返回的是 RxJS Subject 而非 Observable,因此使用 Subscription.unsubscribe 并不会触发 remove listener,不熟悉 RxJS 机制的朋友可以看这个系列:RxJS 系列 – 目录。
我们需要使用另一个方法 -- FocusMonitor.stopMonitoring
constructor() { const focusMonitor = inject(FocusMonitor); const destroyRef = inject(DestroyRef); afterNextRender(() => { const subscription = focusMonitor.monitor(this.button().nativeElement).subscribe(origin => { console.log(origin); }); // 1. this won't remove event listeners subscription.unsubscribe(); // 2. this will remove event listeners destroyRef.onDestroy(() => focusMonitor.stopMonitoring(this.button().nativeElement)); }); }
这个方法才能 remove listeners
相关源码在 focus-monitor.ts
监听 descendant elements focus 事件
FocusMonitor.monitor 的第二个参数表示是否要监听子孙 elements 的 focus 事件。
默认是 false。
开启后,除了 target element 以外,只要 target element 的子孙 elements 任何一个被 focus / blur,FocusMonitor.monitor 都会监听得到。
App Template
<div class="container" #container> <input> <input> </div>
用一个 div container 把 2 个 input wrap 起来。
App 组件
export class AppComponent { private readonly containerRef = viewChild.required<string, ElementRef<HTMLElement>>('container', { read: ElementRef }); constructor() { const focusMonitor = inject(FocusMonitor); afterNextRender(() => { // 监听 container,参数二 'checkChildren': true focusMonitor.monitor(this.containerRef(), true).subscribe(origin => console.log(origin)); }); } }
效果
我们监听的是 container,但 focus 的是其子层 input,事件任然会触发。
补充两个小知识:
-
仔细看它的事件,按理说 input 之前点来点去,它会产生多次 focus 和 blur 事件,
但 FocusMonitor 只监听到了 focus 事件,只有最后一次点击外面才监听到了 blur 事件。
这就是它的机制,不算完整,但符合项目基本需求。
-
FocusMonitor 只能获取到 focus origin,不能获取到 focus event。
这有点可惜,毕竟 focus / blur event 的 relatedTarget 在项目中还是经常会被用到的。
focusVia
FocusMonitor.focusVia 用来取代 element.focus 方法。
focusMonitor.focusVia(this.button().nativeElement, 'keyboard'); focusMonitor.focusVia(this.button().nativeElement, 'mouse'); focusMonitor.focusVia(this.button().nativeElement, 'touch'); focusMonitor.focusVia(this.button().nativeElement, 'program');
focus 的时候可以设置 origin。
也可以设置要不要 scroll (这个是原生 element.focus 就有的 options,不是 Angular Material 扩展的,只有 origin 是扩展的)。
focusMonitor.focusVia(this.button().nativeElement, 'mouse', { preventScroll: true });
focused class for styling
使用 FocusMonitor.monitor 监听的 element 在 focused 时会附带 2 个 class -- cdk-focused 和 cdk-{{ origin }}-focused。
像这样
问:假如有一个 button,它没有使用 FocusMonitor.monitor 监听,但使用了 focusVia 去 focus 它,它会有 focused class 吗?
答:不会,只有使用 FocusMonitor.monitor 监听的 element 才会有 focused class。
问:假如有一个 button,它使用 FocusMonitor.monitor 监听,但没有使用 focusVia 去 focus,它会有 focused class 吗?
答:会
CdkMonitorFocus 指令
CdkMonitorFocus 指令是对 FocusMonitor 的上层封装,纯粹为了方便开发者使用而已,没有额外功能。
App Template
<button cdkMonitorElementFocus (cdkFocusChange)="handleFocus($event)">click</button>
指令内部会执行 FocusMonitor.monitor 方法监听 focus,然后通过 @Output (cdkFocusChange) 把接受到的 origin 发布出来。
App 组件
export class AppComponent {
handleFocus(origin: FocusOrigin) {
console.log(origin);
}
}
组件只要接受和处理就可以了。我们不需要去 stopMonitoring 等等,指令都替我们封装了。
监听子孙 elements focus 也是同一个指令,只是 @Input 不同而已。
<div cdkMonitorSubtreeFocus (cdkFocusChange)="handleFocus($event)"> <button>click</button> </div>
实战例子
这里给一个项目中经常会用到它的案例。
<button>click me</button>
这是一个按钮
它的 Styles
button { padding: 16px 24px; background-color: lightblue; color: blue; border: 2px solid transparent; font-size: 24px; &:focus { border-color: blue; } }
效果
当 keyboard focus 到的时候会出现蓝色的框,当 mouse click 的时候也会出现蓝色的框 (因为 click 之后会自动 focus)。
假如我们只希望在 keyboard focus 时才出现蓝色的框 (通常这样的体验比较合理),那我们就可以使用 CdkMonitorFocus 指令了。
首先,添加指令
<button cdkMonitorElementFocus>click me</button>
还记得上面提到的 focused class for styling 吗?
不同 origin 的 focus,element 会被添加不同的 class。
接着把 CSS selector :focus 换成
&.cdk-keyboard-focused { border-color: blue; }
效果
只有 keyboard focus 才会出现篮框,click 不会了。
FocusMonitor 原理
FocusMonitor 是如何做到监听 focus 并且得知 origin 的呢?
这可不是原生功能丫🤔。
逛一逛源码
我们直接逛一逛它的源码 (里面有很多奇葩场景的特殊处理,这些我们跳过,只看最简单的部分就好)
FocusMonitor.monitor 方法的源码在 focus-monitor.ts
FocusMonitor.monitor 方法的结尾处会监听 element 的 focus 和 blur 事件。
_registerGlobalListeners 方法
两个知识点:
-
它监听的是 focus 而不是 focusin
-
它监听的是 capture,而不是冒泡
除此之外,在结尾处它还 subscribe 了 modality detected,并且在触发后做了一个 setOrigin 的动作。
我们先看什么是 modality detected 和 setOrigin。
modality detected 来自一个 InputModalityDetector Root Level Provider
这个 InputModalityDetector 主要是监听了 keydown,mousedown,touchstart 事件
Input Modality 的中文是 "输入方式",我们可以理解为 -- 监听用户的输入方式。也就是 mouse,keyboard,touch screen 的交互方式咯。
监听到后就发布 InputModalityDetector.modalityDetected
回到 FocusMonitor.monitor 监听到这些 modality 之后,它就调用 setOrigin 方法
origin 就是 modality 发布的 keyboard,mouse,touch。
把 origin 存到 _origin 属性里,1 millisecond 以后清除掉。
好,这里告一段咯,我们继续看看 element focused 以后 callback 是什么。
_onFocus 方法
两个知识点
-
如果 monitor 没有需要监听子孙,而 focus target 和 currentTarget 不一致 (表示是子孙 focused),那就直接 return skip 掉。
-
在执行 _originChanged 方法之前先调用 _getFocusOrigin 方法获取当前 origin。
_getFocusOrigin 方法
原理讲解
看到这里已经有一个大纲:
-
监听 document 的 mousedown,keydown,touchstart
这些代表不同的 origin -- mouse,keyboard,touch
-
监听 element focus
-
上面 2 个监听的触发顺序是这样的
点击 button = mousedown -> focus -> mouseup
再点击另一个 button = mousedown -> blur -> focus -> mouseup
当 mousedown 的时候,它会赋值 'mouse' 给 _origin 属性。
在 focus 的时候,它会拿 _origin 属性作为 emit 的 origin。如果 focus 是通过 Scripts 触发的,那它的 _origin 就是空值,origin 就应该是 program。
同样如果 focus 是通过 focusVia 那会先把 focusVia 的参数 origin 赋值给 _origin 属性。
通过上面这一系列的操作,在接收 focus 事件的同时就可以得到其触发的源 origin 了。
大致上是这样啦,有兴趣了解更多的朋友,可以自己逛一逛,它内部还有很多细节的,比如上面我 highlight 到的 touch buffer ms 等等,我是没力逛了😴。
另外,补充一点,CDK Focus 监听子孙用的不是 focusin 冒泡方案,而是 focus capture 捕获方案。
小心坑 の nativeElement.focus 不一定是 'program' origin
<button mat-button (click)="btn._elementRef.nativeElement.focus()">click to focus</button> <button mat-button #btn>focus button</button>
有 2 个 mat-button
问:在 button1 keydown enter,它 focus to button2 的 origin 会是什么?(注:我使用的是 nativeElement.focus,而不是 MatButton.focus 哦)
答:origin 是 keyboard
可是 nativeElement.focus 应该是 program 啊,这是因为上面我们提到的 1 millisecond 在作怪,要等到 1ms 后 origin 才会被清除。
所以,要记得,nativeElement.focus 不一定就是 program。
另外,假如我们改成 MatButton.focus 呢?
<button mat-button (click)="btn.focus()">click to focus</button> <button mat-button #btn>focus button</button>
那它 100% 是 program (除非我们传入指定的 origin)
因为它内部使用了 focusVia,而不是 nativeElement.focus。
FocusMonitor 局限 の FocusEvent
FocusMonitor.monitor 只返回 Origin,不返回 FocusEvent。
我不懂为什么 Angular Material 团队不顺便返回 FocusEvent😕,没有 FocusEvent 很麻烦丫,要 relatedTarget 时怎么办呢?
只好写个 workaround 了
// note 解释:和 Angular Material FocusMonitor.monitor 的区别 // 1. subscribe 后才会开始监听 // 2. multiple subscribe 外部要负责 share // 3. 会返回 FocusEvent export function focusMonitorWithEvent( focusMonitor: FocusMonitor, element: HTMLElement, checkChildren?: boolean, ): Observable<readonly [NonNullable<FocusOrigin> | null, FocusEvent]> { // 1. Angular Material 的 FocusMonitor.monitor 是立即开始监听的,这里改成 subscribe 后才开始监听 return defer(() => { // 2. 用 Angular Material 的 FocusMonitor.monitor 来监听 focusMonitor.monitor(element, checkChildren); return merge( // 3. 我们自己也同样监听 focus 和 blur,也和它一样用 capture fromEvent<FocusEvent>(element, 'focus', { capture: true }).pipe( // 4. 看要不要 checkChildren filter(event => checkChildren || event.target === element), map(event => { // 5. 这里有好几招可以偷拿 Angular Material FocusMonitor.monitor 的成果 // 5.1 调用 _getFocusOrigin const origin1 = ( focusMonitor as unknown as { _getFocusOrigin: (focusEventTarget: HTMLElement | null) => FocusOrigin } )._getFocusOrigin(event.target as HTMLElement | null); // 5.2 从 element 上拿 class const origin2 = getElementFocusOrigin(element); // 5.3 拿 _lastFocusOrigin const origin3 = (focusMonitor as unknown as { _lastFocusOrigin: FocusOrigin })._lastFocusOrigin; // 5.4 源码的执行是这样 // 调用 _getFocusOrigin > set class > set _lastFocusOrigin // 所以三招都可以用,当然全部都是 hacking way // 6. 返回 origin 和 FocusEvent return [origin1, event] as const; }), ), fromEvent<FocusEvent>(element, 'blur', { capture: true }).pipe( filter(event => checkChildren || event.target === element), map(event => [null, event] as const), ), // 7. unsubscribe 时 stopMonitoring ).pipe(finalize(() => focusMonitor.stopMonitoring(element))); }); }
export function getElementFocusOrigin(element: HTMLElement): NonNullable<FocusOrigin> { if (element.classList.contains('cdk-keyboard-focused')) return 'keyboard'; if (element.classList.contains('cdk-mouse-focused')) return 'mouse'; if (element.classList.contains('cdk-program-focused')) return 'program'; if (element.classList.contains('cdk-touch-focused')) return 'touch'; return 'program'; }
大概这样吧...
InteractivityChecker
InteractivityChecker 是一个 Root Level Provider。
它可以用来检测一个 element 是否能被交互。
所谓的交互有 4 种:
-
isDisabled
export class AppComponent { readonly button = viewChild.required('button', { read: ElementRef }); constructor() { const interactivityChecker = inject(InteractivityChecker); afterNextRender(() => { console.log(interactivityChecker.isDisabled(this.button().nativeElement)); // false }); } }
它的判断方式很简单,就是看 element 有没有 disabled attribute。
相关源码在 interactivity-checker.ts
-
isVisible
interactivityChecker.isVisible(this.button().nativeElement); // true
我们不需要知道的太细,反正它们就是一些奇奇怪怪的判断方式就对了。
-
isTabbable
interactivityChecker.isTabbable(this.button().nativeElement); // true,button default 的 tabIndex 是 0
它判断的方式比较杂,但最 common 的方式是查看 tabIndex。
提醒:tabIndex -1 代表可以 focus,但不代表可以 keyboard tab,只有 tabIndex >= 0 才表示可以 tab。 -
isFocusable
interactivityChecker.isFocusable(this.button().nativeElement); // true
判断 focusable 的方式很原始,就是查看 element 的 nodeName,挨个对
虽然都是一些小功能,但是对 UI Component 开发者来说真的太重要了,
这些很原始很繁琐的功能,往往还需要考虑各种游览器之间的不兼容,所以 CDK 真的是帮了一个大忙。
Strong focus indicators
参考:Docs – Strong focus indicators
Default focused styles
Angular Material 对所有 focsued 组件做了特别的 styles。
当 element 包含 class cdk-program-focused 或者 cdk-keyboard-focused 时 (注:cdk-mouse-focused 不算),会有一个 background-color。
Strong focus styles
如果我们觉得这个 foscued styles 不够明显,那可以依照官网教程,让它变 strong。
styles.scss
@use '@angular/material' as mat; @include mat.core(); @include mat.strong-focus-indicators(); $pure-material-theme: mat.define-theme(( color: ( theme-type: light, primary: mat.$azure-palette, tertiary: mat.$blue-palette, ), density: ( scale: 0, ) )); :root { @include mat.all-component-themes($pure-material-theme); @include mat.strong-focus-indicators-theme($pure-material-theme); } html, body { height: 100%; } body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
加上这两句后,它会多出几个 global variables,如下
html { --mat-focus-indicator-border-color: black; --mat-focus-indicator-display: block; } html { --mat-mdc-focus-indicator-border-color: black; --mat-mdc-focus-indicator-display: block; } :root { --mat-focus-indicator-border-color: #005cbb; --mat-mdc-focus-indicator-border-color: #005cbb; }
效果
除了 background-color 还多了一个很深的 border 框。这样就更明显了。
Only when focused by keyboard
一番操作之后,我们会发现它的 focused styles 不仅仅出现在 focused by keyboard / program。
连 focused by mouse 也会出现 border 框。
这完全是错误的体验。相关 Github Issue – Strong focus indicators should only show when the user uses the keyboard to navigate
要知道 focused by mouse 连 background-color 都不会有,怎么可能出现更 strong 的 border 框呢?
它之所以会这样是因为
它的 CSS selector 指定只要是 :focus,也就是说只要 element focused 不管是 by keyboard, program, mouse 一律都出现 border 框。
好,我们修改一下 styles.scss,纠正它的体验
:root { @include mat.strong-focus-indicators; @include mat.strong-focus-indicators-theme($pure-material-theme); /* 关掉它的全局 display: block */ --mat-focus-indicator-display: none; --mat-mdc-focus-indicator-display: none; /* 只有在 cdk-keyboard-focused 时才 display: block */ .cdk-keyboard-focused { --mat-focus-indicator-display: block; --mat-mdc-focus-indicator-display: block; } }
设置成只有在 .cdk-keyboard-focused (也就是 focused by keyboard) 的情况下才给予 strong focus styles,这样就可以了。
至于 focused by program 要不要使用 strong focus styles 就看个人喜好。参考 Google Ads 会发现,focused by program 只会有 background-color 不会有 border 框,只有 focused by keyboard 才会有 border 框。
但如果你希望 focused by program 也出现 border 框的话,那就加多一个 selector 变成 .cdk-keyboard-focused, .cdk-program-focused 就可以了。
脆弱的 workaround
上面提到的 workaround 相当脆弱,未必适用于每个组件和场景。
比如 checkbox 就不适用
原理是这样的:
-
当 input focused (by whatever origin),mat-mdc-focus-indicator 就会 content: '',但它是 display: none,这时还看不到。
-
当我们开启 strong focus styles,mat-mdc-focus-indicator 变成 display: block,这时就看见了。
-
当我们限制它,只有在 parent 有 .cdk-keyboard-focused 的时候才 display: block,它又消失了。
因为 tab to checkbox 时,<mat-checkbox> 甚至 input checkbox 都不会有 cdk-keyboard-focused。
要解决这个问题,我们需要自行加上 CdkMonitorFocus 指令,像这样
<mat-checkbox cdkMonitorElementFocus cdkMonitorSubtreeFocus>check me</mat-checkbox>
和 checkbox 类似的组件也都会有相同的问题,唉...没辙,只能期待 Angular Material 团队会 fix 这些 issue 😔。
题外话:focused & hovered background-color
上一 part 提到了 focused 的 styles,这里顺便提一下 hovered 的 styles。
我们以 raised button 作为例子
<button mat-raised-button>Click me</button>
效果
hovered 和 focused 后的效果
HTML
两个知识点:
-
::before
它不是直接给 button background-color 而是盖了一层 ::before 在上面
-
alpha
background-color 是由 + color + opacity (alpha) 完成的
使用 ::before + alpha 的好处是,无论 button 身处任何颜色的背景,当它被 hover 或 focus 时,增加的这个颜色都能被看见,因为 alpha 后就有了叠加的效果。
另外,说一说颜色的细节:
hovered 比较浅 alpha 是 8%
focused 比较深 alpha 是 12%
至于颜色,不同 button 会使用不同的颜色,比如
basic button 用的是 Primary (P-40)
icon button 用的是 On Surface Variant (NV-30)
extended fab button 用的是 On Primary Container (P-10)
注:不熟悉颜色的可以参考 Figma – Material 3
总结:hovered 会有一层浅色的 background-color,focused by (program / keyboard) 会有一层比较深色的 background-color,focused by keyboard 会额外再加上一个 border 框。
CDK Focus Trap
Focus Trap 的作用是让 keyboard tab 不出一个范围。
下图是一个 Angular Material Dialog
两个效果:
-
autofocus
当 dialog 打开后,立马自动 focus 到 ok button。
-
tab / shift + tab
不管是 keyboard tab 还是 shift + tab,focus element 始终在 dialog 里面打转,无法 focus out of dialog。
CdkTrapFocus 指令
我们若想做出 dialog 的效果,可以借助 CdkTrapFocus 指令。
App Template (先看 before CdkTrapFocus 的效果)
<form> <input> <input> <input> <button>submit</button> </form> <button>outside</button>
一张 form,里面有很多 tabbable element。
效果
我们可以从 form 里面 tab 出来到 outside。
添加 CdkTrapFocus 指令
<form cdkTrapFocus>
提醒:App 组件需要 imports: [A11yModule]
效果
添加 CdkTrapFocus 指令后就无法从 form 里面 tab 出来了。
Focus Trap 的原理
CDK 是如何实现 Focus Trap 的呢?
首先它会在 CdkTrapFocus 指令 element 的上下各插入一个 tabbable div (它叫 Focus Trap Anchor)。
假设我们 tab 到 form 的最后一个 element -- button,再 tab 多一下就会去到下方的 Focus Trap Anchor。
CDK 监听了 Focus Trap Anchor 的 focus 事件,当 focused 它就会 re-focus to form 里面的第一个 tabbale element,所以我们 tab 不出去。
shift + tab 的情况就反着来,当 tab 到上方的 Focus Trap Anchor 时,它就会 re-focus to form 里面的最后一个 tabbale element,这样就循环了,两个方向都出不去。
cdkFocusRegionStart 和 cdkFocusRegionEnd
by default,re-focus to 第一个或最后一个 tabbable element 是通过 InteractivityChecker.isTabbable 一个一个 element 检查出来的。
我们也可以通过 attribute cdkFocusRegionStart/End 去定义哪一个 element 是第一个或最后一个 tabbable element。
效果
第一个 input 始终都 tab 不到了,因为 tab 是往下走,最后会走到下方的 Focus Trap Anchor,然后被 re-focus 到第二个 input (因为第二个 input 是 region start)。
提醒:但是,shift + tab 依然可以走到第一个 input 哦,因为 tab 是依靠下方的 Focus Trap Anchor re-focus 才避开了第一个 input,而 shift + tab 并不会去到下方的 Focus Trap Anchor,也就避不开第一个 input 了。
cdkFocusRegionEnd 的用法就是 cdkFocusRegionStart 反过来
效果
submit button 始终不会被 shift + tab 到,因为上方的 Focus Trap Anchor 会 re-focus 到第三个 input,避开了结尾的 submit button。
提醒:我们一定要确保 cdkFocusRegionStart/End element 是 tabbable。如果它不是 tabbable 就会 focus 不到,focused element 会停留在 Focus Trap Anchor,
如果用户再 tab 一下就会跳出 focus trap element,整个机制就坏掉了。
我个人是觉得 cdkFocusRegionStart 和 cdkFocusRegionEnd 挺难用的,尤其是它只对一个方向起作用,比如上面的例子,
虽然 shift + tab 去不到 submit button,但是 tab 依然能去到 submit button 啊,那意义到底什么呢🤔?
cdkTrapFocusAutoCapture
我们在 form 的上方添加多一个 outside button
先 focus 上方的 outside button 然后 tab 一下。
从 outside tab 进去 form,直接就跳到了结尾的 button 而不是第一个 input,why?
这是因为 outside tab 首先是去到了上方的 Focus Trap Anchor,而它会 re-focus to form 里面的最后一个 tabbale element 也就是 button。
所以,通常使用 Focus Trap 时,我们不会让用户从外面 tab 进去,取而代之的是替它 autofocus to form inside element。
添加 @Input cdkTrapFocusAutoCapture 到 form element 上
<form cdkTrapFocus cdkTrapFocusAutoCapture>
效果
CdkTrapFocus 指令会 autofocus to first tabbable element。
提醒:这个 first tabbable element 是受 cdkFocusRegionStart 影响的哦。
cdkFocusInitial
上面说 autofocus to first tabbable element 是一个不严谨的描述,更严谨的说法是 focus to initial element。
而这个 initial element 是不固定的,它的具体判断过程是这样:
-
首先查看 CdkTrapFocus element 内有没有含 attribute cdkFocusInitial 的 element。
如果有,同时这个 element 可以被 focus 那就 focus 它,
如果这个 element 不可以被 focus,那就找这个 element 其下第一个 tabbable element (不受 cdkFocusRegion 影响) 来 focus,找不到那就没有任何 autofocus 了。
-
如果没有找到 cdkFocusInitial attribute 那就再找看有没有 cdkFocusRegionStart attribute element。
如果有那就 focus 它,如果它不可被 focus,那就算了,no more autofocus。
如果没有找到 cdkFocusRegionStart attribute 那就再重新找过第一个 tabbable element (用 InteractivityChecker.isTabbable 一个一个 element 检查) 来 focus。
排除 cdkFocusRegionStart 和 cdkFocusRegionEnd 带来的复杂情况,一般上我们这样用就可以了:
What if no tabbable element
假如 CdkTrapFocus element 内没有任何 tabbable element 会怎样?
当 focus 到 anchor 时,by right 它要 re-focus 到 form 里面的 tabbable element (first or last),但是 form 里面没有任何 tabbable element。
于是它会 stay 在原地 (anchor),此时就已经算是脱离 CdkTrapFocus element 了,再 tab 一下就跑到 outside button 了。
总之,最好不要出现这种特殊场景,就很怪嘛。
FocusTrapFactory and FocusTrap
FocusTrapFactory 是 Root Level Provider。
FocusTrap 是一个普通的 class。
顾名思义,FocusTrapFactory 用于创建 FocusTrap 对象。
绝大部分 Focus Trap 的功能是由 FocusTrap 对象完成的,CdkTrapFocus 指令只是它的上层封装而已。
App Template (没有使用 CdkTrapFocus 指令)
<button>outside</button> <form #form> <input> <input cdkFocusInitial> <input> <button>submit</button> </form> <button>outside</button>
App 组件
export class AppComponent { // 1. query form element readonly formElementRef = viewChild.required('form', { read: ElementRef }); constructor() { // 2. inject FocusTrapFactory const focusTrapFactory = inject(FocusTrapFactory); const destroyRef = inject(DestroyRef); afterNextRender(() => { // 3. create FocusTrap // 此时会插入上下 Focus Trap Anchor const focusTrap = focusTrapFactory.create(this.formElementRef().nativeElement); // 4. 会查找 cdkFocusInitial > cdkFocusRegionStart > InteractivityChecker.isTabbable focusTrap.focusInitialElement(); // 5. 销毁 focusTrap destroyRef.onDestroy(() => focusTrap.destroy()); }); } }
效果
Deprecated FocusTrapFactory and FocusTrap
Angular Material v11.0.0 已经废弃了 FocusTrapFactory 和 FocusTrap,并且推出了替代方案 ConfigurableFocusTrapFactory 和 ConfigurableFocusTrap。
但是!直到今天 11-03-2024,Angular Material v17.3.0 所有内部组件/指令都没有使用新的 ConfigurableFocusTrapFactory 和 ConfigurableFocusTrap,
反而一直用着废弃的 FocusTrapFactory 和 FocusTrap 😱。
其实也不用太惊讶,因为这是 Angular Team 一贯风格,包括现在 Angular Team 一直在吹的 Signal,Angular Material 源码里连一行 Signal 代码都找不到。
另外,ConfigurableFocusTrapFactory 和 ConfigurableFocusTrap 是有 breaking changes 的,所以要选择用哪一个还得看项目的需求。
总之我想表达的是,对于新东西不要过于乐观,对于旧事物也不要关于悲观。我们可以先观望看一看 Angular Team 的实际行为来决定是否要跟随它们 (不要只看它们的表态)。
更新 2024-07-16:你说巧不巧,我写上述内容时是 Angular Material v17.3.0,然而在 v17.3.2 Angular 竟然偷偷摸摸的把 deprecated 提示给删了😱
不用惊讶,这就是 Angular 团队会干出来的事。
首先发布一个新功能,告诉你旧的 deprecated 了,叫你用新的。
但是呢,他们自己 (Google) 内部却没有使用新功能。
很长一阵子之后,他们发现换去新功能的弊大于利,于是他们就偷偷把 deprecated 信息给删了。
总结:从这个小小的事件也可以看出 Angular 团队的权衡利弊。他们永远以 Google 内部项目为优先,社区只是他们的白老鼠或者 debuger 而已。(记着我说过的话 -- 了解 Angular 团队才能用好 Angular)
ConfigurableFocusTrapFactory 和 ConfigurableFocusTrap
新旧两个版本有 2 个巨大的区别:
-
Mouse can‘t focus out of Focus Trap anymore
下面是旧版本的体验
Focus Trap 只保护了 tab 和 shift + tab 的操作行为,如果我们用 mouse 点击 outside button,我们就可以脱离 Focus Trap 了。
在新版本中却不是这样
const focusTrapFactory = inject(ConfigurableFocusTrapFactory);
效果
新版 Focus Trap 不仅仅阻止 tab 还阻止了 mouse。
用 mouse 点击 outside button 依然脱离不了 Focus Trap,会被拉回去 Focus Trap 里面。
它的实现手法是这样的,首先监听 document focus
然后判断 focused element 是否在 focus trap element 里,如果不在就 re-focus to first tabbable element。
上面有一段是针对 div.cdk-overlay-pane 的特殊处理,它的意思是如果 focused element 是 under CDK Overlay (以后会教) 那就不需要 re-focus。 -
Only 1 enabled Trap Focus in the world
FocusTrap 是可以 disable 和 enable 的。
focusTrap.enabled = false; focusTrap.enabled = true;
disable 会 remove Focus Trap Anchor 的 tabindex
这样 Focus Trap Anchor 就没功效了。
在旧版本中,我们可以同时拥有多个 enabled 的 FocusTrap,因为它们不会互相影响。但是在新版本中就不行了,新版本多了一个监听 document focus 然后 re-focus 的机制。
假如同时有多个 enabled 的 FocusTrap,那要 re-focus 回去哪一个呢?不知道丫。
所以必须要有一个新机制来管理所有的 FocusTrap,确保知道当前是哪一个,然后才能 re-focus 回到正确的 FocusTrap 里。这套机制由 FocusTrapManager 维护
以上就是新旧版本最大的两个区别。
ConfigurableFocusTrapFactory 的 Configurable 到底能 config 什么呢?
答案是 FocusTrapInertStrategy
它就是上面提到的 -- 监听 document focus 然后 re-focus to Focus Trap 机制。
我们可以完全自定义,下面这个是它的接口
默认的实现是 EventListenerFocusTrapInertStrategy
具体源码上面已经讲解过了。
提醒:不要搞混哦,我们可以自定义的只有这个新版本的新机制,这套机制是针对 mouse focus out of Focus Trap 而已,和原本旧版本就有的 tab 机制没有任何关系,tab 机制是不可以自定义的。
总结
FocusMonitor 用来监听 focus 和 blur,它比原生监听 focus 多了一个 origin 概念,可以让我们知道是 mouse, touch, keyboard, program 哪一种方式触发了 focus。
InteractivityChecker 可以检测 element 是否可交互,有四种交互方式:isDisabled,isVisible,isFocusable,isTabbable。
FocusTrap 可以捆着 keyboard tab 走不出一个 element。
目录
上一篇 Angular Material 18+ 高级教程 – Material Ripple
下一篇 Angular Material 18+ 高级教程 – CDK Accessibility の ListKeyManager
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻