Skip to content

Commit dabfa26

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 b057391 commit dabfa26

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

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

82+
/**
83+
* Type of interaction that led to the dialog being closed. This is used to determine
84+
* whether the focus style will be applied when returning focus to its original location
85+
* after the dialog is closed.
86+
*/
87+
_closeInteractionType: FocusOrigin = 'program';
88+
8289
/** The dialog configuration. */
8390
_config: MatDialogConfig;
8491

@@ -98,7 +105,8 @@ export class MatDialogContainer extends BasePortalOutlet {
98105
private _elementRef: ElementRef,
99106
private _focusTrapFactory: FocusTrapFactory,
100107
private _changeDetectorRef: ChangeDetectorRef,
101-
@Optional() @Inject(DOCUMENT) private _document: any) {
108+
@Optional() @Inject(DOCUMENT) private _document: any,
109+
private _focusMonitor?: FocusMonitor) {
102110

103111
super();
104112
}
@@ -145,11 +153,15 @@ export class MatDialogContainer extends BasePortalOutlet {
145153

146154
/** Restores focus to the element that was focused before the dialog opened. */
147155
private _restoreFocus() {
148-
const toFocus = this._elementFocusedBeforeDialogWasOpened;
156+
const previousElement = this._elementFocusedBeforeDialogWasOpened;
149157

150158
// 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();
159+
if (previousElement && typeof previousElement.focus === 'function') {
160+
if (this._focusMonitor) {
161+
this._focusMonitor.focusVia(previousElement, this._closeInteractionType);
162+
} else {
163+
previousElement.focus();
164+
}
153165
}
154166

155167
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';
@@ -79,7 +80,7 @@ export class MatDialogRef<T, R = any> {
7980

8081
_overlayRef.keydownEvents()
8182
.pipe(filter(event => event.keyCode === ESCAPE && !this.disableClose))
82-
.subscribe(() => this.close());
83+
.subscribe(() => this._closeVia('keyboard'));
8384

8485
if (location) {
8586
// Close the dialog when the user goes forwards/backwards in history or when the location
@@ -114,6 +115,12 @@ export class MatDialogRef<T, R = any> {
114115
this._containerInstance._startExitAnimation();
115116
}
116117

118+
/** Closes the dialog with the specified interaction type. */
119+
_closeVia(interactionType: FocusOrigin, dialogResult?: R) {
120+
this._containerInstance._closeInteractionType = interactionType;
121+
this.close(dialogResult);
122+
}
123+
117124
/**
118125
* Gets an observable that is notified when the dialog is finished opening.
119126
*/

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
@@ -235,7 +235,7 @@ export class MatDialog {
235235
if (config.hasBackdrop) {
236236
overlayRef.backdropClick().subscribe(() => {
237237
if (!dialogRef.disableClose) {
238-
dialogRef.close();
238+
dialogRef._closeVia('mouse');
239239
}
240240
});
241241
}

0 commit comments

Comments
 (0)