Angular Material 18+ 高级教程 – CDK Accessibility の ListKeyManager
介绍
ListKeyManager 的作用是让我们通过 keyboard 去操作 List Items。
一个典型的例子:Menu
有 4 个步骤:
-
tab to menu
-
enter 打开 menu list
-
按上下键选择 item
-
enter 选中 item
ListKeyManager 主要是负责第三个步骤,按上下键的功能。
ListKeyManager,ActiveDescendantKeyManager,FocusKeyManager
ListKeyManager 只是一个普通的 class,它不是 Dependency injection Provider 哦,所以使用它的方式是 new ListKeyManager()。
ActiveDescendantKeyManager 是 ListKeyManager 的派生类,它也没有什么特别的,只是重载了 ListKeyManager 的一个方法而已
从方法名字可以猜得出来,在按上下键时,target item 会被 set active styles。
FocusKeyManager 也是 ListKeyManager 的派生类从方法名字可以猜得出来,在按上下键时,target item 会被 focus。
总结:
-
主要处理上下键核心功能的是 ListKeyManager
-
ActiveDescendantKeyManager 只是多了一个 set item active styles 而已
-
FocusKeyManager 只是多了一个 focus item 而已
ListKeyManager
我们直接看代码学习吧,毕竟它的原理太简单了。
初始化 ListKeyManager
初始化 ListKeyManager 需要传入 item list,它可以是一个 QueryList 或者 Array。
提醒:这个接口目前 v17.3.0 还没有跟上 Angular 新潮流 -- Signal。QueryList 在 Signal-based Query 中已经不公开了,我们根本拿不到 QueryList。
相关 Github Issue – ListKeyManager: Support Signal-based Query。对 Query 不熟悉的朋友,可以看这篇 Angular 17+ 高级教程 – Component 组件 の Query Elements。
更新:v17.3.2 已经支持 Signal-based Query 了。
由于要想监听 Signal value change 只能透过 effect,而 effect 依赖 Dependancy Injection,所以我们必须传入 Injector,这比使用 QueryList 繁琐了一些。
好,回到主题。
list item 必须是一个对象,同时实现 ListKeyManagerOption 接口。
下面我会用 ActiveDescendantKeyManager 作为例子,所以我们也看看 ActiveDescendantKeyManager 的 constructor。
item 必须实现 Highlightable 接口。
好,搞清楚类型后,我们来初始化一个 ActiveDescendantKeyManager。
下面这个是 Item class,它依要求实现了 Highlightable 接口。
class Item implements Highlightable { active = false; setActiveStyles(): void { this.active = true; } setInactiveStyles(): void { this.active = false; } }
初始化 ActiveDescendantKeyManager
export class AppComponent { constructor() { // 1. 创建 Item List const items = [new Item(), new Item(), new Item(), new Item()]; // 2. 初始化 ActiveDescendantKeyManager const keyManager = new ActiveDescendantKeyManager(items); } }
setActiveItem
通过 ListKeyManager 的一些方法,我们可以选择要 active 哪一个 item
console.log(items[0].active); // false keyManager.setFirstItemActive(); console.log(items[0].active); // true
调用 setFirstItemActive,它内部会执行 items[0].setActiveStyles,然后 items[0].active 就变成 true 了。
keyManager.setNextItemActive(); console.log(items[0].active); // false console.log(items[1].active); // true
setNextItemActive 会 active 下一个 item,同时把当前的 inactive。
有 next 就有 prev,有 first 就有 last
keyManager.setPreviousItemActive(); // 前一个 keyManager.setLastItemActive(); // 最后一个
当然也有直接指定第几个 index 要 active 的
keyManager.setActiveItem(2); // active item index 2
skip disabled
Item 实现的接口 ListKeyManagerOptions 有一个 optional 的属性 -- disabled
当 KeyManager 选择 active item 时,它会避开 disabled item。
修改 class Item
class Item implements Highlightable { // 1. 添加 init value constructor(item?: Partial<Item>) { Object.assign(this, item); } active = false; // 2. 添加一个 disabled 属性 disabled = false; setActiveStyles(): void { this.active = true; } setInactiveStyles(): void { this.active = false; } }
调用
// 1. first, last, middle items 都是 disabled const items = [ new Item({ disabled: true }), new Item(), new Item({ disabled: true }), new Item(), new Item({ disabled: true }), ]; const keyManager = new ActiveDescendantKeyManager(items); keyManager.setFirstItemActive(); console.log(items.map(item => item.active)); // [false, true, false, false, false]
由于第一个 item 是 disabled,所以 setFirstItemActive 选了第二个 item 作为 first。
setLastItemActive 也是如此
keyManager.setLastItemActive(); console.log(items.map(item => item.active)); // [false, false, false, true, false]
setNextItemActive 也是如此
keyManager.setFirstItemActive(); keyManager.setNextItemActive(); console.log(items.map(item => item.active)); // [false, false, false, true, false]
setPreviousItemActive 也是如此
keyManager.setLastItemActive(); keyManager.setPreviousItemActive(); console.log(items.map(item => item.active)); // [false, true, false, false, false]
setActiveItem 则不同,不管 item 是否是 disabled,它都照样 set active。
keyManager.setActiveItem(0); console.log(items.map(item => item.active)); // [true, false, false, false, false]
所以使用 setActiveItem 我们需要自己注意 disabled。
另外 -1 代表全部不选。
keyManager.setActiveItem(-1); console.log(items.map(item => item.active)); // [false, false, false, false, false]
skipPredicate
disabled item 之所以会被 skip 掉是因为在 set first / last / next / prev active item 时,ListKeyManager 会做一个检测
如果 item 是 disabled 那 index 就会累加 / 累减去下一个或前一个。
如果想自定义 skip 机制,可以透过 ListKeyManager.skipPredicate 方法,把 skipPredicateFn 放进去。
keyManager.skipPredicate(item => { // 判断是否要 skip 掉这个 item return true; // return true 就 skip });
提醒:setActiveItem 内部没有调用 _setActiveItemByIndex 方法,所以它没有 skipPredicate 机制。
withWrap 循环
如果当前 active item 已经在最后一个,而我们继续 setNextItemActive 会怎样?
const items = [new Item(), new Item(), new Item(), new Item(), new Item()]; const keyManager = new ActiveDescendantKeyManager(items); keyManager.setLastItemActive(); keyManager.setNextItemActive(); console.log(items.map(item => item.active)); // [false, false, false, false, true]
答案是原地不动
如果我们想它 rotate 循环回到第一个,只需要调用 KeyManagerList.withWrap 方法就可以了
const keyManager = new ActiveDescendantKeyManager(items).withWrap(); // 加一个 withWrap keyManager.setLastItemActive(); keyManager.setNextItemActive(); console.log(items.map(item => item.active)); // [true, false, false, false, false]
change Observable
想监听 active item 的变化,可以透过 change 属性
keyManager.change.subscribe(activeItemIndex => { console.log(activeItemIndex); // 0, 1 }); keyManager.setFirstItemActive(); keyManager.setNextItemActive();
它是一个 RxJS Subject。
updateActiveItem
updateActiveItem 是 setActiveItem 的底层调用
updateActiveItem 比 setActiveItem 少了一个 change 事件发布。
另外,如果是 ActiveDescendantKeyManager 的话
updateActiveItem 比 setActiveItem 还少了 setInactiveStyles 和 setActiveStyles 的调用。
FocusKeyManager 则少了 focus 的调用。
activeItem & activeItemIndex
顾名思义,就是获取当前的 active item 和 index,如果没有它会返回 null 和 -1。
console.log([keyManager.activeItem, keyManager.activeItemIndex]); // [null, null] keyManager.setLastItemActive(); console.log(keyManager.activeItemIndex); // 4 console.log(keyManager.activeItem!.active); // true
注:v19 版本后,activeItem 背地里是 Signal 来的哦。
但 activeItemIndex 却不是 Signal 哦。
为什么要不一致呢?当然是因为 Angular Material 团队忍不住要挖坑让你掉咯,这是他们经常会做的事情丫😂
onKeydown
List Key Manager,我们上面都在讲 List 的部分,这里开始讲 Key 的部分。
Key 是 keyboard 的 key。
我们学习了许多操作 active item 的方法 -- first, last, prev, next
这些方法要结合 keydown 事件才会发光发热,比如按 ArrowDown 键要调用 setNextItemActive,按 ArrowUp 键要调用 setPreviousItemActive。
看代码
const keyManager = new ActiveDescendantKeyManager(items).withWrap(); keyManager.setFirstItemActive(); console.log(items.map(item => item.active)); // [true, false, false, false, false] // 1. 模拟一个 keydown const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40 }); keyManager.onKeydown(downEvent); console.log(items.map(item => item.active)); // [false, true, false, false, false]
ListKeyManager.onKeydown 会依据 event.key 和 keyCode 做出相应的操作。
比如 ArrowDown 它会 setNextItemActive,所以 item[1] 变成了 active。
withVerticalOrientation & withHorizontalOrientation
by default,onKeydown 指处理上下键,左右键是不处理的。
如果我们要支持左右键,那需要设置 withHorizontalOrientation。
在没有设置 withHorizontalOrientation 的情况下,按左右键 item active 不会产生任何变化。
const keyManager = new ActiveDescendantKeyManager(items).withWrap(); keyManager.setFirstItemActive(); const rightEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39 }); keyManager.onKeydown(rightEvent); console.log(items.map(item => item.active)); // [true, false, false, false, false]
加上 withHorizontalOrientation
const keyManager = new ActiveDescendantKeyManager(items).withWrap().withHorizontalOrientation('ltr');
ltr 是 left to right 的缩写,rtl 是 right to left 的缩写,它用来表达用户操作习惯 (比如说有些国家的字是从右边读到左边,有些则是左边读到右边,keyboard 的操作也有这样分类)
keyManager.setFirstItemActive(); const rightEvent = new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39 }); keyManager.onKeydown(rightEvent); console.log(items.map(item => item.active)); // [false, true, false, false, false]
ltr 情况下,ArrowRight 键代表 next。
我们也可以关掉 default 的上下键处理
const keyManager = new ActiveDescendantKeyManager(items).withWrap().withVerticalOrientation(false); // 关掉上下键处理 keyManager.setFirstItemActive(); const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40 }); keyManager.onKeydown(downEvent); console.log(items.map(item => item.active)); // [true, false, false, false, false] 原封不动
withHomeAndEnd
by default,Home 键和 End 键是不处理的。我们可以通过 withHomeAndEnd 将它开启
const keyManager = new ActiveDescendantKeyManager(items).withWrap().withHomeAndEnd();
Home 键是 setFirstItemActive,End 键是 setLastItemActive
keyManager.setFirstItemActive(); const endEvent = new KeyboardEvent('keydown', { key: 'End', keyCode: 35 }); keyManager.onKeydown(endEvent); console.log(items.map(item => item.active)); // [false, false, false, false, true] const homeEvent = new KeyboardEvent('keydown', { key: 'Home', keyCode: 36 }); keyManager.onKeydown(homeEvent); console.log(items.map(item => item.active)); // [true, false, false, false, false]
withPageUpDown
by default,PageUp 键和 PageDown 键是不处理的。我们可以通过 withPageUpDown 将它开启
const keyManager = new ActiveDescendantKeyManager(items).withWrap().withPageUpDown(true, 2);
第一个参数是 enabled,第二参数 delta 是指每按一下要跳多少个 item,我写 2 就表示每按一下 PageDown 会执行 2 次 next。
keyManager.setFirstItemActive(); const pageDownEvent = new KeyboardEvent('keydown', { key: 'PageDown', keyCode: 34 }); keyManager.onKeydown(pageDownEvent); console.log(items.map(item => item.active)); // [false, false, true, false, false] const pageUpEvent = new KeyboardEvent('keydown', { key: 'PageUp', keyCode: 33 }); keyManager.onKeydown(pageUpEvent); console.log(items.map(item => item.active)); // [true, false, false, false, false]
另外,不管有没有设置 withWrap 循环,PageDown 和 PageUp 都不会循环。
keyManager.setFirstItemActive(); const pageDownEvent = new KeyboardEvent('keydown', { key: 'PageDown', keyCode: 34 }); keyManager.onKeydown(pageDownEvent); keyManager.onKeydown(pageDownEvent); keyManager.onKeydown(pageDownEvent); console.log(items.map(item => item.active)); // [false, false, false, false, true] 依然在最后一个,因为已经到底了
withAllowedModifierKeys
by default,按键如果搭配 modifier keys (Control, Alt, Shift, Meta / Windows) 是不处理的
keyManager.setFirstItemActive(); const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40, altKey: true }); // ArrowDown + Alt Key keyManager.onKeydown(downEvent); console.log(items.map(item => item.active)); // [true, false, false, false, false] 原封不动
我们可以通过 withAllowedModifierKeys 让它处理。
const keyManager = new ActiveDescendantKeyManager(items) .withWrap() .withAllowedModifierKeys(['ctrlKey', 'altKey', 'shiftKey', 'metaKey']);
不一定要四个都 allow,也可以指令部分 allow。
keyManager.setFirstItemActive(); const downEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40, altKey: true }); keyManager.onKeydown(downEvent); console.log(items.map(item => item.active)); // [false, true, false, false, false] 会动了
tabOut
KeyManagerList 对 Tab 键是没有 List Item 处理的,它只是会转发事件而已。
keyManager.tabOut.subscribe(() => console.log('tab out')); // 监听 Tab 键事件 keyManager.setFirstItemActive(); const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', keyCode: 9 }); keyManager.onKeydown(tabEvent); // 调用 tabOut.next() console.log(items.map(item => item.active)); // [true, false, false, false, false] 原封不动
它只是一个小方便而已,我们也可以自己对 keyboard event 进行判断,并不是一定要依靠 ListKeyManager 的 tabOut + onKeydown。
withTypeAhead
withTypeAhead 是用来做原生 DOM <select> typing select option 体验的。
我们 focus select,然后连续打几个字,select 就会去选择字母开头的 option。
首先每个 item 需要有一个 getLabel 方法来表示 item 是什么字。
这个是 ListKeyManagerOption 的接口
因为 withTypeAhead 是 optional 所以 getLabel 也是 optional,如果我们开启 withTypeAhead 要记得声明 getLabel 方法。
// 1. 每个 item 都有 label const items = [ new Item({ label: 'toyota' }), new Item({ label: 'tesla' }), new Item({ label: 'triumph' }), new Item({ label: 'tata' }), new Item({ label: 'honda' }), ]; const keyManager = new ActiveDescendantKeyManager(items).withTypeAhead(); // 2. 开启 withTypeAhead keyManager.setFirstItemActive(); // 3. 连续 keydown 几个字母 keyManager.onKeydown(new KeyboardEvent('keydown', { key: 't', keyCode: 84 })); keyManager.onKeydown(new KeyboardEvent('keydown', { key: 'e', keyCode: 69 })); keyManager.onKeydown(new KeyboardEvent('keydown', { key: 's', keyCode: 83 })); keyManager.onKeydown(new KeyboardEvent('keydown', { key: 'l', keyCode: 76 })); window.setTimeout(() => { console.log(items.map(item => item.active)); // 4. [false, true, false, false, false] 变成 index 1 active 了 }, 300);
withTypeAhead 默认会有一个 200 milliseonds 的 RxJS debounce time
在这 200ms 内所有 keydown 的字母会把缓存起来,一直到停止 keydown 后的 200ms,ListKeyManager 才会把字母合并成字然后去匹配 item label。
匹配方式是 startsWith & ignorecase,匹配到第一个就 active 那个 item。
相关源码在 list-key-manager.ts
isTyping
isTyping 指的是 withTypeAhead 在 debounce time 的那段期间。
console.log(keyManager.isTyping()); // false keyManager.onKeydown(new KeyboardEvent('keydown', { key: 't', keyCode: 84 })); console.log(keyManager.isTyping()); // true keyManager.onKeydown(new KeyboardEvent('keydown', { key: 'e', keyCode: 69 })); keyManager.onKeydown(new KeyboardEvent('keydown', { key: 's', keyCode: 83 })); keyManager.onKeydown(new KeyboardEvent('keydown', { key: 'l', keyCode: 76 })); window.setTimeout(() => { console.log(items.map(item => item.active)); console.log(keyManager.isTyping()); // false }, 300);
cancelTypeahead
cancelTypeahead 的作用是把 withTypeAhead typing 时累积的字母清掉。
withoutTypeAhead
withTypeAhead 一旦开启就无法关闭,cancelTypeahead 只是把累积的字母清掉而已。
如果我们硬硬要把它关掉,可以调用私有方法
destroy
ListKeyManager 会监听 item list change (for QueryList 和 Signal 的话),如果已经不需要 ListKeyManager 了的话,可以调用 destroy 方法,这样它会取消监听。
另外,我们没有办法可以直接监听到 ListKeyManager on destroy,间接的方法有 2 个思路:
-
继承 ListKeyManager override 原本的 destroy 方法
-
监听 ListKeyManager.change 的 complete 事件
const destroy$ = listKeyManager.change.pipe( materialize(), filter(({ kind }) => kind === 'C'), take(1), ).subscribe(() => console.log('key manager destroy'));
Items changes
假如原本 active 的是 item index 1,
然后 items 变更了,active item 变成了 index 0,
那么 activeItemIndex 会自动被更新
export class AppComponent { constructor() { // 想象 Item 是指令 class Item implements Highlightable { constructor(public name: string) {} setActiveStyles() {} setInactiveStyles() {} } const items = signal([new Item('Derrick'), new Item('Alex')]) ; const keyManager = new ActiveDescendantKeyManager(items, inject(Injector)); keyManager.setActiveItem(1); console.log(keyManager.activeItemIndex); // 1,一开始是 1 items.set([items()[1], items()[0]]); // 变更位置 queueMicrotask(() => { console.log(keyManager.activeItemIndex); // 0, 接着被更新成 0 }); } }
相关源码在 list-key-manager.ts
有两点要特别注意
-
假如 active item 没有在 new items 中,ListKeyManager 并不会把 active item 设置成 null 哦。
如果这时我们拿 active item 来做操作的话,很可能会出现 item 指令已经 destroy 的 error。
items.set([]); queueMicrotask(() => console.log(keyManager.activeItemIndex === 1)); // true,依然是 1,没有变成 -1
-
item 是透过 indexOf 来匹配的,也就是 ===,假如 item 指令被 destroy 换了一个新的实例,
那 ListKeyManager 是没有办法识别它是旧的,它不像 @for 有 track,select 有 compareWith。
items.set([new Item('Alex'), new Item('Derrick')]); // 新的 Item 实例 queueMicrotask(() => console.log(keyManager.activeItemIndex === 1)); // true,等价于旧的 Item 没被找到,所以 index 依旧是 1
要解决这 2 个问题,我们可以仿照 ListKeyManager 那样,自己做监听 items changes > 匹配 > update index。
const keyManager = new ActiveDescendantKeyManager<Item>(items, inject(Injector)); // 加多一个 compareWith 函数 const compareWith = (a: Item, b: Item) => a.name === b.name; // 监听 items changes toObservable(items).subscribe(items => { if (keyManager.activeItem === null) return; // 用 compareWith 做匹配,找不到就是 -1 const newItemIndex = items.findIndex(item => compareWith(item, keyManager.activeItem!)); // 如果和 keyManger 不相同,那就 update keyManager if (keyManager.activeItemIndex !== newItemIndex) { keyManager.setActiveItem(newItemIndex); } });
这样就解决了。
ListKeyManager with real DOM
上面例子用的是模拟 keydown event 主要是想让大家看得清楚 how it work。
这里我补上一个 real DOM 的例子,不过我就不再过多讲解了。
App Template
<ul class="item-list" appItemList> <li class="item" appItem>Item1</li> <li class="item" appItem>Item2</li> <li class="item" appItem>Item3</li> <li class="item" appItem disabled>Item4</li> <li class="item" appItem>Item5</li> <li class="item" appItem>Item6</li> <li class="item" appItem>Item7</li> <li class="item" appItem>Item8</li> <li class="item" appItem>Item9</li> <li class="item" appItem>Item10</li> </ul>
appItemList 和 appItem 是指令。
App Styles
:host { display: block; padding: 56px; } .item-list { display: flex; flex-direction: column; gap: 1px; width: 256px; border: 4px solid black; /* 1. item list focused 会红框 */ &:focus { border-color: red; } .item { padding: 8px 12px; cursor: pointer; user-select: none; /* 2. item disabled 会灰色 */ &[disabled] { background-color: lightgray; } /* 3. active item 会是粉色 */ &.active { background-color: pink; color: red; } } }
App 组件
@Component({ selector: 'app-root', standalone: true, imports: [ItemListDirective, ItemDirective], templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent {}
只要 imports 指令就可以了,因为逻辑都写在指令里。
Item 指令
import type { Highlightable } from '@angular/cdk/a11y'; import { booleanAttribute, Directive, ElementRef, inject, input, signal } from '@angular/core'; @Directive({ selector: '[appItem]', standalone: true, host: { // 2. 当 active 时添加 CSS class active '[class.active]': 'isActive()', }, }) // 1. 实现 Highlightable 接口 export class ItemDirective implements Highlightable { readonly isActive = signal(false); readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute }); setActiveStyles() { this.isActive.set(true); } setInactiveStyles() { this.isActive.set(false); } get disabled(): boolean { return this.disabledInput(); } readonly hostElement: HTMLElement = inject(ElementRef).nativeElement; getLabel(): string { // 3. label 拿节点的 text 就好了 return this.hostElement.textContent ?? ''; } }
Item List 指令
import { ActiveDescendantKeyManager } from '@angular/cdk/a11y'; import { afterNextRender, contentChildren, DestroyRef, Directive, inject } from '@angular/core'; import { ItemDirective } from './item.directive'; @Directive({ selector: '[appItemList]', standalone: true, host: { // 1. 设置成 tabbable 不然没有地方给 user keydown tabindex: '0', '(keydown)': 'handleKeydown($event)', }, }) export class ItemListDirective { // 2. query items readonly items = contentChildren(ItemDirective); private keyManager!: ActiveDescendantKeyManager<ItemDirective>; constructor() { const destroyRef = inject(DestroyRef); afterNextRender(() => { // 3. 创建 ListKeyManager this.keyManager = new ActiveDescendantKeyManager(this.items()) .withWrap() .withTypeAhead(500) .withPageUpDown(true, 3) .withHomeAndEnd(); // 5. 当指令销毁时,销毁 ListKeyManager。 destroyRef.onDestroy(() => this.keyManager.destroy()); }); } handleKeydown(event: KeyboardEvent) { // 4. 接收 keydown event 然后转交给 ListKeyManager 处理 this.keyManager.onKeydown(event); } }
效果
上下键,withWrap 循环,disabled item
Home 键,End 键,PageUp 键,PageDown 键
withTypeAhead,typing item5 and then item7
FocusKeyManager 小知识
focus 有 origin 的概念,这个我们在上一篇有讲解过。
这里值得注意的是,FocusKeyManager 的 focus origin 默认是 'program',而且 ListKeyManager 在任何时候都不会去改变它。
比如说 ListKeyManager 的 onKeydown 方法,虽然它肯定是一个 keyboard event,但是 focus origin 依然是 ’program‘。
所以,我们有责任去维护这个 origin,在调用 ListKeyManager.onKeydown 方法之前,先调用 FocusKeyManager.setFocusOrigin 方法,把 origin 换成 'keyboard',完成后再修改回去。
基于 Styles (not DOM 结构) 的 Up, Down, Left, Right 操作
我们搭个简单的场景,看看 ListKeyManager 的局限。
App Template
<div class="item-list" #itemList> @for (email of emails(); track email) { <div class="item" #item [attr.tabindex]="$index === 0 ? 0 : -1">{{ email }}</div> } </div>
App Styles
:host { display: block; height: 100vh; padding-left: 256px; padding-top: 128px; .item-list { width: 768px; display: flex; flex-wrap: wrap; gap: 8px; .item { padding: 4px 16px; border: 1px solid hsl(0deg 0% 0% / 87%); border-radius: 999px; color: hsl(0deg 0% 0% / 87%); &:focus { background-color: lightblue; color: blue; border-color: blue; outline-width: 0; } } } }
App 组件
export class AppComponent { readonly emails = signal([ 'jd@a.com', 'john.doe@example.com', 'samantha.jones@corporation.com', 'michael@edu.org', 'lucas.smith@bigbusinesssolutions.com', 'elizabeth.johnson@shortmail.net', 'henry.martin@company.org', 'charlotte.evans@nonprofitfoundation.org', 'alex@tech.com', 'alexandra.taylor@longresearchinstitute.com' ]); private readonly itemRefs = viewChildren<string, ElementRef<HTMLElement>>('item', { read : ElementRef }); private readonly focusMonitor = inject(FocusMonitor); private readonly items = computed(() => this.itemRefs().map<FocusableOption>(itemRef => ({ focus: (origin?: FocusOrigin) => this.focusMonitor.focusVia(itemRef.nativeElement, origin ?? 'program'), }))); private readonly itemListRef = viewChild.required<string, ElementRef<HTMLElement>>('itemList', { read: ElementRef }); constructor() { const injector = inject(Injector); afterNextRender(() => { const keyManager = new FocusKeyManager(this.items, injector).withWrap().withHorizontalOrientation('ltr'); const keydown$ = fromEvent<KeyboardEvent>(this.itemListRef().nativeElement, 'keydown'); keydown$.subscribe(event => keyManager.onKeydown(event)); }); } }
效果
按键 ArrowDown 是前往 "下一个",但这里说的 ”下一个“ 并不是我们肉眼看到的下一个,而是 DOM 结构的下一个。
比如我们在第一个 jd@a.com 按 ArrowDown,直接会认为会去到 michael@edu.org,但其实是去右边的 john.doe@example.com。
我不敢说这个体验是对还是错,但 ListKeyManager 没有让我们 “可选择”,这就是它的局限。
实现思路
我们要做到基于 Styles 而非 DOM 也不难。
假设一开始在中间
按 ArrowUp
透过 getBoundingClientRect 获取每个 item 的 coordinate,然后找出与之交会的 items。
因为是 ArrowUp,所以我们往上看,不同方向就看不同方向。(注:如果上没有而且 withWrap 那就往下看)
四个里面拿最靠近的。
两个里面,在选靠前的 (注:这里需要考虑双方的大小,不同情况可能会有微差)
这样就选出要 active 的 item 了,整体思路大致上是这样。
具体实现代码我就不放出来了,有点复杂难讲解,大家自由发挥吧。
总结
ListKeyManager 是一个很常见的功能,Angular Material 的 List,Menu,Stepper 组件都用到了 ListKeyManager。
有兴趣的朋友可以自己去逛一下源码,这几个组件都挺复杂的,它们在 ListKeyManager 基础上有扩展了许多功能。
目录
上一篇 Angular Material 18+ 高级教程 – CDK Accessibility の Focus
下一篇 Angular Material 18+ 高级教程 – CDK Overlay
想查看目录,请移步 Angular 18+ 高级教程 – 目录
喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding 😊💻