Skip to content

Commit 67edd91

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 27811a3 commit 67edd91

File tree

5 files changed

+191
-20
lines changed

5 files changed

+191
-20
lines changed

src/lib/dialog/dialog-container.ts

Lines changed: 21 additions & 12 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
}
105110

@@ -145,11 +150,15 @@ export class MatDialogContainer extends BasePortalOutlet {
145150

146151
/** Restores focus to the element that was focused before the dialog opened. */
147152
private _restoreFocus() {
148-
const toFocus = this._elementFocusedBeforeDialogWasOpened;
153+
const previousElement = this._elementFocusedBeforeDialogWasOpened;
149154

150155
// We need the extra check, because IE can set the `activeElement` to null in some cases.
151-
if (toFocus && typeof toFocus.focus === 'function') {
152-
toFocus.focus();
156+
if (previousElement && typeof previousElement.focus === 'function') {
157+
if (this._focusMonitor) {
158+
this._focusMonitor.focusVia(previousElement, this._closeInteractionType);
159+
} else {
160+
previousElement.focus();
161+
}
153162
}
154163

155164
if (this._focusTrap) {

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

Lines changed: 11 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',
3333
'type': 'button', // Prevents accidental form submits.
3434
}
@@ -65,6 +65,16 @@ 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+
const focusOrigin = event.screenX === 0 && event.screenY === 0 ? 'keyboard' : 'mouse';
75+
76+
this.dialogRef._closeVia(focusOrigin, this.dialogResult);
77+
}
6878
}
6979

7080
/**

src/lib/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} from '@angular/cdk/keycodes';
1011
import {GlobalPositionStrategy, OverlayRef} from '@angular/cdk/overlay';
1112
import {Location} from '@angular/common';
@@ -82,7 +83,7 @@ export class MatDialogRef<T, R = any> {
8283

8384
_overlayRef.keydownEvents()
8485
.pipe(filter(event => event.keyCode === ESCAPE && !this.disableClose))
85-
.subscribe(() => this.close());
86+
.subscribe(() => this._closeVia('keyboard'));
8687

8788
if (location) {
8889
// Close the dialog when the user goes forwards/backwards in history or when the location
@@ -117,6 +118,12 @@ export class MatDialogRef<T, R = any> {
117118
this._containerInstance._startExitAnimation();
118119
}
119120

121+
/** Closes the dialog with the specified interaction type. */
122+
_closeVia(interactionType: FocusOrigin, dialogResult?: R) {
123+
this._containerInstance._closeInteractionType = interactionType;
124+
this.close(dialogResult);
125+
}
126+
120127
/**
121128
* Gets an observable that is notified when the dialog is finished opening.
122129
*/

src/lib/dialog/dialog.spec.ts

Lines changed: 150 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ import {SpyLocation} from '@angular/common/testing';
2525
import {Directionality} from '@angular/cdk/bidi';
2626
import {MatDialogContainer} from './dialog-container';
2727
import {OverlayContainer, ScrollStrategy, ScrollDispatcher, Overlay} from '@angular/cdk/overlay';
28+
import {FocusOrigin, FocusMonitor} from '@angular/cdk/a11y';
2829
import {A, ESCAPE} from '@angular/cdk/keycodes';
29-
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
30+
import {dispatchKeyboardEvent, dispatchMouseEvent, patchElementFocus} from '@angular/cdk/testing';
3031
import {
3132
MAT_DIALOG_DATA,
3233
MatDialog,
@@ -42,6 +43,7 @@ describe('MatDialog', () => {
4243
let overlayContainer: OverlayContainer;
4344
let overlayContainerElement: HTMLElement;
4445
let scrolledSubject = new Subject();
46+
let focusMonitor: FocusMonitor;
4547

4648
let testViewContainerRef: ViewContainerRef;
4749
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;
@@ -61,13 +63,14 @@ describe('MatDialog', () => {
6163
TestBed.compileComponents();
6264
}));
6365

64-
beforeEach(inject([MatDialog, Location, OverlayContainer],
65-
(d: MatDialog, l: Location, oc: OverlayContainer) => {
66+
beforeEach(inject([MatDialog, Location, OverlayContainer, FocusMonitor],
67+
(d: MatDialog, l: Location, oc: OverlayContainer, fm: FocusMonitor) => {
6668
dialog = d;
6769
mockLocation = l as SpyLocation;
6870
overlayContainer = oc;
6971
overlayContainerElement = oc.getContainerElement();
70-
}));
72+
focusMonitor = fm;
73+
}));
7174

7275
afterEach(() => {
7376
overlayContainer.ngOnDestroy();
@@ -967,6 +970,148 @@ describe('MatDialog', () => {
967970
document.body.removeChild(button);
968971
}));
969972

973+
it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => {
974+
const button = document.createElement('button');
975+
let lastFocusOrigin: FocusOrigin = null;
976+
977+
focusMonitor.monitor(button, false)
978+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
979+
980+
document.body.appendChild(button);
981+
button.focus();
982+
983+
// Patch the element focus after the initial and real focus, because otherwise the
984+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
985+
patchElementFocus(button);
986+
987+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
988+
989+
tick(500);
990+
viewContainerFixture.detectChanges();
991+
992+
expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred');
993+
994+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
995+
996+
flushMicrotasks();
997+
viewContainerFixture.detectChanges();
998+
tick(500);
999+
1000+
expect(lastFocusOrigin!)
1001+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1002+
1003+
focusMonitor.stopMonitoring(button);
1004+
document.body.removeChild(button);
1005+
}));
1006+
1007+
it('should re-focus the trigger via mouse when backdrop has been clicked', fakeAsync(() => {
1008+
const button = document.createElement('button');
1009+
let lastFocusOrigin: FocusOrigin = null;
1010+
1011+
focusMonitor.monitor(button, false)
1012+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1013+
1014+
document.body.appendChild(button);
1015+
button.focus();
1016+
1017+
// Patch the element focus after the initial and real focus, because otherwise the
1018+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1019+
patchElementFocus(button);
1020+
1021+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
1022+
1023+
tick(500);
1024+
viewContainerFixture.detectChanges();
1025+
1026+
const backdrop = overlayContainerElement
1027+
.querySelector('.cdk-overlay-backdrop') as HTMLElement;
1028+
1029+
backdrop.click();
1030+
viewContainerFixture.detectChanges();
1031+
tick(500);
1032+
1033+
expect(lastFocusOrigin!)
1034+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1035+
1036+
focusMonitor.stopMonitoring(button);
1037+
document.body.removeChild(button);
1038+
}));
1039+
1040+
it('should re-focus via keyboard if the close button has been triggered through keyboard',
1041+
fakeAsync(() => {
1042+
1043+
const button = document.createElement('button');
1044+
let lastFocusOrigin: FocusOrigin = null;
1045+
1046+
focusMonitor.monitor(button, false)
1047+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1048+
1049+
document.body.appendChild(button);
1050+
button.focus();
1051+
1052+
// Patch the element focus after the initial and real focus, because otherwise the
1053+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1054+
patchElementFocus(button);
1055+
1056+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1057+
1058+
tick(500);
1059+
viewContainerFixture.detectChanges();
1060+
1061+
const closeButton = overlayContainerElement
1062+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1063+
1064+
// Fake the behavior of pressing the SPACE key on a button element. Browsers fire a `click`
1065+
// event with a MouseEvent, which has coordinates that are out of the element boundaries.
1066+
dispatchMouseEvent(closeButton, 'click', 0, 0);
1067+
1068+
viewContainerFixture.detectChanges();
1069+
tick(500);
1070+
1071+
expect(lastFocusOrigin!)
1072+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1073+
1074+
focusMonitor.stopMonitoring(button);
1075+
document.body.removeChild(button);
1076+
}));
1077+
1078+
it('should re-focus via mouse if the close button has been clicked', fakeAsync(() => {
1079+
const button = document.createElement('button');
1080+
let lastFocusOrigin: FocusOrigin = null;
1081+
1082+
focusMonitor.monitor(button, false)
1083+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1084+
1085+
document.body.appendChild(button);
1086+
button.focus();
1087+
1088+
// Patch the element focus after the initial and real focus, because otherwise the
1089+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1090+
patchElementFocus(button);
1091+
1092+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1093+
1094+
tick(500);
1095+
viewContainerFixture.detectChanges();
1096+
1097+
const closeButton = overlayContainerElement
1098+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1099+
1100+
// The dialog close button detects the focus origin by inspecting the click event. If
1101+
// coordinates of the click are not present, it assumes that the click has been triggered
1102+
// by keyboard.
1103+
dispatchMouseEvent(closeButton, 'click', 10, 10);
1104+
1105+
viewContainerFixture.detectChanges();
1106+
tick(500);
1107+
1108+
expect(lastFocusOrigin!)
1109+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1110+
1111+
focusMonitor.stopMonitoring(button);
1112+
document.body.removeChild(button);
1113+
}));
1114+
9701115
it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => {
9711116
// Create a element that has focus before the dialog is opened.
9721117
let button = document.createElement('button');
@@ -989,7 +1134,7 @@ describe('MatDialog', () => {
9891134

9901135
tick(500);
9911136
viewContainerFixture.detectChanges();
992-
flushMicrotasks();
1137+
flush();
9931138

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

src/lib/dialog/dialog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ export class MatDialog {
239239
if (config.hasBackdrop) {
240240
overlayRef.backdropClick().subscribe(() => {
241241
if (!dialogRef.disableClose) {
242-
dialogRef.close();
242+
dialogRef._closeVia('mouse');
243243
}
244244
});
245245
}

0 commit comments

Comments
 (0)