Skip to content

Commit 89c5faf

Browse files
fix(cdk/listbox): prevent wrong activeItemIndex after browser tab switch (#27499)
The "activeItemIndex" property of the cdk/listbox component tracks which item in the listbox is currently active or focused. However, some browsers (e.g. Chrome and Firefox) trigger the "focusout" event when the user switches tabs and returns, and set "event.relatedTarget" to null. This causes the "activeItemIndex" to be out of sync with the actual focused element, leading to incorrect behavior and user confusion. To fix this, in `focusout` we store the current active item in `_previousActiveOption` and restore it on the window `blur` event.
1 parent 0c4f947 commit 89c5faf

File tree

2 files changed

+37
-1
lines changed

2 files changed

+37
-1
lines changed

src/cdk/listbox/listbox.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
forwardRef,
1616
inject,
1717
Input,
18+
NgZone,
1819
OnDestroy,
1920
Output,
2021
QueryList,
@@ -34,7 +35,7 @@ import {
3435
} from '@angular/cdk/keycodes';
3536
import {BooleanInput, coerceArray, coerceBooleanProperty} from '@angular/cdk/coercion';
3637
import {SelectionModel} from '@angular/cdk/collections';
37-
import {defer, merge, Observable, Subject} from 'rxjs';
38+
import {defer, fromEvent, merge, Observable, Subject} from 'rxjs';
3839
import {filter, map, startWith, switchMap, takeUntil} from 'rxjs/operators';
3940
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
4041
import {Directionality} from '@angular/cdk/bidi';
@@ -381,6 +382,9 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
381382
/** The host element of the listbox. */
382383
protected readonly element: HTMLElement = inject(ElementRef).nativeElement;
383384

385+
/** The Angular zone. */
386+
protected readonly ngZone = inject(NgZone);
387+
384388
/** The change detector for this listbox. */
385389
protected readonly changeDetectorRef = inject(ChangeDetectorRef);
386390

@@ -418,6 +422,13 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
418422
/** Whether the listbox currently has focus. */
419423
private _hasFocus = false;
420424

425+
/** A reference to the option that was active before the listbox lost focus. */
426+
private _previousActiveOption: CdkOption<T> | null = null;
427+
428+
constructor() {
429+
this._setPreviousActiveOptionAsActiveOptionOnWindowBlur();
430+
}
431+
421432
ngAfterContentInit() {
422433
if (typeof ngDevMode === 'undefined' || ngDevMode) {
423434
this._verifyNoOptionValueCollisions();
@@ -783,6 +794,11 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
783794
* @param event The focusout event
784795
*/
785796
protected _handleFocusOut(event: FocusEvent) {
797+
// Some browsers (e.g. Chrome and Firefox) trigger the focusout event when the user returns back to the document.
798+
// To prevent losing the active option in this case, we store it in `_previousActiveOption` and restore it on the window `blur` event
799+
// This ensures that the `activeItem` matches the actual focused element when the user returns to the document.
800+
this._previousActiveOption = this.listKeyManager.activeItem;
801+
786802
const otherElement = event.relatedTarget as Element;
787803
if (this.element !== otherElement && !this.element.contains(otherElement)) {
788804
this._onTouched();
@@ -993,6 +1009,23 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
9931009
const index = this.options.toArray().indexOf(this._lastTriggered!);
9941010
return index === -1 ? null : index;
9951011
}
1012+
1013+
/**
1014+
* Set previous active option as active option on window blur.
1015+
* This ensures that the `activeOption` matches the actual focused element when the user returns to the document.
1016+
*/
1017+
private _setPreviousActiveOptionAsActiveOptionOnWindowBlur() {
1018+
this.ngZone.runOutsideAngular(() => {
1019+
fromEvent(window, 'blur')
1020+
.pipe(takeUntil(this.destroyed))
1021+
.subscribe(() => {
1022+
if (this.element.contains(document.activeElement) && this._previousActiveOption) {
1023+
this._setActiveOption(this._previousActiveOption);
1024+
this._previousActiveOption = null;
1025+
}
1026+
});
1027+
});
1028+
}
9961029
}
9971030

9981031
/** Change event that is fired whenever the value of the listbox changes. */

tools/public_api_guard/cdk/listbox.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import { ControlValueAccessor } from '@angular/forms';
1212
import { Highlightable } from '@angular/cdk/a11y';
1313
import * as i0 from '@angular/core';
1414
import { ListKeyManagerOption } from '@angular/cdk/a11y';
15+
import { NgZone } from '@angular/core';
1516
import { OnDestroy } from '@angular/core';
1617
import { QueryList } from '@angular/core';
1718
import { SelectionModel } from '@angular/cdk/collections';
1819
import { Subject } from 'rxjs';
1920

2021
// @public (undocumented)
2122
export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, ControlValueAccessor {
23+
constructor();
2224
protected readonly changeDetectorRef: ChangeDetectorRef;
2325
get compareWith(): undefined | ((o1: T, o2: T) => boolean);
2426
set compareWith(fn: undefined | ((o1: T, o2: T) => boolean));
@@ -53,6 +55,7 @@ export class CdkListbox<T = unknown> implements AfterContentInit, OnDestroy, Con
5355
ngAfterContentInit(): void;
5456
// (undocumented)
5557
ngOnDestroy(): void;
58+
protected readonly ngZone: NgZone;
5659
protected options: QueryList<CdkOption<T>>;
5760
get orientation(): 'horizontal' | 'vertical';
5861
set orientation(value: 'horizontal' | 'vertical');

0 commit comments

Comments
 (0)