Skip to content

Commit

Permalink
fix(dialog): hide all non-overlay content from assistive technology
Browse files Browse the repository at this point in the history
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 angular#7787.
  • Loading branch information
crisbeto committed Dec 21, 2017
1 parent 2436acd commit 873f708
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 2 deletions.
56 changes: 56 additions & 0 deletions src/lib/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
49 changes: 47 additions & 2 deletions src/lib/dialog/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,7 @@ export class MatDialog {
private _openDialogsAtThisLevel: MatDialogRef<any>[] = [];
private _afterAllClosedAtThisLevel = new Subject<void>();
private _afterOpenAtThisLevel = new Subject<MatDialogRef<any>>();
private _ariaHiddenElements = new Map<Element, string|null>();

/** Keeps track of the currently-open dialogs. */
get openDialogs(): MatDialogRef<any>[] {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -127,6 +130,11 @@ export class MatDialog {
const dialogRef =
this._attachDialogContent<T>(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);
Expand Down Expand Up @@ -298,12 +306,49 @@ 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();

// Ensure that the overlay container is attached to the DOM.
if (overlayContainer.parentElement) {
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');
}
}
}

}

}

/**
Expand Down

0 comments on commit 873f708

Please sign in to comment.