Skip to content

Commit 75b373b

Browse files
authored
feat(cdk/a11y): allow safe HTML to be passed to live announcer (angular#32412)
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 angular#31835.
1 parent b6e329a commit 75b373b

File tree

6 files changed

+52
-15
lines changed

6 files changed

+52
-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

goldens/cdk/private/index.api.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
55
```ts
66

7+
import { DomSanitizer } from '@angular/platform-browser';
78
import * as i0 from '@angular/core';
9+
import { SafeHtml } from '@angular/platform-browser';
810
import { Type } from '@angular/core';
911

1012
// @public
@@ -16,6 +18,9 @@ export class _CdkPrivateStyleLoader {
1618
static ɵprov: i0.ɵɵInjectableDeclaration<_CdkPrivateStyleLoader>;
1719
}
1820

21+
// @public
22+
export function _setInnerHtml(element: HTMLElement, html: SafeHtml, sanitizer: DomSanitizer): void;
23+
1924
// @public (undocumented)
2025
export interface TrustedHTML {
2126
// (undocumented)

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: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,21 @@ import {
1818
inject,
1919
DOCUMENT,
2020
} from '@angular/core';
21+
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
2122
import {Subscription} from 'rxjs';
2223
import {
2324
AriaLivePoliteness,
2425
LiveAnnouncerDefaultOptions,
2526
LIVE_ANNOUNCER_ELEMENT_TOKEN,
2627
LIVE_ANNOUNCER_DEFAULT_OPTIONS,
2728
} from './live-announcer-tokens';
28-
import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '../../private';
29+
import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader, _setInnerHtml} from '../../private';
2930

3031
let uniqueIds = 0;
3132

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

3943
private _liveElement: HTMLElement;
4044
private _document = inject(DOCUMENT);
45+
private _sanitizer = inject(DomSanitizer);
4146
private _previousTimeout: ReturnType<typeof setTimeout>;
4247
private _currentPromise: Promise<void> | undefined;
4348
private _currentResolve: (() => void) | undefined;
@@ -54,15 +59,15 @@ export class LiveAnnouncer implements OnDestroy {
5459
* @param message Message to be announced to the screen reader.
5560
* @returns Promise that will be resolved when the message is added to the DOM.
5661
*/
57-
announce(message: string): Promise<void>;
62+
announce(message: LiveAnnouncerMessage): Promise<void>;
5863

5964
/**
6065
* Announces a message to screen readers.
6166
* @param message Message to be announced to the screen reader.
6267
* @param politeness The politeness of the announcer element.
6368
* @returns Promise that will be resolved when the message is added to the DOM.
6469
*/
65-
announce(message: string, politeness?: AriaLivePoliteness): Promise<void>;
70+
announce(message: LiveAnnouncerMessage, politeness?: AriaLivePoliteness): Promise<void>;
6671

6772
/**
6873
* Announces a message to screen readers.
@@ -72,7 +77,7 @@ export class LiveAnnouncer implements OnDestroy {
7277
* 100ms after `announce` has been called.
7378
* @returns Promise that will be resolved when the message is added to the DOM.
7479
*/
75-
announce(message: string, duration?: number): Promise<void>;
80+
announce(message: LiveAnnouncerMessage, duration?: number): Promise<void>;
7681

7782
/**
7883
* Announces a message to screen readers.
@@ -83,9 +88,13 @@ export class LiveAnnouncer implements OnDestroy {
8388
* 100ms after `announce` has been called.
8489
* @returns Promise that will be resolved when the message is added to the DOM.
8590
*/
86-
announce(message: string, politeness?: AriaLivePoliteness, duration?: number): Promise<void>;
91+
announce(
92+
message: LiveAnnouncerMessage,
93+
politeness?: AriaLivePoliteness,
94+
duration?: number,
95+
): Promise<void>;
8796

88-
announce(message: string, ...args: any[]): Promise<void> {
97+
announce(message: LiveAnnouncerMessage, ...args: any[]): Promise<void> {
8998
const defaultOptions = this._defaultOptions;
9099
let politeness: AriaLivePoliteness | undefined;
91100
let duration: number | undefined;
@@ -127,7 +136,11 @@ export class LiveAnnouncer implements OnDestroy {
127136

128137
clearTimeout(this._previousTimeout);
129138
this._previousTimeout = setTimeout(() => {
130-
this._liveElement.textContent = message;
139+
if (!message || typeof message === 'string') {
140+
this._liveElement.textContent = message;
141+
} else {
142+
_setInnerHtml(this._liveElement, message, this._sanitizer);
143+
}
131144

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

src/cdk/private/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
export * from './style-loader';
1010
export * from './visually-hidden/visually-hidden';
1111
export {TrustedHTML, trustedHTMLFromString} from './trusted-types';
12+
export {_setInnerHtml} from './inner-html';

0 commit comments

Comments
 (0)