Skip to content

Commit de23881

Browse files
committedMar 21, 2024
fix(cdk/a11y): support signals in ListKeyManager (#28757)
Updates the `ListKeyManager` to support passing in a signal. Also expands the type to allow readonly arrays. Fixes #28710. (cherry picked from commit da980a8)
1 parent edddab0 commit de23881

File tree

3 files changed

+71
-21
lines changed

3 files changed

+71
-21
lines changed
 

‎src/cdk/a11y/key-manager/list-key-manager.spec.ts

+29-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {DOWN_ARROW, END, HOME, LEFT_ARROW, RIGHT_ARROW, TAB, UP_ARROW} from '@angular/cdk/keycodes';
22
import {createKeyboardEvent} from '../../testing/private';
3-
import {QueryList} from '@angular/core';
4-
import {fakeAsync, tick} from '@angular/core/testing';
3+
import {Component, QueryList, signal} from '@angular/core';
4+
import {TestBed, fakeAsync, tick} from '@angular/core/testing';
55
import {take} from 'rxjs/operators';
66
import {FocusOrigin} from '../focus-monitor/focus-monitor';
77
import {ActiveDescendantKeyManager} from './activedescendant-key-manager';
@@ -106,6 +106,33 @@ describe('Key managers', () => {
106106
expect(keyManager.activeItem).toBeNull();
107107
});
108108

109+
it('should maintain the active item when the signal-based items change', () => {
110+
keyManager.destroy();
111+
112+
@Component({template: '', standalone: true})
113+
class App {}
114+
115+
const fixture = TestBed.createComponent(App);
116+
fixture.detectChanges();
117+
const items = signal([
118+
new FakeFocusable('one'),
119+
new FakeFocusable('two'),
120+
new FakeFocusable('three'),
121+
]);
122+
123+
keyManager = new ListKeyManager<FakeFocusable>(items, fixture.componentRef.injector);
124+
keyManager.setFirstItemActive();
125+
spyOn(keyManager, 'setActiveItem').and.callThrough();
126+
127+
expect(keyManager.activeItemIndex).toBe(0);
128+
expect(keyManager.activeItem!.getLabel()).toBe('one');
129+
items.update(current => [new FakeFocusable('zero'), ...current]);
130+
fixture.detectChanges();
131+
132+
expect(keyManager.activeItemIndex).toBe(1);
133+
expect(keyManager.activeItem!.getLabel()).toBe('one');
134+
});
135+
109136
describe('Key events', () => {
110137
it('should emit tabOut when the tab key is pressed', () => {
111138
const spy = jasmine.createSpy('tabOut spy');

‎src/cdk/a11y/key-manager/list-key-manager.ts

+38-18
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {QueryList} from '@angular/core';
9+
import {EffectRef, Injector, QueryList, Signal, effect, isSignal} from '@angular/core';
1010
import {Subject, Subscription} from 'rxjs';
1111
import {
1212
UP_ARROW,
@@ -54,6 +54,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
5454
private _allowedModifierKeys: ListKeyManagerModifierKey[] = [];
5555
private _homeAndEnd = false;
5656
private _pageUpAndDown = {enabled: false, delta: 10};
57+
private _effectRef: EffectRef | undefined;
5758

5859
/**
5960
* Predicate function that can be used to check whether an item should be skipped
@@ -64,21 +65,25 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
6465
// Buffer for the letters that the user has pressed when the typeahead option is turned on.
6566
private _pressedLetters: string[] = [];
6667

67-
constructor(private _items: QueryList<T> | T[]) {
68+
constructor(items: QueryList<T> | T[] | readonly T[]);
69+
constructor(items: Signal<T[]> | Signal<readonly T[]>, injector: Injector);
70+
constructor(
71+
private _items: QueryList<T> | T[] | readonly T[] | Signal<T[]> | Signal<readonly T[]>,
72+
injector?: Injector,
73+
) {
6874
// We allow for the items to be an array because, in some cases, the consumer may
6975
// not have access to a QueryList of the items they want to manage (e.g. when the
7076
// items aren't being collected via `ViewChildren` or `ContentChildren`).
7177
if (_items instanceof QueryList) {
72-
this._itemChangesSubscription = _items.changes.subscribe((newItems: QueryList<T>) => {
73-
if (this._activeItem) {
74-
const itemArray = newItems.toArray();
75-
const newIndex = itemArray.indexOf(this._activeItem);
78+
this._itemChangesSubscription = _items.changes.subscribe((newItems: QueryList<T>) =>
79+
this._itemsChanged(newItems.toArray()),
80+
);
81+
} else if (isSignal(_items)) {
82+
if (!injector && (typeof ngDevMode === 'undefined' || ngDevMode)) {
83+
throw new Error('ListKeyManager constructed with a signal must receive an injector');
84+
}
7685

77-
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
78-
this._activeItemIndex = newIndex;
79-
}
80-
}
81-
});
86+
this._effectRef = effect(() => this._itemsChanged(_items()), {injector});
8287
}
8388
}
8489

@@ -144,12 +149,11 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
144149
* @param debounceInterval Time to wait after the last keystroke before setting the active item.
145150
*/
146151
withTypeAhead(debounceInterval: number = 200): this {
147-
if (
148-
(typeof ngDevMode === 'undefined' || ngDevMode) &&
149-
this._items.length &&
150-
this._items.some(item => typeof item.getLabel !== 'function')
151-
) {
152-
throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
152+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
153+
const items = this._getItemsArray();
154+
if (items.length > 0 && items.some(item => typeof item.getLabel !== 'function')) {
155+
throw Error('ListKeyManager items in typeahead mode must implement the `getLabel` method.');
156+
}
153157
}
154158

155159
this._typeaheadSubscription.unsubscribe();
@@ -403,6 +407,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
403407
destroy() {
404408
this._typeaheadSubscription.unsubscribe();
405409
this._itemChangesSubscription?.unsubscribe();
410+
this._effectRef?.destroy();
406411
this._letterKeyStream.complete();
407412
this.tabOut.complete();
408413
this.change.complete();
@@ -470,7 +475,22 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
470475
}
471476

472477
/** Returns the items as an array. */
473-
private _getItemsArray(): T[] {
478+
private _getItemsArray(): T[] | readonly T[] {
479+
if (isSignal(this._items)) {
480+
return this._items();
481+
}
482+
474483
return this._items instanceof QueryList ? this._items.toArray() : this._items;
475484
}
485+
486+
/** Callback for when the items have changed. */
487+
private _itemsChanged(newItems: T[] | readonly T[]) {
488+
if (this._activeItem) {
489+
const newIndex = newItems.indexOf(this._activeItem);
490+
491+
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
492+
this._activeItemIndex = newIndex;
493+
}
494+
}
495+
}
476496
}

‎tools/public_api_guard/cdk/a11y.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import { EventEmitter } from '@angular/core';
1313
import * as i0 from '@angular/core';
1414
import * as i1 from '@angular/cdk/observers';
1515
import { InjectionToken } from '@angular/core';
16+
import { Injector } from '@angular/core';
1617
import { NgZone } from '@angular/core';
1718
import { Observable } from 'rxjs';
1819
import { OnChanges } from '@angular/core';
1920
import { OnDestroy } from '@angular/core';
2021
import { Platform } from '@angular/cdk/platform';
2122
import { QueryList } from '@angular/core';
23+
import { Signal } from '@angular/core';
2224
import { SimpleChanges } from '@angular/core';
2325
import { Subject } from 'rxjs';
2426

@@ -347,7 +349,8 @@ export class IsFocusableConfig {
347349

348350
// @public
349351
export class ListKeyManager<T extends ListKeyManagerOption> {
350-
constructor(_items: QueryList<T> | T[]);
352+
constructor(items: QueryList<T> | T[] | readonly T[]);
353+
constructor(items: Signal<T[]> | Signal<readonly T[]>, injector: Injector);
351354
get activeItem(): T | null;
352355
get activeItemIndex(): number | null;
353356
cancelTypeahead(): this;

0 commit comments

Comments
 (0)
Please sign in to comment.