Skip to content

Commit 60c6895

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 b8fba33 commit 60c6895

File tree

6 files changed

+196
-16
lines changed

6 files changed

+196
-16
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: 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;
@@ -178,10 +186,11 @@ export class MatDialogContainer extends BasePortalOutlet {
178186

179187
/** Restores focus to the element that was focused before the dialog opened. */
180188
private _restoreFocus() {
181-
const toFocus = this._elementFocusedBeforeDialogWasOpened;
189+
const previousElement = this._elementFocusedBeforeDialogWasOpened;
182190

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

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

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: 9 additions & 2 deletions
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,14 +93,14 @@ export class MatDialogRef<T, R = any> {
9293
}))
9394
.subscribe(event => {
9495
event.preventDefault();
95-
this.close();
96+
this._closeVia('keyboard');
9697
});
9798

9899
_overlayRef.backdropClick().subscribe(() => {
99100
if (this.disableClose) {
100101
this._containerInstance._recaptureFocus();
101102
} else {
102-
this.close();
103+
this._closeVia('mouse');
103104
}
104105
});
105106
}
@@ -134,6 +135,12 @@ export class MatDialogRef<T, R = any> {
134135
this._state = MatDialogState.CLOSING;
135136
}
136137

138+
/** Closes the dialog with the specified interaction type. */
139+
_closeVia(interactionType: FocusOrigin, dialogResult?: R) {
140+
this._containerInstance._closeInteractionType = interactionType;
141+
this.close(dialogResult);
142+
}
143+
137144
/**
138145
* Gets an observable that is notified when the dialog is finished opening.
139146
*/

src/material/dialog/dialog.spec.ts

