Skip to content

Commit 06821d8

Browse files
committed
feat(cdk/dialog): add closePredicate option
Adds the `closePredicate` config option to the CDK dialog that allows developers to programmatically determine whether the user is allowed to close a dialog. Also adds some test coverage that we had in the Material dialog, but not the CDK one.
1 parent d3a8c5b commit 06821d8

File tree

5 files changed

+234
-20
lines changed

5 files changed

+234
-20
lines changed

goldens/cdk/dialog/index.api.md

+2
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet =
121121
closeOnDestroy?: boolean;
122122
closeOnNavigation?: boolean;
123123
closeOnOverlayDetachments?: boolean;
124+
closePredicate?: <Result = unknown, Component = unknown, Config extends DialogConfig = DialogConfig>(result: Result | undefined, config: Config, componentInstance: Component | null) => boolean;
124125
container?: Type<C> | {
125126
type: Type<C>;
126127
providers: (config: DialogConfig<D, R, C>) => StaticProvider[];
@@ -171,6 +172,7 @@ export class DialogRef<R = unknown, C = unknown> {
171172
readonly config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>;
172173
readonly containerInstance: BasePortalOutlet & {
173174
_closeInteractionType?: FocusOrigin;
175+
_recaptureFocus?: () => void;
174176
};
175177
disableClose: boolean | undefined;
176178
readonly id: string;

src/cdk/dialog/dialog-config.ts

+11
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet =
5151
/** Whether the dialog closes with the escape key or pointer events outside the panel element. */
5252
disableClose?: boolean = false;
5353

54+
/** Function used to determine whether the dialog is allowed to close. */
55+
closePredicate?: <
56+
Result = unknown,
57+
Component = unknown,
58+
Config extends DialogConfig = DialogConfig,
59+
>(
60+
result: Result | undefined,
61+
config: Config,
62+
componentInstance: Component | null,
63+
) => boolean;
64+
5465
/** Width of the dialog. */
5566
width?: string = '';
5667

src/cdk/dialog/dialog-container.ts

+1-17
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
FocusTrapFactory,
1414
InteractivityChecker,
1515
} from '../a11y';
16-
import {OverlayRef} from '../overlay';
1716
import {Platform, _getFocusedElementPierceShadowDom} from '../platform';
1817
import {
1918
BasePortalOutlet,
@@ -79,7 +78,6 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
7978
readonly _config: C;
8079
private _interactivityChecker = inject(InteractivityChecker);
8180
protected _ngZone = inject(NgZone);
82-
private _overlayRef = inject(OverlayRef);
8381
private _focusMonitor = inject(FocusMonitor);
8482
private _renderer = inject(Renderer2);
8583

@@ -146,7 +144,6 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
146144

147145
protected _contentAttached() {
148146
this._initializeFocusTrap();
149-
this._handleBackdropClicks();
150147
this._captureInitialFocus();
151148
}
152149

@@ -348,9 +345,7 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
348345
/** Focuses the dialog container. */
349346
private _focusDialogContainer(options?: FocusOptions) {
350347
// Note that there is no focus method when rendering on the server.
351-
if (this._elementRef.nativeElement.focus) {
352-
this._elementRef.nativeElement.focus(options);
353-
}
348+
this._elementRef.nativeElement.focus?.(options);
354349
}
355350

356351
/** Returns whether focus is inside the dialog. */
@@ -372,15 +367,4 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
372367
}
373368
}
374369
}
375-
376-
/** Sets up the listener that handles clicks on the dialog backdrop. */
377-
private _handleBackdropClicks() {
378-
// Clicking on the backdrop will move focus out of dialog.
379-
// Recapture it if closing via the backdrop is disabled.
380-
this._overlayRef.backdropClick().subscribe(() => {
381-
if (this._config.disableClose) {
382-
this._recaptureFocus();
383-
}
384-
});
385-
}
386370
}

src/cdk/dialog/dialog-ref.ts

+20-3
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ export class DialogRef<R = unknown, C = unknown> {
3737
readonly componentRef: ComponentRef<C> | null;
3838

3939
/** Instance of the container that is rendering out the dialog content. */
40-
readonly containerInstance: BasePortalOutlet & {_closeInteractionType?: FocusOrigin};
40+
readonly containerInstance: BasePortalOutlet & {
41+
_closeInteractionType?: FocusOrigin;
42+
_recaptureFocus?: () => void;
43+
};
4144

4245
/** Whether the user is allowed to close the dialog. */
4346
disableClose: boolean | undefined;
@@ -78,8 +81,12 @@ export class DialogRef<R = unknown, C = unknown> {
7881
});
7982

