Skip to content

Commit

Permalink
fix(modal): add missing 'aria-modal' and 'aria-hidden' attributes
Browse files Browse the repository at this point in the history
Add missing 'aria-modal' on the modal window.
Add 'aria-hidden' on all sibling DOM branches from the modal window up to the document body

Fixes #2575
Closes #2965
  • Loading branch information
maxokorokov committed Jan 23, 2019
1 parent c01d798 commit eee0afb
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 3 deletions.
33 changes: 31 additions & 2 deletions src/modal/modal-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ import {NgbModalWindow} from './modal-window';

@Injectable({providedIn: 'root'})
export class NgbModalStack {
private _windowAttributes = ['ariaLabelledBy', 'backdrop', 'centered', 'keyboard', 'size', 'windowClass'];
private _activeWindowCmptHasChanged = new Subject();
private _ariaHiddenValues: Map<Element, string> = new Map();
private _backdropAttributes = ['backdropClass'];
private _modalRefs: NgbModalRef[] = [];
private _windowAttributes = ['ariaLabelledBy', 'backdrop', 'centered', 'keyboard', 'size', 'windowClass'];
private _windowCmpts: ComponentRef<NgbModalWindow>[] = [];
private _activeWindowCmptHasChanged = new Subject();

constructor(
private _applicationRef: ApplicationRef, private _injector: Injector, @Inject(DOCUMENT) private _document: any,
Expand All @@ -35,6 +36,8 @@ export class NgbModalStack {
if (this._windowCmpts.length) {
const activeWindowCmpt = this._windowCmpts[this._windowCmpts.length - 1];
ngbFocusTrap(activeWindowCmpt.location.nativeElement, this._activeWindowCmptHasChanged);
this._revertAriaHidden();
this._setAriaHidden(activeWindowCmpt.location.nativeElement);
}
});
}
Expand All @@ -48,6 +51,7 @@ export class NgbModalStack {
const removeBodyClass = () => {
if (!this._modalRefs.length) {
renderer.removeClass(this._document.body, 'modal-open');
this._revertAriaHidden();
}
};

Expand Down Expand Up @@ -159,6 +163,31 @@ export class NgbModalStack {
return new ContentRef([[componentRef.location.nativeElement]], componentRef.hostView, componentRef);
}

private _setAriaHidden(element: Element) {
const parent = element.parentElement;
if (parent && element !== this._document.body) {
Array.from(parent.children).forEach(sibling => {
if (sibling !== element && sibling.nodeName !== 'SCRIPT') {
this._ariaHiddenValues.set(sibling, sibling.getAttribute('aria-hidden'));
sibling.setAttribute('aria-hidden', 'true');
}
});

this._setAriaHidden(parent);
}
}

private _revertAriaHidden() {
this._ariaHiddenValues.forEach((value, element) => {
if (value) {
element.setAttribute('aria-hidden', value);
} else {
element.removeAttribute('aria-hidden');
}
});
this._ariaHiddenValues.clear();
}

private _registerModalRef(ngbModalRef: NgbModalRef) {
const unregisterModalRef = () => {
const index = this._modalRefs.indexOf(ngbModalRef);
Expand Down
1 change: 1 addition & 0 deletions src/modal/modal-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {ModalDismissReasons} from './modal-dismiss-reasons';
'tabindex': '-1',
'(keyup.esc)': 'escKey($event)',
'(click)': 'backdropClick($event)',
'[attr.aria-modal]': 'true',
'[attr.aria-labelledby]': 'ariaLabelledBy',
},
template: `
Expand Down
153 changes: 152 additions & 1 deletion src/modal/modal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,7 @@ describe('ngb-modal', () => {
});

describe('accessibility', () => {

it('should support aria-labelledby', () => {
const id = 'aria-labelledby-id';

Expand All @@ -817,6 +818,128 @@ describe('ngb-modal', () => {
fixture.detectChanges();
expect(fixture.nativeElement).not.toHaveModal();
});

it('should have aria-modal attribute', () => {
const a11yFixture = TestBed.createComponent(TestA11yComponent);
const modalInstance = a11yFixture.componentInstance.open();
a11yFixture.detectChanges();

const modalElement = <HTMLElement>document.querySelector('ngb-modal-window');
expect(modalElement.getAttribute('aria-modal')).toBe('true');

modalInstance.close();
fixture.detectChanges();
expect(fixture.nativeElement).not.toHaveModal();
});

it('should add aria-hidden attributes to siblings when attached to body', async(async() => {
const a11yFixture = TestBed.createComponent(TestA11yComponent);
const modalInstance = a11yFixture.componentInstance.open();
a11yFixture.detectChanges();

const modal = document.querySelector('ngb-modal-window');
const backdrop = document.querySelector('ngb-modal-backdrop');
const application = document.querySelector('div[ng-version]');
let ariaHidden = document.querySelectorAll('[aria-hidden]');

expect(ariaHidden.length).toBeGreaterThan(2); // 2 exist in the DOM initially
expect(document.body.hasAttribute('aria-hidden')).toBe(false);
expect(application.getAttribute('aria-hidden')).toBe('true');
expect(backdrop.getAttribute('aria-hidden')).toBe('true');
expect(modal.hasAttribute('aria-hidden')).toBe(false);

modalInstance.close();
fixture.detectChanges();
await a11yFixture.whenStable();

ariaHidden = document.querySelectorAll('[aria-hidden]');

expect(ariaHidden.length).toBe(2); // 2 exist in the DOM initially
expect(a11yFixture.nativeElement).not.toHaveModal();
}));

it('should add aria-hidden attributes to siblings when attached to a container', async(async() => {
const a11yFixture = TestBed.createComponent(TestA11yComponent);
const modalInstance = a11yFixture.componentInstance.open({container: '#container'});
a11yFixture.detectChanges();

const modal = document.querySelector('ngb-modal-window');
const backdrop = document.querySelector('ngb-modal-backdrop');
const application = document.querySelector('div[ng-version]');
const ariaRestoreTrue = document.querySelector('.to-restore-true');
const ariaRestoreFalse = document.querySelector('.to-restore-false');

expect(document.body.hasAttribute('aria-hidden')).toBe(false);
expect(application.hasAttribute('aria-hidden')).toBe(false);
expect(modal.hasAttribute('aria-hidden')).toBe(false);
expect(backdrop.getAttribute('aria-hidden')).toBe('true');
expect(ariaRestoreTrue.getAttribute('aria-hidden')).toBe('true');
expect(ariaRestoreFalse.getAttribute('aria-hidden')).toBe('true');

Array.from(document.querySelectorAll('.to-hide')).forEach(element => {
expect(element.getAttribute('aria-hidden')).toBe('true');
});

Array.from(document.querySelectorAll('.not-to-hide')).forEach(element => {
expect(element.hasAttribute('aria-hidden')).toBe(false);
});

modalInstance.close();
fixture.detectChanges();
await a11yFixture.whenStable();

const ariaHidden = document.querySelectorAll('[aria-hidden]');

expect(ariaHidden.length).toBe(2); // 2 exist in the DOM initially
expect(ariaRestoreTrue.getAttribute('aria-hidden')).toBe('true');
expect(ariaRestoreFalse.getAttribute('aria-hidden')).toBe('false');
expect(a11yFixture.nativeElement).not.toHaveModal();
}));

it('should add aria-hidden attributes with modal stacks', async(async() => {
const a11yFixture = TestBed.createComponent(TestA11yComponent);
const firstModalInstance = a11yFixture.componentInstance.open();
const secondModalInstance = a11yFixture.componentInstance.open();
a11yFixture.detectChanges();

let modals = document.querySelectorAll('ngb-modal-window');
let backdrops = document.querySelectorAll('ngb-modal-backdrop');
let ariaHidden = document.querySelectorAll('[aria-hidden]');

const hiddenElements = ariaHidden.length;
expect(hiddenElements).toBeGreaterThan(2); // 2 exist in the DOM initially

expect(modals.length).toBe(2);
expect(backdrops.length).toBe(2);

expect(modals[0].hasAttribute('aria-hidden')).toBe(true);
expect(backdrops[0].hasAttribute('aria-hidden')).toBe(true);

expect(modals[1].hasAttribute('aria-hidden')).toBe(false);
expect(backdrops[1].hasAttribute('aria-hidden')).toBe(true);

secondModalInstance.close();
fixture.detectChanges();
await a11yFixture.whenStable();

ariaHidden = document.querySelectorAll('[aria-hidden]');
expect(document.querySelectorAll('ngb-modal-window').length).toBe(1);
expect(document.querySelectorAll('ngb-modal-backdrop').length).toBe(1);

expect(ariaHidden.length).toBe(hiddenElements - 2);

expect(modals[0].hasAttribute('aria-hidden')).toBe(false);
expect(backdrops[0].hasAttribute('aria-hidden')).toBe(true);

firstModalInstance.close();
fixture.detectChanges();
await a11yFixture.whenStable();

ariaHidden = document.querySelectorAll('[aria-hidden]');

expect(ariaHidden.length).toBe(2); // 2 exist in the DOM initially
expect(a11yFixture.nativeElement).not.toHaveModal();
}));
});

});
Expand Down Expand Up @@ -967,10 +1090,38 @@ class TestComponent {
openTplIf(options?: Object) { return this.modalService.open(this.tplContentWithIf, options); }
}

@Component({
selector: 'test-a11y-cmpt',
template: `
<div class="to-hide to-restore-true" aria-hidden="true">
<div class="not-to-hide"></div>
</div>
<div class="not-to-hide">
<div class="to-hide">
<div class="not-to-hide"></div>
</div>
<div class="not-to-hide" id="container"></div>
<div class="to-hide">
<div class="not-to-hide"></div>
</div>
</div>
<div class="to-hide to-restore-false" aria-hidden="false">
<div class="not-to-hide"></div>
</div>
`
})
class TestA11yComponent {
constructor(private modalService: NgbModal) {}

open(options?: any) { return this.modalService.open('foo', options); }
}

@NgModule({
declarations: [
TestComponent, CustomInjectorCmpt, DestroyableCmpt, WithActiveModalCmpt, WithAutofocusModalCmpt,
WithFirstFocusableModalCmpt, WithSkipTabindexFirstFocusableModalCmpt
WithFirstFocusableModalCmpt, WithSkipTabindexFirstFocusableModalCmpt, TestA11yComponent
],
exports: [TestComponent, DestroyableCmpt],
imports: [CommonModule, NgbModalModule],
Expand Down

0 comments on commit eee0afb

Please sign in to comment.