Skip to content

Commit 02c668c

Browse files
authored
refactor(cdk/dialog): Remove use of focusInitialElementWhenReady (#28727)
1 parent 9a502e7 commit 02c668c

File tree

6 files changed

+117
-71
lines changed

6 files changed

+117
-71
lines changed

src/cdk/dialog/dialog-container.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,16 @@ import {
3131
ElementRef,
3232
EmbeddedViewRef,
3333
Inject,
34+
Injector,
3435
NgZone,
3536
OnDestroy,
3637
Optional,
3738
ViewChild,
3839
ViewEncapsulation,
40+
afterNextRender,
3941
inject,
4042
} from '@angular/core';
43+
import {take} from 'rxjs/operators';
4144
import {DialogConfig} from './dialog-config';
4245

4346
export function throwDialogContentAlreadyAttachedError() {
@@ -102,6 +105,8 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
102105

103106
protected readonly _changeDetectorRef = inject(ChangeDetectorRef);
104107

108+
private _injector = inject(Injector);
109+
105110
constructor(
106111
protected _elementRef: ElementRef,
107112
protected _focusTrapFactory: FocusTrapFactory,
@@ -266,13 +271,33 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
266271
break;
267272
case true:
268273
case 'first-tabbable':
269-
this._focusTrap?.focusInitialElementWhenReady().then(focusedSuccessfully => {
270-
// If we weren't able to find a focusable element in the dialog, then focus the dialog
271-
// container instead.
274+
const doFocus = () => {
275+
const focusedSuccessfully = this._focusTrap?.focusInitialElement();
276+
// If we weren't able to find a focusable element in the dialog, then focus the
277+
// dialog container instead.
272278
if (!focusedSuccessfully) {
273279
this._focusDialogContainer();
274280
}
275-
});
281+
};
282+
283+
// TODO(mmalerba): Make this behave consistently across zonefull / zoneless.
284+
if (!this._ngZone.isStable) {
285+
// Subscribing `onStable` has slightly different behavior than `afterNextRender`.
286+
// `afterNextRender` does not wait for state changes queued up in a Promise
287+
// to avoid change after checked errors. In most cases we would consider this an
288+
// acceptable behavior change, the dialog at least made its best effort to focus the
289+
// first element. However, this is particularly problematic when combined with the
290+
// current behavior of the mat-radio-group, which adjusts the tabindex of its child
291+
// radios based on the selected value of the group. When the selected value is bound
292+
// via `[(ngModel)]` it hits this "state change in a promise" edge-case and can wind up
293+
// putting the focus on a radio button that is not supposed to be eligible to receive
294+
// focus. For now, we side-step this whole sequence of events by continuing to use
295+
// `onStable` in zonefull apps, but it should be noted that zoneless apps can still
296+
// suffer from this issue.
297+
this._ngZone.onStable.pipe(take(1)).subscribe(doFocus);
298+
} else {
299+
afterNextRender(doFocus, {injector: this._injector});
300+
}
276301
break;
277302
case 'first-heading':
278303
this._focusByCssSelector('h1, h2, h3, h4, h5, h6, [role="heading"]');

src/cdk/dialog/dialog.spec.ts

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
1+
import {Directionality} from '@angular/cdk/bidi';
2+
import {A, ESCAPE} from '@angular/cdk/keycodes';
3+
import {Overlay, OverlayContainer, ScrollDispatcher} from '@angular/cdk/overlay';
4+
import {_supportsShadowDom} from '@angular/cdk/platform';
15
import {
2-
ComponentFixture,
3-
fakeAsync,
4-
flushMicrotasks,
5-
TestBed,
6-
tick,
7-
flush,
8-
} from '@angular/core/testing';
6+
createKeyboardEvent,
7+
dispatchEvent,
8+
dispatchKeyboardEvent,
9+
} from '@angular/cdk/testing/private';
10+
import {Location} from '@angular/common';
11+
import {SpyLocation} from '@angular/common/testing';
912
import {
1013
ChangeDetectionStrategy,
1114
Component,
1215
ComponentRef,
1316
Directive,
14-
inject,
1517
Inject,
1618
InjectionToken,
1719
Injector,
1820
TemplateRef,
1921
ViewChild,
2022
ViewContainerRef,
2123
ViewEncapsulation,
24+
inject,
2225
} from '@angular/core';
23-
import {By} from '@angular/platform-browser';
24-
import {Location} from '@angular/common';
25-
import {SpyLocation} from '@angular/common/testing';
26-
import {Directionality} from '@angular/cdk/bidi';
27-
import {Overlay, OverlayContainer, ScrollDispatcher} from '@angular/cdk/overlay';
28-
import {A, ESCAPE} from '@angular/cdk/keycodes';
29-
import {_supportsShadowDom} from '@angular/cdk/platform';
3026
import {
31-
dispatchKeyboardEvent,
32-
createKeyboardEvent,
33-
dispatchEvent,
34-
} from '@angular/cdk/testing/private';
27+
ComponentFixture,
28+
TestBed,
29+
fakeAsync,
30+
flush,
31+
flushMicrotasks,
32+
tick,
33+
} from '@angular/core/testing';
34+
import {By} from '@angular/platform-browser';
3535
import {Subject} from 'rxjs';
3636
import {DIALOG_DATA, Dialog, DialogModule, DialogRef} from './index';
3737

@@ -282,17 +282,20 @@ describe('Dialog', () => {
282282
const spy = jasmine.createSpy('backdropClick spy');
283283
dialogRef.backdropClick.subscribe(spy);
284284
viewContainerFixture.detectChanges();
285+
flush();
285286

286287
const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
287288

288289
backdrop.click();
289290
viewContainerFixture.detectChanges();
291+
flush();
290292
expect(spy).toHaveBeenCalledTimes(1);
291293

292294
// Additional clicks after the dialog has closed should not be emitted
293295
dialogRef.disableClose = false;
294296
backdrop.click();
295297
viewContainerFixture.detectChanges();
298+
flush();
296299
expect(spy).toHaveBeenCalledTimes(1);
297300
}));
298301

