Skip to content

Commit dd52f76

Browse files
committed
feat(live-announcer): add ability to clear live element
Currently when we announce a message, we leave the element in place, however this can cause the element to be read out again when the user continues navigating using the arrow keys. These changes add an API where the consumer can clear the element manually or after a duration. Doing this automatically is tricky, because we'd have to make a lot of assumptions that might not be correct in all cases or may break some screen readers. These are some alternatives that were considered: * Removing the element from the a11y tree via `aria-hidden` after the screen reader has started announcing it. This works, but because of the politeness, we can't know exactly when the screen reader will announce the text, which could end up hiding it too early. * Clearing the element on the next `keydown`/`click`/`focus`. This could be flaky and might end up interrupting the screen reader when it doesn't have to (e.g. clicking on a non-focusable element). * Moving the element to a different place in the DOM (e.g. prepending it to the `body` instead of appending). This works, but we'd have to keep moving the element based on the focus direction. Fixes #11991.
1 parent 2e4a511 commit dd52f76

File tree

4 files changed

+94
-6
lines changed

4 files changed

+94
-6
lines changed

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ describe('LiveAnnouncer', () => {
6060
expect(ariaLiveElement.getAttribute('aria-live')).toBe('polite');
6161
}));
6262

63+
it('should be able to clear out the aria-live element manually', fakeAsync(() => {
64+
announcer.announce('Hey Google');
65+
tick(100);
66+
expect(ariaLiveElement.textContent).toBe('Hey Google');
67+
68+
announcer.clear();
69+
expect(ariaLiveElement.textContent).toBeFalsy();
70+
}));
71+
72+
it('should be able to clear out the aria-live element by setting a duration', fakeAsync(() => {
73+
announcer.announce('Hey Google', 2000);
74+
tick(100);
75+
expect(ariaLiveElement.textContent).toBe('Hey Google');
76+
77+
tick(2000);
78+
expect(ariaLiveElement.textContent).toBeFalsy();
79+
}));
80+
81+
it('should clear the duration of previous messages when announcing a new one', fakeAsync(() => {
82+
announcer.announce('Hey Google', 2000);
83+
tick(100);
84+
expect(ariaLiveElement.textContent).toBe('Hey Google');
85+
86+
announcer.announce('Hello there');
87+
tick(2500);
88+
expect(ariaLiveElement.textContent).toBe('Hello there');
89+
}));
90+
6391
it('should remove the aria-live element from the DOM on destroy', fakeAsync(() => {
6492
announcer.announce('Hey Google');
6593

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

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export type AriaLivePoliteness = 'off' | 'polite' | 'assertive';
3131
export class LiveAnnouncer implements OnDestroy {
3232
private readonly _liveElement: HTMLElement;
3333
private _document: Document;
34+
private _previousMessageTimeout: number;
3435

3536
constructor(
3637
@Optional() @Inject(LIVE_ANNOUNCER_ELEMENT_TOKEN) elementToken: any,
@@ -46,15 +47,55 @@ export class LiveAnnouncer implements OnDestroy {
4647

4748
/**
4849
* Announces a message to screenreaders.
49-
* @param message Message to be announced to the screenreader
50-
* @param politeness The politeness of the announcer element
50+
* @param message Message to be announced to the screenreader.
5151
* @returns Promise that will be resolved when the message is added to the DOM.
5252
*/
53-
announce(message: string, politeness: AriaLivePoliteness = 'polite'): Promise<void> {
54-
this._liveElement.textContent = '';
53+
announce(message: string): Promise<void>;
54+
55+
/**
56+
* Announces a message to screenreaders.
57+
* @param message Message to be announced to the screenreader.
58+
* @param politeness The politeness of the announcer element.
59+
* @returns Promise that will be resolved when the message is added to the DOM.
60+
*/
61+
announce(message: string, politeness?: AriaLivePoliteness): Promise<void>;
62+
63+
/**
64+
* Announces a message to screenreaders.
65+
* @param message Message to be announced to the screenreader.
66+
* @param duration Time in milliseconds after which to clear out the announcer element. Note
67+
* that this takes effect after the message has been added to the DOM, which can be up to
68+
* 100ms after `announce` has been called.
69+
* @returns Promise that will be resolved when the message is added to the DOM.
70+
*/
71+
announce(message: string, duration?: number): Promise<void>;
72+
73+
/**
74+
* Announces a message to screenreaders.
75+
* @param message Message to be announced to the screenreader.
76+
* @param politeness The politeness of the announcer element.
77+
* @param duration Time in milliseconds after which to clear out the announcer element. Note
78+
* that this takes effect after the message has been added to the DOM, which can be up to
79+
* 100ms after `announce` has been called.
80+
* @returns Promise that will be resolved when the message is added to the DOM.
81+
*/
82+
announce(message: string, politeness?: AriaLivePoliteness, duration?: number): Promise<void>;
83+
84+
announce(message: string, ...args: any[]): Promise<void> {
85+
let politeness: AriaLivePoliteness;
86+
let duration: number;
87+
88+
if (args.length === 1 && typeof args[0] === 'number') {
89+
duration = args[0];
90+
} else {
91+
[politeness, duration] = args;
92+
}
93+
94+
this.clear();
95+
clearTimeout(this._previousMessageTimeout);
5596

5697
// TODO: ensure changing the politeness works on all environments we support.
57-
this._liveElement.setAttribute('aria-live', politeness);
98+
this._liveElement.setAttribute('aria-live', politeness! || 'polite');
5899

59100
// This 100ms timeout is necessary for some browser + screen-reader combinations:
60101
// - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout.
@@ -66,11 +107,26 @@ export class LiveAnnouncer implements OnDestroy {
66107
setTimeout(() => {
67108
this._liveElement.textContent = message;
68109
resolve();
110+
111+
if (typeof duration === 'number') {
112+
this._previousMessageTimeout = setTimeout(() => this.clear(), duration);
113+
}
69114
}, 100);
70115
});
71116
});
72117
}
73118

119+
/**
120+
* Clears the current text from the announcer element. Can be used to prevent
121+
* screen readers from reading the text out again while the user is going
122+
* through the page landmarks.
123+
*/
124+
clear() {
125+
if (this._liveElement) {
126+
this._liveElement.textContent = '';
127+
}
128+
}
129+
74130
ngOnDestroy() {
75131
if (this._liveElement && this._liveElement.parentNode) {
76132
this._liveElement.parentNode.removeChild(this._liveElement);

src/demo-app/live-announcer/live-announcer-demo.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@ export class LiveAnnouncerDemo {
1919
constructor(private live: LiveAnnouncer) {}
2020

2121
announceText(message: string) {
22-
this.live.announce(message);
22+
this.live.announce(message, 1500);
2323
}
2424
}

src/lib/snack-bar/snack-bar.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,10 @@ export class MatSnackBar implements OnDestroy {
208208
if (this._openedSnackBarRef == snackBarRef) {
209209
this._openedSnackBarRef = null;
210210
}
211+
212+
if (config.announcementMessage) {
213+
this._live.clear();
214+
}
211215
});
212216

213217
if (this._openedSnackBarRef) {

0 commit comments

Comments
 (0)