diff --git a/goldens/cdk/dialog/index.api.md b/goldens/cdk/dialog/index.api.md index 3b5dce8c3740..3b63c30c02d7 100644 --- a/goldens/cdk/dialog/index.api.md +++ b/goldens/cdk/dialog/index.api.md @@ -38,7 +38,7 @@ import { ViewContainerRef } from '@angular/core'; export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; // @public -export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { +export class CdkDialogContainer extends BasePortalOutlet implements DialogContainer, OnDestroy { constructor(...args: unknown[]); // (undocumented) _addAriaLabelledBy(id: string): void; @@ -62,6 +62,8 @@ export class CdkDialogContainer extends B // (undocumented) protected _focusTrapFactory: FocusTrapFactory; // (undocumented) + _focusTrapped: Observable; + // (undocumented) ngOnDestroy(): void; // (undocumented) protected _ngZone: NgZone; @@ -111,7 +113,7 @@ export interface DialogCloseOptions { } // @public -export class DialogConfig { +export class DialogConfig { ariaDescribedBy?: string | null; ariaLabel?: string | null; ariaLabelledBy?: string | null; @@ -149,6 +151,13 @@ export class DialogConfig; + _closeInteractionType?: FocusOrigin; + _recaptureFocus?: () => void; +}; + // @public (undocumented) export class DialogModule { // (undocumented) @@ -161,7 +170,7 @@ export class DialogModule { // @public export class DialogRef { - constructor(overlayRef: OverlayRef, config: DialogConfig, BasePortalOutlet>); + constructor(overlayRef: OverlayRef, config: DialogConfig, DialogContainer>); addPanelClass(classes: string | string[]): this; readonly backdropClick: Observable; close(result?: R, options?: DialogCloseOptions): void; @@ -169,11 +178,8 @@ export class DialogRef { readonly componentInstance: C | null; readonly componentRef: ComponentRef | null; // (undocumented) - readonly config: DialogConfig, BasePortalOutlet>; - readonly containerInstance: BasePortalOutlet & { - _closeInteractionType?: FocusOrigin; - _recaptureFocus?: () => void; - }; + readonly config: DialogConfig, DialogContainer>; + readonly containerInstance: DialogContainer; disableClose: boolean | undefined; readonly id: string; readonly keydownEvents: Observable; diff --git a/src/cdk/dialog/dialog-config.ts b/src/cdk/dialog/dialog-config.ts index e61ed8e561d7..4585b11e4ba0 100644 --- a/src/cdk/dialog/dialog-config.ts +++ b/src/cdk/dialog/dialog-config.ts @@ -9,7 +9,9 @@ import {ViewContainerRef, Injector, StaticProvider, Type} from '@angular/core'; import {Direction} from '../bidi'; import {PositionStrategy, ScrollStrategy} from '../overlay'; +import {Observable} from 'rxjs'; import {BasePortalOutlet} from '../portal'; +import {FocusOrigin} from '../a11y'; /** Options for where to set focus to automatically on dialog open */ export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; @@ -17,8 +19,15 @@ export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; /** Valid ARIA roles for a dialog. */ export type DialogRole = 'dialog' | 'alertdialog'; +/** Component that can be used as the container for the dialog. */ +export type DialogContainer = BasePortalOutlet & { + _focusTrapped?: Observable; + _closeInteractionType?: FocusOrigin; + _recaptureFocus?: () => void; +}; + /** Configuration for opening a modal dialog. */ -export class DialogConfig { +export class DialogConfig { /** * Where the attached component should live in Angular's *logical* component tree. * This affects what is available for injection and the change detection order for the diff --git a/src/cdk/dialog/dialog-container.ts b/src/cdk/dialog/dialog-container.ts index 038a113a028c..433582a1f4ee 100644 --- a/src/cdk/dialog/dialog-container.ts +++ b/src/cdk/dialog/dialog-container.ts @@ -39,7 +39,8 @@ import { inject, DOCUMENT, } from '@angular/core'; -import {DialogConfig} from './dialog-config'; +import {DialogConfig, DialogContainer} from './dialog-config'; +import {Observable, Subject} from 'rxjs'; export function throwDialogContentAlreadyAttachedError() { throw Error('Attempting to attach dialog content after content is already attached'); @@ -71,7 +72,7 @@ export function throwDialogContentAlreadyAttachedError() { }) export class CdkDialogContainer extends BasePortalOutlet - implements OnDestroy + implements DialogContainer, OnDestroy { protected _elementRef = inject>(ElementRef); protected _focusTrapFactory = inject(FocusTrapFactory); @@ -80,13 +81,16 @@ export class CdkDialogContainer protected _ngZone = inject(NgZone); private _focusMonitor = inject(FocusMonitor); private _renderer = inject(Renderer2); - + protected readonly _changeDetectorRef = inject(ChangeDetectorRef); + private _injector = inject(Injector); private _platform = inject(Platform); protected _document = inject(DOCUMENT, {optional: true})!; /** The portal outlet inside of this container into which the dialog content will be loaded. */ @ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet; + _focusTrapped: Observable = new Subject(); + /** The class that traps and manages focus within the dialog. */ private _focusTrap: FocusTrap | null = null; @@ -108,10 +112,6 @@ export class CdkDialogContainer */ _ariaLabelledByQueue: string[] = []; - protected readonly _changeDetectorRef = inject(ChangeDetectorRef); - - private _injector = inject(Injector); - private _isDestroyed = false; constructor(...args: unknown[]); @@ -156,6 +156,7 @@ export class CdkDialogContainer } ngOnDestroy() { + (this._focusTrapped as Subject).complete(); this._isDestroyed = true; this._restoreFocus(); } @@ -291,6 +292,7 @@ export class CdkDialogContainer this._focusByCssSelector(this._config.autoFocus!, options); break; } + (this._focusTrapped as Subject).next(); }, {injector: this._injector}, ); diff --git a/src/cdk/dialog/dialog-ref.ts b/src/cdk/dialog/dialog-ref.ts index cfe4ad0f9862..8794605f3fe7 100644 --- a/src/cdk/dialog/dialog-ref.ts +++ b/src/cdk/dialog/dialog-ref.ts @@ -9,9 +9,8 @@ import {OverlayRef} from '../overlay'; import {ESCAPE, hasModifierKey} from '../keycodes'; import {Observable, Subject, Subscription} from 'rxjs'; -import {DialogConfig} from './dialog-config'; +import {DialogConfig, DialogContainer} from './dialog-config'; import {FocusOrigin} from '../a11y'; -import {BasePortalOutlet} from '../portal'; import {ComponentRef} from '@angular/core'; /** Additional options that can be passed in when closing a dialog. */ @@ -37,10 +36,7 @@ export class DialogRef { readonly componentRef: ComponentRef | null; /** Instance of the container that is rendering out the dialog content. */ - readonly containerInstance: BasePortalOutlet & { - _closeInteractionType?: FocusOrigin; - _recaptureFocus?: () => void; - }; + readonly containerInstance: DialogContainer; /** Whether the user is allowed to close the dialog. */ disableClose: boolean | undefined; @@ -65,7 +61,7 @@ export class DialogRef { constructor( readonly overlayRef: OverlayRef, - readonly config: DialogConfig, BasePortalOutlet>, + readonly config: DialogConfig, DialogContainer>, ) { this.disableClose = config.disableClose; this.backdropClick = overlayRef.backdropClick(); @@ -114,7 +110,7 @@ export class DialogRef { closedSubject.next(result); closedSubject.complete(); (this as {componentInstance: C}).componentInstance = ( - this as {containerInstance: BasePortalOutlet} + this as {containerInstance: DialogContainer} ).containerInstance = null!; } } @@ -149,7 +145,7 @@ export class DialogRef { /** Whether the dialog is allowed to close. */ private _canClose(result?: R): boolean { - const config = this.config as DialogConfig; + const config = this.config as DialogConfig; return ( !!this.containerInstance && diff --git a/src/cdk/dialog/dialog.ts b/src/cdk/dialog/dialog.ts index 5ff00f544073..45ac68f5961c 100644 --- a/src/cdk/dialog/dialog.ts +++ b/src/cdk/dialog/dialog.ts @@ -19,7 +19,7 @@ import { signal, } from '@angular/core'; import {Observable, Subject, defer} from 'rxjs'; -import {startWith} from 'rxjs/operators'; +import {startWith, take} from 'rxjs/operators'; import {_IdGenerator} from '../a11y'; import {Direction, Directionality} from '../bidi'; import { @@ -30,8 +30,8 @@ import { OverlayContainer, OverlayRef, } from '../overlay'; -import {BasePortalOutlet, ComponentPortal, TemplatePortal} from '../portal'; -import {DialogConfig} from './dialog-config'; +import {ComponentPortal, TemplatePortal} from '../portal'; +import {DialogConfig, DialogContainer} from './dialog-config'; import {DialogRef} from './dialog-ref'; import {CdkDialogContainer} from './dialog-container'; @@ -141,14 +141,24 @@ export class Dialog implements OnDestroy { const dialogRef = new DialogRef(overlayRef, config); const dialogContainer = this._attachContainer(overlayRef, dialogRef, config); - (dialogRef as {containerInstance: BasePortalOutlet}).containerInstance = dialogContainer; - this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config); + (dialogRef as {containerInstance: DialogContainer}).containerInstance = dialogContainer; // If this is the first dialog that we're opening, hide all the non-overlay content. if (!this.openDialogs.length) { - this._hideNonDialogContentFromAssistiveTechnology(); + // Resolve this ahead of time, because some internal apps + // mock it out and depend on it being synchronous. + const overlayContainer = this._overlayContainer.getContainerElement(); + + if (dialogContainer._focusTrapped) { + dialogContainer._focusTrapped.pipe(take(1)).subscribe(() => { + this._hideNonDialogContentFromAssistiveTechnology(overlayContainer); + }); + } else { + this._hideNonDialogContentFromAssistiveTechnology(overlayContainer); + } } + this._attachDialogContent(componentOrTemplateRef, dialogRef, dialogContainer, config); (this.openDialogs as DialogRef[]).push(dialogRef); dialogRef.closed.subscribe(() => this._removeOpenDialog(dialogRef, true)); this.afterOpened.next(dialogRef); @@ -233,14 +243,14 @@ export class Dialog implements OnDestroy { overlay: OverlayRef, dialogRef: DialogRef, config: DialogConfig>, - ): BasePortalOutlet { + ): DialogContainer { const userInjector = config.injector || config.viewContainerRef?.injector; const providers: StaticProvider[] = [ {provide: DialogConfig, useValue: config}, {provide: DialogRef, useValue: dialogRef}, {provide: OverlayRef, useValue: overlay}, ]; - let containerType: Type; + let containerType: Type; if (config.container) { if (typeof config.container === 'function') { @@ -274,7 +284,7 @@ export class Dialog implements OnDestroy { private _attachDialogContent( componentOrTemplateRef: ComponentType | TemplateRef, dialogRef: DialogRef, - dialogContainer: BasePortalOutlet, + dialogContainer: DialogContainer, config: DialogConfig>, ) { if (componentOrTemplateRef instanceof TemplateRef) { @@ -316,7 +326,7 @@ export class Dialog implements OnDestroy { private _createInjector( config: DialogConfig>, dialogRef: DialogRef, - dialogContainer: BasePortalOutlet, + dialogContainer: DialogContainer, fallbackInjector: Injector | undefined, ): Injector { const userInjector = config.injector || config.viewContainerRef?.injector; @@ -379,9 +389,7 @@ export class Dialog implements OnDestroy { } /** Hides all of the content that isn't an overlay from assistive technology. */ - private _hideNonDialogContentFromAssistiveTechnology() { - const overlayContainer = this._overlayContainer.getContainerElement(); - + private _hideNonDialogContentFromAssistiveTechnology(overlayContainer: HTMLElement) { // Ensure that the overlay container is attached to the DOM. if (overlayContainer.parentElement) { const siblings = overlayContainer.parentElement.children;