From 3b401c2eeeb3967059205a32daea2077f24aba17 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Mon, 18 Dec 2017 20:08:36 +0100 Subject: [PATCH] fix(dialog): hide all non-overlay content from assistive technology 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. --- src/lib/dialog/dialog.spec.ts | 56 +++++++++++++++++++++++++++++++++++ src/lib/dialog/dialog.ts | 44 +++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts index 100ce79b9417..b0f96aa86b43 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -658,6 +658,62 @@ describe('MatDialog', () => { expect(dialog.getDialogById('pizza')).toBe(dialogRef); }); + it('should toggle `aria-hidden` on the overlay container siblings', fakeAsync(() => { + const sibling = document.createElement('div'); + overlayContainerElement.parentNode!.appendChild(sibling); + + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.getAttribute('aria-hidden')).toBe('true', 'Expected sibling to be hidden'); + expect(overlayContainerElement.hasAttribute('aria-hidden')) + .toBe(false, 'Expected overlay container not to be hidden.'); + + dialogRef.close(); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.hasAttribute('aria-hidden')) + .toBe(false, 'Expected sibling to no longer be hidden.'); + sibling.parentNode!.removeChild(sibling); + })); + + it('should restore `aria-hidden` to the overlay container siblings on close', fakeAsync(() => { + const sibling = document.createElement('div'); + + sibling.setAttribute('aria-hidden', 'true'); + overlayContainerElement.parentNode!.appendChild(sibling); + + const dialogRef = dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.getAttribute('aria-hidden')).toBe('true', 'Expected sibling to be hidden.'); + + dialogRef.close(); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.getAttribute('aria-hidden')).toBe('true', 'Expected sibling to remain hidden.'); + sibling.parentNode!.removeChild(sibling); + })); + + it('should not set `aria-hidden` on `aria-live` elements', fakeAsync(() => { + const sibling = document.createElement('div'); + + sibling.setAttribute('aria-live', 'polite'); + overlayContainerElement.parentNode!.appendChild(sibling); + + dialog.open(PizzaMsg, {viewContainerRef: testViewContainerRef}); + viewContainerFixture.detectChanges(); + flush(); + + expect(sibling.hasAttribute('aria-hidden')) + .toBe(false, 'Expected live element not to be hidden.'); + sibling.parentNode!.removeChild(sibling); + })); + describe('disableClose option', () => { it('should prevent closing via clicks on the backdrop', () => { dialog.open(PizzaMsg, { diff --git a/src/lib/dialog/dialog.ts b/src/lib/dialog/dialog.ts index ab9c92fe46ce..ba0d0e13e497 100644 --- a/src/lib/dialog/dialog.ts +++ b/src/lib/dialog/dialog.ts @@ -14,6 +14,7 @@ import { OverlayConfig, OverlayRef, ScrollStrategy, + OverlayContainer, } from '@angular/cdk/overlay'; import {ComponentPortal, ComponentType, PortalInjector, TemplatePortal} from '@angular/cdk/portal'; import {Location} from '@angular/common'; @@ -67,6 +68,7 @@ export class MatDialog { private _openDialogsAtThisLevel: MatDialogRef[] = []; private _afterAllClosedAtThisLevel = new Subject(); private _afterOpenAtThisLevel = new Subject>(); + private _ariaHiddenElements = new Map(); /** Keeps track of the currently-open dialogs. */ get openDialogs(): MatDialogRef[] { @@ -96,7 +98,8 @@ export class MatDialog { private _injector: Injector, @Optional() location: Location, @Inject(MAT_DIALOG_SCROLL_STRATEGY) private _scrollStrategy, - @Optional() @SkipSelf() private _parentDialog: MatDialog) { + @Optional() @SkipSelf() private _parentDialog: MatDialog, + private _overlayContainer: OverlayContainer) { // Close all of the dialogs when the user goes forwards/backwards in history or when the // location hash changes. Note that this usually doesn't include clicking on links (unless @@ -127,6 +130,11 @@ export class MatDialog { const dialogRef = this._attachDialogContent(componentOrTemplateRef, dialogContainer, overlayRef, config); + // If this is the first dialog that we're opening, hide all the non-overlay content. + if (!this.openDialogs.length) { + this._hideNonDialogContentFromAssistiveTechnology(); + } + this.openDialogs.push(dialogRef); dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef)); this.afterOpen.next(dialogRef); @@ -298,12 +306,44 @@ export class MatDialog { if (index > -1) { this.openDialogs.splice(index, 1); - // no open dialogs are left, call next on afterAllClosed Subject + // If all the dialogs were closed, remove/restore the `aria-hidden` + // to a the siblings and emit to the `afterAllClosed` stream. if (!this.openDialogs.length) { + this._ariaHiddenElements.forEach((previousValue, element) => { + if (previousValue) { + element.setAttribute('aria-hidden', previousValue); + } else { + element.removeAttribute('aria-hidden'); + } + }); + + this._ariaHiddenElements.clear(); this._afterAllClosed.next(); } } } + + /** + * Hides all of the content that isn't an overlay from assistive technology. + */ + private _hideNonDialogContentFromAssistiveTechnology() { + const overlayContainer = this._overlayContainer.getContainerElement(); + const siblings = overlayContainer.parentElement!.children; + + for (let i = siblings.length - 1; i > -1; i--) { + let sibling = siblings[i]; + + if (sibling !== overlayContainer && + sibling.nodeName !== 'SCRIPT' && + sibling.nodeName !== 'STYLE' && + !sibling.hasAttribute('aria-live')) { + + this._ariaHiddenElements.set(sibling, sibling.getAttribute('aria-hidden')); + sibling.setAttribute('aria-hidden', 'true'); + } + } + } + } /**