@@ -303,6 +306,7 @@ describe('Dialog', () => {
303306
dialogRef.keydownEvents.subscribe(spy);
304307

305308
viewContainerFixture.detectChanges();
309+
flush();
306310

307311
let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
308312
let container = overlayContainerElement.querySelector('cdk-dialog-container') as HTMLElement;
@@ -757,9 +761,11 @@ describe('Dialog', () => {
757761
fakeAsync(() => {
758762
const templateInjectFixture = TestBed.createComponent(TemplateInjectorParentComponent);
759763
templateInjectFixture.detectChanges();
764+
flush();
760765

761766
dialog.open(templateInjectFixture.componentInstance.templateRef);
762767
templateInjectFixture.detectChanges();
768+
flush();
763769

764770
expect(templateInjectFixture.componentInstance.innerComponentValue).toBe(
765771
'hello from parent component',
@@ -840,7 +846,7 @@ describe('Dialog', () => {
840846
});
841847

842848
viewContainerFixture.detectChanges();
843-
flushMicrotasks();
849+
flush();
844850

845851
expect(document.activeElement!.tagName)
846852
.withContext('Expected first tabbable element (input) in the dialog to be focused.')
@@ -910,17 +916,15 @@ describe('Dialog', () => {
910916

911917
let dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
912918

913-
flushMicrotasks();
914919
viewContainerFixture.detectChanges();
915-
flushMicrotasks();
920+
flush();
916921

917922
expect(document.activeElement!.id).not.toBe(
918923
'dialog-trigger',
919924
'Expected the focus to change when dialog was opened.',
920925
);
921926

922927
dialogRef.close();
923-
flushMicrotasks();
924928
viewContainerFixture.detectChanges();
925929
flush();
926930

@@ -939,18 +943,18 @@ describe('Dialog', () => {
939943
viewContainerFixture.destroy();
940944
const fixture = TestBed.createComponent(ShadowDomComponent);
941945
fixture.detectChanges();
946+
flush();
942947
const button = fixture.debugElement.query(By.css('button'))!.nativeElement;
943948

944949
button.focus();
945950

946951
const dialogRef = dialog.open(PizzaMsg);
947-
flushMicrotasks();
948952
fixture.detectChanges();
949-
flushMicrotasks();
953+
flush();
950954

951955
const spy = spyOn(button, 'focus').and.callThrough();
952956
dialogRef.close();
953-
flushMicrotasks();
957+
flush();
954958
fixture.detectChanges();
955959
tick(500);
956960

@@ -994,7 +998,7 @@ describe('Dialog', () => {
994998
dialog.open(DialogWithoutFocusableElements);
995999

9961000
viewContainerFixture.detectChanges();
997-
flushMicrotasks();
1001+
flush();
9981002

9991003
expect(document.activeElement!.tagName.toLowerCase())
10001004
.withContext('Expected dialog container to be focused.')
@@ -1013,14 +1017,12 @@ describe('Dialog', () => {
10131017
restoreFocus: false,
10141018
});
10151019

1016-
flushMicrotasks();
10171020
viewContainerFixture.detectChanges();
1018-
flushMicrotasks();
1021+
flush();
10191022

10201023
expect(document.activeElement!.id).not.toBe('dialog-trigger');
10211024

10221025
dialogRef.close();
1023-
flushMicrotasks();
10241026
viewContainerFixture.detectChanges();
10251027
flush();
10261028

src/material/bottom-sheet/bottom-sheet.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import {OverlayContainer, ScrollStrategy} from '@angular/cdk/overlay';
44
import {_supportsShadowDom} from '@angular/cdk/platform';
55
import {ViewportRuler} from '@angular/cdk/scrolling';
66
import {
7-
dispatchKeyboardEvent,
87
createKeyboardEvent,
98
dispatchEvent,
9+
dispatchKeyboardEvent,
1010
} from '@angular/cdk/testing/private';
1111
import {Location} from '@angular/common';
1212
import {SpyLocation} from '@angular/common/testing';
@@ -23,11 +23,11 @@ import {
2323
} from '@angular/core';
2424
import {
2525
ComponentFixture,
26+
TestBed,
2627
fakeAsync,
2728
flush,
2829
flushMicrotasks,
2930
inject,
30-
TestBed,
3131
tick,
3232
} from '@angular/core/testing';
3333
import {By} from '@angular/platform-browser';
@@ -640,7 +640,7 @@ describe('MatBottomSheet', () => {
640640
});
641641

642642
viewContainerFixture.detectChanges();
643-
flushMicrotasks();
643+
flush();
644644

645645
expect(document.activeElement!.tagName)
646646
.withContext('Expected first tabbable element (input) in the dialog to be focused.')

src/material/dialog/dialog-container.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export class MatDialogContainer extends CdkDialogContainer<MatDialogConfig> impl
9494
/** Current timer for dialog animations. */
9595
private _animationTimer: ReturnType<typeof setTimeout> | null = null;
9696

97+
private _isDestroyed = false;
98+
9799
constructor(
98100
elementRef: ElementRef,
99101
focusTrapFactory: FocusTrapFactory,
@@ -262,6 +264,10 @@ export class MatDialogContainer extends CdkDialogContainer<MatDialogConfig> impl
262264
* be called by sub-classes that use different animation implementations.
263265
*/
264266
protected _openAnimationDone(totalTime: number) {
267+
if (this._isDestroyed) {
268+
return;
269+
}
270+
265271
if (this._config.delayFocusTrap) {
266272
this._trapFocus();
267273
}
@@ -275,6 +281,8 @@ export class MatDialogContainer extends CdkDialogContainer<MatDialogConfig> impl
275281
if (this._animationTimer !== null) {
276282
clearTimeout(this._animationTimer);
277283
}
284+
285+
this._isDestroyed = true;
278286
}
279287

280288
override attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {

0 commit comments

Comments
 (0)