Skip to content

Commit db873ea

Browse files
authored
fix(dialog): recapture focus when clicking on backdrop when cl… (#18826)
`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.
1 parent d6564cc commit db873ea

File tree

5 files changed

+61
-21
lines changed

5 files changed

+61
-21
lines changed

src/material/dialog/dialog-container.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,30 +148,31 @@ export class MatDialogContainer extends BasePortalOutlet {
148148
return this._portalOutlet.attachDomPortal(portal);
149149
}
150150

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

155-
if (!this._focusTrap) {
156-
this._focusTrap = this._focusTrapFactory.create(element);
156+
if (!focusWasTrapped) {
157+
this._elementRef.nativeElement.focus();
158+
}
157159
}
160+
}
158161

162+
/** Moves the focus inside the focus trap. */
163+
private _trapFocus() {
159164
// If we were to attempt to focus immediately, then the content of the dialog would not yet be
160165
// ready in instances where change detection has to run first. To deal with this, we simply
161166
// wait for the microtask queue to be empty.
162167
if (this._config.autoFocus) {
163-
this._focusTrap.focusInitialElementWhenReady();
164-
} else {
165-
const activeElement = this._document.activeElement;
166-
168+
this._getFocusTrap().focusInitialElementWhenReady();
169+
} else if (!this._containsFocus()) {
167170
// Otherwise ensure that focus is on the dialog container. It's possible that a different
168171
// component tried to move focus while the open animation was running. See:
169172
// https://github.com/angular/components/issues/16215. Note that we only want to do this
170173
// if the focus isn't inside the dialog already, because it's possible that the consumer
171174
// turned off `autoFocus` in order to move focus themselves.
172-
if (activeElement !== element && !element.contains(activeElement)) {
173-
element.focus();
174-
}
175+
this._elementRef.nativeElement.focus();
175176
}
176177
}
177178

@@ -214,6 +215,22 @@ export class MatDialogContainer extends BasePortalOutlet {
214215
}
215216
}
216217

218+
/** Returns whether focus is inside the dialog. */
219+
private _containsFocus() {
220+
const element = this._elementRef.nativeElement;
221+
const activeElement = this._document.activeElement;
222+
return element === activeElement || element.contains(activeElement);
223+
}
224+
225+
/** Gets the focus trap associated with the dialog. */
226+
private _getFocusTrap() {
227+
if (!this._focusTrap) {
228+
this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement);
229+
}
230+
231+
return this._focusTrap;
232+
}
233+
217234
/** Callback, invoked whenever an animation on the host completes. */
218235
_onAnimationDone(event: AnimationEvent) {
219236
if (event.toState === 'enter') {

src/material/dialog/dialog-ref.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ export class MatDialogRef<T, R = any> {
9494
event.preventDefault();
9595
this.close();
9696
});
97+
98+
_overlayRef.backdropClick().subscribe(() => {
99+
if (this.disableClose) {
100+
this._containerInstance._recaptureFocus();
101+
} else {
102+
this.close();
103+
}
104+
});
97105
}
98106

99107
/**

src/material/dialog/dialog.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,29 @@ describe('MatDialog', () => {
973973

974974
expect(overlayContainerElement.querySelector('mat-dialog-container')).toBeFalsy();
975975
}));
976+
977+
it('should recapture focus when clicking on the backdrop', fakeAsync(() => {
978+
dialog.open(PizzaMsg, {
979+
disableClose: true,
980+
viewContainerRef: testViewContainerRef
981+
});
982+
983+
viewContainerFixture.detectChanges();
984+
flushMicrotasks();
985+
986+
let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
987+
let input = overlayContainerElement.querySelector('input') as HTMLInputElement;
988+
989+
expect(document.activeElement).toBe(input, 'Expected input to be focused on open');
990+
991+
input.blur(); // Programmatic clicks might not move focus so we simulate it.
992+
backdrop.click();
993+
viewContainerFixture.detectChanges();
994+
flush();
995+
996+
expect(document.activeElement).toBe(input, 'Expected input to stay focused after click');
997+
}));
998+
976999
});
9771000

9781001
describe('hasBackdrop option', () => {

src/material/dialog/dialog.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -248,15 +248,6 @@ export class MatDialog implements OnDestroy {
248248
const dialogRef =
249249
new MatDialogRef<T, R>(overlayRef, dialogContainer, config.id);
250250

251-
// When the dialog backdrop is clicked, we want to close it.
252-
if (config.hasBackdrop) {
253-
overlayRef.backdropClick().subscribe(() => {
254-
if (!dialogRef.disableClose) {
255-
dialogRef.close();
256-
}
257-
});
258-
}
259-
260251
if (componentOrTemplateRef instanceof TemplateRef) {
261252
dialogContainer.attachTemplatePortal(
262253
new TemplatePortal<T>(componentOrTemplateRef, null!,

tools/public_api_guard/material/dialog.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export declare class MatDialogContainer extends BasePortalOutlet {
9999
_config: MatDialogConfig);
100100
_onAnimationDone(event: AnimationEvent): void;
101101
_onAnimationStart(event: AnimationEvent): void;
102+
_recaptureFocus(): void;
102103
_startExitAnimation(): void;
103104
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
104105
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C>;

0 commit comments

Comments
 (0)