From f18c416bbce3a9cf8c433bdd84b0783d6859cc51 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 5 Mar 2021 04:38:00 +0100 Subject: [PATCH] fix(material/dialog): focus restoration not working inside shadow dom (#21811) Related to #21796. The dialog focus restoration works by grabbing `document.activeElement` before the dialog is opened and restoring focus to the element on destroy. This won't work if the element is inside the shadow DOM, because the browser will return the shadow root. These changes add a workaround. (cherry picked from commit be508daa22b0d9f724001637fc91e1f05173602c) --- .../mdc-dialog/BUILD.bazel | 1 + .../mdc-dialog/dialog.spec.ts | 38 ++++++++++++++++++- src/material/dialog/BUILD.bazel | 1 + src/material/dialog/dialog-container.ts | 14 +++++-- src/material/dialog/dialog.spec.ts | 37 +++++++++++++++++- 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/src/material-experimental/mdc-dialog/BUILD.bazel b/src/material-experimental/mdc-dialog/BUILD.bazel index 432a6c8cab28..12a65f47ac2b 100644 --- a/src/material-experimental/mdc-dialog/BUILD.bazel +++ b/src/material-experimental/mdc-dialog/BUILD.bazel @@ -65,6 +65,7 @@ ng_test_library( "//src/cdk/bidi", "//src/cdk/keycodes", "//src/cdk/overlay", + "//src/cdk/platform", "//src/cdk/scrolling", "//src/cdk/testing/private", "@npm//@angular/common", diff --git a/src/material-experimental/mdc-dialog/dialog.spec.ts b/src/material-experimental/mdc-dialog/dialog.spec.ts index 551997ffbb8a..cfde5a9011a7 100644 --- a/src/material-experimental/mdc-dialog/dialog.spec.ts +++ b/src/material-experimental/mdc-dialog/dialog.spec.ts @@ -2,6 +2,7 @@ import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {A, ESCAPE} from '@angular/cdk/keycodes'; import {Overlay, OverlayContainer, ScrollStrategy} from '@angular/cdk/overlay'; +import {_supportsShadowDom} from '@angular/cdk/platform'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { createKeyboardEvent, @@ -23,7 +24,8 @@ import { NgZone, TemplateRef, ViewChild, - ViewContainerRef + ViewContainerRef, + ViewEncapsulation } from '@angular/core'; import { ComponentFixture, @@ -34,6 +36,7 @@ import { TestBed, tick, } from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations'; import {numbers} from '@material/dialog'; import {Subject} from 'rxjs'; @@ -1075,6 +1078,32 @@ describe('MDC-based MatDialog', () => { document.body.removeChild(button); })); + it('should re-focus trigger element inside the shadow DOM when dialog closes', fakeAsync(() => { + if (!_supportsShadowDom()) { + return; + } + + viewContainerFixture.destroy(); + const fixture = TestBed.createComponent(ShadowDomComponent); + fixture.detectChanges(); + const button = fixture.debugElement.query(By.css('button'))!.nativeElement; + + button.focus(); + + const dialogRef = dialog.open(PizzaMsg); + flushMicrotasks(); + fixture.detectChanges(); + flushMicrotasks(); + + const spy = spyOn(button, 'focus').and.callThrough(); + dialogRef.close(); + flushMicrotasks(); + fixture.detectChanges(); + tick(500); + + expect(spy).toHaveBeenCalled(); + })); + it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => { const button = document.createElement('button'); let lastFocusOrigin: FocusOrigin = null; @@ -1870,6 +1899,12 @@ class DialogWithInjectedData { class DialogWithoutFocusableElements { } +@Component({ + template: ``, + encapsulation: ViewEncapsulation.ShadowDom +}) +class ShadowDomComponent {} + // Create a real (non-test) NgModule as a workaround for // https://github.com/angular/angular/issues/10760 const TEST_DIRECTIVES = [ @@ -1882,6 +1917,7 @@ const TEST_DIRECTIVES = [ DialogWithInjectedData, DialogWithoutFocusableElements, ComponentWithContentElementTemplateRef, + ShadowDomComponent, ]; @NgModule({ diff --git a/src/material/dialog/BUILD.bazel b/src/material/dialog/BUILD.bazel index a13a3df9f276..2c9a4e88d846 100644 --- a/src/material/dialog/BUILD.bazel +++ b/src/material/dialog/BUILD.bazel @@ -61,6 +61,7 @@ ng_test_library( "//src/cdk/bidi", "//src/cdk/keycodes", "//src/cdk/overlay", + "//src/cdk/platform", "//src/cdk/scrolling", "//src/cdk/testing/private", "@npm//@angular/common", diff --git a/src/material/dialog/dialog-container.ts b/src/material/dialog/dialog-container.ts index 10dbe5c40bb0..32088f65589e 100644 --- a/src/material/dialog/dialog-container.ts +++ b/src/material/dialog/dialog-container.ts @@ -182,7 +182,7 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet { // We need the extra check, because IE can set the `activeElement` to null in some cases. if (this._config.restoreFocus && previousElement && typeof previousElement.focus === 'function') { - const activeElement = this._document.activeElement; + const activeElement = this._getActiveElement(); const element = this._elementRef.nativeElement; // Make sure that focus is still inside the dialog or is on the body (usually because a @@ -213,7 +213,7 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet { /** Captures the element that was focused before the dialog was opened. */ private _capturePreviouslyFocusedElement() { if (this._document) { - this._elementFocusedBeforeDialogWasOpened = this._document.activeElement as HTMLElement; + this._elementFocusedBeforeDialogWasOpened = this._getActiveElement() as HTMLElement; } } @@ -228,9 +228,17 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet { /** Returns whether focus is inside the dialog. */ private _containsFocus() { const element = this._elementRef.nativeElement; - const activeElement = this._document.activeElement; + const activeElement = this._getActiveElement(); return element === activeElement || element.contains(activeElement); } + + /** Gets the currently-focused element on the page. */ + private _getActiveElement(): Element | null { + // If the `activeElement` is inside a shadow root, `document.activeElement` will + // point to the shadow root so we have to descend into it ourselves. + const activeElement = this._document.activeElement; + return activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement; + } } /** diff --git a/src/material/dialog/dialog.spec.ts b/src/material/dialog/dialog.spec.ts index 307d4b2b6184..687e31d70ac5 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -19,13 +19,15 @@ import { ViewChild, ViewContainerRef, ComponentFactoryResolver, - NgZone + NgZone, + ViewEncapsulation } from '@angular/core'; import {By} from '@angular/platform-browser'; import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Location} from '@angular/common'; import {SpyLocation} from '@angular/common/testing'; import {Directionality} from '@angular/cdk/bidi'; +import {_supportsShadowDom} from '@angular/cdk/platform'; import {MatDialogContainer} from './dialog-container'; import {OverlayContainer, ScrollStrategy, Overlay} from '@angular/cdk/overlay'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; @@ -1166,6 +1168,32 @@ describe('MatDialog', () => { document.body.removeChild(button); })); + it('should re-focus trigger element inside the shadow DOM when dialog closes', fakeAsync(() => { + if (!_supportsShadowDom()) { + return; + } + + viewContainerFixture.destroy(); + const fixture = TestBed.createComponent(ShadowDomComponent); + fixture.detectChanges(); + const button = fixture.debugElement.query(By.css('button'))!.nativeElement; + + button.focus(); + + const dialogRef = dialog.open(PizzaMsg); + flushMicrotasks(); + fixture.detectChanges(); + flushMicrotasks(); + + const spy = spyOn(button, 'focus').and.callThrough(); + dialogRef.close(); + flushMicrotasks(); + fixture.detectChanges(); + tick(500); + + expect(spy).toHaveBeenCalled(); + })); + it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => { const button = document.createElement('button'); let lastFocusOrigin: FocusOrigin = null; @@ -1947,6 +1975,12 @@ class DialogWithInjectedData { @Component({template: '

Pasta

'}) class DialogWithoutFocusableElements {} +@Component({ + template: ``, + encapsulation: ViewEncapsulation.ShadowDom +}) +class ShadowDomComponent {} + // Create a real (non-test) NgModule as a workaround for // https://github.com/angular/angular/issues/10760 const TEST_DIRECTIVES = [ @@ -1959,6 +1993,7 @@ const TEST_DIRECTIVES = [ DialogWithInjectedData, DialogWithoutFocusableElements, ComponentWithContentElementTemplateRef, + ShadowDomComponent, ]; @NgModule({