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({