diff --git a/src/cdk/dialog/dialog-container.ts b/src/cdk/dialog/dialog-container.ts index f3c49378980d..04793c8861b3 100644 --- a/src/cdk/dialog/dialog-container.ts +++ b/src/cdk/dialog/dialog-container.ts @@ -25,6 +25,7 @@ import { import {DOCUMENT} from '@angular/common'; import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, ComponentRef, ElementRef, @@ -35,6 +36,8 @@ import { Optional, ViewChild, ViewEncapsulation, + inject, + signal, } from '@angular/core'; import {DialogConfig} from './dialog-config'; @@ -97,6 +100,8 @@ export class CdkDialogContainer */ _ariaLabelledByQueue: string[] = []; + private readonly _changeDetectorRef = inject(ChangeDetectorRef); + constructor( protected _elementRef: ElementRef, protected _focusTrapFactory: FocusTrapFactory, @@ -112,7 +117,21 @@ export class CdkDialogContainer this._document = _document; if (this._config.ariaLabelledBy) { - this._ariaLabelledByQueue.push(this._config.ariaLabelledBy); + this._addAriaLabelledBy(this._config.ariaLabelledBy); + } + } + + _addAriaLabelledBy(id: string) { + this._ariaLabelledByQueue.push(id); + this._changeDetectorRef.markForCheck(); + } + + _removeAriaLabelledBy(id: string) { + const index = this._ariaLabelledByQueue.indexOf(id); + + if (index > -1) { + this._ariaLabelledByQueue.splice(index, 1); + this._changeDetectorRef.markForCheck(); } } diff --git a/src/material/dialog/dialog-content-directives.ts b/src/material/dialog/dialog-content-directives.ts index 254542bce6e9..f41fa5697bfc 100644 --- a/src/material/dialog/dialog-content-directives.ts +++ b/src/material/dialog/dialog-content-directives.ts @@ -120,7 +120,7 @@ export class MatDialogTitle implements OnInit, OnDestroy { Promise.resolve().then(() => { // Note: we null check the queue, because there are some internal // tests that are mocking out `MatDialogRef` incorrectly. - this._dialogRef._containerInstance?._ariaLabelledByQueue?.push(this.id); + this._dialogRef._containerInstance?._addAriaLabelledBy(this.id); }); } } @@ -128,15 +128,11 @@ export class MatDialogTitle implements OnInit, OnDestroy { ngOnDestroy() { // Note: we null check the queue, because there are some internal // tests that are mocking out `MatDialogRef` incorrectly. - const queue = this._dialogRef?._containerInstance?._ariaLabelledByQueue; + const instance = this._dialogRef?._containerInstance; - if (queue) { + if (instance) { Promise.resolve().then(() => { - const index = queue.indexOf(this.id); - - if (index > -1) { - queue.splice(index, 1); - } + instance._removeAriaLabelledBy(this.id); }); } } diff --git a/src/material/dialog/dialog.spec.ts b/src/material/dialog/dialog.spec.ts index 5289e36cfeba..8bc805c378fa 100644 --- a/src/material/dialog/dialog.spec.ts +++ b/src/material/dialog/dialog.spec.ts @@ -30,6 +30,7 @@ import { ViewContainerRef, ViewEncapsulation, forwardRef, + signal, } from '@angular/core'; import { ComponentFixture, @@ -1623,6 +1624,64 @@ describe('MDC-based MatDialog', () => { runContentElementTests(); }); + it('should set the aria-labelledby attribute to the id of the title under OnPush host', fakeAsync(() => { + @Component({ + standalone: true, + imports: [MatDialogTitle], + template: `@if (showTitle()) {

This is the first title

}`, + }) + class DialogCmp { + showTitle = signal(true); + } + + @Component({ + template: '', + selector: 'child', + standalone: true, + }) + class Child { + dialogRef?: MatDialogRef; + + constructor( + readonly viewContainerRef: ViewContainerRef, + readonly dialog: MatDialog, + ) {} + + open() { + this.dialogRef = this.dialog.open(DialogCmp, {viewContainerRef: this.viewContainerRef}); + } + } + + @Component({ + standalone: true, + imports: [Child], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class OnPushHost { + @ViewChild(Child, {static: true}) child: Child; + } + + const hostFixture = TestBed.createComponent(OnPushHost); + hostFixture.componentInstance.child.open(); + hostFixture.autoDetectChanges(); + flush(); + + const overlayContainer = TestBed.inject(OverlayContainer); + const title = overlayContainer.getContainerElement().querySelector('[mat-dialog-title]')!; + const container = overlayContainerElement.querySelector('mat-dialog-container')!; + + expect(title.id).withContext('Expected title element to have an id.').toBeTruthy(); + expect(container.getAttribute('aria-labelledby')) + .withContext('Expected the aria-labelledby to match the title id.') + .toBe(title.id); + + hostFixture.componentInstance.child.dialogRef?.componentInstance.showTitle.set(false); + hostFixture.detectChanges(); + flush(); + expect(container.getAttribute('aria-labelledby')).toBe(null); + })); + function runContentElementTests() { it('should close the dialog when clicking on the close button', fakeAsync(() => { expect(overlayContainerElement.querySelectorAll('.mat-mdc-dialog-container').length).toBe( diff --git a/tools/public_api_guard/cdk/dialog.md b/tools/public_api_guard/cdk/dialog.md index 49dc0e525806..b83bc66aa6db 100644 --- a/tools/public_api_guard/cdk/dialog.md +++ b/tools/public_api_guard/cdk/dialog.md @@ -45,6 +45,8 @@ export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading'; // @public export class CdkDialogContainer extends BasePortalOutlet implements OnDestroy { constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _document: any, _config: C, _interactivityChecker: InteractivityChecker, _ngZone: NgZone, _overlayRef: OverlayRef, _focusMonitor?: FocusMonitor | undefined); + // (undocumented) + _addAriaLabelledBy(id: string): void; _ariaLabelledByQueue: string[]; attachComponentPortal(portal: ComponentPortal): ComponentRef; // @deprecated @@ -68,6 +70,8 @@ export class CdkDialogContainer extends B protected _ngZone: NgZone; _portalOutlet: CdkPortalOutlet; _recaptureFocus(): void; + // (undocumented) + _removeAriaLabelledBy(id: string): void; protected _trapFocus(): void; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration, "cdk-dialog-container", never, {}, {}, never, never, true, never>;