Skip to content

Commit

Permalink
fix(material/dialog): focus restoration not working inside shadow dom (
Browse files Browse the repository at this point in the history
…#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.
  • Loading branch information
crisbeto authored Mar 5, 2021
1 parent 40cce76 commit be508da
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 5 deletions.
1 change: 1 addition & 0 deletions src/material-experimental/mdc-dialog/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 37 additions & 1 deletion src/material-experimental/mdc-dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,7 +24,8 @@ import {
NgZone,
TemplateRef,
ViewChild,
ViewContainerRef
ViewContainerRef,
ViewEncapsulation
} from '@angular/core';
import {
ComponentFixture,
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1870,6 +1899,12 @@ class DialogWithInjectedData {
class DialogWithoutFocusableElements {
}

@Component({
template: `<button>I'm a button</button>`,
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 = [
Expand All @@ -1882,6 +1917,7 @@ const TEST_DIRECTIVES = [
DialogWithInjectedData,
DialogWithoutFocusableElements,
ComponentWithContentElementTemplateRef,
ShadowDomComponent,
];

@NgModule({
Expand Down
1 change: 1 addition & 0 deletions src/material/dialog/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions src/material/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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;
}
}

/**
Expand Down
37 changes: 36 additions & 1 deletion src/material/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1947,6 +1975,12 @@ class DialogWithInjectedData {
@Component({template: '<p>Pasta</p>'})
class DialogWithoutFocusableElements {}

@Component({
template: `<button>I'm a button</button>`,
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 = [
Expand All @@ -1959,6 +1993,7 @@ const TEST_DIRECTIVES = [
DialogWithInjectedData,
DialogWithoutFocusableElements,
ComponentWithContentElementTemplateRef,
ShadowDomComponent,
];

@NgModule({
Expand Down

0 comments on commit be508da

Please sign in to comment.