8083
this.backdropClick.subscribe(() => {
81-
if (!this.disableClose) {
84+
if (!this.disableClose && this._canClose()) {
8285
this.close(undefined, {focusOrigin: 'mouse'});
86+
} else {
87+
// Clicking on the backdrop will move focus out of dialog.
88+
// Recapture it if closing via the backdrop is disabled.
89+
this.containerInstance._recaptureFocus?.();
8390
}
8491
});
8592

@@ -97,7 +104,7 @@ export class DialogRef<R = unknown, C = unknown> {
97104
* @param options Additional options to customize the closing behavior.
98105
*/
99106
close(result?: R, options?: DialogCloseOptions): void {
100-
if (this.containerInstance) {
107+
if (this._canClose(result)) {
101108
const closedSubject = this.closed as Subject<R | undefined>;
102109
this.containerInstance._closeInteractionType = options?.focusOrigin || 'program';
103110
// Drop the detach subscription first since it can be triggered by the
@@ -139,4 +146,14 @@ export class DialogRef<R = unknown, C = unknown> {
139146
this.overlayRef.removePanelClass(classes);
140147
return this;
141148
}
149+
150+
/** Whether the dialog is allowed to close. */
151+
private _canClose(result?: R): boolean {
152+
const config = this.config as DialogConfig<unknown, unknown, BasePortalOutlet>;
153+
154+
return (
155+
!!this.containerInstance &&
156+
(!config.closePredicate || config.closePredicate(result, config, this.componentInstance))
157+
);
158+
}
142159
}

src/cdk/dialog/dialog.spec.ts

+200
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,35 @@ describe('Dialog', () => {
689689
expect(dialog.getDialogById('pizza')).toBe(dialogRef);
690690
});
691691

692+
it('should recapture focus to the first tabbable element when clicking on the backdrop', fakeAsync(() => {
693+
// When testing focus, all of the elements must be in the DOM.
694+
document.body.appendChild(overlayContainerElement);
695+
696+
dialog.open(PizzaMsg, {
697+
disableClose: true,
698+
viewContainerRef: testViewContainerRef,
699+
});
700+
701+
viewContainerFixture.detectChanges();
702+
flushMicrotasks();
703+
704+
const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
705+
const input = overlayContainerElement.querySelector('input') as HTMLInputElement;
706+
707+
expect(document.activeElement).withContext('Expected input to be focused on open').toBe(input);
708+
709+
input.blur(); // Programmatic clicks might not move focus so we simulate it.
710+
backdrop.click();
711+
viewContainerFixture.detectChanges();
712+
flush();
713+
714+
expect(document.activeElement)
715+
.withContext('Expected input to stay focused after click')
716+
.toBe(input);
717+
718+
overlayContainerElement.remove();
719+
}));
720+
692721
describe('disableClose option', () => {
693722
it('should prevent closing via clicks on the backdrop', fakeAsync(() => {
694723
dialog.open(PizzaMsg, {
@@ -778,6 +807,169 @@ describe('Dialog', () => {
778807
);
779808
});
780809

810+
describe('closePredicate option', () => {
811+
function getDialogs() {
812+
return overlayContainerElement.querySelectorAll('cdk-dialog-container');
813+
}
814+
815+
it('should determine whether closing via the backdrop is allowed', fakeAsync(() => {
816+
let canClose = false;
817+
const closedSpy = jasmine.createSpy('closed spy');
818+
const ref = dialog.open(PizzaMsg, {
819+
closePredicate: () => canClose,
820+
viewContainerRef: testViewContainerRef,
821+
});
822+
823+
ref.closed.subscribe(closedSpy);
824+
viewContainerFixture.detectChanges();
825+
826+
expect(getDialogs().length).toBe(1);
827+
828+
let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
829+
backdrop.click();
830+
viewContainerFixture.detectChanges();
831+
flush();
832+
833+
expect(getDialogs().length).toBe(1);
834+
expect(closedSpy).not.toHaveBeenCalled();
835+
836+
canClose = true;
837+
backdrop.click();
838+
viewContainerFixture.detectChanges();
839+
flush();
840+
841+
expect(getDialogs().length).toBe(0);
842+
expect(closedSpy).toHaveBeenCalledTimes(1);
843+
}));
844+
845+
it('should determine whether closing via the escape key is allowed', fakeAsync(() => {
846+
let canClose = false;
847+
const closedSpy = jasmine.createSpy('closed spy');
848+
const ref = dialog.open(PizzaMsg, {
849+
closePredicate: () => canClose,
850+
viewContainerRef: testViewContainerRef,
851+
});
852+
853+
ref.closed.subscribe(closedSpy);
854+
viewContainerFixture.detectChanges();
855+
856+
expect(getDialogs().length).toBe(1);
857+
858+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
859+
viewContainerFixture.detectChanges();
860+
flush();
861+
862+
expect(getDialogs().length).toBe(1);
863+
expect(closedSpy).not.toHaveBeenCalled();
864+
865+
canClose = true;
866+
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
867+
viewContainerFixture.detectChanges();
868+
flush();
869+
870+
expect(getDialogs().length).toBe(0);
871+
expect(closedSpy).toHaveBeenCalledTimes(1);
872+
}));
873+
874+
it('should determine whether closing via the `close` method is allowed', fakeAsync(() => {
875+
let canClose = false;
876+
const closedSpy = jasmine.createSpy('closed spy');
877+
const ref = dialog.open(PizzaMsg, {
878+
closePredicate: () => canClose,
879+
viewContainerRef: testViewContainerRef,
880+
});
881+
882+
ref.closed.subscribe(closedSpy);
883+
viewContainerFixture.detectChanges();
884+
885+
expect(getDialogs().length).toBe(1);
886+
887+
ref.close();
888+
viewContainerFixture.detectChanges();
889+
flush();
890+
891+
expect(getDialogs().length).toBe(1);
892+
expect(closedSpy).not.toHaveBeenCalled();
893+
894+
canClose = true;
895+
ref.close('hello');
896+
viewContainerFixture.detectChanges();
897+
flush();
898+
899+
expect(getDialogs().length).toBe(0);
900+
expect(closedSpy).toHaveBeenCalledTimes(1);
901+
expect(closedSpy).toHaveBeenCalledWith('hello');
902+
}));
903+
904+
it('should not be closed by `closeAll` if not allowed by the predicate', fakeAsync(() => {
905+
let canClose = false;
906+
const config = {closePredicate: () => canClose};
907+
const spy = jasmine.createSpy('afterAllClosed spy');
908+
dialog.open(PizzaMsg, config);
909+
viewContainerFixture.detectChanges();
910+
dialog.open(PizzaMsg, config);
911+
viewContainerFixture.detectChanges();
912+
dialog.open(PizzaMsg, config);
913+
viewContainerFixture.detectChanges();
914+
915+
const subscription = dialog.afterAllClosed.subscribe(spy);
916+
expect(getDialogs().length).toBe(3);
917+
expect(dialog.openDialogs.length).toBe(3);
918+
919+
dialog.closeAll();
920+
viewContainerFixture.detectChanges();
921+
flush();
922+
923+
expect(getDialogs().length).toBe(3);
924+
expect(dialog.openDialogs.length).toBe(3);
925+
expect(spy).not.toHaveBeenCalled();
926+
927+
canClose = true;
928+
dialog.closeAll();
929+
viewContainerFixture.detectChanges();
930+
flush();
931+
932+
expect(getDialogs().length).toBe(0);
933+
expect(dialog.openDialogs.length).toBe(0);
934+
expect(spy).toHaveBeenCalledTimes(1);
935+
936+
subscription.unsubscribe();
937+
}));
938+
939+
it('should recapture focus to the first tabbable element when clicking on the backdrop while the `closePredicate` is blocking the close sequence', fakeAsync(() => {
940+
// When testing focus, all of the elements must be in the DOM.
941+
document.body.appendChild(overlayContainerElement);
942+
943+
dialog.open(PizzaMsg, {
944+
closePredicate: () => false,
945+
viewContainerRef: testViewContainerRef,
946+
});
947+
948+
viewContainerFixture.detectChanges();
949+
flushMicrotasks();
950+
951+
const backdrop = overlayContainerElement.querySelector(
952+
'.cdk-overlay-backdrop',
953+
) as HTMLElement;
954+
const input = overlayContainerElement.querySelector('input') as HTMLInputElement;
955+
956+
expect(document.activeElement)
957+
.withContext('Expected input to be focused on open')
958+
.toBe(input);
959+
960+
input.blur(); // Programmatic clicks might not move focus so we simulate it.
961+
backdrop.click();
962+
viewContainerFixture.detectChanges();
963+
flush();
964+
965+
expect(document.activeElement)
966+
.withContext('Expected input to stay focused after click')
967+
.toBe(input);
968+
969+
overlayContainerElement.remove();
970+
}));
971+
});
972+
781973
describe('hasBackdrop option', () => {
782974
it('should have a backdrop', () => {
783975
dialog.open(PizzaMsg, {
@@ -1273,6 +1465,10 @@ class PizzaMsg {
12731465
<h2>This is the title</h2>
12741466
`,
12751467
imports: [DialogModule],
1468+
host: {
1469+
// Avoids conflicting ID warning
1470+
'id': 'content-element-dialog',
1471+
},
12761472
})
12771473
class ContentElementDialog {
12781474
closeButtonAriaLabel: string;
@@ -1299,6 +1495,10 @@ class DialogWithInjectedData {
12991495
@Component({
13001496
template: '<p>Pasta</p>',
13011497
imports: [DialogModule],
1498+
host: {
1499+
// Avoids conflicting ID warning
1500+
'id': 'dialog-without-focusable',
1501+
},
13021502
})
13031503
class DialogWithoutFocusableElements {}
13041504

0 commit comments

Comments
 (0)