Skip to content

Commit 3b80628

Browse files
committed
feat(cdk/a11y): allow safe HTML to be passed to live announcer
Adds support for passing safe HTML into the `LiveAnnouncer`. This can be necessary when announcing content in a different language from the rest of the page. Fixes #31835.
1 parent 1d42431 commit 3b80628

File tree

4 files changed

+58
-15
lines changed

4 files changed

+58
-15
lines changed

goldens/cdk/a11y/index.api.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { OnChanges } from '@angular/core';
1818
import { OnDestroy } from '@angular/core';
1919
import { Provider } from '@angular/core';
2020
import { QueryList } from '@angular/core';
21+
import { SafeHtml } from '@angular/platform-browser';
2122
import { Signal } from '@angular/core';
2223
import { SimpleChanges } from '@angular/core';
2324
import { Subject } from 'rxjs';
@@ -404,10 +405,10 @@ export const LIVE_ANNOUNCER_ELEMENT_TOKEN: InjectionToken<HTMLElement | null>;
404405
// @public (undocumented)
405406
export class LiveAnnouncer implements OnDestroy {
406407
constructor(...args: unknown[]);
407-
announce(message: string): Promise<void>;
408-
announce(message: string, politeness?: AriaLivePoliteness): Promise<void>;
409-
announce(message: string, duration?: number): Promise<void>;
410-
announce(message: string, politeness?: AriaLivePoliteness, duration?: number): Promise<void>;
408+
announce(message: LiveAnnouncerMessage): Promise<void>;
409+
announce(message: LiveAnnouncerMessage, politeness?: AriaLivePoliteness): Promise<void>;
410+
announce(message: LiveAnnouncerMessage, duration?: number): Promise<void>;
411+
announce(message: LiveAnnouncerMessage, politeness?: AriaLivePoliteness, duration?: number): Promise<void>;
411412
clear(): void;
412413
// (undocumented)
413414
ngOnDestroy(): void;
@@ -423,6 +424,9 @@ export interface LiveAnnouncerDefaultOptions {
423424
politeness?: AriaLivePoliteness;
424425
}
425426

427+
// @public
428+
export type LiveAnnouncerMessage = string | SafeHtml;
429+
426430
// @public @deprecated
427431
export const MESSAGES_CONTAINER_ID = "cdk-describedby-message-container";
428432

src/cdk/a11y/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ ng_project(
1818
deps = [
1919
"//:node_modules/@angular/common",
2020
"//:node_modules/@angular/core",
21+
"//:node_modules/@angular/platform-browser",
2122
"//:node_modules/rxjs",
2223
"//src:dev_mode_types",
2324
"//src/cdk/coercion",

src/cdk/a11y/live-announcer/live-announcer.spec.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import {MutationObserverFactory} from '../../observers';
22
import {ComponentPortal} from '../../portal';
33
import {Component, inject, Injector} from '@angular/core';
44
import {ComponentFixture, TestBed, fakeAsync, flush, tick} from '@angular/core/testing';
5-
import {By} from '@angular/platform-browser';
5+
import {By, DomSanitizer} from '@angular/platform-browser';
66
import {A11yModule} from '../index';
7-
import {LiveAnnouncer} from './live-announcer';
7+
import {LiveAnnouncer, LiveAnnouncerMessage} from './live-announcer';
88
import {
99
AriaLivePoliteness,
1010
LIVE_ANNOUNCER_DEFAULT_OPTIONS,
@@ -202,6 +202,19 @@ describe('LiveAnnouncer', () => {
202202
tick(100);
203203
expect(modal.getAttribute('aria-owns')).toBe(`foo bar ${ariaLiveElement.id}`);
204204
}));
205+
206+
it('should be able to announce safe HTML', fakeAsync(() => {
207+
const sanitizer = TestBed.inject(DomSanitizer);
208+
const message = sanitizer.bypassSecurityTrustHtml(
209+
'<span class="message" lang="fr">Bonjour</span>',
210+
);
211+
fixture.componentInstance.announce(message);
212+
213+
// This flushes our 100ms timeout for the screenreaders.
214+
tick(100);
215+
216+
expect(ariaLiveElement.querySelector('.message')?.textContent).toBe('Bonjour');
217+
}));
205218
});
206219

207220
describe('with a custom element', () => {
@@ -378,13 +391,13 @@ function getLiveElement(): Element {
378391
}
379392

380393
@Component({
381-
template: `<button (click)="announceText('Test')">Announce</button>`,
394+
template: `<button (click)="announce('Test')">Announce</button>`,
382395
imports: [A11yModule],
383396
})
384397
class TestApp {
385398
live = inject(LiveAnnouncer);
386399

387-
announceText(message: string) {
400+
announce(message: LiveAnnouncerMessage) {
388401
this.live.announce(message);
389402
}
390403
}

src/cdk/a11y/live-announcer/live-announcer.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,23 @@ import {
1717
OnDestroy,
1818
inject,
1919
DOCUMENT,
20+
SecurityContext,
2021
} from '@angular/core';
22+
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
2123
import {Subscription} from 'rxjs';
2224
import {
2325
AriaLivePoliteness,
2426
LiveAnnouncerDefaultOptions,
2527
LIVE_ANNOUNCER_ELEMENT_TOKEN,
2628
LIVE_ANNOUNCER_DEFAULT_OPTIONS,
2729
} from './live-announcer-tokens';
28-
import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '../../private';
30+
import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader, trustedHTMLFromString} from '../../private';
2931

3032
let uniqueIds = 0;
3133

34+
/** Possible types for a message that can be announced by the `LiveAnnouncer`. */
35+
export type LiveAnnouncerMessage = string | SafeHtml;
36+
3237
@Injectable({providedIn: 'root'})
3338
export class LiveAnnouncer implements OnDestroy {
3439
private _ngZone = inject(NgZone);
@@ -38,6 +43,7 @@ export class LiveAnnouncer implements OnDestroy {
3843

3944
private _liveElement: HTMLElement;
4045
private _document = inject(DOCUMENT);
46+
private _sanitizer = inject(DomSanitizer);
4147
private _previousTimeout: ReturnType<typeof setTimeout>;
4248
private _currentPromise: Promise<void> | undefined;
4349
private _currentResolve: (() => void) | undefined;
@@ -54,15 +60,15 @@ export class LiveAnnouncer implements OnDestroy {
5460
* @param message Message to be announced to the screen reader.
5561
* @returns Promise that will be resolved when the message is added to the DOM.
5662
*/
57-
announce(message: string): Promise<void>;
63+
announce(message: LiveAnnouncerMessage): Promise<void>;
5864

5965
/**
6066
* Announces a message to screen readers.
6167
* @param message Message to be announced to the screen reader.
6268
* @param politeness The politeness of the announcer element.
6369
* @returns Promise that will be resolved when the message is added to the DOM.
6470
*/
65-
announce(message: string, politeness?: AriaLivePoliteness): Promise<void>;
71+
announce(message: LiveAnnouncerMessage, politeness?: AriaLivePoliteness): Promise<void>;
6672

6773
/**
6874
* Announces a message to screen readers.
@@ -72,7 +78,7 @@ export class LiveAnnouncer implements OnDestroy {
7278
* 100ms after `announce` has been called.
7379
* @returns Promise that will be resolved when the message is added to the DOM.
7480
*/
75-
announce(message: string, duration?: number): Promise<void>;
81+
announce(message: LiveAnnouncerMessage, duration?: number): Promise<void>;
7682

7783
/**
7884
* Announces a message to screen readers.
@@ -83,9 +89,13 @@ export class LiveAnnouncer implements OnDestroy {
8389
* 100ms after `announce` has been called.
8490
* @returns Promise that will be resolved when the message is added to the DOM.
8591
*/
86-
announce(message: string, politeness?: AriaLivePoliteness, duration?: number): Promise<void>;
92+
announce(
93+
message: LiveAnnouncerMessage,
94+
politeness?: AriaLivePoliteness,
95+
duration?: number,
96+
): Promise<void>;
8797

88-
announce(message: string, ...args: any[]): Promise<void> {
98+
announce(message: LiveAnnouncerMessage, ...args: any[]): Promise<void> {
8999
const defaultOptions = this._defaultOptions;
90100
let politeness: AriaLivePoliteness | undefined;
91101
let duration: number | undefined;
@@ -127,7 +137,22 @@ export class LiveAnnouncer implements OnDestroy {
127137

128138
clearTimeout(this._previousTimeout);
129139
this._previousTimeout = setTimeout(() => {
130-
this._liveElement.textContent = message;
140+
if (!message || typeof message === 'string') {
141+
this._liveElement.textContent = message;
142+
} else {
143+
const cleanMessage = this._sanitizer.sanitize(SecurityContext.HTML, message);
144+
145+
if (cleanMessage === null && (typeof ngDevMode === 'undefined' || ngDevMode)) {
146+
throw new Error(
147+
`The message provided to LiveAnnouncer was not trusted as safe HTML by ` +
148+
`Angular's DomSanitizer. Attempted message was "${message}".`,
149+
);
150+
}
151+
152+
this._liveElement.innerHTML = trustedHTMLFromString(
153+
cleanMessage || '',
154+
) as unknown as string;
155+
}
131156

132157
if (typeof duration === 'number') {
133158
this._previousTimeout = setTimeout(() => this.clear(), duration);

0 commit comments

Comments
 (0)