Skip to content

Commit 764a1cc

Browse files
committed
feat(material/dialog): add closePredicate option
Adds the `closePredicate` config option to the Material dialog that allows developers to programmatically determine whether the user is allowed to close a dialog. Fixes #14292.
1 parent 06821d8 commit 764a1cc

File tree

6 files changed

+191
-3
lines changed

6 files changed

+191
-3
lines changed

goldens/material/dialog/index.api.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ComponentPortal } from '@angular/cdk/portal';
99
import { ComponentRef } from '@angular/core';
1010
import { ComponentType } from '@angular/cdk/overlay';
1111
import { Dialog } from '@angular/cdk/dialog';
12+
import { DialogConfig } from '@angular/cdk/dialog';
1213
import { DialogRef } from '@angular/cdk/dialog';
1314
import { Direction } from '@angular/cdk/bidi';
1415
import { EventEmitter } from '@angular/core';
@@ -138,6 +139,7 @@ export class MatDialogConfig<D = any> {
138139
autoFocus?: AutoFocusTarget | string | boolean;
139140
backdropClass?: string | string[];
140141
closeOnNavigation?: boolean;
142+
closePredicate?: <Result = unknown, Component = unknown, Config extends DialogConfig = MatDialogConfig>(result: Result | undefined, config: Config, componentInstance: Component | null) => boolean;
141143
data?: D | null;
142144
delayFocusTrap?: boolean;
143145
direction?: Direction;
@@ -203,7 +205,7 @@ export class MatDialogModule {
203205

204206
// @public
205207
export class MatDialogRef<T, R = any> {
206-
constructor(_ref: DialogRef<R, T>, config: MatDialogConfig, _containerInstance: MatDialogContainer);
208+
constructor(_ref: DialogRef<R, T>, _config: MatDialogConfig, _containerInstance: MatDialogContainer);
207209
addPanelClass(classes: string | string[]): this;
208210
afterClosed(): Observable<R | undefined>;
209211
afterOpened(): Observable<void>;

goldens/material/dialog/testing/index.api.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ComponentRef } from '@angular/core';
1313
import { ComponentType } from '@angular/cdk/overlay';
1414
import { ContentContainerComponentHarness } from '@angular/cdk/testing';
1515
import { Dialog } from '@angular/cdk/dialog';
16+
import { DialogConfig } from '@angular/cdk/dialog';
1617
import { DialogRef } from '@angular/cdk/dialog';
1718
import { Direction } from '@angular/cdk/bidi';
1819
import { EventEmitter } from '@angular/core';

src/material/dialog/dialog-config.ts

+12
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {ViewContainerRef, Injector} from '@angular/core';
1010
import {Direction} from '@angular/cdk/bidi';
1111
import {ScrollStrategy} from '@angular/cdk/overlay';
12+
import {DialogConfig} from '@angular/cdk/dialog';
1213
import {_defaultParams} from './dialog-animations';
1314

1415
/** Options for where to set focus to automatically on dialog open */
@@ -68,6 +69,17 @@ export class MatDialogConfig<D = any> {
6869
/** Whether the user can use escape or clicking on the backdrop to close the modal. */
6970
disableClose?: boolean = false;
7071

72+
/** Function used to determine whether the dialog is allowed to close. */
73+
closePredicate?: <
74+
Result = unknown,
75+
Component = unknown,
76+
Config extends DialogConfig = MatDialogConfig,
77+
>(
78+
result: Result | undefined,
79+
config: Config,
80+
componentInstance: Component | null,
81+
) => boolean;
82+
7183
/** Width of the dialog. */
7284
width?: string = '';
7385

src/material/dialog/dialog-ref.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@ export class MatDialogRef<T, R = any> {
6666

6767
constructor(
6868
private _ref: DialogRef<R, T>,
69-
config: MatDialogConfig,
69+
private _config: MatDialogConfig,
7070
public _containerInstance: MatDialogContainer,
7171
) {
72-
this.disableClose = config.disableClose;
72+
this.disableClose = _config.disableClose;
7373
this.id = _ref.id;
7474

7575
// Used to target panels specifically tied to dialogs.
@@ -121,6 +121,12 @@ export class MatDialogRef<T, R = any> {
121121
* @param dialogResult Optional result to return to the dialog opener.
122122
*/
123123
close(dialogResult?: R): void {
124+
const closePredicate = this._config.closePredicate;
125+
126+
if (closePredicate && !closePredicate(dialogResult, this._config, this.componentInstance)) {
127+
return;
128+
}
129+
124130
this._result = dialogResult;
125131

126132
// Transition the backdrop in parallel to the dialog.

src/material/dialog/dialog.spec.ts

+165
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,171 @@ describe('MatDialog', () => {
10221022
);
10231023
});
10241024

1025+
describe('closePredicate option', () => {
1026+
function getDialogs() {
1027+
return overlayContainerElement.querySelectorAll('mat-dialog-container');
1028+
}
1029+
1030+
it('should determine whether closing via the backdrop is allowed', fakeAsync(() => {
1031+
let canClose = false;
1032+
const closedSpy = jasmine.createSpy('closed spy');
1033+
const ref = dialog.open(PizzaMsg, {
1034+
closePredicate: () => canClose,
1035+
viewContainerRef: testViewContainerRef,
1036+
});
1037+
1038+
ref.afterClosed().subscribe(closedSpy);
1039+
viewContainerFixture.detectChanges();
1040+
1041+
expect(getDialogs().length).toBe(1);
1042+
1043+
let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
1044+
backdrop.click();
1045+
viewContainerFixture.detectChanges();
1046+
flush();
1047+
1048+
expect(getDialogs().length).toBe(1);
1049+
expect(closedSpy).not.toHaveBeenCalled();
1050+
1051+
canClose = true;
1052+
backdrop.click();
1053+
viewContainerFixture.detectChanges();
1054+
flush();
1055+
1056+
expect(getDialogs().length).toBe(0);
1057+
expect(closedSpy).toHaveBeenCalledTimes(1);
1058+
}));
1059+
1060+
it('should determine whether closing via the escape key is allowed', fakeAsync(() => {
1061+
let canClose = false;
1062+
const closedSpy = jasmine.createSpy('closed spy');
1063+
const ref = dialog.open(PizzaMsg, {
1064+
closePredicate: () => canClose,
1065+
viewContainerRef: testViewContainerRef,
1066+
});
1067+
1068+
ref.afterClosed().subscribe(closedSpy);
1069+
viewContainerFixture.detectChanges();
1070+
1071+
expect(getDialogs().length).toBe(1);
1072+
1073+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
1074+
viewContainerFixture.detectChanges();
1075+
flush();
1076+
1077+
expect(getDialogs().length).toBe(1);
1078+
expect(closedSpy).not.toHaveBeenCalled();
1079+
1080+
canClose = true;
1081+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
1082+
viewContainerFixture.detectChanges();
1083+
flush();
1084+
1085+
expect(getDialogs().length).toBe(0);
1086+
expect(closedSpy).toHaveBeenCalledTimes(1);
1087+
}));
1088+
1089+
it('should determine whether closing via the `close` method is allowed', fakeAsync(() => {
1090+
let canClose = false;
1091+
const closedSpy = jasmine.createSpy('closed spy');
1092+
const ref = dialog.open(PizzaMsg, {
1093+
closePredicate: () => canClose,
1094+
viewContainerRef: testViewContainerRef,
1095+
});
1096+
1097+
ref.afterClosed().subscribe(closedSpy);
1098+
viewContainerFixture.detectChanges();
1099+
1100+
expect(getDialogs().length).toBe(1);
1101+
1102+
ref.close();
1103+
viewContainerFixture.detectChanges();
1104+
flush();
1105+
1106+
expect(getDialogs().length).toBe(1);
1107+
expect(closedSpy).not.toHaveBeenCalled();
1108+
1109+
canClose = true;
1110+
ref.close('hello');
1111+
viewContainerFixture.detectChanges();
1112+
flush();
1113+
1114+
expect(getDialogs().length).toBe(0);
1115+
expect(closedSpy).toHaveBeenCalledTimes(1);
1116+
expect(closedSpy).toHaveBeenCalledWith('hello');
1117+
}));
1118+
1119+
it('should not be closed by `closeAll` if not allowed by the predicate', fakeAsync(() => {
1120+
let canClose = false;
1121+
const config = {closePredicate: () => canClose};
1122+
const spy = jasmine.createSpy('afterAllClosed spy');
1123+
dialog.open(PizzaMsg, config);
1124+
viewContainerFixture.detectChanges();
1125+
dialog.open(PizzaMsg, config);
1126+
viewContainerFixture.detectChanges();
1127+
dialog.open(PizzaMsg, config);
1128+
viewContainerFixture.detectChanges();
1129+
1130+
const subscription = dialog.afterAllClosed.subscribe(spy);
1131+
expect(getDialogs().length).toBe(3);
1132+
expect(dialog.openDialogs.length).toBe(3);
1133+
1134+
dialog.closeAll();
1135+
viewContainerFixture.detectChanges();
1136+
flush();
1137+
1138+
expect(getDialogs().length).toBe(3);
1139+
expect(dialog.openDialogs.length).toBe(3);
1140+
expect(spy).not.toHaveBeenCalled();
1141+
1142+
canClose = true;
1143+
dialog.closeAll();
1144+
viewContainerFixture.detectChanges();
1145+
flush();
1146+
1147+
expect(getDialogs().length).toBe(0);
1148+
expect(dialog.openDialogs.length).toBe(0);
1149+
expect(spy).toHaveBeenCalledTimes(1);
1150+
1151+
subscription.unsubscribe();
1152+
}));
1153+
1154+
it('should recapture focus to the first tabbable element when clicking on the backdrop while the `closePredicate` is blocking the close sequence', fakeAsync(() => {
1155+
// When testing focus, all of the elements must be in the DOM.
1156+
document.body.appendChild(overlayContainerElement);
1157+
1158+
dialog.open(PizzaMsg, {
1159+
closePredicate: () => false,
1160+
viewContainerRef: testViewContainerRef,
1161+
});
1162+
1163+
viewContainerFixture.detectChanges();
1164+
flush();
1165+
viewContainerFixture.detectChanges();
1166+
flush();
1167+
1168+
const backdrop = overlayContainerElement.querySelector(
1169+
'.cdk-overlay-backdrop',
1170+
) as HTMLElement;
1171+
const input = overlayContainerElement.querySelector('input') as HTMLInputElement;
1172+
1173+
expect(document.activeElement)
1174+
.withContext('Expected input to be focused on open')
1175+
.toBe(input);
1176+
1177+
input.blur(); // Programmatic clicks might not move focus so we simulate it.
1178+
backdrop.click();
1179+
viewContainerFixture.detectChanges();
1180+
flush();
1181+
1182+
expect(document.activeElement)
1183+
.withContext('Expected input to stay focused after click')
1184+
.toBe(input);
1185+
1186+
overlayContainerElement.remove();
1187+
}));
1188+
});
1189+
10251190
it(
10261191
'should recapture focus to the first header when clicking on the backdrop with ' +
10271192
'autoFocus set to "first-heading"',

src/material/dialog/dialog.ts

+2
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ export class MatDialog implements OnDestroy {
141141
positionStrategy: this._overlay.position().global().centerHorizontally().centerVertically(),
142142
// Disable closing since we need to sync it up to the animation ourselves.
143143
disableClose: true,
144+
// Closing is tied to our animation so the close predicate has to be implemented separately.
145+
closePredicate: undefined,
144146
// Disable closing on destroy, because this service cleans up its open dialogs as well.
145147
// We want to do the cleanup here, rather than the CDK service, because the CDK destroys
146148
// the dialogs immediately whereas we want it to wait for the animations to finish.

0 commit comments

Comments
 (0)