Skip to content

Add closePredicate option to dialog #30919

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions goldens/cdk/dialog/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet =
closeOnDestroy?: boolean;
closeOnNavigation?: boolean;
closeOnOverlayDetachments?: boolean;
closePredicate?: <Result = unknown, Component = unknown, Config extends DialogConfig = DialogConfig>(result: Result | undefined, config: Config, componentInstance: Component | null) => boolean;
container?: Type<C> | {
type: Type<C>;
providers: (config: DialogConfig<D, R, C>) => StaticProvider[];
Expand Down Expand Up @@ -171,6 +172,7 @@ export class DialogRef<R = unknown, C = unknown> {
readonly config: DialogConfig<any, DialogRef<R, C>, BasePortalOutlet>;
readonly containerInstance: BasePortalOutlet & {
_closeInteractionType?: FocusOrigin;
_recaptureFocus?: () => void;
};
disableClose: boolean | undefined;
readonly id: string;
Expand Down
4 changes: 3 additions & 1 deletion goldens/material/dialog/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ComponentPortal } from '@angular/cdk/portal';
import { ComponentRef } from '@angular/core';
import { ComponentType } from '@angular/cdk/overlay';
import { Dialog } from '@angular/cdk/dialog';
import { DialogConfig } from '@angular/cdk/dialog';
import { DialogRef } from '@angular/cdk/dialog';
import { Direction } from '@angular/cdk/bidi';
import { EventEmitter } from '@angular/core';
Expand Down Expand Up @@ -138,6 +139,7 @@ export class MatDialogConfig<D = any> {
autoFocus?: AutoFocusTarget | string | boolean;
backdropClass?: string | string[];
closeOnNavigation?: boolean;
closePredicate?: <Result = unknown, Component = unknown, Config extends DialogConfig = MatDialogConfig>(result: Result | undefined, config: Config, componentInstance: Component | null) => boolean;
data?: D | null;
delayFocusTrap?: boolean;
direction?: Direction;
Expand Down Expand Up @@ -203,7 +205,7 @@ export class MatDialogModule {

// @public
export class MatDialogRef<T, R = any> {
constructor(_ref: DialogRef<R, T>, config: MatDialogConfig, _containerInstance: MatDialogContainer);
constructor(_ref: DialogRef<R, T>, _config: MatDialogConfig, _containerInstance: MatDialogContainer);
addPanelClass(classes: string | string[]): this;
afterClosed(): Observable<R | undefined>;
afterOpened(): Observable<void>;
Expand Down
1 change: 1 addition & 0 deletions goldens/material/dialog/testing/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ComponentRef } from '@angular/core';
import { ComponentType } from '@angular/cdk/overlay';
import { ContentContainerComponentHarness } from '@angular/cdk/testing';
import { Dialog } from '@angular/cdk/dialog';
import { DialogConfig } from '@angular/cdk/dialog';
import { DialogRef } from '@angular/cdk/dialog';
import { Direction } from '@angular/cdk/bidi';
import { EventEmitter } from '@angular/core';
Expand Down
11 changes: 11 additions & 0 deletions src/cdk/dialog/dialog-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ export class DialogConfig<D = unknown, R = unknown, C extends BasePortalOutlet =
/** Whether the dialog closes with the escape key or pointer events outside the panel element. */
disableClose?: boolean = false;

/** Function used to determine whether the dialog is allowed to close. */
closePredicate?: <
Result = unknown,
Component = unknown,
Config extends DialogConfig = DialogConfig,
>(
result: Result | undefined,
config: Config,
componentInstance: Component | null,
) => boolean;

/** Width of the dialog. */
width?: string = '';

Expand Down
18 changes: 1 addition & 17 deletions src/cdk/dialog/dialog-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
FocusTrapFactory,
InteractivityChecker,
} from '../a11y';
import {OverlayRef} from '../overlay';
import {Platform, _getFocusedElementPierceShadowDom} from '../platform';
import {
BasePortalOutlet,
Expand Down Expand Up @@ -79,7 +78,6 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
readonly _config: C;
private _interactivityChecker = inject(InteractivityChecker);
protected _ngZone = inject(NgZone);
private _overlayRef = inject(OverlayRef);
private _focusMonitor = inject(FocusMonitor);
private _renderer = inject(Renderer2);

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

protected _contentAttached() {
this._initializeFocusTrap();
this._handleBackdropClicks();
this._captureInitialFocus();
}

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

/** Returns whether focus is inside the dialog. */
Expand All @@ -372,15 +367,4 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
}
}
}

