diff --git a/goldens/cdk/dialog/index.api.md b/goldens/cdk/dialog/index.api.md index cc02126ef92a..e3ea175a745d 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; @@ -121,7 +123,7 @@ export interface DialogCloseOptions { } // @public -export class DialogConfig { +export class DialogConfig { ariaDescribedBy?: string | null; ariaLabel?: string | null; ariaLabelledBy?: string | null; @@ -159,6 +161,13 @@ export class DialogConfig; + _closeInteractionType?: FocusOrigin; + _recaptureFocus?: () => void; +}; + // @public (undocumented) export class DialogModule { // (undocumented) @@ -171,7 +180,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; @@ -179,10 +188,8 @@ export class DialogRef { readonly componentInstance: C | null; readonly componentRef: ComponentRef | null; // (undocumented) - readonly config: DialogConfig, BasePortalOutlet>; - readonly containerInstance: BasePortalOutlet & { - _closeInteractionType?: FocusOrigin; - }; + 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 4c0f2a439576..5aeb50847f07 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 520d68d82fec..49857324fd1c 100644 --- a/src/cdk/dialog/dialog-container.ts +++ b/src/cdk/dialog/dialog-container.ts @@ -39,7 +39,8 @@ import { afterNextRender, inject, } 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); @@ -81,13 +82,16 @@ export class CdkDialogContainer private _overlayRef = inject(OverlayRef); 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; @@ -109,10 +113,6 @@ export class CdkDialogContainer */ _ariaLabelledByQueue: string[] = []; - protected readonly _changeDetectorRef = inject(ChangeDetectorRef); - - private _injector = inject(Injector); - private _isDestroyed = false; constructor(...args: unknown[]); @@ -158,6 +158,7 @@ export class CdkDialogContainer } ngOnDestroy() { + (this._focusTrapped as Subject).complete(); this._isDestroyed = true; this._restoreFocus(); } @@ -293,6 +294,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 4e971d30548a..573186442e43 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,7 +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}; + readonly containerInstance: DialogContainer; /** Whether the user is allowed to close the dialog. */ disableClose: boolean | undefined; @@ -62,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(); @@ -107,7 +106,7 @@ export class DialogRef { closedSubject.next(result); closedSubject.complete(); (this as {componentInstance: C}).componentInstance = ( - this as {containerInstance: BasePortalOutlet} + this as {containerInstance: DialogContainer} ).containerInstance = null!; } } diff --git a/src/cdk/dialog/dialog.ts b/src/cdk/dialog/dialog.ts index 1804a41844ea..5e98fb8b7660 100644 --- a/src/cdk/dialog/dialog.ts +++ b/src/cdk/dialog/dialog.ts @@ -16,14 +16,14 @@ import { ComponentRef, inject, } from '@angular/core'; -import {BasePortalOutlet, ComponentPortal, TemplatePortal} from '../portal'; +import {ComponentPortal, TemplatePortal} from '../portal'; import {of as observableOf, Observable, Subject, defer} from 'rxjs'; import {DialogRef} from './dialog-ref'; -import {DialogConfig} from './dialog-config'; +import {DialogConfig, DialogContainer} from './dialog-config'; import {Directionality} from '../bidi'; import {_IdGenerator} from '../a11y'; import {ComponentType, Overlay, OverlayRef, OverlayConfig, OverlayContainer} from '../overlay'; -import {startWith} from 'rxjs/operators'; +import {startWith, take} from 'rxjs/operators'; import {DEFAULT_DIALOG_CONFIG, DIALOG_DATA, DIALOG_SCROLL_STRATEGY} from './dialog-injectors'; import {CdkDialogContainer} from './dialog-container'; @@ -118,14 +118,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); @@ -209,14 +219,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') { @@ -250,7 +260,7 @@ export class Dialog implements OnDestroy { private _attachDialogContent( componentOrTemplateRef: ComponentType | TemplateRef, dialogRef: DialogRef, - dialogContainer: BasePortalOutlet, + dialogContainer: DialogContainer, config: DialogConfig>, ) { if (componentOrTemplateRef instanceof TemplateRef) { @@ -292,7 +302,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; @@ -355,9 +365,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;