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

介绍

ListKeyManager 的作用是让我们通过 keyboard 去操作 List Items。

一个典型的例子:Menu

有 4 个步骤:

  1. tab to menu

  2. enter 打开 menu list

  3. 按上下键选择 item

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

总结:

  1. 主要处理上下键核心功能的是 ListKeyManager

  2. ActiveDescendantKeyManager 只是多了一个 set item active styles 而已

  3. 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 个思路:

  1. 继承 ListKeyManager override 原本的 destroy 方法

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

有两点要特别注意

  1. 假如 active item 没有在 new items 中,ListKeyManager 并不会把 active item 设置成 null 哦。

    如果这时我们拿 active item 来做操作的话,很可能会出现 item 指令已经 destroy 的 error。

    items.set([]);
    queueMicrotask(() => console.log(keyManager.activeItemIndex === 1)); // true,依然是 1,没有变成 -1
  2. 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;
      }
    }
  }
}
View Code

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

效果

按键 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 😊💻

 

posted @ 2024-03-12 12:05  兴杰  阅读(40)  评论(0编辑  收藏  举报