/** Sets up the listener that handles clicks on the dialog backdrop. */
private _handleBackdropClicks() {
// Clicking on the backdrop will move focus out of dialog.
// Recapture it if closing via the backdrop is disabled.
this._overlayRef.backdropClick().subscribe(() => {
if (this._config.disableClose) {
this._recaptureFocus();
}
});
}
}
23 changes: 20 additions & 3 deletions src/cdk/dialog/dialog-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ export class DialogRef<R = unknown, C = unknown> {
readonly componentRef: ComponentRef<C> | null;

/** Instance of the container that is rendering out the dialog content. */
readonly containerInstance: BasePortalOutlet & {_closeInteractionType?: FocusOrigin};
readonly containerInstance: BasePortalOutlet & {
_closeInteractionType?: FocusOrigin;
_recaptureFocus?: () => void;
};

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

this.backdropClick.subscribe(() => {
if (!this.disableClose) {
if (!this.disableClose && this._canClose()) {
this.close(undefined, {focusOrigin: 'mouse'});
} else {
// Clicking on the backdrop will move focus out of dialog.
// Recapture it if closing via the backdrop is disabled.
this.containerInstance._recaptureFocus?.();
}
});

Expand All @@ -97,7 +104,7 @@ export class DialogRef<R = unknown, C = unknown> {
* @param options Additional options to customize the closing behavior.
*/
close(result?: R, options?: DialogCloseOptions): void {
if (this.containerInstance) {
if (this._canClose(result)) {
const closedSubject = this.closed as Subject<R | undefined>;
this.containerInstance._closeInteractionType = options?.focusOrigin || 'program';
// Drop the detach subscription first since it can be triggered by the
Expand Down Expand Up @@ -139,4 +146,14 @@ export class DialogRef<R = unknown, C = unknown> {
this.overlayRef.removePanelClass(classes);
return this;
}

/** Whether the dialog is allowed to close. */
private _canClose(result?: R): boolean {
const config = this.config as DialogConfig<unknown, unknown, BasePortalOutlet>;

return (
!!this.containerInstance &&
(!config.closePredicate || config.closePredicate(result, config, this.componentInstance))
);
}
}
200 changes: 200 additions & 0 deletions src/cdk/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,35 @@ describe('Dialog', () => {
expect(dialog.getDialogById('pizza')).toBe(dialogRef);
});

it('should recapture focus to the first tabbable element when clicking on the backdrop', fakeAsync(() => {
// When testing focus, all of the elements must be in the DOM.
document.body.appendChild(overlayContainerElement);

dialog.open(PizzaMsg, {
disableClose: true,
viewContainerRef: testViewContainerRef,
});

viewContainerFixture.detectChanges();
flushMicrotasks();

const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
const input = overlayContainerElement.querySelector('input') as HTMLInputElement;

expect(document.activeElement).withContext('Expected input to be focused on open').toBe(input);

input.blur(); // Programmatic clicks might not move focus so we simulate it.
backdrop.click();
viewContainerFixture.detectChanges();
flush();

expect(document.activeElement)
.withContext('Expected input to stay focused after click')
.toBe(input);

overlayContainerElement.remove();
}));

describe('disableClose option', () => {
it('should prevent closing via clicks on the backdrop', fakeAsync(() => {
dialog.open(PizzaMsg, {
Expand Down Expand Up @@ -778,6 +807,169 @@ describe('Dialog', () => {
);
});

describe('closePredicate option', () => {
function getDialogs() {
return overlayContainerElement.querySelectorAll('cdk-dialog-container');
}

it('should determine whether closing via the backdrop is allowed', fakeAsync(() => {
let canClose = false;
const closedSpy = jasmine.createSpy('closed spy');
const ref = dialog.open(PizzaMsg, {
closePredicate: () => canClose,
viewContainerRef: testViewContainerRef,
});

ref.closed.subscribe(closedSpy);
viewContainerFixture.detectChanges();

expect(getDialogs().length).toBe(1);

let backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
backdrop.click();
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(1);
expect(closedSpy).not.toHaveBeenCalled();

canClose = true;
backdrop.click();
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(0);
expect(closedSpy).toHaveBeenCalledTimes(1);
}));

it('should determine whether closing via the escape key is allowed', fakeAsync(() => {
let canClose = false;
const closedSpy = jasmine.createSpy('closed spy');
const ref = dialog.open(PizzaMsg, {
closePredicate: () => canClose,
viewContainerRef: testViewContainerRef,
});

ref.closed.subscribe(closedSpy);
viewContainerFixture.detectChanges();

expect(getDialogs().length).toBe(1);

dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(1);
expect(closedSpy).not.toHaveBeenCalled();

canClose = true;
dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(0);
expect(closedSpy).toHaveBeenCalledTimes(1);
}));

it('should determine whether closing via the `close` method is allowed', fakeAsync(() => {
let canClose = false;
const closedSpy = jasmine.createSpy('closed spy');
const ref = dialog.open(PizzaMsg, {
closePredicate: () => canClose,
viewContainerRef: testViewContainerRef,
});

ref.closed.subscribe(closedSpy);
viewContainerFixture.detectChanges();

expect(getDialogs().length).toBe(1);

ref.close();
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(1);
expect(closedSpy).not.toHaveBeenCalled();

canClose = true;
ref.close('hello');
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(0);
expect(closedSpy).toHaveBeenCalledTimes(1);
expect(closedSpy).toHaveBeenCalledWith('hello');
}));

it('should not be closed by `closeAll` if not allowed by the predicate', fakeAsync(() => {
let canClose = false;
const config = {closePredicate: () => canClose};
const spy = jasmine.createSpy('afterAllClosed spy');
dialog.open(PizzaMsg, config);
viewContainerFixture.detectChanges();
dialog.open(PizzaMsg, config);
viewContainerFixture.detectChanges();
dialog.open(PizzaMsg, config);
viewContainerFixture.detectChanges();

const subscription = dialog.afterAllClosed.subscribe(spy);
expect(getDialogs().length).toBe(3);
expect(dialog.openDialogs.length).toBe(3);

dialog.closeAll();
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(3);
expect(dialog.openDialogs.length).toBe(3);
expect(spy).not.toHaveBeenCalled();

canClose = true;
dialog.closeAll();
viewContainerFixture.detectChanges();
flush();

expect(getDialogs().length).toBe(0);
expect(dialog.openDialogs.length).toBe(0);
expect(spy).toHaveBeenCalledTimes(1);

subscription.unsubscribe();
}));

it('should recapture focus to the first tabbable element when clicking on the backdrop while the `closePredicate` is blocking the close sequence', fakeAsync(() => {
// When testing focus, all of the elements must be in the DOM.
document.body.appendChild(overlayContainerElement);

dialog.open(PizzaMsg, {
closePredicate: () => false,
viewContainerRef: testViewContainerRef,
});

viewContainerFixture.detectChanges();
flushMicrotasks();

const backdrop = overlayContainerElement.querySelector(
'.cdk-overlay-backdrop',
) as HTMLElement;
const input = overlayContainerElement.querySelector('input') as HTMLInputElement;

expect(document.activeElement)
.withContext('Expected input to be focused on open')
.toBe(input);

input.blur(); // Programmatic clicks might not move focus so we simulate it.
backdrop.click();
viewContainerFixture.detectChanges();
flush();

expect(document.activeElement)
.withContext('Expected input to stay focused after click')
.toBe(input);

overlayContainerElement.remove();
}));
});

describe('hasBackdrop option', () => {
it('should have a backdrop', () => {
dialog.open(PizzaMsg, {
Expand Down Expand Up @@ -1273,6 +1465,10 @@ class PizzaMsg {
<h2>This is the title</h2>
`,
imports: [DialogModule],
host: {
// Avoids conflicting ID warning
'id': 'content-element-dialog',
},
})
class ContentElementDialog {
closeButtonAriaLabel: string;
Expand All @@ -1299,6 +1495,10 @@ class DialogWithInjectedData {
@Component({
template: '<p>Pasta</p>',
imports: [DialogModule],
host: {
// Avoids conflicting ID warning
'id': 'dialog-without-focusable',
},
})
class DialogWithoutFocusableElements {}

Expand Down
Loading
Loading