From 3b80628eca7c49edebfd489557e1edea91b45bd7 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 21 Nov 2025 07:17:53 +0100 Subject: [PATCH] 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. --- goldens/cdk/a11y/index.api.md | 12 ++++-- src/cdk/a11y/BUILD.bazel | 1 + .../live-announcer/live-announcer.spec.ts | 21 ++++++++-- src/cdk/a11y/live-announcer/live-announcer.ts | 39 +++++++++++++++---- 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/goldens/cdk/a11y/index.api.md b/goldens/cdk/a11y/index.api.md index 0a68560adbda..55978ba40ffe 100644 --- a/goldens/cdk/a11y/index.api.md +++ b/goldens/cdk/a11y/index.api.md @@ -18,6 +18,7 @@ import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { Provider } from '@angular/core'; import { QueryList } from '@angular/core'; +import { SafeHtml } from '@angular/platform-browser'; import { Signal } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { Subject } from 'rxjs'; @@ -404,10 +405,10 @@ export const LIVE_ANNOUNCER_ELEMENT_TOKEN: InjectionToken; // @public (undocumented) export class LiveAnnouncer implements OnDestroy { constructor(...args: unknown[]); - announce(message: string): Promise; - announce(message: string, politeness?: AriaLivePoliteness): Promise; - announce(message: string, duration?: number): Promise; - announce(message: string, politeness?: AriaLivePoliteness, duration?: number): Promise; + announce(message: LiveAnnouncerMessage): Promise; + announce(message: LiveAnnouncerMessage, politeness?: AriaLivePoliteness): Promise; + announce(message: LiveAnnouncerMessage, duration?: number): Promise; + announce(message: LiveAnnouncerMessage, politeness?: AriaLivePoliteness, duration?: number): Promise; clear(): void; // (undocumented) ngOnDestroy(): void; @@ -423,6 +424,9 @@ export interface LiveAnnouncerDefaultOptions { politeness?: AriaLivePoliteness; } +// @public +export type LiveAnnouncerMessage = string | SafeHtml; + // @public @deprecated export const MESSAGES_CONTAINER_ID = "cdk-describedby-message-container"; diff --git a/src/cdk/a11y/BUILD.bazel b/src/cdk/a11y/BUILD.bazel index 1ae4f86e5f44..29c2ab199e75 100644 --- a/src/cdk/a11y/BUILD.bazel +++ b/src/cdk/a11y/BUILD.bazel @@ -18,6 +18,7 @@ ng_project( deps = [ "//:node_modules/@angular/common", "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", "//:node_modules/rxjs", "//src:dev_mode_types", "//src/cdk/coercion", diff --git a/src/cdk/a11y/live-announcer/live-announcer.spec.ts b/src/cdk/a11y/live-announcer/live-announcer.spec.ts index 2ebbfa266c13..fd88a16f9a12 100644 --- a/src/cdk/a11y/live-announcer/live-announcer.spec.ts +++ b/src/cdk/a11y/live-announcer/live-announcer.spec.ts @@ -2,9 +2,9 @@ import {MutationObserverFactory} from '../../observers'; import {ComponentPortal} from '../../portal'; import {Component, inject, Injector} from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, flush, tick} from '@angular/core/testing'; -import {By} from '@angular/platform-browser'; +import {By, DomSanitizer} from '@angular/platform-browser'; import {A11yModule} from '../index'; -import {LiveAnnouncer} from './live-announcer'; +import {LiveAnnouncer, LiveAnnouncerMessage} from './live-announcer'; import { AriaLivePoliteness, LIVE_ANNOUNCER_DEFAULT_OPTIONS, @@ -202,6 +202,19 @@ describe('LiveAnnouncer', () => { tick(100); expect(modal.getAttribute('aria-owns')).toBe(`foo bar ${ariaLiveElement.id}`); })); + + it('should be able to announce safe HTML', fakeAsync(() => { + const sanitizer = TestBed.inject(DomSanitizer); + const message = sanitizer.bypassSecurityTrustHtml( + 'Bonjour', + ); + fixture.componentInstance.announce(message); + + // This flushes our 100ms timeout for the screenreaders. + tick(100); + + expect(ariaLiveElement.querySelector('.message')?.textContent).toBe('Bonjour'); + })); }); describe('with a custom element', () => { @@ -378,13 +391,13 @@ function getLiveElement(): Element { } @Component({ - template: ``, + template: ``, imports: [A11yModule], }) class TestApp { live = inject(LiveAnnouncer); - announceText(message: string) { + announce(message: LiveAnnouncerMessage) { this.live.announce(message); } } diff --git a/src/cdk/a11y/live-announcer/live-announcer.ts b/src/cdk/a11y/live-announcer/live-announcer.ts index b18a86f98576..1e9188186c4c 100644 --- a/src/cdk/a11y/live-announcer/live-announcer.ts +++ b/src/cdk/a11y/live-announcer/live-announcer.ts @@ -17,7 +17,9 @@ import { OnDestroy, inject, DOCUMENT, + SecurityContext, } from '@angular/core'; +import {DomSanitizer, SafeHtml} from '@angular/platform-browser'; import {Subscription} from 'rxjs'; import { AriaLivePoliteness, @@ -25,10 +27,13 @@ import { LIVE_ANNOUNCER_ELEMENT_TOKEN, LIVE_ANNOUNCER_DEFAULT_OPTIONS, } from './live-announcer-tokens'; -import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '../../private'; +import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader, trustedHTMLFromString} from '../../private'; let uniqueIds = 0; +/** Possible types for a message that can be announced by the `LiveAnnouncer`. */ +export type LiveAnnouncerMessage = string | SafeHtml; + @Injectable({providedIn: 'root'}) export class LiveAnnouncer implements OnDestroy { private _ngZone = inject(NgZone); @@ -38,6 +43,7 @@ export class LiveAnnouncer implements OnDestroy { private _liveElement: HTMLElement; private _document = inject(DOCUMENT); + private _sanitizer = inject(DomSanitizer); private _previousTimeout: ReturnType; private _currentPromise: Promise | undefined; private _currentResolve: (() => void) | undefined; @@ -54,7 +60,7 @@ export class LiveAnnouncer implements OnDestroy { * @param message Message to be announced to the screen reader. * @returns Promise that will be resolved when the message is added to the DOM. */ - announce(message: string): Promise; + announce(message: LiveAnnouncerMessage): Promise; /** * Announces a message to screen readers. @@ -62,7 +68,7 @@ export class LiveAnnouncer implements OnDestroy { * @param politeness The politeness of the announcer element. * @returns Promise that will be resolved when the message is added to the DOM. */ - announce(message: string, politeness?: AriaLivePoliteness): Promise; + announce(message: LiveAnnouncerMessage, politeness?: AriaLivePoliteness): Promise; /** * Announces a message to screen readers. @@ -72,7 +78,7 @@ export class LiveAnnouncer implements OnDestroy { * 100ms after `announce` has been called. * @returns Promise that will be resolved when the message is added to the DOM. */ - announce(message: string, duration?: number): Promise; + announce(message: LiveAnnouncerMessage, duration?: number): Promise; /** * Announces a message to screen readers. @@ -83,9 +89,13 @@ export class LiveAnnouncer implements OnDestroy { * 100ms after `announce` has been called. * @returns Promise that will be resolved when the message is added to the DOM. */ - announce(message: string, politeness?: AriaLivePoliteness, duration?: number): Promise; + announce( + message: LiveAnnouncerMessage, + politeness?: AriaLivePoliteness, + duration?: number, + ): Promise; - announce(message: string, ...args: any[]): Promise { + announce(message: LiveAnnouncerMessage, ...args: any[]): Promise { const defaultOptions = this._defaultOptions; let politeness: AriaLivePoliteness | undefined; let duration: number | undefined; @@ -127,7 +137,22 @@ export class LiveAnnouncer implements OnDestroy { clearTimeout(this._previousTimeout); this._previousTimeout = setTimeout(() => { - this._liveElement.textContent = message; + if (!message || typeof message === 'string') { + this._liveElement.textContent = message; + } else { + const cleanMessage = this._sanitizer.sanitize(SecurityContext.HTML, message); + + if (cleanMessage === null && (typeof ngDevMode === 'undefined' || ngDevMode)) { + throw new Error( + `The message provided to LiveAnnouncer was not trusted as safe HTML by ` + + `Angular's DomSanitizer. Attempted message was "${message}".`, + ); + } + + this._liveElement.innerHTML = trustedHTMLFromString( + cleanMessage || '', + ) as unknown as string; + } if (typeof duration === 'number') { this._previousTimeout = setTimeout(() => this.clear(), duration);