Skip to content

Commit b31f039

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 3352201 commit b31f039

File tree

5 files changed

+187
-13
lines changed

5 files changed

+187
-13
lines changed

src/lib/dialog/dialog-container.ts

Lines changed: 17 additions & 5 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+
* where 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
/** The dialog configuration. */
8491
_config: MatDialogConfig;
8592

@@ -99,7 +106,8 @@ export class MatDialogContainer extends BasePortalOutlet {
99106
private _elementRef: ElementRef,
100107
private _focusTrapFactory: FocusTrapFactory,
101108
private _changeDetectorRef: ChangeDetectorRef,
102-
@Optional() @Inject(DOCUMENT) private _document: any) {
109+
@Optional() @Inject(DOCUMENT) private _document: any,
110+
private _focusMonitor?: FocusMonitor) {
103111

104112
super();
105113
}
@@ -146,11 +154,15 @@ export class MatDialogContainer extends BasePortalOutlet {
146154

147155
/** Restores focus to the element that was focused before the dialog opened. */
148156
private _restoreFocus() {
149-
const toFocus = this._elementFocusedBeforeDialogWasOpened;
157+
const previousElement = this._elementFocusedBeforeDialogWasOpened;
150158

151159
// We need the extra check, because IE can set the `activeElement` to null in some cases.
152-
if (toFocus && typeof toFocus.focus === 'function') {
153-
toFocus.focus();
160+
if (previousElement && typeof previousElement.focus === 'function') {
161+
if (this._focusMonitor) {
162+
this._focusMonitor.focusVia(previousElement, this._closeInteractionType);
163+
} else {
164+
previousElement.focus();
165+
}
154166
}
155167

156168
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
@@ -11,6 +11,7 @@ import {ESCAPE} from '@angular/cdk/keycodes';
1111
import {Location} from '@angular/common';
1212
import {filter} from 'rxjs/operators/filter';
1313
import {take} from 'rxjs/operators/take';
14+
import {FocusOrigin} from '@angular/cdk/a11y';
1415
import {DialogPosition} from './dialog-config';
1516
import {Observable} from 'rxjs/Observable';
1617
import {Subject} from 'rxjs/Subject';
@@ -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} 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,
@@ -40,6 +41,7 @@ describe('MatDialog', () => {
4041
let dialog: MatDialog;
4142
let overlayContainer: OverlayContainer;
4243
let overlayContainerElement: HTMLElement;
44+
let focusMonitor: FocusMonitor;
4345

4446
let testViewContainerRef: ViewContainerRef;
4547
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;
@@ -56,13 +58,14 @@ describe('MatDialog', () => {
5658
TestBed.compileComponents();
5759
}));
5860

59-
beforeEach(inject([MatDialog, Location, OverlayContainer],
60-
(d: MatDialog, l: Location, oc: OverlayContainer) => {
61+
beforeEach(inject([MatDialog, Location, OverlayContainer, FocusMonitor],
62+
(d: MatDialog, l: Location, oc: OverlayContainer, fm: FocusMonitor) => {
6163
dialog = d;
6264
mockLocation = l as SpyLocation;
6365
overlayContainer = oc;
6466
overlayContainerElement = oc.getContainerElement();
65-
}));
67+
focusMonitor = fm;
68+
}));
6669

6770
afterEach(() => {
6871
overlayContainer.ngOnDestroy();
@@ -913,6 +916,148 @@ describe('MatDialog', () => {
913916
document.body.removeChild(button);
914917
}));
915918

919+
it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => {
920+
const button = document.createElement('button');
921+
let lastFocusOrigin: FocusOrigin = null;
922+
923+
focusMonitor.monitor(button, false)
924+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
925+
926+
document.body.appendChild(button);
927+
button.focus();
928+
929+
// Patch the element focus after the initial and real focus, because otherwise the
930+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
931+
patchElementFocus(button);
932+
933+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
934+
935+
tick(500);
936+
viewContainerFixture.detectChanges();
937+
938+
expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred');
939+
940+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
941+
942+
flushMicrotasks();
943+
viewContainerFixture.detectChanges();
944+
tick(500);
945+
946+
expect(lastFocusOrigin!)
947+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
948+
949+
focusMonitor.stopMonitoring(button);
950+
document.body.removeChild(button);
951+
}));
952+
953+
it('should re-focus the trigger via mouse when backdrop has been clicked', fakeAsync(() => {
954+
const button = document.createElement('button');
955+
let lastFocusOrigin: FocusOrigin = null;
956+
957+
focusMonitor.monitor(button, false)
958+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
959+
960+
document.body.appendChild(button);
961+
button.focus();
962+
963+
// Patch the element focus after the initial and real focus, because otherwise the
964+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
965+
patchElementFocus(button);
966+
967+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
968+
969+
tick(500);
970+
viewContainerFixture.detectChanges();
971+
972+
const backdrop = overlayContainerElement
973+
.querySelector('.cdk-overlay-backdrop') as HTMLElement;
974+
975+
backdrop.click();
976+
viewContainerFixture.detectChanges();
977+
tick(500);
978+
979+
expect(lastFocusOrigin!)
980+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
981+
982+
focusMonitor.stopMonitoring(button);
983+
document.body.removeChild(button);
984+
}));
985+
986+
it('should re-focus via keyboard if the close button has been triggered through keyboard',
987+
fakeAsync(() => {
988+
989+
const button = document.createElement('button');
990+
let lastFocusOrigin: FocusOrigin = null;
991+
992+
focusMonitor.monitor(button, false)
993+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
994+
995+
document.body.appendChild(button);
996+
button.focus();
997+
998+
// Patch the element focus after the initial and real focus, because otherwise the
999+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1000+
patchElementFocus(button);
1001+
1002+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1003+
1004+
tick(500);
1005+
viewContainerFixture.detectChanges();
1006+
1007+
const closeButton = overlayContainerElement
1008+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1009+
1010+
// Fake the behavior of pressing the SPACE key on a button element. Browsers fire a `click`
1011+
// event with a MouseEvent, which has coordinates that are out of the element boundaries.
1012+
dispatchMouseEvent(closeButton, 'click', 0, 0);
1013+
1014+
viewContainerFixture.detectChanges();
1015+
tick(500);
1016+
1017+
expect(lastFocusOrigin!)
1018+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1019+
1020+
focusMonitor.stopMonitoring(button);
1021+
document.body.removeChild(button);
1022+
}));
1023+
1024+
it('should re-focus via mouse if the close button has been clicked', fakeAsync(() => {
1025+
const button = document.createElement('button');
1026+
let lastFocusOrigin: FocusOrigin = null;
1027+
1028+
focusMonitor.monitor(button, false)
1029+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1030+
1031+
document.body.appendChild(button);
1032+
button.focus();
1033+
1034+
// Patch the element focus after the initial and real focus, because otherwise the
1035+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1036+
patchElementFocus(button);
1037+
1038+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1039+
1040+
tick(500);
1041+
viewContainerFixture.detectChanges();
1042+
1043+
const closeButton = overlayContainerElement
1044+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1045+
1046+
// The dialog close button detects the focus origin by inspecting the click event. If
1047+
// coordinates of the click are not present, it assumes that the click has been triggered
1048+
// by keyboard.
1049+
dispatchMouseEvent(closeButton, 'click', 10, 10);
1050+
1051+
viewContainerFixture.detectChanges();
1052+
tick(500);
1053+
1054+
expect(lastFocusOrigin!)
1055+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1056+
1057+
focusMonitor.stopMonitoring(button);
1058+
document.body.removeChild(button);
1059+
}));
1060+
9161061
it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => {
9171062
// Create a element that has focus before the dialog is opened.
9181063
let button = document.createElement('button');
@@ -935,7 +1080,7 @@ describe('MatDialog', () => {
9351080

9361081
tick(500);
9371082
viewContainerFixture.detectChanges();
938-
flushMicrotasks();
1083+
flush();
9391084

9401085
expect(document.activeElement.id).toBe('input-to-be-focused',
9411086
'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
@@ -230,7 +230,7 @@ export class MatDialog {
230230
if (config.hasBackdrop) {
231231
overlayRef.backdropClick().subscribe(() => {
232232
if (!dialogRef.disableClose) {
233-
dialogRef.close();
233+
dialogRef._closeVia('mouse');
234234
}
235235
});
236236
}

0 commit comments

Comments
 (0)