Skip to content

Commit c8f02e9

Browse files
committed
fix(dialog): restore focus with the proper focus origin
* Restores the trigger focus upon dialog close with the proper focus origin that caused the dialog closing. For example, a backdrop click leads to a focus restore via `mouse`. Pressing `escape` leads to a focus restore via `keyboard`. Clicking the `matDialogClose` button will depend on the type of interaction (e.g. `click` or `keyboard`) References #8420.
1 parent 86aad59 commit c8f02e9

File tree

6 files changed

+213
-38
lines changed

6 files changed

+213
-38
lines changed

src/material/dialog/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ ng_test_library(
5656
),
5757
deps = [
5858
":dialog",
59+
"//src/cdk/a11y",
5960
"//src/cdk/bidi",
6061
"//src/cdk/keycodes",
6162
"//src/cdk/overlay",

src/material/dialog/dialog-container.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
CdkPortalOutlet,
2929
TemplatePortal
3030
} from '@angular/cdk/portal';
31-
import {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y';
31+
import {FocusTrap, FocusMonitor, FocusOrigin, FocusTrapFactory} from '@angular/cdk/a11y';
3232
import {MatDialogConfig} from './dialog-config';
3333

3434

@@ -80,6 +80,13 @@ export class MatDialogContainer extends BasePortalOutlet {
8080
/** Element that was focused before the dialog was opened. Save this to restore upon close. */
8181
private _elementFocusedBeforeDialogWasOpened: HTMLElement | null = null;
8282

83+
/**
84+
* Type of interaction that led to the dialog being closed. This is used to determine
85+
* whether the focus style will be applied when returning focus to its original location
86+
* after the dialog is closed.
87+
*/
88+
_closeInteractionType: FocusOrigin = 'program';
89+
8390
/** State of the dialog animation. */
8491
_state: 'void' | 'enter' | 'exit' = 'enter';
8592

@@ -92,14 +99,12 @@ export class MatDialogContainer extends BasePortalOutlet {
9299
/** ID for the container DOM element. */
93100
_id: string;
94101

95-
constructor(
96-
private _elementRef: ElementRef,
97-
private _focusTrapFactory: FocusTrapFactory,
98-
private _changeDetectorRef: ChangeDetectorRef,
99-
@Optional() @Inject(DOCUMENT) private _document: any,
100-
/** The dialog configuration. */
101-
public _config: MatDialogConfig) {
102-
102+
constructor(private _elementRef: ElementRef,
103+
private _focusTrapFactory: FocusTrapFactory,
104+
private _changeDetectorRef: ChangeDetectorRef,
105+
@Optional() @Inject(DOCUMENT) private _document: any,
106+
/** The dialog configuration. */ public _config: MatDialogConfig,
107+
private _focusMonitor?: FocusMonitor) {
103108
super();
104109
this._ariaLabelledBy = _config.ariaLabelledBy || null;
105110
}
@@ -151,11 +156,14 @@ export class MatDialogContainer extends BasePortalOutlet {
151156

152157
/** Restores focus to the element that was focused before the dialog opened. */
153158
private _restoreFocus() {
154-
const toFocus = this._elementFocusedBeforeDialogWasOpened;
159+
const previousElement = this._elementFocusedBeforeDialogWasOpened;
155160

156-
// We need the extra check, because IE can set the `activeElement` to null in some cases.
157-
if (this._config.restoreFocus && toFocus && typeof toFocus.focus === 'function') {
158-
toFocus.focus();
161+
if (this._config.restoreFocus && previousElement && previousElement.focus) {
162+
if (this._focusMonitor) {
163+
this._focusMonitor.focusVia(previousElement, this._closeInteractionType);
164+
} else {
165+
previousElement.focus();
166+
}
159167
}
160168

161169
if (this._focusTrap) {

src/material/dialog/dialog-content-directives.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ let dialogElementUid = 0;
2828
selector: `button[mat-dialog-close], button[matDialogClose]`,
2929
exportAs: 'matDialogClose',
3030
host: {
31-
'(click)': 'dialogRef.close(dialogResult)',
31+
'(click)': '_onButtonClick($event)',
3232
'[attr.aria-label]': 'ariaLabel || null',
3333
'type': 'button', // Prevents accidental form submits.
3434
}
@@ -65,6 +65,15 @@ export class MatDialogClose implements OnInit, OnChanges {
6565
this.dialogResult = proxiedChange.currentValue;
6666
}
6767
}
68+
69+
_onButtonClick(event: MouseEvent) {
70+
// Determinate the focus origin using the click event, because using the FocusMonitor will
71+
// result in incorrect origins. Most of the time, close buttons will be auto focused in the
72+
// dialog, and therefore clicking the button won't result in a focus change. This means that
73+
// the FocusMonitor won't detect any origin change, and will always output `program`.
74+
this.dialogRef._closeVia(
75+
event.screenX === 0 && event.screenY === 0 ? 'keyboard' : 'mouse', this.dialogResult);
76+
}
6877
}
6978

7079
/**

src/material/dialog/dialog-ref.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {FocusOrigin} from '@angular/cdk/a11y';
910
import {ESCAPE, hasModifierKey} from '@angular/cdk/keycodes';
1011
import {GlobalPositionStrategy, OverlayRef} from '@angular/cdk/overlay';
1112
import {Location} from '@angular/common';
@@ -83,7 +84,7 @@ export class MatDialogRef<T, R = any> {
8384
}))
8485
.subscribe(event => {
8586
event.preventDefault();
86-
this.close();
87+
this._closeVia('keyboard');
8788
});
8889
}
8990

@@ -108,6 +109,12 @@ export class MatDialogRef<T, R = any> {
108109
this._containerInstance._startExitAnimation();
109110
}
110111

112+
/** Closes the dialog with the specified interaction type. */
113+
_closeVia(interactionType: FocusOrigin, dialogResult?: R) {
114+
this._containerInstance._closeInteractionType = interactionType;
115+
this.close(dialogResult);
116+
}
117+
111118
/**
112119
* Gets an observable that is notified when the dialog is finished opening.
113120
*/

src/material/dialog/dialog.spec.ts

Lines changed: 172 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y';
2+
import {Directionality} from '@angular/cdk/bidi';
3+
import {A, ESCAPE} from '@angular/cdk/keycodes';
4+
import {Overlay, OverlayContainer, ScrollStrategy} from '@angular/cdk/overlay';
5+
import {ScrollDispatcher} from '@angular/cdk/scrolling';
16
import {
2-
ComponentFixture,
3-
fakeAsync,
4-
flushMicrotasks,
5-
inject,
6-
TestBed,
7-
tick,
8-
flush,
9-
} from '@angular/core/testing';
7+
createKeyboardEvent,
8+
dispatchKeyboardEvent,
9+
dispatchMouseEvent,
10+
patchElementFocus
11+
} from '@angular/cdk/testing';
12+
import {Location} from '@angular/common';
13+
import {SpyLocation} from '@angular/common/testing';
1014
import {
1115
ChangeDetectionStrategy,
1216
Component,
@@ -18,31 +22,34 @@ import {
1822
ViewChild,
1923
ViewContainerRef
2024
} from '@angular/core';
25+
import {
26+
ComponentFixture,
27+
fakeAsync,
28+
flush,
29+
flushMicrotasks,
30+
inject,
31+
TestBed,
32+
tick,
33+
} from '@angular/core/testing';
2134
import {By} from '@angular/platform-browser';
2235
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
23-
import {Location} from '@angular/common';
24-
import {SpyLocation} from '@angular/common/testing';
25-
import {Directionality} from '@angular/cdk/bidi';
36+
import {Subject} from 'rxjs';
2637
import {MatDialogContainer} from './dialog-container';
27-
import {OverlayContainer, ScrollStrategy, Overlay} from '@angular/cdk/overlay';
28-
import {ScrollDispatcher} from '@angular/cdk/scrolling';
29-
import {A, ESCAPE} from '@angular/cdk/keycodes';
30-
import {dispatchKeyboardEvent, createKeyboardEvent} from '@angular/cdk/testing';
3138
import {
3239
MAT_DIALOG_DATA,
40+
MAT_DIALOG_DEFAULT_OPTIONS,
3341
MatDialog,
3442
MatDialogModule,
35-
MatDialogRef,
36-
MAT_DIALOG_DEFAULT_OPTIONS
43+
MatDialogRef
3744
} from './index';
38-
import {Subject} from 'rxjs';
3945

4046

4147
describe('MatDialog', () => {
4248
let dialog: MatDialog;
4349
let overlayContainer: OverlayContainer;
4450
let overlayContainerElement: HTMLElement;
4551
let scrolledSubject = new Subject();
52+
let focusMonitor: FocusMonitor;
4653

4754
let testViewContainerRef: ViewContainerRef;
4855
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;
@@ -62,13 +69,14 @@ describe('MatDialog', () => {
6269
TestBed.compileComponents();
6370
}));
6471

65-
beforeEach(inject([MatDialog, Location, OverlayContainer],
66-
(d: MatDialog, l: Location, oc: OverlayContainer) => {
72+
beforeEach(inject([MatDialog, Location, OverlayContainer, FocusMonitor],
73+
(d: MatDialog, l: Location, oc: OverlayContainer, fm: FocusMonitor) => {
6774
dialog = d;
6875
mockLocation = l as SpyLocation;
6976
overlayContainer = oc;
7077
overlayContainerElement = oc.getContainerElement();
71-
}));
78+
focusMonitor = fm;
79+
}));
7280

7381
afterEach(() => {
7482
overlayContainer.ngOnDestroy();
@@ -1035,6 +1043,148 @@ describe('MatDialog', () => {
10351043
document.body.removeChild(button);
10361044
}));
10371045

1046+
it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => {
1047+
const button = document.createElement('button');
1048+
let lastFocusOrigin: FocusOrigin = null;
1049+
1050+
focusMonitor.monitor(button, false)
1051+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1052+
1053+
document.body.appendChild(button);
1054+
button.focus();
1055+
1056+
// Patch the element focus after the initial and real focus, because otherwise the
1057+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1058+
patchElementFocus(button);
1059+
1060+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
1061+
1062+
tick(500);
1063+
viewContainerFixture.detectChanges();
1064+
1065+
expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred');
1066+
1067+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
1068+
1069+
flushMicrotasks();
1070+
viewContainerFixture.detectChanges();
1071+
tick(500);
1072+
1073+
expect(lastFocusOrigin!)
1074+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1075+
1076+
focusMonitor.stopMonitoring(button);
1077+
document.body.removeChild(button);
1078+
}));
1079+
1080+
it('should re-focus the trigger via mouse when backdrop has been clicked', fakeAsync(() => {
1081+
const button = document.createElement('button');
1082+
let lastFocusOrigin: FocusOrigin = null;
1083+
1084+
focusMonitor.monitor(button, false)
1085+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1086+
1087+
document.body.appendChild(button);
1088+
button.focus();
1089+
1090+
// Patch the element focus after the initial and real focus, because otherwise the
1091+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1092+
patchElementFocus(button);
1093+
1094+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
1095+
1096+
tick(500);
1097+
viewContainerFixture.detectChanges();
1098+
1099+
const backdrop = overlayContainerElement
1100+
.querySelector('.cdk-overlay-backdrop') as HTMLElement;
1101+
1102+
backdrop.click();
1103+
viewContainerFixture.detectChanges();
1104+
tick(500);
1105+
1106+
expect(lastFocusOrigin!)
1107+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1108+
1109+
focusMonitor.stopMonitoring(button);
1110+
document.body.removeChild(button);
1111+
}));
1112+
1113+
it('should re-focus via keyboard if the close button has been triggered through keyboard',
1114+
fakeAsync(() => {
1115+
1116+
const button = document.createElement('button');
1117+
let lastFocusOrigin: FocusOrigin = null;
1118+
1119+
focusMonitor.monitor(button, false)
1120+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1121+
1122+
document.body.appendChild(button);
1123+
button.focus();
1124+
1125+
// Patch the element focus after the initial and real focus, because otherwise the
1126+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1127+
patchElementFocus(button);
1128+
1129+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1130+
1131+
tick(500);
1132+
viewContainerFixture.detectChanges();
1133+
1134+
const closeButton = overlayContainerElement
1135+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1136+
1137+
// Fake the behavior of pressing the SPACE key on a button element. Browsers fire a `click`
1138+
// event with a MouseEvent, which has coordinates that are out of the element boundaries.
1139+
dispatchMouseEvent(closeButton, 'click', 0, 0);
1140+
1141+
viewContainerFixture.detectChanges();
1142+
tick(500);
1143+
1144+
expect(lastFocusOrigin!)
1145+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1146+
1147+
focusMonitor.stopMonitoring(button);
1148+
document.body.removeChild(button);
1149+
}));
1150+
1151+
it('should re-focus via mouse if the close button has been clicked', fakeAsync(() => {
1152+
const button = document.createElement('button');
1153+
let lastFocusOrigin: FocusOrigin = null;
1154+
1155+
focusMonitor.monitor(button, false)
1156+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1157+
1158+
document.body.appendChild(button);
1159+
button.focus();
1160+
1161+
// Patch the element focus after the initial and real focus, because otherwise the
1162+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1163+
patchElementFocus(button);
1164+
1165+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1166+
1167+
tick(500);
1168+
viewContainerFixture.detectChanges();
1169+
1170+
const closeButton = overlayContainerElement
1171+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1172+
1173+
// The dialog close button detects the focus origin by inspecting the click event. If
1174+
// coordinates of the click are not present, it assumes that the click has been triggered
1175+
// by keyboard.
1176+
dispatchMouseEvent(closeButton, 'click', 10, 10);
1177+
1178+
viewContainerFixture.detectChanges();
1179+
tick(500);
1180+
1181+
expect(lastFocusOrigin!)
1182+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1183+
1184+
focusMonitor.stopMonitoring(button);
1185+
document.body.removeChild(button);
1186+
}));
1187+
10381188
it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => {
10391189
// Create a element that has focus before the dialog is opened.
10401190
let button = document.createElement('button');
@@ -1057,7 +1207,7 @@ describe('MatDialog', () => {
10571207

10581208
tick(500);
10591209
viewContainerFixture.detectChanges();
1060-
flushMicrotasks();
1210+
flush();
10611211

10621212
expect(document.activeElement!.id).toBe('input-to-be-focused',
10631213
'Expected that the trigger was refocused after the dialog is closed.');

src/material/dialog/dialog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ export class MatDialog implements OnDestroy {
254254
if (config.hasBackdrop) {
255255
overlayRef.backdropClick().subscribe(() => {
256256
if (!dialogRef.disableClose) {
257-
dialogRef.close();
257+
dialogRef._closeVia('mouse');
258258
}
259259
});
260260
}

0 commit comments

Comments
 (0)