diff --git a/src/cdk-experimental/dialog/dialog-container.ts b/src/cdk-experimental/dialog/dialog-container.ts index 92ff8bd6ab9c..16fa340d8843 100644 --- a/src/cdk-experimental/dialog/dialog-container.ts +++ b/src/cdk-experimental/dialog/dialog-container.ts @@ -73,6 +73,8 @@ export function throwDialogContentAlreadyAttachedError() { }, }) export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { + private _document: Document; + /** State of the dialog animation. */ _state: 'void' | 'enter' | 'exit' = 'enter'; @@ -120,11 +122,13 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory, private _changeDetectorRef: ChangeDetectorRef, - @Optional() @Inject(DOCUMENT) private _document: any, + @Optional() @Inject(DOCUMENT) _document: any, /** The dialog configuration. */ public _config: DialogConfig) { super(); + this._document = _document; + // We use a Subject with a distinctUntilChanged, rather than a callback attached to .done, // because some browsers fire the done event twice and we don't want to emit duplicate events. // See: https://github.com/angular/angular/issues/24084 @@ -248,7 +252,17 @@ export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { const toFocus = this._elementFocusedBeforeDialogWasOpened; // We need the extra check, because IE can set the `activeElement` to null in some cases. if (toFocus && typeof toFocus.focus === 'function') { - toFocus.focus(); + const activeElement = this._document.activeElement; + const element = this._elementRef.nativeElement; + + // Make sure that focus is still inside the dialog or is on the body (usually because a + // non-focusable element like the backdrop was clicked) before moving it. It's possible that + // the consumer moved it themselves before the animation was done, in which case we shouldn't + // do anything. + if (!activeElement || activeElement === this._document.body || activeElement === element || + element.contains(activeElement)) { + toFocus.focus(); + } } } } diff --git a/src/cdk-experimental/dialog/dialog.spec.ts b/src/cdk-experimental/dialog/dialog.spec.ts index b372a180733a..6e7cdb3b38f4 100644 --- a/src/cdk-experimental/dialog/dialog.spec.ts +++ b/src/cdk-experimental/dialog/dialog.spec.ts @@ -910,6 +910,46 @@ describe('Dialog', () => { document.body.removeChild(button); })); + it('should not move focus if it was moved outside the dialog while animating', fakeAsync(() => { + // Create a element that has focus before the dialog is opened. + const button = document.createElement('button'); + const otherButton = document.createElement('button'); + const body = document.body; + button.id = 'dialog-trigger'; + otherButton.id = 'other-button'; + body.appendChild(button); + body.appendChild(otherButton); + button.focus(); + + const dialogRef = dialog.openFromComponent(PizzaMsg, { + viewContainerRef: testViewContainerRef + }); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flushMicrotasks(); + + expect(document.activeElement!.id) + .not.toBe('dialog-trigger', 'Expected the focus to change when dialog was opened.'); + + // Start the closing sequence and move focus out of dialog. + dialogRef.close(); + otherButton.focus(); + + expect(document.activeElement!.id) + .toBe('other-button', 'Expected focus to be on the alternate button.'); + + flushMicrotasks(); + viewContainerFixture.detectChanges(); + flush(); + + expect(document.activeElement!.id) + .toBe('other-button', 'Expected focus to stay on the alternate button.'); + + body.removeChild(button); + body.removeChild(otherButton); + })); + it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => { // Create a element that has focus before the dialog is opened. let button = document.createElement('button');