Skip to content

Commit b34c034

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 2c8dca8 commit b34c034

File tree

7 files changed

+198
-16
lines changed

7 files changed

+198
-16
lines changed

src/material/dialog/BUILD.bazel

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

src/material/dialog/dialog-container.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
TemplatePortal,
3030
DomPortal
3131
} from '@angular/cdk/portal';
32-
import {FocusTrap, FocusTrapFactory} from '@angular/cdk/a11y';
32+
import {FocusTrap, FocusMonitor, FocusOrigin, FocusTrapFactory} from '@angular/cdk/a11y';
3333
import {MatDialogConfig} from './dialog-config';
3434

3535

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

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

@@ -100,7 +107,8 @@ export class MatDialogContainer extends BasePortalOutlet {
100107
private _changeDetectorRef: ChangeDetectorRef,
101108
@Optional() @Inject(DOCUMENT) _document: any,
102109
/** The dialog configuration. */
103-
public _config: MatDialogConfig) {
110+
public _config: MatDialogConfig,
111+
private _focusMonitor?: FocusMonitor) {
104112

105113
super();
106114
this._ariaLabelledBy = _config.ariaLabelledBy || null;
@@ -177,10 +185,11 @@ export class MatDialogContainer extends BasePortalOutlet {
177185

178186
/** Restores focus to the element that was focused before the dialog opened. */
179187
private _restoreFocus() {
180-
const toFocus = this._elementFocusedBeforeDialogWasOpened;
188+
const previousElement = this._elementFocusedBeforeDialogWasOpened;
181189

182190
// We need the extra check, because IE can set the `activeElement` to null in some cases.
183-
if (this._config.restoreFocus && toFocus && typeof toFocus.focus === 'function') {
191+
if (this._config.restoreFocus && previousElement &&
192+
typeof previousElement.focus === 'function') {
184193
const activeElement = this._document.activeElement;
185194
const element = this._elementRef.nativeElement;
186195

@@ -189,8 +198,13 @@ export class MatDialogContainer extends BasePortalOutlet {
189198
// the consumer moved it themselves before the animation was done, in which case we shouldn't
190199
// do anything.
191200
if (!activeElement || activeElement === this._document.body || activeElement === element ||
192-
element.contains(activeElement)) {
193-
toFocus.focus();
201+
element.contains(activeElement)) {
202+
if (this._focusMonitor) {
203+
this._focusMonitor.focusVia(previousElement, this._closeInteractionType);
204+
this._closeInteractionType = null;
205+
} else {
206+
previousElement.focus();
207+
}
194208
}
195209
}
196210

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: '[mat-dialog-close], [matDialogClose]',
2929
exportAs: 'matDialogClose',
3030
host: {
31-
'(click)': 'dialogRef.close(dialogResult)',
31+
'(click)': '_onButtonClick($event)',
3232
'[attr.aria-label]': 'ariaLabel || null',
3333
'[attr.type]': 'type',
3434
}
@@ -68,6 +68,15 @@ export class MatDialogClose implements OnInit, OnChanges {
6868
this.dialogResult = proxiedChange.currentValue;
6969
}
7070
}
71+
72+
_onButtonClick(event: MouseEvent) {
73+
// Determinate the focus origin using the click event, because using the FocusMonitor will
74+
// result in incorrect origins. Most of the time, close buttons will be auto focused in the
75+
// dialog, and therefore clicking the button won't result in a focus change. This means that
76+
// the FocusMonitor won't detect any origin change, and will always output `program`.
77+
this.dialogRef._closeVia(
78+
event.screenX === 0 && event.screenY === 0 ? 'keyboard' : 'mouse', this.dialogResult);
79+
}
7180
}
7281

7382
/**

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 {Observable, Subject} from 'rxjs';
@@ -92,7 +93,7 @@ export class MatDialogRef<T, R = any> {
9293
}))
9394
.subscribe(event => {
9495
event.preventDefault();
95-
this.close();
96+
this._closeVia('keyboard');
9697
});
9798
}
9899

@@ -128,6 +129,12 @@ export class MatDialogRef<T, R = any> {
128129
this._state = MatDialogState.CLOSING;
129130
}
130131

132+
/** Closes the dialog with the specified interaction type. */
133+
_closeVia(interactionType: FocusOrigin, dialogResult?: R) {
134+
this._containerInstance._closeInteractionType = interactionType;
135+
this.close(dialogResult);
136+
}
137+
131138
/**
132139
* Gets an observable that is notified when the dialog is finished opening.
133140
*/

src/material/dialog/dialog.spec.ts

Lines changed: 154 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {FocusMonitor, FocusOrigin} from '@angular/cdk/a11y';
12
import {
23
ComponentFixture,
34
fakeAsync,
@@ -28,7 +29,11 @@ import {MatDialogContainer} from './dialog-container';
2829
import {OverlayContainer, ScrollStrategy, Overlay} from '@angular/cdk/overlay';
2930
import {ScrollDispatcher} from '@angular/cdk/scrolling';
3031
import {A, ESCAPE} from '@angular/cdk/keycodes';
31-
import {dispatchKeyboardEvent, createKeyboardEvent} from '@angular/cdk/testing/private';
32+
import {
33+
dispatchKeyboardEvent,
34+
createKeyboardEvent,
35+
patchElementFocus, dispatchMouseEvent
36+
} from '@angular/cdk/testing/private';
3237
import {
3338
MAT_DIALOG_DATA,
3439
MatDialog,
@@ -39,12 +44,12 @@ import {
3944
} from './index';
4045
import {Subject} from 'rxjs';
4146

42-
4347
describe('MatDialog', () => {
4448
let dialog: MatDialog;
4549
let overlayContainer: OverlayContainer;
4650
let overlayContainerElement: HTMLElement;
4751
let scrolledSubject = new Subject();
52+
let focusMonitor: FocusMonitor;
4853

4954
let testViewContainerRef: ViewContainerRef;
5055
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;
@@ -64,13 +69,14 @@ describe('MatDialog', () => {
6469
TestBed.compileComponents();
6570
}));
6671

67-
beforeEach(inject([MatDialog, Location, OverlayContainer],
68-
(d: MatDialog, l: Location, oc: OverlayContainer) => {
72+
beforeEach(inject([MatDialog, Location, OverlayContainer, FocusMonitor],
73+
(d: MatDialog, l: Location, oc: OverlayContainer, fm: FocusMonitor) => {
6974
dialog = d;
7075
mockLocation = l as SpyLocation;
7176
overlayContainer = oc;
7277
overlayContainerElement = oc.getContainerElement();
73-
}));
78+
focusMonitor = fm;
79+
}));
7480

7581
afterEach(() => {
7682
overlayContainer.ngOnDestroy();
@@ -1090,6 +1096,148 @@ describe('MatDialog', () => {
10901096
document.body.removeChild(button);
10911097
}));
10921098

1099+
it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => {
1100+
const button = document.createElement('button');
1101+
let lastFocusOrigin: FocusOrigin = null;
1102+
1103+
focusMonitor.monitor(button, false)
1104+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1105+
1106+
document.body.appendChild(button);
1107+
button.focus();
1108+
1109+
// Patch the element focus after the initial and real focus, because otherwise the
1110+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1111+
patchElementFocus(button);
1112+
1113+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
1114+
1115+
tick(500);
1116+
viewContainerFixture.detectChanges();
1117+
1118+
expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred');
1119+
1120+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
1121+
1122+
flushMicrotasks();
1123+
viewContainerFixture.detectChanges();
1124+
tick(500);
1125+
1126+
expect(lastFocusOrigin!)
1127+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1128+
1129+
focusMonitor.stopMonitoring(button);
1130+
document.body.removeChild(button);
1131+
}));
1132+
1133+
it('should re-focus the trigger via mouse when backdrop has been clicked', fakeAsync(() => {
1134+
const button = document.createElement('button');
1135+
let lastFocusOrigin: FocusOrigin = null;
1136+
1137+
focusMonitor.monitor(button, false)
1138+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1139+
1140+
document.body.appendChild(button);
1141+
button.focus();
1142+
1143+
// Patch the element focus after the initial and real focus, because otherwise the
1144+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1145+
patchElementFocus(button);
1146+
1147+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
1148+
1149+
tick(500);
1150+
viewContainerFixture.detectChanges();
1151+
1152+
const backdrop = overlayContainerElement
1153+
.querySelector('.cdk-overlay-backdrop') as HTMLElement;
1154+
1155+
backdrop.click();
1156+
viewContainerFixture.detectChanges();
1157+
tick(500);
1158+
1159+
expect(lastFocusOrigin!)
1160+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1161+
1162+
focusMonitor.stopMonitoring(button);
1163+
document.body.removeChild(button);
1164+
}));
1165+
1166+
it('should re-focus via keyboard if the close button has been triggered through keyboard',
1167+
fakeAsync(() => {
1168+
1169+
const button = document.createElement('button');
1170+
let lastFocusOrigin: FocusOrigin = null;
1171+
1172+
focusMonitor.monitor(button, false)
1173+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1174+
1175+
document.body.appendChild(button);
1176+
button.focus();
1177+
1178+
// Patch the element focus after the initial and real focus, because otherwise the
1179+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1180+
patchElementFocus(button);
1181+
1182+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1183+
1184+
tick(500);
1185+
viewContainerFixture.detectChanges();
1186+
1187+
const closeButton = overlayContainerElement
1188+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1189+
1190+
// Fake the behavior of pressing the SPACE key on a button element. Browsers fire a `click`
1191+
// event with a MouseEvent, which has coordinates that are out of the element boundaries.
1192+
dispatchMouseEvent(closeButton, 'click', 0, 0);
1193+
1194+
viewContainerFixture.detectChanges();
1195+
tick(500);
1196+
1197+
expect(lastFocusOrigin!)
1198+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1199+
1200+
focusMonitor.stopMonitoring(button);
1201+
document.body.removeChild(button);
1202+
}));
1203+
1204+
it('should re-focus via mouse if the close button has been clicked', fakeAsync(() => {
1205+
const button = document.createElement('button');
1206+
let lastFocusOrigin: FocusOrigin = null;
1207+
1208+
focusMonitor.monitor(button, false)
1209+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1210+
1211+
document.body.appendChild(button);
1212+
button.focus();
1213+
1214+
// Patch the element focus after the initial and real focus, because otherwise the
1215+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1216+
patchElementFocus(button);
1217+
1218+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1219+
1220+
tick(500);
1221+
viewContainerFixture.detectChanges();
1222+
1223+
const closeButton = overlayContainerElement
1224+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1225+
1226+
// The dialog close button detects the focus origin by inspecting the click event. If
1227+
// coordinates of the click are not present, it assumes that the click has been triggered
1228+
// by keyboard.
1229+
dispatchMouseEvent(closeButton, 'click', 10, 10);
1230+
1231+
viewContainerFixture.detectChanges();
1232+
tick(500);
1233+
1234+
expect(lastFocusOrigin!)
1235+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1236+
1237+
focusMonitor.stopMonitoring(button);
1238+
document.body.removeChild(button);
1239+
}));
1240+
10931241
it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => {
10941242
// Create a element that has focus before the dialog is opened.
10951243
let button = document.createElement('button');
@@ -1112,7 +1260,7 @@ describe('MatDialog', () => {
11121260

11131261
tick(500);
11141262
viewContainerFixture.detectChanges();
1115-
flushMicrotasks();
1263+
flush();
11161264

11171265
expect(document.activeElement!.id).toBe('input-to-be-focused',
11181266
'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
@@ -249,7 +249,7 @@ export class MatDialog implements OnDestroy {
249249
if (config.hasBackdrop) {
250250
overlayRef.backdropClick().subscribe(() => {
251251
if (!dialogRef.disableClose) {
252-
dialogRef.close();
252+
dialogRef._closeVia('mouse');
253253
}
254254
});
255255
}

tools/public_api_guard/material/dialog.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export declare class MatDialogClose implements OnInit, OnChanges {
5454
dialogResult: any;
5555
type: 'submit' | 'button' | 'reset';
5656
constructor(dialogRef: MatDialogRef<any>, _elementRef: ElementRef<HTMLElement>, _dialog: MatDialog);
57+
_onButtonClick(event: MouseEvent): void;
5758
ngOnChanges(changes: SimpleChanges): void;
5859
ngOnInit(): void;
5960
static ɵdir: i0.ɵɵDirectiveDefWithMeta<MatDialogClose, "[mat-dialog-close], [matDialogClose]", ["matDialogClose"], { "ariaLabel": "aria-label"; "type": "type"; "dialogResult": "mat-dialog-close"; "_matDialogClose": "matDialogClose"; }, {}, never>;
@@ -90,13 +91,14 @@ export declare class MatDialogConfig<D = any> {
9091
export declare class MatDialogContainer extends BasePortalOutlet {
9192
_animationStateChanged: EventEmitter<AnimationEvent>;
9293
_ariaLabelledBy: string | null;
94+
_closeInteractionType: FocusOrigin | null;
9395
_config: MatDialogConfig;
9496
_id: string;
9597
_portalOutlet: CdkPortalOutlet;
9698
_state: 'void' | 'enter' | 'exit';
9799
attachDomPortal: (portal: DomPortal<HTMLElement>) => void;
98100
constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _changeDetectorRef: ChangeDetectorRef, _document: any,
99-
_config: MatDialogConfig);
101+
_config: MatDialogConfig, _focusMonitor?: FocusMonitor | undefined);
100102
_onAnimationDone(event: AnimationEvent): void;
101103
_onAnimationStart(event: AnimationEvent): void;
102104
_startExitAnimation(): void;
@@ -122,6 +124,7 @@ export declare class MatDialogRef<T, R = any> {
122124
disableClose: boolean | undefined;
123125
readonly id: string;
124126
constructor(_overlayRef: OverlayRef, _containerInstance: MatDialogContainer, id?: string);
127+
_closeVia(interactionType: FocusOrigin, dialogResult?: R): void;
125128
addPanelClass(classes: string | string[]): this;
126129
afterClosed(): Observable<R | undefined>;
127130
afterOpened(): Observable<void>;

0 commit comments

Comments
 (0)