Skip to content

Commit a1357a4

Browse files
authored
fix(cdk/a11y): live announcer not working with aria-modal element (#25978)
When an `aria-modal="true"` element is present, some browsers exclude all the content outside of them from the a11y tree which breaks the `LiveAnnouncer`. These changes add some logic to set an `aria-owns` on the modals so that the announcement is made. Fixes #22733.
1 parent 6c4f715 commit a1357a4

File tree

3 files changed

+91
-8
lines changed

3 files changed

+91
-8
lines changed

src/cdk/a11y/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ ng_test_library(
4949
":a11y",
5050
"//src/cdk/keycodes",
5151
"//src/cdk/observers",
52+
"//src/cdk/overlay",
5253
"//src/cdk/platform",
5354
"//src/cdk/portal",
5455
"//src/cdk/testing/private",

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

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {MutationObserverFactory} from '@angular/cdk/observers';
2+
import {Overlay} from '@angular/cdk/overlay';
3+
import {ComponentPortal} from '@angular/cdk/portal';
24
import {Component} from '@angular/core';
35
import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing';
46
import {By} from '@angular/platform-browser';
@@ -12,24 +14,24 @@ import {
1214

1315
describe('LiveAnnouncer', () => {
1416
let announcer: LiveAnnouncer;
17+
let overlay: Overlay;
1518
let ariaLiveElement: Element;
1619
let fixture: ComponentFixture<TestApp>;
1720

1821
describe('with default element', () => {
1922
beforeEach(() =>
2023
TestBed.configureTestingModule({
2124
imports: [A11yModule],
22-
declarations: [TestApp],
25+
declarations: [TestApp, TestModal],
2326
}),
2427
);
2528

26-
beforeEach(fakeAsync(
27-
inject([LiveAnnouncer], (la: LiveAnnouncer) => {
28-
announcer = la;
29-
ariaLiveElement = getLiveElement();
30-
fixture = TestBed.createComponent(TestApp);
31-
}),
32-
));
29+
beforeEach(fakeAsync(() => {
30+
overlay = TestBed.inject(Overlay);
31+
announcer = TestBed.inject(LiveAnnouncer);
32+
ariaLiveElement = getLiveElement();
33+
fixture = TestBed.createComponent(TestApp);
34+
}));
3335

3436
it('should correctly update the announce text', fakeAsync(() => {
3537
let buttonElement = fixture.debugElement.query(By.css('button'))!.nativeElement;
@@ -172,6 +174,49 @@ describe('LiveAnnouncer', () => {
172174
// Since we're testing whether the timeouts were flushed, we don't need any
173175
// assertions here. `fakeAsync` will fail the test if a timer was left over.
174176
}));
177+
178+
it('should add aria-owns to open aria-modal elements', fakeAsync(() => {
179+
const portal = new ComponentPortal(TestModal);
180+
const overlayRef = overlay.create();
181+
const componentRef = overlayRef.attach(portal);
182+
const modal = componentRef.location.nativeElement;
183+
fixture.detectChanges();
184+
185+
expect(ariaLiveElement.id).toBeTruthy();
186+
expect(modal.hasAttribute('aria-owns')).toBe(false);
187+
188+
announcer.announce('Hey Google', 'assertive');
189+
tick(100);
190+
expect(modal.getAttribute('aria-owns')).toBe(ariaLiveElement.id);
191+
192+
// Verify that the ID isn't duplicated.
193+
announcer.announce('Hey Google again', 'assertive');
194+
tick(100);
195+
expect(modal.getAttribute('aria-owns')).toBe(ariaLiveElement.id);
196+
}));
197+
198+
it('should expand aria-owns of open aria-modal elements', fakeAsync(() => {
199+
const portal = new ComponentPortal(TestModal);
200+
const overlayRef = overlay.create();
201+
const componentRef = overlayRef.attach(portal);
202+
const modal = componentRef.location.nativeElement;
203+
fixture.detectChanges();
204+
205+
componentRef.instance.ariaOwns = 'foo bar';
206+
componentRef.changeDetectorRef.detectChanges();
207+
208+
expect(ariaLiveElement.id).toBeTruthy();
209+
expect(modal.getAttribute('aria-owns')).toBe('foo bar');
210+
211+
announcer.announce('Hey Google', 'assertive');
212+
tick(100);
213+
expect(modal.getAttribute('aria-owns')).toBe(`foo bar ${ariaLiveElement.id}`);
214+
215+
// Verify that the ID isn't duplicated.
216+
announcer.announce('Hey Google again', 'assertive');
217+
tick(100);
218+
expect(modal.getAttribute('aria-owns')).toBe(`foo bar ${ariaLiveElement.id}`);
219+
}));
175220
});
176221

177222
describe('with a custom element', () => {
@@ -359,6 +404,11 @@ class TestApp {
359404
}
360405
}
361406

407+
@Component({template: '', host: {'[attr.aria-owns]': 'ariaOwns', 'aria-modal': 'true'}})
408+
class TestModal {
409+
ariaOwns: string | null = null;
410+
}
411+
362412
@Component({
363413
template: `
364414
<div

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
LIVE_ANNOUNCER_DEFAULT_OPTIONS,
2727
} from './live-announcer-tokens';
2828

29+
let uniqueIds = 0;
30+
2931
@Injectable({providedIn: 'root'})
3032
export class LiveAnnouncer implements OnDestroy {
3133
private _liveElement: HTMLElement;
@@ -111,6 +113,10 @@ export class LiveAnnouncer implements OnDestroy {
111113
// TODO: ensure changing the politeness works on all environments we support.
112114
this._liveElement.setAttribute('aria-live', politeness);
113115

116+
if (this._liveElement.id) {
117+
this._exposeAnnouncerToModals(this._liveElement.id);
118+
}
119+
114120
// This 100ms timeout is necessary for some browser + screen-reader combinations:
115121
// - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout.
116122
// - With Chrome and IE11 with NVDA or JAWS, a repeated (identical) message won't be read a
@@ -171,11 +177,37 @@ export class LiveAnnouncer implements OnDestroy {
171177

172178
liveEl.setAttribute('aria-atomic', 'true');
173179
liveEl.setAttribute('aria-live', 'polite');
180+
liveEl.id = `cdk-live-announcer-${uniqueIds++}`;
174181

175182
this._document.body.appendChild(liveEl);
176183

177184
return liveEl;
178185
}
186+
187+
/**
188+
* Some browsers won't expose the accessibility node of the live announcer element if there is an
189+
* `aria-modal` and the live announcer is outside of it. This method works around the issue by
190+
* pointing the `aria-owns` of all modals to the live announcer element.
191+
*/
192+
private _exposeAnnouncerToModals(id: string) {
193+
// Note that the selector here is limited to CDK overlays at the moment in order to reduce the
194+
// section of the DOM we need to look through. This should cover all the cases we support, but
195+
// the selector can be expanded if it turns out to be too narrow.
196+
const modals = this._document.querySelectorAll(
197+
'body > .cdk-overlay-container [aria-modal="true"]',
198+
);
199+
200+
for (let i = 0; i < modals.length; i++) {
201+
const modal = modals[i];
202+
const ariaOwns = modal.getAttribute('aria-owns');
203+
204+
if (!ariaOwns) {
205+
modal.setAttribute('aria-owns', id);
206+
} else if (ariaOwns.indexOf(id) === -1) {
207+
modal.setAttribute('aria-owns', ariaOwns + ' ' + id);
208+
}
209+
}
210+
}
179211
}
180212

181213
/**

0 commit comments

Comments
 (0)