From a66869e819dbbd77b1e8816ce8314fb26072fff2 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 4 Feb 2021 22:23:04 +0100 Subject: [PATCH] fix(material/dialog): focus restoration not working inside shadow dom 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. --- .../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({