Angular Material 18+ 高级教程 – CDK Accessibility の Focus

介绍

CDK Focus 是对原生 DOM focus 的上层封装和扩展。

Focus Origin

原生 DOM focus 我们只能知道 element 被 focus 了,但是无法知道它是怎么被 focus 的,但 CDK Focus 可以。

比如说,有一个 button,我们有三种方式可以 focus 它:

  1. 用 mouse 点击这个 button

  2. 用 keyboard tab

  3. 用 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');
        }
      });
    });
  }
}

几个知识点:

  1. DOM Manupulation

    FocusMonitor.monitor 是 DOM Manupulation,它底层执行的是 element.addEventListenter('focus'),所以它需要 ElementRef 或者 HtmlElement。

    最好是在组件 lifecycle AfterRenderHooks 阶段才监听。

  2. runOutsideAngular

    FocusMonitor.monitor 是 ngZone.runOutsideAngular 的,所以 focus 事件不会被 Zone.js 监听到。

    如果我们在事件 callback 想让 Angular refreshView 需要自己手动 tick。

    对 Change Detection 不熟悉的朋友,可以复习这篇 Change Detection

    如果你的项目是 Zoneless ChangeDetection 那可以忽略这第二部分。

  3. RxJS Subject

    FocusMonitor.monitor 返回的是 RxJS Subject

    subscribe 它就可以接受到 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,事件任然会触发。

补充两个小知识:

  1. 仔细看它的事件,按理说 input 之前点来点去,它会产生多次 focus 和 blur 事件,

    但 FocusMonitor 只监听到了 focus 事件,只有最后一次点击外面才监听到了 blur 事件。

    这就是它的机制,不算完整,但符合项目基本需求。

  2. 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 方法

两个知识点:

  1. 它监听的是 focus 而不是 focusin

  2. 它监听的是 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 方法

两个知识点

  1. 如果 monitor 没有需要监听子孙,而 focus target 和 currentTarget 不一致 (表示是子孙 focused),那就直接 return skip 掉。

  2. 在执行 _originChanged 方法之前先调用 _getFocusOrigin 方法获取当前 origin。

_getFocusOrigin 方法

原理讲解

看到这里已经有一个大纲:

  1. 监听 document 的 mousedown,keydown,touchstart

    这些代表不同的 origin -- mouse,keyboard,touch

  2. 监听 element focus

  3. 上面 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 种:

  1. 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

  2. isVisible

    interactivityChecker.isVisible(this.button().nativeElement); // true

    我们不需要知道的太细,反正它们就是一些奇奇怪怪的判断方式就对了。

  3. isTabbable

    interactivityChecker.isTabbable(this.button().nativeElement); // true,button default 的 tabIndex 是 0

    它判断的方式比较杂,但最 common 的方式是查看 tabIndex。
    提醒:tabIndex -1 代表可以 focus,但不代表可以 keyboard tab,只有 tabIndex >= 0 才表示可以 tab。

  4. 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; }
View Code

加上这两句后,它会多出几个 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 就不适用

原理是这样的:

  1. 当 input focused (by whatever origin),mat-mdc-focus-indicator 就会 content: '',但它是 display: none,这时还看不到。

  2. 当我们开启 strong focus styles,mat-mdc-focus-indicator 变成 display: block,这时就看见了。

  3. 当我们限制它,只有在 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

两个知识点:

  1. ::before

    它不是直接给 button background-color 而是盖了一层 ::before 在上面

  2. 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

两个效果:

  1. autofocus

    当 dialog 打开后,立马自动 focus 到 ok button。

  2. 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 是不固定的,它的具体判断过程是这样:

  1. 首先查看 CdkTrapFocus element 内有没有含 attribute cdkFocusInitial 的 element。

    如果有,同时这个 element 可以被 focus 那就 focus 它,

    如果这个 element 不可以被 focus,那就找这个 element 其下第一个 tabbable element (不受 cdkFocusRegion 影响) 来 focus,找不到那就没有任何 autofocus 了。

  2. 如果没有找到 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 个巨大的区别:

  1. 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。

    相关源码在 event-listener-inert-strategy.ts

    上面有一段是针对 div.cdk-overlay-pane 的特殊处理,它的意思是如果 focused element 是 under CDK Overlay (以后会教) 那就不需要 re-focus。
  2. 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 😊💻

 

posted @ 2024-03-10 09:52  兴杰  阅读(151)  评论(0编辑  收藏  举报