Skip to content

Commit

Permalink
fix(dialog): recapture focus when clicking on backdrop when cl… (#18826)
Browse files Browse the repository at this point in the history
`MatDialog` has an option to disable closing by clicking on the backdrop, but this can lead to focus being bumped back to the `body` and allowing users to tab past the dialog. These changes add some extra logic to recapture focus when the user clicks on the backdrop.

Fixes #18799.
  • Loading branch information
crisbeto authored and mmalerba committed Apr 14, 2020
1 parent 0cace8e commit c5ddfe8
Show file tree
Hide file tree
Showing 5 changed files with 61 additions and 21 deletions.
41 changes: 29 additions & 12 deletions src/material/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,30 +148,31 @@ export class MatDialogContainer extends BasePortalOutlet {
return this._portalOutlet.attachDomPortal(portal);
}

/** Moves the focus inside the focus trap. */
private _trapFocus() {
const element = this._elementRef.nativeElement;
/** Moves focus back into the dialog if it was moved out. */
_recaptureFocus() {
if (!this._containsFocus()) {
const focusWasTrapped = this._getFocusTrap().focusInitialElement();

if (!this._focusTrap) {
this._focusTrap = this._focusTrapFactory.create(element);
if (!focusWasTrapped) {
this._elementRef.nativeElement.focus();
}
}
}

/** Moves the focus inside the focus trap. */
private _trapFocus() {
// If we were to attempt to focus immediately, then the content of the dialog would not yet be
// ready in instances where change detection has to run first. To deal with this, we simply
// wait for the microtask queue to be empty.
if (this._config.autoFocus) {
this._focusTrap.focusInitialElementWhenReady();
} else {
const activeElement = this._document.activeElement;

this._getFocusTrap().focusInitialElementWhenReady();
} else if (!this._containsFocus()) {
// Otherwise ensure that focus is on the dialog container. It's possible that a different
// component tried to move focus while the open animation was running. See:
// https://github.com/angular/components/issues/16215. Note that we only want to do this
// if the focus isn't inside the dialog already, because it's possible that the consumer
// turned off `autoFocus` in order to move focus themselves.
if (activeElement !== element && !element.contains(activeElement)) {
element.focus();
}
this._elementRef.nativeElement.focus();
}
}

Expand Down Expand Up @@ -214,6 +215,22 @@ export class MatDialogContainer extends BasePortalOutlet {
}
}

/** Returns whether focus is inside the dialog. */
private _containsFocus() {
const element = this._elementRef.nativeElement;
const activeElement = this._document.activeElement;
return element === activeElement || element.contains(activeElement);
}

/** Gets the focus trap associated with the dialog. */
private _getFocusTrap() {
if (!this._focusTrap) {
this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
}

return this._focusTrap;
}

/** Callback, invoked whenever an animation on the host completes. */
_onAnimationDone(event: AnimationEvent) {
if (event.toState === 'enter') {
Expand Down
8 changes: 8 additions & 0 deletions src/material/dialog/dialog-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ export class MatDialogRef<T, R = any> {
event.preventDefault();
this.close();
});

_overlayRef.backdropClick().subscribe(() => {
if (this.disableClose) {
this._containerInstance._recaptureFocus();
} else {
this.close();
}
});
}

/**
Expand Down
23 changes: 23 additions & 0 deletions src/material/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,29 @@ describe('MatDialog', () => {

expect(overlayContainerElement.querySelector('mat-dialog-container')).toBeFalsy();
}));

it('should recapture focus when clicking on the backdrop', fakeAsync(() => {
dialog.open(PizzaMsg, {
disableClose: true,
viewContainerRef: testViewContainerRef
});

viewContainerFixture.detectChanges();
flushMicrotasks();

let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
let input = overlayContainerElement.querySelector('input') as HTMLInputElement;

expect(document.activeElement).toBe(input, 'Expected input to be focused on open');

input.blur(); // Programmatic clicks might not move focus so we simulate it.
backdrop.click();
viewContainerFixture.detectChanges();
flush();

expect(document.activeElement).toBe(input, 'Expected input to stay focused after click');
}));

});

describe('hasBackdrop option', () => {
Expand Down
9 changes: 0 additions & 9 deletions src/material/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,15 +248,6 @@ export class MatDialog implements OnDestroy {
const dialogRef =
new MatDialogRef<T, R>(overlayRef, dialogContainer, config.id);

// When the dialog backdrop is clicked, we want to close it.
if (config.hasBackdrop) {
overlayRef.backdropClick().subscribe(() => {
if (!dialogRef.disableClose) {
dialogRef.close();
}
});
}

if (componentOrTemplateRef instanceof TemplateRef) {
dialogContainer.attachTemplatePortal(
new TemplatePortal<T>(componentOrTemplateRef, null!,
Expand Down
1 change: 1 addition & 0 deletions tools/public_api_guard/material/dialog.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export declare class MatDialogContainer extends BasePortalOutlet {
_config: MatDialogConfig);
_onAnimationDone(event: AnimationEvent): void;
_onAnimationStart(event: AnimationEvent): void;
_recaptureFocus(): void;
_startExitAnimation(): void;
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C>;
Expand Down

0 comments on commit c5ddfe8

Please sign in to comment.