Skip to content

Commit f18c416

Browse files
crisbetoannieyw
authored andcommitted
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 be508da)
1 parent 627b0eb commit f18c416

File tree

5 files changed

+86
-5
lines changed

5 files changed

+86
-5
lines changed

src/material-experimental/mdc-dialog/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ ng_test_library(
6565
"//src/cdk/bidi",
6666
"//src/cdk/keycodes",
6767
"//src/cdk/overlay",
68+
"//src/cdk/platform",
6869
"//src/cdk/scrolling",
6970
"//src/cdk/testing/private",
7071
"@npm//@angular/common",

src/material-experimental/mdc-dialog/dialog.spec.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y';
22
import {Directionality} from '@angular/cdk/bidi';
33
import {A, ESCAPE} from '@angular/cdk/keycodes';
44
import {Overlay, OverlayContainer, ScrollStrategy} from '@angular/cdk/overlay';
5+
import {_supportsShadowDom} from '@angular/cdk/platform';
56
import {ScrollDispatcher} from '@angular/cdk/scrolling';
67
import {
78
createKeyboardEvent,
@@ -23,7 +24,8 @@ import {
2324
NgZone,
2425
TemplateRef,
2526
ViewChild,
26-
ViewContainerRef
27+
ViewContainerRef,
28+
ViewEncapsulation
2729
} from '@angular/core';
2830
import {
2931
ComponentFixture,
@@ -34,6 +36,7 @@ import {
3436
TestBed,
3537
tick,
3638
} from '@angular/core/testing';
39+
import {By} from '@angular/platform-browser';
3740
import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations';
3841
import {numbers} from '@material/dialog';
3942
import {Subject} from 'rxjs';
@@ -1075,6 +1078,32 @@ describe('MDC-based MatDialog', () => {
10751078
document.body.removeChild(button);
10761079
}));
10771080

1081+
it('should re-focus trigger element inside the shadow DOM when dialog closes', fakeAsync(() => {
1082+
if (!_supportsShadowDom()) {
1083+
return;
1084+
}
1085+
1086+
viewContainerFixture.destroy();
1087+
const fixture = TestBed.createComponent(ShadowDomComponent);
1088+
fixture.detectChanges();
1089+
const button = fixture.debugElement.query(By.css('button'))!.nativeElement;
1090+
1091+
button.focus();
1092+
1093+
const dialogRef = dialog.open(PizzaMsg);
1094+
flushMicrotasks();
1095+
fixture.detectChanges();
1096+
flushMicrotasks();
1097+
1098+
const spy = spyOn(button, 'focus').and.callThrough();
1099+
dialogRef.close();
1100+
flushMicrotasks();
1101+
fixture.detectChanges();
1102+
tick(500);
1103+
1104+
expect(spy).toHaveBeenCalled();
1105+
}));
1106+
10781107
it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => {
10791108
const button = document.createElement('button');
10801109
let lastFocusOrigin: FocusOrigin = null;
@@ -1870,6 +1899,12 @@ class DialogWithInjectedData {
18701899
class DialogWithoutFocusableElements {
18711900
}
18721901

1902+
@Component({
1903+
template: `<button>I'm a button</button>`,
1904+
encapsulation: ViewEncapsulation.ShadowDom
1905+
})
1906+
class ShadowDomComponent {}
1907+
18731908
// Create a real (non-test) NgModule as a workaround for
18741909
// https://github.com/angular/angular/issues/10760
18751910
const TEST_DIRECTIVES = [
@@ -1882,6 +1917,7 @@ const TEST_DIRECTIVES = [
18821917
DialogWithInjectedData,
18831918
DialogWithoutFocusableElements,
18841919
ComponentWithContentElementTemplateRef,
1920+
ShadowDomComponent,
18851921
];
18861922

18871923
@NgModule({

src/material/dialog/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ ng_test_library(
6161
"//src/cdk/bidi",
6262
"//src/cdk/keycodes",
6363
"//src/cdk/overlay",
64+
"//src/cdk/platform",
6465
"//src/cdk/scrolling",
6566
"//src/cdk/testing/private",
6667
"@npm//@angular/common",

src/material/dialog/dialog-container.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet {
182182
// We need the extra check, because IE can set the `activeElement` to null in some cases.
183183
if (this._config.restoreFocus && previousElement &&
184184
typeof previousElement.focus === 'function') {
185-
const activeElement = this._document.activeElement;
185+
const activeElement = this._getActiveElement();
186186
const element = this._elementRef.nativeElement;
187187

188188
// 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 {
213213
/** Captures the element that was focused before the dialog was opened. */
214214
private _capturePreviouslyFocusedElement() {
215215
if (this._document) {
216-
this._elementFocusedBeforeDialogWasOpened = this._document.activeElement as HTMLElement;
216+
this._elementFocusedBeforeDialogWasOpened = this._getActiveElement() as HTMLElement;
217217
}
218218
}
219219

@@ -228,9 +228,17 @@ export abstract class _MatDialogContainerBase extends BasePortalOutlet {
228228
/** Returns whether focus is inside the dialog. */
229229
private _containsFocus() {
230230
const element = this._elementRef.nativeElement;
231-
const activeElement = this._document.activeElement;
231+
const activeElement = this._getActiveElement();
232232
return element === activeElement || element.contains(activeElement);
233233
}
234+
235+
/** Gets the currently-focused element on the page. */
236+
private _getActiveElement(): Element | null {
237+
// If the `activeElement` is inside a shadow root, `document.activeElement` will
238+
// point to the shadow root so we have to descend into it ourselves.
239+
const activeElement = this._document.activeElement;
240+
return activeElement?.shadowRoot?.activeElement as HTMLElement || activeElement;
241+
}
234242
}
235243

236244
/**

src/material/dialog/dialog.spec.ts

+36-1
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ import {
1919
ViewChild,
2020
ViewContainerRef,
2121
ComponentFactoryResolver,
22-
NgZone
22+
NgZone,
23+
ViewEncapsulation
2324
} from '@angular/core';
2425
import {By} from '@angular/platform-browser';
2526
import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations';
2627
import {Location} from '@angular/common';
2728
import {SpyLocation} from '@angular/common/testing';
2829
import {Directionality} from '@angular/cdk/bidi';
30+
import {_supportsShadowDom} from '@angular/cdk/platform';
2931
import {MatDialogContainer} from './dialog-container';
3032
import {OverlayContainer, ScrollStrategy, Overlay} from '@angular/cdk/overlay';
3133
import {ScrollDispatcher} from '@angular/cdk/scrolling';
@@ -1166,6 +1168,32 @@ describe('MatDialog', () => {
11661168
document.body.removeChild(button);
11671169
}));
11681170

1171+
it('should re-focus trigger element inside the shadow DOM when dialog closes', fakeAsync(() => {
1172+
if (!_supportsShadowDom()) {
1173+
return;
1174+
}
1175+
1176+
viewContainerFixture.destroy();
1177+
const fixture = TestBed.createComponent(ShadowDomComponent);
1178+
fixture.detectChanges();
1179+
const button = fixture.debugElement.query(By.css('button'))!.nativeElement;
1180+
1181+
button.focus();
1182+
1183+
const dialogRef = dialog.open(PizzaMsg);
1184+
flushMicrotasks();
1185+
fixture.detectChanges();
1186+
flushMicrotasks();
1187+
1188+
const spy = spyOn(button, 'focus').and.callThrough();
1189+
dialogRef.close();
1190+
flushMicrotasks();
1191+
fixture.detectChanges();
1192+
tick(500);
1193+
1194+
expect(spy).toHaveBeenCalled();
1195+
}));
1196+
11691197
it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => {
11701198
const button = document.createElement('button');
11711199
let lastFocusOrigin: FocusOrigin = null;
@@ -1947,6 +1975,12 @@ class DialogWithInjectedData {
19471975
@Component({template: '<p>Pasta</p>'})
19481976
class DialogWithoutFocusableElements {}
19491977

1978+
@Component({
1979+
template: `<button>I'm a button</button>`,
1980+
encapsulation: ViewEncapsulation.ShadowDom
1981+
})
1982+
class ShadowDomComponent {}
1983+
19501984
// Create a real (non-test) NgModule as a workaround for
19511985
// https://github.com/angular/angular/issues/10760
19521986
const TEST_DIRECTIVES = [
@@ -1959,6 +1993,7 @@ const TEST_DIRECTIVES = [
19591993
DialogWithInjectedData,
19601994
DialogWithoutFocusableElements,
19611995
ComponentWithContentElementTemplateRef,
1996+
ShadowDomComponent,
19621997
];
19631998

19641999
@NgModule({

0 commit comments

Comments
 (0)