Skip to content

Commit 9ff32fd

Browse files
committed
fix(material/dialog): update aria-labelledby if title is swapped (#27609)
Currently the dialog assigns the ID of the title as the `aria-labelleledby` of the container, but it doesn't update it if the title is swapped out or removed. These changes add a queue of possible IDs that the container can use as titles are being created or destroyed. Fixes #27599. (cherry picked from commit 642d886)
1 parent 65253eb commit 9ff32fd

File tree

9 files changed

+126
-20
lines changed

9 files changed

+126
-20
lines changed

src/cdk/dialog/dialog-container.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function throwDialogContentAlreadyAttachedError() {
6060
'[attr.id]': '_config.id || null',
6161
'[attr.role]': '_config.role',
6262
'[attr.aria-modal]': '_config.ariaModal',
63-
'[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledBy',
63+
'[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledByQueue[0]',
6464
'[attr.aria-label]': '_config.ariaLabel',
6565
'[attr.aria-describedby]': '_config.ariaDescribedBy || null',
6666
},
@@ -87,8 +87,13 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
8787
*/
8888
_closeInteractionType: FocusOrigin | null = null;
8989

90-
/** ID of the element that should be considered as the dialog's label. */
91-
_ariaLabelledBy: string | null;
90+
/**
91+
* Queue of the IDs of the dialog's label element, based on their definition order. The first
92+
* ID will be used as the `aria-labelledby` value. We use a queue here to handle the case
93+
* where there are two or more titles in the DOM at a time and the first one is destroyed while
94+
* the rest are present.
95+
*/
96+
_ariaLabelledByQueue: string[] = [];
9297

9398
constructor(
9499
protected _elementRef: ElementRef,
@@ -101,8 +106,12 @@ export class CdkDialogContainer<C extends DialogConfig = DialogConfig>
101106
private _focusMonitor?: FocusMonitor,
102107
) {
103108
super();
104-
this._ariaLabelledBy = this._config.ariaLabelledBy || null;
109+
105110
this._document = _document;
111+
112+
if (this._config.ariaLabelledBy) {
113+
this._ariaLabelledByQueue.push(this._config.ariaLabelledBy);
114+
}
106115
}
107116

108117
protected _contentAttached() {

src/material/dialog/dialog-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ function parseCssTime(time: string | number | undefined): number | null {
145145
'[attr.aria-modal]': '_config.ariaModal',
146146
'[id]': '_config.id',
147147
'[attr.role]': '_config.role',
148-
'[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledBy',
148+
'[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledByQueue[0]',
149149
'[attr.aria-label]': '_config.ariaLabel',
150150
'[attr.aria-describedby]': '_config.ariaDescribedBy || null',
151151
'[class._mat-animation-noopable]': '!_animationsEnabled',

src/material/dialog/dialog-content-directives.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ElementRef,
1212
Input,
1313
OnChanges,
14+
OnDestroy,
1415
OnInit,
1516
Optional,
1617
SimpleChanges,
@@ -97,7 +98,7 @@ export class MatDialogClose implements OnInit, OnChanges {
9798
'[id]': 'id',
9899
},
99100
})
100-
export class MatDialogTitle implements OnInit {
101+
export class MatDialogTitle implements OnInit, OnDestroy {
101102
@Input() id: string = `mat-mdc-dialog-title-${dialogElementUid++}`;
102103

103104
constructor(
@@ -115,10 +116,24 @@ export class MatDialogTitle implements OnInit {
115116

116117
if (this._dialogRef) {
117118
Promise.resolve().then(() => {
118-
const container = this._dialogRef._containerInstance;
119+
// Note: we null check the queue, because there are some internal
120+
// tests that are mocking out `MatDialogRef` incorrectly.
121+
this._dialogRef._containerInstance?._ariaLabelledByQueue?.push(this.id);
122+
});
123+
}
124+
}
125+
126+
ngOnDestroy() {
127+
// Note: we null check the queue, because there are some internal
128+
// tests that are mocking out `MatDialogRef` incorrectly.
129+
const queue = this._dialogRef?._containerInstance?._ariaLabelledByQueue;
130+
131+
if (queue) {
132+
Promise.resolve().then(() => {
133+
const index = queue.indexOf(this.id);
119134

120-
if (container && !container._ariaLabelledBy) {
121-
container._ariaLabelledBy = this.id;
135+
if (index > -1) {
136+
queue.splice(index, 1);
122137
}
123138
});
124139
}

src/material/dialog/dialog.spec.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,12 +1587,14 @@ describe('MDC-based MatDialog', () => {
15871587

15881588
describe('dialog content elements', () => {
15891589
let dialogRef: MatDialogRef<any>;
1590+
let hostInstance: ContentElementDialog | ComponentWithContentElementTemplateRef;
15901591

15911592
describe('inside component dialog', () => {
15921593
beforeEach(fakeAsync(() => {
15931594
dialogRef = dialog.open(ContentElementDialog, {viewContainerRef: testViewContainerRef});
15941595
viewContainerFixture.detectChanges();
15951596
flush();
1597+
hostInstance = dialogRef.componentInstance;
15961598
}));
15971599

15981600
runContentElementTests();
@@ -1609,6 +1611,7 @@ describe('MDC-based MatDialog', () => {
16091611

16101612
viewContainerFixture.detectChanges();
16111613
flush();
1614+
hostInstance = fixture.componentInstance;
16121615
}));
16131616

16141617
runContentElementTests();
@@ -1682,6 +1685,49 @@ describe('MDC-based MatDialog', () => {
16821685
.toBe(title.id);
16831686
}));
16841687

1688+
it('should update the aria-labelledby attribute if two titles are swapped', fakeAsync(() => {
1689+
const container = overlayContainerElement.querySelector('mat-dialog-container')!;
1690+
let title = overlayContainerElement.querySelector('[mat-dialog-title]')!;
1691+
1692+
flush();
1693+
viewContainerFixture.detectChanges();
1694+
1695+
const previousId = title.id;
1696+
expect(title.id).toBeTruthy();
1697+
expect(container.getAttribute('aria-labelledby')).toBe(title.id);
1698+
1699+
hostInstance.shownTitle = 'second';
1700+
viewContainerFixture.detectChanges();
1701+
flush();
1702+
viewContainerFixture.detectChanges();
1703+
title = overlayContainerElement.querySelector('[mat-dialog-title]')!;
1704+
1705+
expect(title.id).toBeTruthy();
1706+
expect(title.id).not.toBe(previousId);
1707+
expect(container.getAttribute('aria-labelledby')).toBe(title.id);
1708+
}));
1709+
1710+
it('should update the aria-labelledby attribute if multiple titles are present and one is removed', fakeAsync(() => {
1711+
const container = overlayContainerElement.querySelector('mat-dialog-container')!;
1712+
1713+
hostInstance.shownTitle = 'all';
1714+
viewContainerFixture.detectChanges();
1715+
flush();
1716+
viewContainerFixture.detectChanges();
1717+
1718+
const titles = overlayContainerElement.querySelectorAll('[mat-dialog-title]');
1719+
1720+
expect(titles.length).toBe(3);
1721+
expect(container.getAttribute('aria-labelledby')).toBe(titles[0].id);
1722+
1723+
hostInstance.shownTitle = 'second';
1724+
viewContainerFixture.detectChanges();
1725+
flush();
1726+
viewContainerFixture.detectChanges();
1727+
1728+
expect(container.getAttribute('aria-labelledby')).toBe(titles[1].id);
1729+
}));
1730+
16851731
it('should add correct css class according to given [align] input in [mat-dialog-actions]', () => {
16861732
let actions = overlayContainerElement.querySelector('mat-dialog-actions')!;
16871733

@@ -2116,7 +2162,9 @@ class PizzaMsg {
21162162

21172163
@Component({
21182164
template: `
2119-
<h1 mat-dialog-title>This is the title</h1>
2165+
<h1 mat-dialog-title *ngIf="shouldShowTitle('first')">This is the first title</h1>
2166+
<h1 mat-dialog-title *ngIf="shouldShowTitle('second')">This is the second title</h1>
2167+
<h1 mat-dialog-title *ngIf="shouldShowTitle('third')">This is the third title</h1>
21202168
<mat-dialog-content>Lorem ipsum dolor sit amet.</mat-dialog-content>
21212169
<mat-dialog-actions align="end">
21222170
<button mat-dialog-close>Close</button>
@@ -2130,12 +2178,20 @@ class PizzaMsg {
21302178
</mat-dialog-actions>
21312179
`,
21322180
})
2133-
class ContentElementDialog {}
2181+
class ContentElementDialog {
2182+
shownTitle: 'first' | 'second' | 'third' | 'all' = 'first';
2183+
2184+
shouldShowTitle(name: string) {
2185+
return this.shownTitle === 'all' || this.shownTitle === name;
2186+
}
2187+
}
21342188

21352189
@Component({
21362190
template: `
21372191
<ng-template>
2138-
<h1 mat-dialog-title>This is the title</h1>
2192+
<h1 mat-dialog-title *ngIf="shouldShowTitle('first')">This is the first title</h1>
2193+
<h1 mat-dialog-title *ngIf="shouldShowTitle('second')">This is the second title</h1>
2194+
<h1 mat-dialog-title *ngIf="shouldShowTitle('third')">This is the third title</h1>
21392195
<mat-dialog-content>Lorem ipsum dolor sit amet.</mat-dialog-content>
21402196
<mat-dialog-actions align="end">
21412197
<button mat-dialog-close>Close</button>
@@ -2152,6 +2208,12 @@ class ContentElementDialog {}
21522208
})
21532209
class ComponentWithContentElementTemplateRef {
21542210
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
2211+
2212+
shownTitle: 'first' | 'second' | 'third' | 'all' = 'first';
2213+
2214+
shouldShowTitle(name: string) {
2215+
return this.shownTitle === 'all' || this.shownTitle === name;
2216+
}
21552217
}
21562218

21572219
@Component({template: '', providers: [MatDialog]})

src/material/legacy-dialog/dialog-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {_MatDialogContainerBase, matDialogAnimations} from '@angular/material/di
4646
'[attr.aria-modal]': '_config.ariaModal',
4747
'[id]': '_config.id',
4848
'[attr.role]': '_config.role',
49-
'[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledBy',
49+
'[attr.aria-labelledby]': '_config.ariaLabel ? null : _ariaLabelledByQueue[0]',
5050
'[attr.aria-label]': '_config.ariaLabel',
5151
'[attr.aria-describedby]': '_config.ariaDescribedBy || null',
5252
'[@dialogContainer]': `_getAnimationState()`,

src/material/legacy-dialog/dialog-content-directives.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
Optional,
1515
SimpleChanges,
1616
ElementRef,
17+
OnDestroy,
1718
} from '@angular/core';
1819
import {MatLegacyDialog} from './dialog';
1920
import {MatLegacyDialogRef} from './dialog-ref';
@@ -106,7 +107,7 @@ export class MatLegacyDialogClose implements OnInit, OnChanges {
106107
'[id]': 'id',
107108
},
108109
})
109-
export class MatLegacyDialogTitle implements OnInit {
110+
export class MatLegacyDialogTitle implements OnInit, OnDestroy {
110111
/** Unique id for the dialog title. If none is supplied, it will be auto-generated. */
111112
@Input() id: string = `mat-dialog-title-${dialogElementUid++}`;
112113

@@ -125,10 +126,24 @@ export class MatLegacyDialogTitle implements OnInit {
125126

126127
if (this._dialogRef) {
127128
Promise.resolve().then(() => {
128-
const container = this._dialogRef._containerInstance;
129+
// Note: we null check the queue, because there are some internal
130+
// tests that are mocking out `MatDialogRef` incorrectly.
131+
this._dialogRef._containerInstance?._ariaLabelledByQueue?.push(this.id);
132+
});
133+
}
134+
}
135+
136+
ngOnDestroy() {
137+
// Note: we null check the queue, because there are some internal
138+
// tests that are mocking out `MatDialogRef` incorrectly.
139+
const queue = this._dialogRef?._containerInstance?._ariaLabelledByQueue;
140+
141+
if (queue) {
142+
Promise.resolve().then(() => {
143+
const index = queue.indexOf(this.id);
129144

130-
if (container && !container._ariaLabelledBy) {
131-
container._ariaLabelledBy = this.id;
145+
if (index > -1) {
146+
queue.splice(index, 1);
132147
}
133148
});
134149
}

tools/public_api_guard/cdk/dialog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading';
4545
// @public
4646
export class CdkDialogContainer<C extends DialogConfig = DialogConfig> extends BasePortalOutlet implements OnDestroy {
4747
constructor(_elementRef: ElementRef, _focusTrapFactory: FocusTrapFactory, _document: any, _config: C, _interactivityChecker: InteractivityChecker, _ngZone: NgZone, _overlayRef: OverlayRef, _focusMonitor?: FocusMonitor | undefined);
48-
_ariaLabelledBy: string | null;
48+
_ariaLabelledByQueue: string[];
4949
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
5050
// @deprecated
5151
attachDomPortal: (portal: DomPortal) => void;

tools/public_api_guard/material/dialog.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,13 @@ export const enum MatDialogState {
274274
}
275275

276276
// @public
277-
export class MatDialogTitle implements OnInit {
277+
export class MatDialogTitle implements OnInit, OnDestroy {
278278
constructor(_dialogRef: MatDialogRef<any>, _elementRef: ElementRef<HTMLElement>, _dialog: MatDialog);
279279
// (undocumented)
280280
id: string;
281281
// (undocumented)
282+
ngOnDestroy(): void;
283+
// (undocumented)
282284
ngOnInit(): void;
283285
// (undocumented)
284286
static ɵdir: i0.ɵɵDirectiveDeclaration<MatDialogTitle, "[mat-dialog-title], [matDialogTitle]", ["matDialogTitle"], { "id": { "alias": "id"; "required": false; }; }, {}, never, never, false, never>;

tools/public_api_guard/material/legacy-dialog.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { _MatDialogContainerBase as _MatLegacyDialogContainerBase } from '@angul
3131
import { MatDialogState as MatLegacyDialogState } from '@angular/material/dialog';
3232
import { NgZone } from '@angular/core';
3333
import { OnChanges } from '@angular/core';
34+
import { OnDestroy } from '@angular/core';
3435
import { OnInit } from '@angular/core';
3536
import { Overlay } from '@angular/cdk/overlay';
3637
import { OverlayContainer } from '@angular/cdk/overlay';
@@ -171,10 +172,12 @@ export class MatLegacyDialogRef<T, R = any> extends MatDialogRef<T, R> {
171172
export { MatLegacyDialogState }
172173

173174
// @public @deprecated
174-
export class MatLegacyDialogTitle implements OnInit {
175+
export class MatLegacyDialogTitle implements OnInit, OnDestroy {
175176
constructor(_dialogRef: MatLegacyDialogRef<any>, _elementRef: ElementRef<HTMLElement>, _dialog: MatLegacyDialog);
176177
id: string;
177178
// (undocumented)
179+
ngOnDestroy(): void;
180+
// (undocumented)
178181
ngOnInit(): void;
179182
// (undocumented)
180183
static ɵdir: i0.ɵɵDirectiveDeclaration<MatLegacyDialogTitle, "[mat-dialog-title], [matDialogTitle]", ["matDialogTitle"], { "id": { "alias": "id"; "required": false; }; }, {}, never, never, false, never>;

0 commit comments

Comments
 (0)