Skip to content

Commit 717f252

Browse files
crisbetoandrewseguin
authored andcommitted
fix(select): not scrolling active option into view when typing (#7620)
* Fixes an issue that caused the active option not to be scrolled into view if it was changed via typing. The issue got introduced after we switched to using `aria-activedescendant` for focus management and it happens because the key presses in typeahead mode are debounced in order to allow the user to type in longer words. * Introduces a `change` subject on the `ListKeyManager`, allowing users to subscribe to changes. This wasn't necessary before, because everything was synchronous, but it is now because of the typeahead. * Fixes a potential memory leak in the select due to the `onStable` subscription. * Refactors the selector to remove the need for managing subscriptions manually. Uses a `takeUntil(destroyed)` instead.
1 parent f7a12b6 commit 717f252

File tree

4 files changed

+95
-44
lines changed

4 files changed

+95
-44
lines changed

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,19 @@ describe('Key managers', () => {
242242
expect(keyManager.activeItemIndex).toBe(0, 'Expected first item to become active.');
243243
});
244244

245+
it('should emit an event whenever the active item changes', () => {
246+
const spy = jasmine.createSpy('change spy');
247+
const subscription = keyManager.change.subscribe(spy);
248+
249+
keyManager.onKeydown(fakeKeyEvents.downArrow);
250+
expect(spy).toHaveBeenCalledTimes(1);
251+
252+
keyManager.onKeydown(fakeKeyEvents.upArrow);
253+
expect(spy).toHaveBeenCalledTimes(2);
254+
255+
subscription.unsubscribe();
256+
});
257+
245258
});
246259

