diff --git a/goldens/cdk/dialog/index.api.md b/goldens/cdk/dialog/index.api.md index 215130c04d30..3b5dce8c3740 100644 --- a/goldens/cdk/dialog/index.api.md +++ b/goldens/cdk/dialog/index.api.md @@ -121,6 +121,7 @@ export class DialogConfig(result: Result | undefined, config: Config, componentInstance: Component | null) => boolean; container?: Type | { type: Type; providers: (config: DialogConfig) => StaticProvider[]; @@ -171,6 +172,7 @@ export class DialogRef { readonly config: DialogConfig, BasePortalOutlet>; readonly containerInstance: BasePortalOutlet & { _closeInteractionType?: FocusOrigin; + _recaptureFocus?: () => void; }; disableClose: boolean | undefined; readonly id: string; diff --git a/goldens/material/dialog/index.api.md b/goldens/material/dialog/index.api.md index 0f03e73aabcd..ce48b5bfbf1f 100644 --- a/goldens/material/dialog/index.api.md +++ b/goldens/material/dialog/index.api.md @@ -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'; @@ -138,6 +139,7 @@ export class MatDialogConfig { autoFocus?: AutoFocusTarget | string | boolean; backdropClass?: string | string[]; closeOnNavigation?: boolean; + closePredicate?: (result: Result | undefined, config: Config, componentInstance: Component | null) => boolean; data?: D | null; delayFocusTrap?: boolean; direction?: Direction; @@ -203,7 +205,7 @@ export class MatDialogModule { // @public export class MatDialogRef { - constructor(_ref: DialogRef, config: MatDialogConfig, _containerInstance: MatDialogContainer); + constructor(_ref: DialogRef, _config: MatDialogConfig, _containerInstance: MatDialogContainer); addPanelClass(classes: string | string[]): this; afterClosed(): Observable; afterOpened(): Observable; diff --git a/goldens/material/dialog/testing/index.api.md b/goldens/material/dialog/testing/index.api.md index 84d44ab276ed..675ee5dc987c 100644 --- a/goldens/material/dialog/testing/index.api.md +++ b/goldens/material/dialog/testing/index.api.md @@ -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'; diff --git a/src/cdk/dialog/dialog-config.ts b/src/cdk/dialog/dialog-config.ts index b115a20ae496..888b5efc3c51 100644 --- a/src/cdk/dialog/dialog-config.ts +++ b/src/cdk/dialog/dialog-config.ts @@ -51,6 +51,17 @@ export class DialogConfig( + result: Result | undefined, + config: Config, + componentInstance: Component | null, + ) => boolean; + /** Width of the dialog. */ width?: string = ''; diff --git a/src/cdk/dialog/dialog-container.ts b/src/cdk/dialog/dialog-container.ts index 62606608722e..038a113a028c 100644 --- a/src/cdk/dialog/dialog-container.ts +++ b/src/cdk/dialog/dialog-container.ts @@ -13,7 +13,6 @@ import { FocusTrapFactory, InteractivityChecker, } from '../a11y'; -import {OverlayRef} from '../overlay'; import {Platform, _getFocusedElementPierceShadowDom} from '../platform'; import { BasePortalOutlet, @@ -79,7 +78,6 @@ export class CdkDialogContainer readonly _config: C; private _interactivityChecker = inject(InteractivityChecker); protected _ngZone = inject(NgZone); - private _overlayRef = inject(OverlayRef); private _focusMonitor = inject(FocusMonitor); private _renderer = inject(Renderer2); @@ -146,7 +144,6 @@ export class CdkDialogContainer protected _contentAttached() { this._initializeFocusTrap(); - this._handleBackdropClicks(); this._captureInitialFocus(); } @@ -348,9 +345,7 @@ export class CdkDialogContainer /** 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. */ @@ -372,15 +367,4 @@ export class CdkDialogContainer } } } - - /** 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(); - } - }); - } } diff --git a/src/cdk/dialog/dialog-ref.ts b/src/cdk/dialog/dialog-ref.ts index 4e971d30548a..cfe4ad0f9862 100644 --- a/src/cdk/dialog/dialog-ref.ts +++ b/src/cdk/dialog/dialog-ref.ts @@ -37,7 +37,10 @@ export class DialogRef { readonly componentRef: ComponentRef | 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; @@ -78,8 +81,12 @@ export class DialogRef { }); 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?.(); } }); @@ -97,7 +104,7 @@ export class DialogRef { * @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; this.containerInstance._closeInteractionType = options?.focusOrigin || 'program'; // Drop the detach subscription first since it can be triggered by the @@ -139,4 +146,14 @@ export class DialogRef { this.overlayRef.removePanelClass(classes); return this; } + + /** Whether the dialog is allowed to close. */ + private _canClose(result?: R): boolean { + const config = this.config as DialogConfig; + + return ( + !!this.containerInstance && + (!config.closePredicate || config.closePredicate(result, config, this.componentInstance)) + ); + } } diff --git a/src/cdk/dialog/dialog.spec.ts b/src/cdk/dialog/dialog.spec.ts index 9bf9c6fae664..ef57e478b505 100644 --- a/src/cdk/dialog/dialog.spec.ts +++ b/src/cdk/dialog/dialog.spec.ts @@ -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, { @@ -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, { @@ -1273,6 +1465,10 @@ class PizzaMsg {

This is the title

`, imports: [DialogModule], + host: { + // Avoids conflicting ID warning + 'id': 'content-element-dialog', + }, }) class ContentElementDialog { closeButtonAriaLabel: string; @@ -1299,6 +1495,10 @@ class DialogWithInjectedData { @Component({ template: '

Pasta

', imports: [DialogModule], + host: { + // Avoids conflicting ID warning + 'id': 'dialog-without-focusable', + }, }) class DialogWithoutFocusableElements {} diff --git a/src/material/dialog/dialog-config.ts b/src/material/dialog/dialog-config.ts index 6edb8d706cbf..a684362fbcc9 100644 --- a/src/material/dialog/dialog-config.ts +++ b/src/material/dialog/dialog-config.ts @@ -9,6 +9,7 @@ import {ViewContainerRef, Injector} from '@angular/core'; import {Direction} from '@angular/cdk/bidi'; import {ScrollStrategy} from '@angular/cdk/overlay'; +import {DialogConfig} from '@angular/cdk/dialog'; import {_defaultParams} from './dialog-animations'; /** Options for where to set focus to automatically on dialog open */ @@ -68,6 +69,17 @@ export class MatDialogConfig { /** Whether the user can use escape or clicking on the backdrop to close the modal. */ disableClose?: boolean = false; + /** Function used to determine whether the dialog is allowed to close. */ + closePredicate?: < + Result = unknown, + Component = unknown, + Config extends DialogConfig = MatDialogConfig, + >( + result: Result | undefined, + config: Config, + componentInstance: Component | null, + ) => boolean; + /** Width of the dialog. */ width?: string = ''; diff --git a/src/material/dialog/dialog-ref.ts b/src/material/dialog/dialog-ref.ts index b20a6936ac68..c247fad81cb1 100644 --- a/src/material/dialog/dialog-ref.ts +++ b/src/material/dialog/dialog-ref.ts @@ -66,10 +66,10 @@ export class MatDialogRef { constructor( private _ref: DialogRef, - config: MatDialogConfig, + private _config: MatDialogConfig, public _containerInstance: MatDialogContainer, ) { - this.disableClose = config.disableClose; + this.disableClose = _config.disableClose; this.id = _ref.id; // Used to target panels specifically tied to dialogs. @@ -121,6 +121,12 @@ export class MatDialogRef { * @param dialogResult Optional result to return to the dialog opener. */ close(dialogResult?: R): void { + const closePredicate = this._config.closePredicate; + + if (closePredicate && !closePredicate(dialogResult, this._config, this.componentInstance)) { + return; + } + this._result = dialogResult; // Transition the backdrop in parallel to the dialog. diff --git a/src/material/dialog/dialog.spec.ts b/src/material/dialog/dialog.spec.ts index 1abfc6b387e0..a043ddfda090 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -1022,6 +1022,171 @@ describe('MatDialog', () => { ); }); + describe('closePredicate option', () => { + function getDialogs() { + return overlayContainerElement.querySelectorAll('mat-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.afterClosed().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.afterClosed().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.afterClosed().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(); + flush(); + viewContainerFixture.detectChanges(); + flush(); + + 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(); + })); + }); + it( 'should recapture focus to the first header when clicking on the backdrop with ' + 'autoFocus set to "first-heading"', diff --git a/src/material/dialog/dialog.ts b/src/material/dialog/dialog.ts index d4083b695dd9..ac1a8890a352 100644 --- a/src/material/dialog/dialog.ts +++ b/src/material/dialog/dialog.ts @@ -141,6 +141,8 @@ export class MatDialog implements OnDestroy { positionStrategy: this._overlay.position().global().centerHorizontally().centerVertically(), // Disable closing since we need to sync it up to the animation ourselves. disableClose: true, + // Closing is tied to our animation so the close predicate has to be implemented separately. + closePredicate: undefined, // Disable closing on destroy, because this service cleans up its open dialogs as well. // We want to do the cleanup here, rather than the CDK service, because the CDK destroys // the dialogs immediately whereas we want it to wait for the animations to finish.