Lines changed: 152 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,
@@ -31,7 +32,9 @@ import {A, ESCAPE} from '@angular/cdk/keycodes';
3132
import {
3233
dispatchKeyboardEvent,
3334
createKeyboardEvent,
34-
dispatchEvent
35+
dispatchEvent,
36+
patchElementFocus,
37+
dispatchMouseEvent
3538
} from '@angular/cdk/testing/private';
3639
import {
3740
MAT_DIALOG_DATA,
@@ -43,12 +46,12 @@ import {
4346
} from './index';
4447
import {Subject} from 'rxjs';
4548

46-
4749
describe('MatDialog', () => {
4850
let dialog: MatDialog;
4951
let overlayContainer: OverlayContainer;
5052
let overlayContainerElement: HTMLElement;
5153
let scrolledSubject = new Subject();
54+
let focusMonitor: FocusMonitor;
5255

5356
let testViewContainerRef: ViewContainerRef;
5457
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;
@@ -68,13 +71,14 @@ describe('MatDialog', () => {
6871
TestBed.compileComponents();
6972
}));
7073

71-
beforeEach(inject([MatDialog, Location, OverlayContainer],
72-
(d: MatDialog, l: Location, oc: OverlayContainer) => {
74+
beforeEach(inject([MatDialog, Location, OverlayContainer, FocusMonitor],
75+
(d: MatDialog, l: Location, oc: OverlayContainer, fm: FocusMonitor) => {
7376
dialog = d;
7477
mockLocation = l as SpyLocation;
7578
overlayContainer = oc;
7679
overlayContainerElement = oc.getContainerElement();
77-
}));
80+
focusMonitor = fm;
81+
}));
7882

7983
afterEach(() => {
8084
overlayContainer.ngOnDestroy();
@@ -1145,6 +1149,148 @@ describe('MatDialog', () => {
11451149
document.body.removeChild(button);
11461150
}));
11471151

1152+
it('should re-focus the trigger via keyboard when closed via escape key', fakeAsync(() => {
1153+
const button = document.createElement('button');
1154+
let lastFocusOrigin: FocusOrigin = null;
1155+
1156+
focusMonitor.monitor(button, false)
1157+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1158+
1159+
document.body.appendChild(button);
1160+
button.focus();
1161+
1162+
// Patch the element focus after the initial and real focus, because otherwise the
1163+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1164+
patchElementFocus(button);
1165+
1166+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
1167+
1168+
tick(500);
1169+
viewContainerFixture.detectChanges();
1170+
1171+
expect(lastFocusOrigin!).toBeNull('Expected the trigger button to be blurred');
1172+
1173+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
1174+
1175+
flushMicrotasks();
1176+
viewContainerFixture.detectChanges();
1177+
tick(500);
1178+
1179+
expect(lastFocusOrigin!)
1180+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1181+
1182+
focusMonitor.stopMonitoring(button);
1183+
document.body.removeChild(button);
1184+
}));
1185+
1186+
it('should re-focus the trigger via mouse when backdrop has been clicked', fakeAsync(() => {
1187+
const button = document.createElement('button');
1188+
let lastFocusOrigin: FocusOrigin = null;
1189+
1190+
focusMonitor.monitor(button, false)
1191+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1192+
1193+
document.body.appendChild(button);
1194+
button.focus();
1195+
1196+
// Patch the element focus after the initial and real focus, because otherwise the
1197+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1198+
patchElementFocus(button);
1199+
1200+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
1201+
1202+
tick(500);
1203+
viewContainerFixture.detectChanges();
1204+
1205+
const backdrop = overlayContainerElement
1206+
.querySelector('.cdk-overlay-backdrop') as HTMLElement;
1207+
1208+
backdrop.click();
1209+
viewContainerFixture.detectChanges();
1210+
tick(500);
1211+
1212+
expect(lastFocusOrigin!)
1213+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1214+
1215+
focusMonitor.stopMonitoring(button);
1216+
document.body.removeChild(button);
1217+
}));
1218+
1219+
it('should re-focus via keyboard if the close button has been triggered through keyboard',
1220+
fakeAsync(() => {
1221+
1222+
const button = document.createElement('button');
1223+
let lastFocusOrigin: FocusOrigin = null;
1224+
1225+
focusMonitor.monitor(button, false)
1226+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1227+
1228+
document.body.appendChild(button);
1229+
button.focus();
1230+
1231+
// Patch the element focus after the initial and real focus, because otherwise the
1232+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1233+
patchElementFocus(button);
1234+
1235+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1236+
1237+
tick(500);
1238+
viewContainerFixture.detectChanges();
1239+
1240+
const closeButton = overlayContainerElement
1241+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1242+
1243+
// Fake the behavior of pressing the SPACE key on a button element. Browsers fire a `click`
1244+
// event with a MouseEvent, which has coordinates that are out of the element boundaries.
1245+
dispatchMouseEvent(closeButton, 'click', 0, 0);
1246+
1247+
viewContainerFixture.detectChanges();
1248+
tick(500);
1249+
1250+
expect(lastFocusOrigin!)
1251+
.toBe('keyboard', 'Expected the trigger button to be focused via keyboard');
1252+
1253+
focusMonitor.stopMonitoring(button);
1254+
document.body.removeChild(button);
1255+
}));
1256+
1257+
it('should re-focus via mouse if the close button has been clicked', fakeAsync(() => {
1258+
const button = document.createElement('button');
1259+
let lastFocusOrigin: FocusOrigin = null;
1260+
1261+
focusMonitor.monitor(button, false)
1262+
.subscribe(focusOrigin => lastFocusOrigin = focusOrigin);
1263+
1264+
document.body.appendChild(button);
1265+
button.focus();
1266+
1267+
// Patch the element focus after the initial and real focus, because otherwise the
1268+
// `activeElement` won't be set, and the dialog won't be able to restore focus to an element.
1269+
patchElementFocus(button);
1270+
1271+
dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
1272+
1273+
tick(500);
1274+
viewContainerFixture.detectChanges();
1275+
1276+
const closeButton = overlayContainerElement
1277+
.querySelector('button[mat-dialog-close]') as HTMLElement;
1278+
1279+
// The dialog close button detects the focus origin by inspecting the click event. If
1280+
// coordinates of the click are not present, it assumes that the click has been triggered
1281+
// by keyboard.
1282+
dispatchMouseEvent(closeButton, 'click', 10, 10);
1283+
1284+
viewContainerFixture.detectChanges();
1285+
tick(500);
1286+
1287+
expect(lastFocusOrigin!)
1288+
.toBe('mouse', 'Expected the trigger button to be focused via mouse');
1289+
1290+
focusMonitor.stopMonitoring(button);
1291+
document.body.removeChild(button);
1292+
}));
1293+
11481294
it('should allow the consumer to shift focus in afterClosed', fakeAsync(() => {
11491295
// Create a element that has focus before the dialog is opened.
11501296
let button = document.createElement('button');
@@ -1167,7 +1313,7 @@ describe('MatDialog', () => {
11671313

11681314
tick(500);
11691315
viewContainerFixture.detectChanges();
1170-
flushMicrotasks();
1316+
flush();
11711317

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

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) => 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
_recaptureFocus(): void;
@@ -123,6 +125,7 @@ export declare class MatDialogRef<T, R = any> {
123125
disableClose: boolean | undefined;
124126
readonly id: string;
125127
constructor(_overlayRef: OverlayRef, _containerInstance: MatDialogContainer, id?: string);
128+
_closeVia(interactionType: FocusOrigin, dialogResult?: R): void;
126129
addPanelClass(classes: string | string[]): this;
127130
afterClosed(): Observable<R | undefined>;
128131
afterOpened(): Observable<void>;

0 commit comments

Comments
 (0)