Skip to content

Commit f110064

Browse files
crisbetotinayuangao
authored andcommitted
fix(dialog): hide all non-overlay content from assistive technology (#9016)
Hides all non-overlay content from assistive technology by applying `aria-hidden` to it. This prevents users from being able to move focus out of the dialog using the screen reader navigational shortcuts. Fixes #7787.
1 parent 5592404 commit f110064

File tree

2 files changed

+103
-2
lines changed

2 files changed

+103
-2
lines changed

src/lib/dialog/dialog.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,62 @@ describe('MatDialog', () => {
664664
expect(dialog.getDialogById('pizza')).toBe(dialogRef);
665665
});
666666

667+
it('should toggle `aria-hidden` on the overlay container siblings', fakeAsync(() => {
668+
const sibling = document.createElement('div');
669+
overlayContainerElement.parentNode!.appendChild(sibling);
670+
671+
const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
672+
viewContainerFixture.detectChanges();
673+
flush();
674+
675+
expect(sibling.getAttribute('aria-hidden')).toBe('true', 'Expected sibling to be hidden');
676+
expect(overlayContainerElement.hasAttribute('aria-hidden'))
677+
.toBe(false, 'Expected overlay container not to be hidden.');
678+
679+
dialogRef.close();
680+
viewContainerFixture.detectChanges();
681+
flush();
682+
683+
expect(sibling.hasAttribute('aria-hidden'))
684+
.toBe(false, 'Expected sibling to no longer be hidden.');
685+
sibling.parentNode!.removeChild(sibling);
686+
}));
687+
688+
it('should restore `aria-hidden` to the overlay container siblings on close', fakeAsync(() => {
689+
const sibling = document.createElement('div');
690+
691+
sibling.setAttribute('aria-hidden', 'true');
692+
overlayContainerElement.parentNode!.appendChild(sibling);
693+
694+
const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
695+
viewContainerFixture.detectChanges();
696+
flush();
697+
698+
expect(sibling.getAttribute('aria-hidden')).toBe('true', 'Expected sibling to be hidden.');
699+
700+
dialogRef.close();
701+
viewContainerFixture.detectChanges();
702+
flush();
703+
704+
expect(sibling.getAttribute('aria-hidden')).toBe('true', 'Expected sibling to remain hidden.');
705+
sibling.parentNode!.removeChild(sibling);
706+
}));
707+
708+
it('should not set `aria-hidden` on `aria-live` elements', fakeAsync(() => {
709+
const sibling = document.createElement('div');
710+
711+
sibling.setAttribute('aria-live', 'polite');
712+
overlayContainerElement.parentNode!.appendChild(sibling);
713+
714+
dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef});
715+
viewContainerFixture.detectChanges();
716+
flush();
717+
718+
expect(sibling.hasAttribute('aria-hidden'))
719+
.toBe(false, 'Expected live element not to be hidden.');
720+
sibling.parentNode!.removeChild(sibling);
721+
}));
722+
667723
describe('disableClose option', () => {
668724
it('should prevent closing via clicks on the backdrop', () => {
669725
dialog.open(PizzaMsg, {

src/lib/dialog/dialog.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
OverlayConfig,
1414
OverlayRef,
1515
ScrollStrategy,
16+
OverlayContainer,
1617
} from '@angular/cdk/overlay';
1718
import {ComponentPortal, ComponentType, PortalInjector, TemplatePortal} from '@angular/cdk/portal';
1819
import {Location} from '@angular/common';
@@ -68,6 +69,7 @@ export class MatDialog {
6869
private _openDialogsAtThisLevel: MatDialogRef<any>[] = [];
6970
private _afterAllClosedAtThisLevel = new Subject<void>();
7071
private _afterOpenAtThisLevel = new Subject<MatDialogRef<any>>();
72+
private _ariaHiddenElements = new Map<Element, string|null>();
7173

7274
/** Keeps track of the currently-open dialogs. */
7375
get openDialogs(): MatDialogRef<any>[] {
@@ -98,7 +100,8 @@ export class MatDialog {
98100
@Optional() location: Location,
99101
@Optional() @Inject(MAT_DIALOG_DEFAULT_OPTIONS) private _defaultOptions,
100102
@Inject(MAT_DIALOG_SCROLL_STRATEGY) private _scrollStrategy,
101-
@Optional() @SkipSelf() private _parentDialog: MatDialog) {
103+
@Optional() @SkipSelf() private _parentDialog: MatDialog,
104+
private _overlayContainer: OverlayContainer) {
102105

103106
// Close all of the dialogs when the user goes forwards/backwards in history or when the
104107
// location hash changes. Note that this usually doesn't include clicking on links (unless
@@ -129,6 +132,11 @@ export class MatDialog {
129132
const dialogRef =
130133
this._attachDialogContent<T>(componentOrTemplateRef, dialogContainer, overlayRef, config);
131134

135+
// If this is the first dialog that we're opening, hide all the non-overlay content.
136+
if (!this.openDialogs.length) {
137+
this._hideNonDialogContentFromAssistiveTechnology();
138+
}
139+
132140
this.openDialogs.push(dialogRef);
133141
dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef));
134142
this.afterOpen.next(dialogRef);
@@ -295,12 +303,49 @@ export class MatDialog {
295303
if (index > -1) {
296304
this.openDialogs.splice(index, 1);
297305

298-
// no open dialogs are left, call next on afterAllClosed Subject
306+
// If all the dialogs were closed, remove/restore the `aria-hidden`
307+
// to a the siblings and emit to the `afterAllClosed` stream.
299308
if (!this.openDialogs.length) {
309+
this._ariaHiddenElements.forEach((previousValue, element) => {
310+
if (previousValue) {
311+
element.setAttribute('aria-hidden', previousValue);
312+
} else {
313+
element.removeAttribute('aria-hidden');
314+
}
315+
});
316+
317+
this._ariaHiddenElements.clear();
300318
this._afterAllClosed.next();
301319
}
302320
}
303321
}
322+
323+
/**
324+
* Hides all of the content that isn't an overlay from assistive technology.
325+
*/
326+
private _hideNonDialogContentFromAssistiveTechnology() {
327+
const overlayContainer = this._overlayContainer.getContainerElement();
328+
329+
// Ensure that the overlay container is attached to the DOM.
330+
if (overlayContainer.parentElement) {
331+
const siblings = overlayContainer.parentElement.children;
332+
333+
for (let i = siblings.length - 1; i > -1; i--) {
334+
let sibling = siblings[i];
335+
336+
if (sibling !== overlayContainer &&
337+
sibling.nodeName !== 'SCRIPT' &&
338+
sibling.nodeName !== 'STYLE' &&
339+
!sibling.hasAttribute('aria-live')) {
340+
341+
this._ariaHiddenElements.set(sibling, sibling.getAttribute('aria-hidden'));
342+
sibling.setAttribute('aria-hidden', 'true');
343+
}
344+
}
345+
}
346+
347+
}
348+
304349
}
305350

306351
/**

0 commit comments

Comments
 (0)