diff --git a/specs/Modal.spec.js b/specs/Modal.spec.js index 60836313..44dfbe2c 100644 --- a/specs/Modal.spec.js +++ b/specs/Modal.spec.js @@ -100,6 +100,92 @@ export default () => { }); }); + it("restores each app element's original aria-hidden value", () => { + withElementCollector(() => { + const visible = createHTMLElement("div"); + const hidden = createHTMLElement("div"); + const explicitFalse = createHTMLElement("div"); + const node = createHTMLElement("div"); + hidden.setAttribute("aria-hidden", "true"); + explicitFalse.setAttribute("aria-hidden", "false"); + const targets = [visible, hidden, explicitFalse]; + + ReactDOM.render(, node); + targets.forEach(el => el.getAttribute("aria-hidden").should.eql("true")); + ReactDOM.render(, node); + + should(visible.getAttribute("aria-hidden")).equal(null); + hidden.getAttribute("aria-hidden").should.eql("true"); + explicitFalse.getAttribute("aria-hidden").should.eql("false"); + ReactDOM.unmountComponentAtNode(node); + }); + }); + + it("restores overlapping app elements only after their last modal closes", () => { + withElementCollector(() => { + const first = createHTMLElement("div"); + const shared = createHTMLElement("div"); + const last = createHTMLElement("div"); + const node = createHTMLElement("div"); + shared.setAttribute("aria-hidden", "false"); + last.setAttribute("aria-hidden", "true"); + const render = (openFirst, openLast) => ReactDOM.render( +
+ + +
, + node + ); + + render(true, true); + render(false, true); + should(first.getAttribute("aria-hidden")).equal(null); + shared.getAttribute("aria-hidden").should.eql("true"); + last.getAttribute("aria-hidden").should.eql("true"); + render(false, false); + shared.getAttribute("aria-hidden").should.eql("false"); + last.getAttribute("aria-hidden").should.eql("true"); + ReactDOM.unmountComponentAtNode(node); + }); + }); + + it("restores the original target when props change before unmount", () => { + withElementCollector(() => { + const first = createHTMLElement("div"); + const second = createHTMLElement("div"); + const node = createHTMLElement("div"); + first.setAttribute("aria-hidden", "false"); + + ReactDOM.render(, node); + ReactDOM.render(, node); + ReactDOM.unmountComponentAtNode(node); + + first.getAttribute("aria-hidden").should.eql("false"); + should(second.getAttribute("aria-hidden")).equal(null); + }); + }); + + it("keeps a shared target hidden when the last-opened modal closes first", () => { + withElementCollector(() => { + const target = createHTMLElement("div"); + const node = createHTMLElement("div"); + const render = (first, second) => ReactDOM.render( +
+ + +
, + node + ); + + render(true, true); + render(true, false); + target.getAttribute("aria-hidden").should.eql("true"); + render(false, false); + should(target.getAttribute("aria-hidden")).equal(null); + ReactDOM.unmountComponentAtNode(node); + }); + }); + it("renders into the body, not in context", () => { withElementCollector(() => { const node = createHTMLElement("div"); diff --git a/src/components/ModalPortal.js b/src/components/ModalPortal.js index 7f20df67..ec8ead47 100644 --- a/src/components/ModalPortal.js +++ b/src/components/ModalPortal.js @@ -25,8 +25,6 @@ const CLASS_NAMES = { const isTabKey = event => event.code === "Tab" || event.keyCode === 9; const isEscKey = event => event.code === "Escape" || event.keyCode === 27; -let ariaHiddenInstances = 0; - export default class ModalPortal extends Component { static defaultProps = { style: { @@ -90,6 +88,7 @@ export default class ModalPortal extends Component { this.shouldClose = null; this.moveFromContentToOverlay = null; + this.ariaHiddenElements = null; } componentDidMount() { @@ -171,22 +170,15 @@ export default class ModalPortal extends Component { htmlOpenClassName ); - if (ariaHideApp) { - ariaHiddenInstances += 1; - ariaAppHider.hide(appElement); + if (ariaHideApp && !this.ariaHiddenElements) { + this.ariaHiddenElements = ariaAppHider.hide(appElement); } portalOpenInstances.register(this); } afterClose = () => { - const { - appElement, - ariaHideApp, - htmlOpenClassName, - bodyOpenClassName, - parentSelector - } = this.props; + const { htmlOpenClassName, bodyOpenClassName, parentSelector } = this.props; const parentDocument = (parentSelector && parentSelector().ownerDocument) || document; @@ -201,13 +193,9 @@ export default class ModalPortal extends Component { htmlOpenClassName ); - // Reset aria-hidden attribute if all modals have been removed - if (ariaHideApp && ariaHiddenInstances > 0) { - ariaHiddenInstances -= 1; - - if (ariaHiddenInstances === 0) { - ariaAppHider.show(appElement); - } + if (this.ariaHiddenElements) { + ariaAppHider.show(this.ariaHiddenElements); + this.ariaHiddenElements = null; } if (this.props.shouldFocusAfterRender) { diff --git a/src/helpers/ariaAppHider.js b/src/helpers/ariaAppHider.js index 2620bcad..658bebaa 100644 --- a/src/helpers/ariaAppHider.js +++ b/src/helpers/ariaAppHider.js @@ -2,10 +2,12 @@ import warning from "warning"; import { canUseDOM } from "./safeHTMLElement"; let globalElement = null; +let hiddenElements = new Map(); /* eslint-disable no-console */ /* istanbul ignore next */ export function resetState() { + hiddenElements.clear(); if (globalElement) { if (globalElement.removeAttribute) { globalElement.removeAttribute("aria-hidden"); @@ -75,14 +77,35 @@ export function validateElement(appElement) { } export function hide(appElement) { - for (let el of validateElement(appElement)) { + const elements = Array.prototype.slice.call(validateElement(appElement)); + for (let el of elements) { + const previous = hiddenElements.get(el); + if (previous) { + previous.count += 1; + } else { + hiddenElements.set(el, { + count: 1, + value: el.getAttribute("aria-hidden") + }); + } el.setAttribute("aria-hidden", "true"); } + return elements; } export function show(appElement) { for (let el of validateElement(appElement)) { - el.removeAttribute("aria-hidden"); + const previous = hiddenElements.get(el); + if (!previous) continue; + previous.count -= 1; + if (previous.count === 0) { + if (previous.value === null) { + el.removeAttribute("aria-hidden"); + } else { + el.setAttribute("aria-hidden", previous.value); + } + hiddenElements.delete(el); + } } }