247260
describe('programmatic focus', () => {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
4242
*/
4343
tabOut: Subject<void> = new Subject<void>();
4444

45+
/** Stream that emits whenever the active item of the list manager changes. */
46+
change = new Subject<number>();
47+
4548
/**
4649
* Turns on wrapping mode, which ensures that the active item will wrap to
4750
* the other end of list when there are no more items in the given direction.
@@ -98,6 +101,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
98101
setActiveItem(index: number): void {
99102
this._activeItemIndex = index;
100103
this._activeItem = this._items.toArray()[index];
104+
this.change.next(index);
101105
}
102106

103107
/**

src/lib/select/select.spec.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import {DOWN_ARROW, END, ENTER, HOME, SPACE, TAB, UP_ARROW} from '@angular/cdk/k
33
import {OverlayContainer} from '@angular/cdk/overlay';
44
import {Platform} from '@angular/cdk/platform';
55
import {ScrollDispatcher, ViewportRuler} from '@angular/cdk/scrolling';
6-
import {dispatchFakeEvent, dispatchKeyboardEvent, wrappedErrorMessage} from '@angular/cdk/testing';
6+
import {
7+
dispatchFakeEvent,
8+
dispatchEvent,
9+
createKeyboardEvent,
10+
dispatchKeyboardEvent,
11+
wrappedErrorMessage,
12+
} from '@angular/cdk/testing';
713
import {
814
ChangeDetectionStrategy,
915
Component,
@@ -3158,6 +3164,47 @@ describe('MatSelect', () => {
31583164
// <(option index + group labels) * height> - <panel height> = (9 + 3) * 48 - 256 = 320
31593165
expect(panel.scrollTop).toBe(320, 'Expected scroll to be at the 9th option.');
31603166
}));
3167+
3168+
it('should scroll top the top when pressing HOME', fakeAsync(() => {
3169+
for (let i = 0; i < 20; i++) {
3170+
dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW);
3171+
tick();
3172+
fixture.detectChanges();
3173+
}
3174+
3175+
expect(panel.scrollTop).toBeGreaterThan(0, 'Expected panel to be scrolled down.');
3176+
3177+
dispatchKeyboardEvent(host, 'keydown', HOME);
3178+
tick();
3179+
fixture.detectChanges();
3180+
3181+
expect(panel.scrollTop).toBe(0, 'Expected panel to be scrolled to the top');
3182+
}));
3183+
3184+
it('should scroll to the bottom of the panel when pressing END', fakeAsync(() => {
3185+
dispatchKeyboardEvent(host, 'keydown', END);
3186+
tick();
3187+
fixture.detectChanges();
3188+
3189+
// <option amount> * <option height> - <panel height> = 30 * 48 - 256 = 1184
3190+
expect(panel.scrollTop).toBe(1184, 'Expected panel to be scrolled to the bottom');
3191+
}));
3192+
3193+
it('should scroll to the active option when typing', fakeAsync(() => {
3194+
for (let i = 0; i < 15; i++) {
3195+
// Press the letter 'o' 15 times since all the options are named 'Option <index>'
3196+
dispatchEvent(host, createKeyboardEvent('keydown', 79, undefined, 'o'));
3197+
tick();
3198+
fixture.detectChanges();
3199+
tick(200);
3200+
}
3201+
3202+
tick(200);
3203+
3204+
// <option index * height> - <panel height> = 16 * 48 - 256 = 512
3205+
expect(panel.scrollTop).toBe(512, 'Expected scroll to be at the 16th option.');
3206+
}));
3207+
31613208
});
31623209
});
31633210

src/lib/select/select.ts

Lines changed: 30 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
ScrollStrategy,
1919
ViewportRuler,
2020
} from '@angular/cdk/overlay';
21-
import {filter, first, startWith} from '@angular/cdk/rxjs';
21+
import {filter, first, startWith, takeUntil, RxChain} from '@angular/cdk/rxjs';
2222
import {
2323
AfterContentInit,
2424
Attribute,
@@ -67,7 +67,6 @@ import {MatFormField, MatFormFieldControl} from '@angular/material/form-field';
6767
import {Observable} from 'rxjs/Observable';
6868
import {merge} from 'rxjs/observable/merge';
6969
import {Subject} from 'rxjs/Subject';
70-
import {Subscription} from 'rxjs/Subscription';
7170
import {fadeInContent, transformPanel} from './select-animations';
7271
import {
7372
getMatSelectDynamicMultipleError,
@@ -193,15 +192,6 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
193192
/** Whether or not the overlay panel is open. */
194193
private _panelOpen = false;
195194

196-
/** Subscriptions to option events. */
197-
private _optionSubscription = Subscription.EMPTY;
198-
199-
/** Subscription to changes in the option list. */
200-
private _changeSubscription = Subscription.EMPTY;
201-
202-
/** Subscription to tab events while overlay is focused. */
203-
private _tabSubscription = Subscription.EMPTY;
204-
205195
/** Whether filling out the select is required in the form. */
206196
private _required: boolean = false;
207197

@@ -220,6 +210,9 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
220210
/** Unique id for this input. */
221211
private _uid = `mat-select-${nextUniqueId++}`;
222212

213+
/** Emits whenever the component is destroyed. */
214+
private _destroy = new Subject<void>();
215+
223216
/** The last measured value for the trigger's client bounding rect. */
224217
_triggerRect: ClientRect;
225218

@@ -453,10 +446,13 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
453446
ngAfterContentInit() {
454447
this._initKeyManager();
455448

456-
this._changeSubscription = startWith.call(this.options.changes, null).subscribe(() => {
457-
this._resetOptions();
458-
this._initializeSelection();
459-
});
449+
RxChain.from(this.options.changes)
450+
.call(startWith, null)
451+
.call(takeUntil, this._destroy)
452+
.subscribe(() => {
453+
this._resetOptions();
454+
this._initializeSelection();
455+
});
460456
}
461457

462458
ngDoCheck() {
@@ -466,9 +462,8 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
466462
}
467463

468464
ngOnDestroy() {
469-
this._dropSubscriptions();
470-
this._changeSubscription.unsubscribe();
471-
this._tabSubscription.unsubscribe();
465+
this._destroy.next();
466+
this._destroy.complete();
472467
}
473468

474469
/** Toggles the overlay panel open or closed. */
@@ -493,7 +488,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
493488
this._changeDetectorRef.markForCheck();
494489

495490
// Set the font size on the panel element once it exists.
496-
first.call(this._ngZone.onStable).subscribe(() => {
491+
first.call(this._ngZone.onStable.asObservable()).subscribe(() => {
497492
if (this._triggerFontSize && this.overlayDir.overlayRef &&
498493
this.overlayDir.overlayRef.overlayElement) {
499494
this.overlayDir.overlayRef.overlayElement.style.fontSize = `${this._triggerFontSize}px`;
@@ -621,13 +616,6 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
621616
this._keyManager.activeItem._selectViaInteraction();
622617
} else {
623618
this._keyManager.onKeydown(event);
624-
625-
// TODO(crisbeto): get rid of the Promise.resolve when #6441 gets in.
626-
Promise.resolve().then(() => {
627-
if (this.panelOpen) {
628-
this._scrollActiveOptionIntoView();
629-
}
630-
});
631619
}
632620
}
633621

@@ -781,28 +769,32 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
781769
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
782770
private _initKeyManager() {
783771
this._keyManager = new ActiveDescendantKeyManager<MatOption>(this.options).withTypeAhead();
784-
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.close());
772+
773+
takeUntil.call(this._keyManager.tabOut, this._destroy)
774+
.subscribe(() => this.close());
775+
776+
RxChain.from(this._keyManager.change)
777+
.call(takeUntil, this._destroy)
778+
.call(filter, () => this._panelOpen && !!this.panel)
779+
.subscribe(() => this._scrollActiveOptionIntoView());
785780
}
786781

787782
/** Drops current option subscriptions and IDs and resets from scratch. */
788783
private _resetOptions(): void {
789-
this._dropSubscriptions();
790-
this._listenToOptions();
791-
this._setOptionIds();
792-
this._setOptionMultiple();
793-
this._setOptionDisableRipple();
794-
}
795-
796-
/** Listens to user-generated selection events on each option. */
797-
private _listenToOptions(): void {
798-
this._optionSubscription = filter.call(this.optionSelectionChanges,
799-
event => event.isUserInput).subscribe(event => {
784+
RxChain.from(this.optionSelectionChanges)
785+
.call(takeUntil, merge(this._destroy, this.options.changes))
786+
.call(filter, event => event.isUserInput)
787+
.subscribe(event => {
800788
this._onSelect(event.source);
801789

802790
if (!this.multiple) {
803791
this.close();
804792
}
805793
});
794+
795+
this._setOptionIds();
796+
this._setOptionMultiple();
797+
this._setOptionDisableRipple();
806798
}
807799

808800
/** Invoked when an option is clicked. */
@@ -848,11 +840,6 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
848840
}
849841
}
850842

851-
/** Unsubscribes from all option subscriptions. */
852-
private _dropSubscriptions(): void {
853-
this._optionSubscription.unsubscribe();
854-
}
855-
856843
/** Emits change event to set the model value. */
857844
private _propagateChanges(fallbackValue?: any): void {
858845
let valueToEmit: any = null;

0 commit comments

Comments
 (0)