Skip to content

Commit 6b2a71e

Browse files
crisbetojelbourn
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 b66cd4b commit 6b2a71e

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
@@ -658,6 +658,62 @@ describe('MatDialog', () => {
658658
expect(dialog.getDialogById('pizza')).toBe(dialogRef);
659659
});
660660

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

src/lib/dialog/dialog.ts

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

7173
/** Keeps track of the currently-open dialogs. */
7274
get openDialogs(): MatDialogRef<any>[] {
@@ -96,7 +98,8 @@ export class MatDialog {
9698
private _injector: Injector,
9799
@Optional() location: Location,
98100
@Inject(MAT_DIALOG_SCROLL_STRATEGY) private _scrollStrategy,
99-
@Optional() @SkipSelf() private _parentDialog: MatDialog) {
101+
@Optional() @SkipSelf() private _parentDialog: MatDialog,
102+
private _overlayContainer: OverlayContainer) {
100103

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

133+
// If this is the first dialog that we're opening, hide all the non-overlay content.
134+
if (!this.openDialogs.length) {
135+
this._hideNonDialogContentFromAssistiveTechnology();
136+
}
137+
130138
this.openDialogs.push(dialogRef);
131139
dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef));
132140
this.afterOpen.next(dialogRef);
@@ -298,12 +306,49 @@ export class MatDialog {
298306
if (index > -1) {
299307
this.openDialogs.splice(index, 1);
300308

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

309354
/**

0 commit comments

Comments
 (0)