From 8050773e651f633766fd4791bb76676b415b7517 Mon Sep 17 00:00:00 2001 From: Bruno Dias Date: Fri, 14 May 2021 22:00:49 -0300 Subject: [PATCH] [chore] clean up all element leaks between tests. --- specs/Modal.events.spec.js | 192 +++--- specs/Modal.spec.js | 956 +++++++++++++++-------------- specs/Modal.style.spec.js | 50 +- specs/Modal.testability.spec.js | 12 +- specs/helper.js | 127 +++- src/components/Modal.js | 10 +- src/helpers/ariaAppHider.js | 31 +- src/helpers/bodyTrap.js | 25 +- src/helpers/classList.js | 62 +- src/helpers/focusManager.js | 20 +- src/helpers/portalOpenInstances.js | 17 +- 11 files changed, 863 insertions(+), 639 deletions(-) diff --git a/specs/Modal.events.spec.js b/specs/Modal.events.spec.js index bc02c9f7..5e223288 100644 --- a/specs/Modal.events.spec.js +++ b/specs/Modal.events.spec.js @@ -11,74 +11,71 @@ import { mouseUpAt, escKeyDown, tabKeyDown, - renderModal, - emptyDOM, - unmountModal + withModal } from "./helper"; export default () => { - afterEach("Unmount modal", emptyDOM); - it("should trigger the onAfterOpen callback", () => { const afterOpenCallback = sinon.spy(); - renderModal({ isOpen: true, onAfterOpen: afterOpenCallback }); + const props = { isOpen: true, onAfterOpen: afterOpenCallback }; + withModal(props, null, () => {}); afterOpenCallback.called.should.be.ok(); }); it("should call onAfterOpen with overlay and content references", () => { const afterOpenCallback = sinon.spy(); - const modal = renderModal({ isOpen: true, onAfterOpen: afterOpenCallback }); - - sinon.assert.calledWith(afterOpenCallback, { - overlayEl: modal.portal.overlay, - contentEl: modal.portal.content + const props = { isOpen: true, onAfterOpen: afterOpenCallback }; + withModal(props, null, modal => { + sinon.assert.calledWith(afterOpenCallback, { + overlayEl: modal.portal.overlay, + contentEl: modal.portal.content + }); }); }); it("should trigger the onAfterClose callback", () => { const onAfterCloseCallback = sinon.spy(); - const modal = renderModal({ + withModal({ isOpen: true, onAfterClose: onAfterCloseCallback }); - - modal.portal.close(); - onAfterCloseCallback.called.should.be.ok(); }); it("should not trigger onAfterClose callback when unmounting a closed modal", () => { const onAfterCloseCallback = sinon.spy(); - renderModal({ isOpen: false, onAfterClose: onAfterCloseCallback }); - unmountModal(); + withModal({ isOpen: false, onAfterClose: onAfterCloseCallback }); onAfterCloseCallback.called.should.not.be.ok(); }); it("should trigger onAfterClose callback when unmounting an opened modal", () => { const onAfterCloseCallback = sinon.spy(); - renderModal({ isOpen: true, onAfterClose: onAfterCloseCallback }); - unmountModal(); + withModal({ isOpen: true, onAfterClose: onAfterCloseCallback }); onAfterCloseCallback.called.should.be.ok(); }); it("keeps focus inside the modal when child has no tabbable elements", () => { let tabPrevented = false; - const modal = renderModal({ isOpen: true }, "hello"); - const content = mcontent(modal); - document.activeElement.should.be.eql(content); - tabKeyDown(content, { - preventDefault() { - tabPrevented = true; - } + const props = { isOpen: true }; + withModal(props, "hello", modal => { + const content = mcontent(modal); + document.activeElement.should.be.eql(content); + tabKeyDown(content, { + preventDefault() { + tabPrevented = true; + } + }); + tabPrevented.should.be.eql(true); }); - tabPrevented.should.be.eql(true); }); it("handles case when child has no tabbable elements", () => { - const modal = renderModal({ isOpen: true }, "hello"); - const content = mcontent(modal); - tabKeyDown(content); - document.activeElement.should.be.eql(content); + const props = { isOpen: true }; + withModal(props, "hello", modal => { + const content = mcontent(modal); + tabKeyDown(content); + document.activeElement.should.be.eql(content); + }); }); it("traps tab in the modal on shift + tab", () => { @@ -90,39 +87,48 @@ export default () => { {bottomButton} ); - const modal = renderModal({ isOpen: true }, modalContent); - const content = mcontent(modal); - tabKeyDown(content, { shiftKey: true }); - document.activeElement.textContent.should.be.eql("bottom"); + const props = { isOpen: true }; + withModal(props, modalContent, modal => { + const content = mcontent(modal); + tabKeyDown(content, { shiftKey: true }); + document.activeElement.textContent.should.be.eql("bottom"); + }); }); describe("shouldCloseOnEsc", () => { context("when true", () => { it("should close on Esc key event", () => { const requestCloseCallback = sinon.spy(); - const modal = renderModal({ - isOpen: true, - shouldCloseOnEsc: true, - onRequestClose: requestCloseCallback - }); - escKeyDown(mcontent(modal)); - requestCloseCallback.called.should.be.ok(); - // Check if event is passed to onRequestClose callback. - const event = requestCloseCallback.getCall(0).args[0]; - event.should.be.ok(); + withModal( + { + isOpen: true, + shouldCloseOnEsc: true, + onRequestClose: requestCloseCallback + }, + null, + modal => { + escKeyDown(mcontent(modal)); + requestCloseCallback.called.should.be.ok(); + // Check if event is passed to onRequestClose callback. + const event = requestCloseCallback.getCall(0).args[0]; + event.should.be.ok(); + } + ); }); }); context("when false", () => { it("should not close on Esc key event", () => { const requestCloseCallback = sinon.spy(); - const modal = renderModal({ + const props = { isOpen: true, shouldCloseOnEsc: false, onRequestClose: requestCloseCallback + }; + withModal(props, null, modal => { + escKeyDown(mcontent(modal)); + requestCloseCallback.called.should.be.false; }); - escKeyDown(mcontent(modal)); - requestCloseCallback.called.should.be.false; }); }); }); @@ -130,80 +136,93 @@ export default () => { describe("shouldCloseOnoverlayClick", () => { it("when false, click on overlay should not close", () => { const requestCloseCallback = sinon.spy(); - const modal = renderModal({ + const props = { isOpen: true, shouldCloseOnOverlayClick: false + }; + withModal(props, null, modal => { + const overlay = moverlay(modal); + clickAt(overlay); + requestCloseCallback.called.should.not.be.ok(); }); - const overlay = moverlay(modal); - clickAt(overlay); - requestCloseCallback.called.should.not.be.ok(); }); it("when true, click on overlay must close", () => { const requestCloseCallback = sinon.spy(); - const modal = renderModal({ + const props = { isOpen: true, shouldCloseOnOverlayClick: true, onRequestClose: requestCloseCallback + }; + withModal(props, null, modal => { + clickAt(moverlay(modal)); + requestCloseCallback.called.should.be.ok(); }); - clickAt(moverlay(modal)); - requestCloseCallback.called.should.be.ok(); }); it("overlay mouse down and content mouse up, should not close", () => { const requestCloseCallback = sinon.spy(); - const modal = renderModal({ + const props = { isOpen: true, shouldCloseOnOverlayClick: true, onRequestClose: requestCloseCallback + }; + withModal(props, null, modal => { + mouseDownAt(moverlay(modal)); + mouseUpAt(mcontent(modal)); + requestCloseCallback.called.should.not.be.ok(); }); - mouseDownAt(moverlay(modal)); - mouseUpAt(mcontent(modal)); - requestCloseCallback.called.should.not.be.ok(); }); it("content mouse down and overlay mouse up, should not close", () => { const requestCloseCallback = sinon.spy(); - const modal = renderModal({ + const props = { isOpen: true, shouldCloseOnOverlayClick: true, onRequestClose: requestCloseCallback + }; + withModal(props, null, modal => { + mouseDownAt(mcontent(modal)); + mouseUpAt(moverlay(modal)); + requestCloseCallback.called.should.not.be.ok(); }); - mouseDownAt(mcontent(modal)); - mouseUpAt(moverlay(modal)); - requestCloseCallback.called.should.not.be.ok(); }); }); it("should not stop event propagation", () => { let hasPropagated = false; - const modal = renderModal({ + const props = { isOpen: true, shouldCloseOnOverlayClick: true + }; + withModal(props, null, modal => { + const propagated = () => (hasPropagated = true); + window.addEventListener("click", propagated); + const event = new MouseEvent("click", { bubbles: true }); + moverlay(modal).dispatchEvent(event); + hasPropagated.should.be.ok(); + window.removeEventListener("click", propagated); }); - window.addEventListener("click", () => { - hasPropagated = true; - }); - moverlay(modal).dispatchEvent(new MouseEvent("click", { bubbles: true })); - hasPropagated.should.be.ok(); }); it("verify event passing on overlay click", () => { const requestCloseCallback = sinon.spy(); - const modal = renderModal({ + const props = { isOpen: true, shouldCloseOnOverlayClick: true, onRequestClose: requestCloseCallback + }; + withModal(props, null, modal => { + // click the overlay + clickAt(moverlay(modal), { + // Used to test that this was the event received + fakeData: "ABC" + }); + requestCloseCallback.called.should.be.ok(); + // Check if event is passed to onRequestClose callback. + const event = requestCloseCallback.getCall(0).args[0]; + event.should.be.ok(); }); - // click the overlay - clickAt(moverlay(modal), { - // Used to test that this was the event received - fakeData: "ABC" - }); - requestCloseCallback.called.should.be.ok(); - // Check if event is passed to onRequestClose callback. - const event = requestCloseCallback.getCall(0).args[0]; - event.should.be.ok(); }); it("on nested modals, only the topmost should handle ESC key.", () => { @@ -214,7 +233,7 @@ export default () => { innerModal = ref; }; - renderModal( + withModal( { isOpen: true, onRequestClose: requestCloseCallback @@ -225,12 +244,13 @@ export default () => { ref={innerModalRef} > Test - + , + () => { + const content = mcontent(innerModal); + escKeyDown(content); + innerRequestCloseCallback.called.should.be.ok(); + requestCloseCallback.called.should.not.be.ok(); + } ); - - const content = mcontent(innerModal); - escKeyDown(content); - innerRequestCloseCallback.called.should.be.ok(); - requestCloseCallback.called.should.not.be.ok(); }); }; diff --git a/specs/Modal.spec.js b/specs/Modal.spec.js index 6131db84..47c25482 100644 --- a/specs/Modal.spec.js +++ b/specs/Modal.spec.js @@ -3,207 +3,210 @@ import should from "should"; import React, { Component } from "react"; import ReactDOM from "react-dom"; import Modal from "react-modal"; -import * as ariaAppHider from "react-modal/helpers/ariaAppHider"; import { - isBodyWithReactModalOpenClass, + setElement as ariaAppSetElement, + resetState as ariaAppHiderResetState +} from "react-modal/helpers/ariaAppHider"; +import { resetState as bodyTrapReset } from "react-modal/helpers/bodyTrap"; +import { resetState as classListReset } from "react-modal/helpers/classList"; +import { resetState as focusManagerReset } from "react-modal/helpers/focusManager"; +import { resetState as portalInstancesReset } from "react-modal/helpers/portalOpenInstances"; +import { + log, + isDocumentWithReactModalOpenClass, isHtmlWithReactModalOpenClass, htmlClassList, contentAttribute, mcontent, moverlay, escKeyDown, - renderModal, - unmountModal, - emptyDOM, - documentBodyClassList + withModal, + documentClassList, + withElementCollector, + createHTMLElement } from "./helper"; -export default () => { - afterEach("cleaned up all rendered modals", emptyDOM); +Modal.setCreateHTMLElement(createHTMLElement); - it("scopes tab navigation to the modal"); - it("focuses the last focused element when tabbing in from browser chrome"); - it("renders children [tested indirectly]"); +export default () => { + beforeEach("check for leaks", () => log("before")); + afterEach("clean up", () => ( + log("after", true), + bodyTrapReset(), + classListReset(), + focusManagerReset(), + portalInstancesReset(), + ariaAppHiderResetState() + )); it("can be open initially", () => { - const modal = renderModal({ isOpen: true }, "hello"); - mcontent(modal).should.be.ok(); + const props = { isOpen: true }; + withModal(props, "hello", modal => { + mcontent(modal).should.be.ok(); + }); }); it("can be closed initially", () => { - const modal = renderModal({}, "hello"); - should(ReactDOM.findDOMNode(mcontent(modal))).not.be.ok(); + const props = {}; + withModal(props, "hello", modal => { + should(ReactDOM.findDOMNode(mcontent(modal))).not.be.ok(); + }); }); it("doesn't render the portal if modal is closed", () => { - const modal = renderModal({}, "hello"); - should(ReactDOM.findDOMNode(modal.portal)).not.be.ok(); + const props = {}; + withModal(props, "hello", modal => { + should(ReactDOM.findDOMNode(modal.portal)).not.be.ok(); + }); }); it("has default props", () => { - const node = document.createElement("div"); - Modal.setAppElement(document.createElement("div")); - // eslint-disable-next-line react/no-render-return-value - const modal = ReactDOM.render(, node); - const props = modal.props; - props.isOpen.should.not.be.ok(); - props.ariaHideApp.should.be.ok(); - props.closeTimeoutMS.should.be.eql(0); - props.shouldFocusAfterRender.should.be.ok(); - props.shouldCloseOnOverlayClick.should.be.ok(); - props.preventScroll.should.be.false(); - ReactDOM.unmountComponentAtNode(node); - ariaAppHider.resetForTesting(); - Modal.setAppElement(document.body); // restore default + withElementCollector(() => { + // eslint-disable-next-line react/no-render-return-value + const modal = ; + const props = modal.props; + props.isOpen.should.not.be.ok(); + props.ariaHideApp.should.be.ok(); + props.closeTimeoutMS.should.be.eql(0); + props.shouldFocusAfterRender.should.be.ok(); + props.shouldCloseOnOverlayClick.should.be.ok(); + props.preventScroll.should.be.false(); + }); }); it("accepts appElement as a prop", () => { - const el = document.createElement("div"); - const node = document.createElement("div"); - ReactDOM.render(, node); - el.getAttribute("aria-hidden").should.be.eql("true"); - ReactDOM.unmountComponentAtNode(node); + withElementCollector(() => { + const el = createHTMLElement("div"); + const props = { + isOpen: true, + ariaHideApp: true, + appElement: el + }; + withModal(props, null, () => { + el.getAttribute("aria-hidden").should.be.eql("true"); + }); + }); }); it("accepts array of appElement as a prop", () => { - const el1 = document.createElement("div"); - const el2 = document.createElement("div"); - const node = document.createElement("div"); - ReactDOM.render(, node); - el1.getAttribute("aria-hidden").should.be.eql("true"); - el2.getAttribute("aria-hidden").should.be.eql("true"); - ReactDOM.unmountComponentAtNode(node); + withElementCollector(() => { + const el1 = createHTMLElement("div"); + const el2 = createHTMLElement("div"); + const node = createHTMLElement("div"); + ReactDOM.render(, node); + el1.getAttribute("aria-hidden").should.be.eql("true"); + el2.getAttribute("aria-hidden").should.be.eql("true"); + ReactDOM.unmountComponentAtNode(node); + }); }); it("renders into the body, not in context", () => { - const node = document.createElement("div"); - class App extends Component { - render() { - return ( -
- - hello - -
- ); - } - } - Modal.setAppElement(node); - ReactDOM.render(, node); - document.body - .querySelector(".ReactModalPortal") - .parentNode.should.be.eql(document.body); - ReactDOM.unmountComponentAtNode(node); + withElementCollector(() => { + const node = createHTMLElement("div"); + Modal.setAppElement(node); + ReactDOM.render(, node); + document.body + .querySelector(".ReactModalPortal") + .parentNode.should.be.eql(document.body); + ReactDOM.unmountComponentAtNode(node); + }); }); it("allow setting appElement of type string", () => { - const node = document.createElement("div"); - class App extends Component { - render() { - return ( -
- - hello - -
- ); - } - } - const appElement = "body"; - Modal.setAppElement(appElement); - ReactDOM.render(, node); - document.body - .querySelector(".ReactModalPortal") - .parentNode.should.be.eql(document.body); - ReactDOM.unmountComponentAtNode(node); + withElementCollector(() => { + const node = createHTMLElement("div"); + const appElement = "body"; + Modal.setAppElement(appElement); + ReactDOM.render(, node); + document.body + .querySelector(".ReactModalPortal") + .parentNode.should.be.eql(document.body); + ReactDOM.unmountComponentAtNode(node); + }); }); // eslint-disable-next-line max-len it("allow setting appElement of type string matching multiple elements", () => { - const el1 = document.createElement("div"); - el1.id = "id1"; - document.body.appendChild(el1); - const el2 = document.createElement("div"); - el2.id = "id2"; - document.body.appendChild(el2); - const node = document.createElement("div"); - class App extends Component { - render() { - return ( -
- - hello - -
- ); - } - } - const appElement = "#id1, #id2"; - Modal.setAppElement(appElement); - ReactDOM.render(, node); - el1.getAttribute("aria-hidden").should.be.eql("true"); - el2.getAttribute("aria-hidden").should.be.eql("true"); - ReactDOM.unmountComponentAtNode(node); - document.body.removeChild(el1); - document.body.removeChild(el2); + withElementCollector(() => { + const el1 = createHTMLElement("div"); + el1.id = "id1"; + document.body.appendChild(el1); + const el2 = createHTMLElement("div"); + el2.id = "id2"; + document.body.appendChild(el2); + const node = createHTMLElement("div"); + const appElement = "#id1, #id2"; + Modal.setAppElement(appElement); + ReactDOM.render(, node); + el1.getAttribute("aria-hidden").should.be.eql("true"); + ReactDOM.unmountComponentAtNode(node); + }); }); it("default parentSelector should be document.body.", () => { - const modal = renderModal({ isOpen: true }); - modal.props.parentSelector().should.be.eql(document.body); + const props = { isOpen: true }; + withModal(props, null, (modal) => { + modal.props.parentSelector().should.be.eql(document.body); + }); }); it("renders the modal content with a dialog aria role when provided ", () => { const child = "I am a child of Modal, and he has sent me here..."; - const modal = renderModal({ isOpen: true, role: "dialog" }, child); - contentAttribute(modal, "role").should.be.eql("dialog"); + const props = { isOpen: true, role: "dialog" }; + withModal(props, child, (modal) => { + contentAttribute(modal, "role").should.be.eql("dialog"); + }); }); // eslint-disable-next-line max-len it("renders the modal content with the default aria role when not provided", () => { const child = "I am a child of Modal, and he has sent me here..."; - const modal = renderModal({ isOpen: true }, child); - contentAttribute(modal, "role").should.be.eql("dialog"); + const props = { isOpen: true }; + withModal(props, child, modal => { + contentAttribute(modal, "role").should.be.eql("dialog"); + }); }); it("does not render the aria role when provided role with null", () => { const child = "I am a child of Modal, and he has sent me here..."; - const modal = renderModal({ isOpen: true, role: null }, child); - should(contentAttribute(modal, "role")).be.eql(null); + const props = { isOpen: true, role: null }; + withModal(props, child, modal => { + should(contentAttribute(modal, "role")).be.eql(null); + }); }); it("sets aria-label based on the contentLabel prop", () => { const child = "I am a child of Modal, and he has sent me here..."; - const modal = renderModal( + withModal( { isOpen: true, contentLabel: "Special Modal" }, - child + child, + modal => { + contentAttribute(modal, "aria-label").should.be.eql("Special Modal"); + } ); - - contentAttribute(modal, "aria-label").should.be.eql("Special Modal"); }); it("removes the portal node", () => { - renderModal({ isOpen: true }, "hello"); - unmountModal(); + const props = { isOpen: true }; + withModal(props, "hello"); should(document.querySelector(".ReactModalPortal")).not.be.ok(); }); it("removes the portal node after closeTimeoutMS", done => { const closeTimeoutMS = 100; - renderModal({ isOpen: true, closeTimeoutMS }, "hello"); function checkDOM(count) { const portal = document.querySelectorAll(".ReactModalPortal"); portal.length.should.be.eql(count); } - unmountModal(); - - // content is still mounted after modal is gone - checkDOM(1); + const props = { isOpen: true, closeTimeoutMS }; + withModal(props, "hello", () => { + checkDOM(1); + }); setTimeout(() => { // content is unmounted after specified timeout @@ -213,53 +216,53 @@ export default () => { }); it("focuses the modal content by default", () => { - const modal = renderModal({ isOpen: true }, null); - document.activeElement.should.be.eql(mcontent(modal)); + const props = { isOpen: true }; + withModal(props, null, modal => { + document.activeElement.should.be.eql(mcontent(modal)); + }); }); it("does not focus modal content if shouldFocusAfterRender is false", () => { - const modal = renderModal( + withModal( { isOpen: true, shouldFocusAfterRender: false }, - null + null, + modal => { + document.activeElement.should.not.be.eql(mcontent(modal)); + } ); - document.activeElement.should.not.be.eql(mcontent(modal)); }); it("give back focus to previous element or modal.", done => { - function cleanup() { - unmountModal(); - done(); - } - const modalA = renderModal( + withModal( { isOpen: true, className: "modal-a", - onRequestClose: cleanup - }, - null - ); - - const modalContent = mcontent(modalA); - document.activeElement.should.be.eql(modalContent); - - const modalB = renderModal( - { - isOpen: true, - className: "modal-b", - onRequestClose() { - const modalContent = mcontent(modalB); - document.activeElement.should.be.eql(mcontent(modalA)); - escKeyDown(modalContent); - document.activeElement.should.be.eql(modalContent); - } + onRequestClose: function() { done(); } }, - null + null, + modalA => { + const modalContent = mcontent(modalA); + document.activeElement.should.be.eql(modalContent); + + const modalB = withModal( + { + isOpen: true, + className: "modal-b", + onRequestClose() { + const modalContent = mcontent(modalB); + document.activeElement.should.be.eql(mcontent(modalA)); + escKeyDown(modalContent); + document.activeElement.should.be.eql(modalContent); + } + }, + null + ); + escKeyDown(modalContent); + } ); - - escKeyDown(modalContent); }); - xit("does not steel focus when a descendent is already focused", () => { + it("does not steel focus when a descendent is already focused", () => { let content; const input = ( { }} /> ); - renderModal({ isOpen: true }, input, () => { + const props = { isOpen: true }; + withModal(props, input, () => { document.activeElement.should.be.eql(content); }); }); it("supports id prop", () => { - const modal = renderModal({ isOpen: true, id: "id" }); - mcontent(modal) - .id - .should.be.eql("id"); + const props = { isOpen: true, id: "id" }; + withModal(props, null, modal => { + mcontent(modal) + .id + .should.be.eql("id"); + }); }); it("supports portalClassName", () => { - const modal = renderModal({ + const props = { isOpen: true, portalClassName: "myPortalClass" + }; + withModal(props, null, modal => { + modal.node.className.includes("myPortalClass").should.be.ok(); }); - modal.node.className.includes("myPortalClass").should.be.ok(); }); it("supports custom className", () => { - const modal = renderModal({ isOpen: true, className: "myClass" }); - mcontent(modal) - .className.includes("myClass") - .should.be.ok(); + const props = { isOpen: true, className: "myClass" }; + withModal(props, null, modal => { + mcontent(modal) + .className.includes("myClass") + .should.be.ok(); + }); }); it("supports custom overlayElement", () => { @@ -303,10 +313,11 @@ export default () => { ); - const modal = renderModal({ isOpen: true, overlayElement }); - const modalOverlay = moverlay(modal); - - modalOverlay.id.should.eql("custom"); + const props = { isOpen: true, overlayElement }; + withModal(props, null, modal => { + const modalOverlay = moverlay(modal); + modalOverlay.id.should.eql("custom"); + }); }); it("supports custom contentElement", () => { @@ -316,473 +327,486 @@ export default () => { ); - const modal = renderModal({ isOpen: true, contentElement }, "hello"); - const modalContent = mcontent(modal); - - modalContent.id.should.eql("custom"); - modalContent.textContent.should.be.eql("hello"); + const props = { isOpen: true, contentElement }; + withModal(props, "hello", modal => { + const modalContent = mcontent(modal); + modalContent.id.should.eql("custom"); + modalContent.textContent.should.be.eql("hello"); + }); }); it("supports overlayClassName", () => { - const modal = renderModal({ + const props = { isOpen: true, overlayClassName: "myOverlayClass" + }; + withModal(props, null, modal => { + moverlay(modal) + .className.includes("myOverlayClass") + .should.be.ok(); }); - moverlay(modal) - .className.includes("myOverlayClass") - .should.be.ok(); }); it("overrides content classes with custom object className", () => { - const modal = renderModal({ + const props = { isOpen: true, className: { base: "myClass", afterOpen: "myClass_after-open", beforeClose: "myClass_before-close" } + }; + withModal(props, null, modal => { + mcontent(modal).className.should.be.eql("myClass myClass_after-open"); }); - mcontent(modal).className.should.be.eql("myClass myClass_after-open"); - unmountModal(); }); it("overrides overlay classes with custom object overlayClassName", () => { - const modal = renderModal({ + const props = { isOpen: true, overlayClassName: { base: "myOverlayClass", afterOpen: "myOverlayClass_after-open", beforeClose: "myOverlayClass_before-close" } + }; + withModal(props, null, modal => { + moverlay(modal).className.should.be.eql( + "myOverlayClass myOverlayClass_after-open" + ); }); - moverlay(modal).className.should.be.eql( - "myOverlayClass myOverlayClass_after-open" - ); - unmountModal(); }); it("supports overriding react modal open class in document.body.", () => { - renderModal({ isOpen: true, bodyOpenClassName: "custom-modal-open" }); - (document.body.className.indexOf("custom-modal-open") > -1).should.be.ok(); + const props = { isOpen: true, bodyOpenClassName: "custom-modal-open" }; + withModal(props, null, () => { + (document.body.className.indexOf("custom-modal-open") > -1).should.be.ok(); + }); }); it("supports setting react modal open class in .", () => { - renderModal({ isOpen: true, htmlOpenClassName: "custom-modal-open" }); - isHtmlWithReactModalOpenClass("custom-modal-open").should.be.ok(); + const props = { isOpen: true, htmlOpenClassName: "custom-modal-open" }; + withModal(props, null, () => { + isHtmlWithReactModalOpenClass("custom-modal-open").should.be.ok(); + }); }); // eslint-disable-next-line max-len it("don't append class to document.body if modal is closed.", () => { - renderModal({ isOpen: false }); - isBodyWithReactModalOpenClass().should.not.be.ok(); + const props = { isOpen: false }; + withModal(props, null, () => { + isDocumentWithReactModalOpenClass().should.not.be.ok(); + }); }); // eslint-disable-next-line max-len it("don't append any class to document.body when bodyOpenClassName is null.", () => { - renderModal({ isOpen: true, bodyOpenClassName: null }); - documentBodyClassList().should.be.empty(); + const props = { isOpen: true, bodyOpenClassName: null }; + withModal(props, null, () => { + documentClassList().should.be.empty(); + }); }); it("don't append class to if modal is closed.", () => { - renderModal({ isOpen: false, htmlOpenClassName: "custom-modal-open" }); - isHtmlWithReactModalOpenClass().should.not.be.ok(); + const props = { isOpen: false, htmlOpenClassName: "custom-modal-open" }; + withModal(props, null, () => { + isHtmlWithReactModalOpenClass().should.not.be.ok(); + }); }); it("append class to document.body if modal is open.", () => { - renderModal({ isOpen: true }); - isBodyWithReactModalOpenClass().should.be.ok(); + const props = { isOpen: true }; + withModal(props, null, () => { + isDocumentWithReactModalOpenClass().should.be.ok(); + }); }); it("don't append class to if not defined.", () => { - renderModal({ isOpen: true }); - htmlClassList().should.be.empty(); + const props = { isOpen: true }; + withModal(props, null, () => { + htmlClassList().should.be.empty(); + }); }); // eslint-disable-next-line max-len it("removes class from document.body when unmounted without closing", () => { - renderModal({ isOpen: true }); - unmountModal(); - isBodyWithReactModalOpenClass().should.not.be.ok(); + withModal({ isOpen: true }); + isDocumentWithReactModalOpenClass().should.not.be.ok(); }); it("remove class from document.body when no modals opened", () => { - renderModal({ isOpen: true }); - renderModal({ isOpen: true }); - isBodyWithReactModalOpenClass().should.be.ok(); - unmountModal(); - isBodyWithReactModalOpenClass().should.be.ok(); - unmountModal(); - isBodyWithReactModalOpenClass().should.not.be.ok(); + const propsA = { isOpen: true }; + withModal(propsA, null, () => { + isDocumentWithReactModalOpenClass().should.be.ok(); + }); + const propsB = { isOpen: true }; + withModal(propsB, null, () => { + isDocumentWithReactModalOpenClass().should.be.ok(); + }); + isDocumentWithReactModalOpenClass().should.not.be.ok(); isHtmlWithReactModalOpenClass().should.not.be.ok(); }); it("supports adding/removing multiple document.body classes", () => { - renderModal({ + const props = { isOpen: true, bodyOpenClassName: "A B C" + }; + withModal(props, null, () => { + document.body.classList.contains("A", "B", "C").should.be.ok(); }); - document.body.classList.contains("A", "B", "C").should.be.ok(); - unmountModal(); document.body.classList.contains("A", "B", "C").should.not.be.ok(); + ; }); it("does not remove shared classes if more than one modal is open", () => { - renderModal({ + const props = { isOpen: true, bodyOpenClassName: "A" + }; + withModal(props, null, () => { + isDocumentWithReactModalOpenClass("A").should.be.ok(); + withModal({ + isOpen: true, + bodyOpenClassName: "A B" + }, null, () => { + isDocumentWithReactModalOpenClass("A B").should.be.ok(); + }); + isDocumentWithReactModalOpenClass("A").should.be.ok(); }); - renderModal({ - isOpen: true, - bodyOpenClassName: "A B" - }); - - isBodyWithReactModalOpenClass("A B").should.be.ok(); - unmountModal(); - isBodyWithReactModalOpenClass("A B").should.not.be.ok(); - isBodyWithReactModalOpenClass("A").should.be.ok(); - unmountModal(); - isBodyWithReactModalOpenClass("A").should.not.be.ok(); + isDocumentWithReactModalOpenClass("A").should.not.be.ok(); }); it("should not add classes to document.body for unopened modals", () => { - renderModal({ isOpen: true }); - isBodyWithReactModalOpenClass().should.be.ok(); - renderModal({ isOpen: false, bodyOpenClassName: "testBodyClass" }); - isBodyWithReactModalOpenClass("testBodyClass").should.not.be.ok(); + const props = { isOpen: true }; + withModal(props, null, () => { + isDocumentWithReactModalOpenClass().should.be.ok(); + }); + withModal({ isOpen: false, bodyOpenClassName: "testBodyClass" }); + isDocumentWithReactModalOpenClass("testBodyClass").should.not.be.ok(); }); it("should not remove classes from document.body if modal is closed", () => { - renderModal({ isOpen: true }); - isBodyWithReactModalOpenClass().should.be.ok(); - renderModal({ isOpen: false, bodyOpenClassName: "testBodyClass" }); - renderModal({ isOpen: false }); - isBodyWithReactModalOpenClass("testBodyClass").should.not.be.ok(); - isBodyWithReactModalOpenClass().should.be.ok(); - renderModal({ isOpen: false }); - renderModal({ isOpen: false }); - isBodyWithReactModalOpenClass().should.be.ok(); + const props = { isOpen: true }; + withModal(props, null, () => { + isDocumentWithReactModalOpenClass().should.be.ok(); + withModal({ isOpen: false, bodyOpenClassName: "testBodyClass" }, null, () => { + isDocumentWithReactModalOpenClass("testBodyClass").should.not.be.ok(); + }); + isDocumentWithReactModalOpenClass().should.be.ok(); + }); }); it("should not remove classes from if modal is closed", () => { - const modalA = renderModal({ isOpen: false }); - isHtmlWithReactModalOpenClass().should.not.be.ok(); - const modalB = renderModal({ - isOpen: true, - htmlOpenClassName: "testHtmlClass" + const props = { isOpen: false }; + withModal(props, null, () => { + isHtmlWithReactModalOpenClass().should.not.be.ok(); + withModal({ + isOpen: true, + htmlOpenClassName: "testHtmlClass" + }, null, () => { + isHtmlWithReactModalOpenClass("testHtmlClass").should.be.ok(); + }); + isHtmlWithReactModalOpenClass("testHtmlClass").should.not.be.ok(); }); - modalA.portal.close(); - isHtmlWithReactModalOpenClass("testHtmlClass").should.be.ok(); - modalB.portal.close(); - isHtmlWithReactModalOpenClass().should.not.be.ok(); - renderModal({ isOpen: false }); }); it("additional aria attributes", () => { - const modal = renderModal( + withModal( { isOpen: true, aria: { labelledby: "a" } }, - "hello" + "hello", + modal => mcontent(modal) + .getAttribute("aria-labelledby") + .should.be.eql("a") ); - mcontent(modal) - .getAttribute("aria-labelledby") - .should.be.eql("a"); - unmountModal(); }); it("additional data attributes", () => { - const modal = renderModal( + withModal( { isOpen: true, data: { background: "green" } }, - "hello" + "hello", + modal => mcontent(modal) + .getAttribute("data-background") + .should.be.eql("green") ); - mcontent(modal) - .getAttribute("data-background") - .should.be.eql("green"); - unmountModal(); }); it("additional testId attribute", () => { - const modal = renderModal({ isOpen: true, testId: "foo-bar" }, "hello"); - mcontent(modal) - .getAttribute("data-testid") - .should.be.eql("foo-bar"); - unmountModal(); + withModal( + { isOpen: true, testId: "foo-bar" }, + "hello", + modal => mcontent(modal) + .getAttribute("data-testid") + .should.be.eql("foo-bar") + ) }); it("raises an exception if the appElement selector does not match", () => { - should(() => ariaAppHider.setElement(".test")).throw(); + should(() => ariaAppSetElement(".test")).throw(); }); it("removes aria-hidden from appElement when unmounted w/o closing", () => { - const el = document.createElement("div"); - const node = document.createElement("div"); - ReactDOM.render(, node); - el.getAttribute("aria-hidden").should.be.eql("true"); - ReactDOM.unmountComponentAtNode(node); - should(el.getAttribute("aria-hidden")).not.be.ok(); + withElementCollector(() => { + const el = createHTMLElement("div"); + const node = createHTMLElement("div"); + ReactDOM.render(, node); + el.getAttribute("aria-hidden").should.be.eql("true"); + ReactDOM.unmountComponentAtNode(node); + should(el.getAttribute("aria-hidden")).not.be.ok(); + }); }); // eslint-disable-next-line max-len it("removes aria-hidden when closed and another modal with ariaHideApp set to false is open", () => { - const rootNode = document.createElement("div"); - document.body.appendChild(rootNode); - - const appElement = document.createElement("div"); - document.body.appendChild(appElement); - - Modal.setAppElement(appElement); - - const initialState = ( -
- - -
- ); - - ReactDOM.render(initialState, rootNode); - appElement.getAttribute("aria-hidden").should.be.eql("true"); - - const updatedState = ( -
- - -
- ); - - ReactDOM.render(updatedState, rootNode); - should(appElement.getAttribute("aria-hidden")).not.be.ok(); - - ReactDOM.unmountComponentAtNode(rootNode); + withElementCollector(() => { + const rootNode = createHTMLElement("div"); + const appElement = createHTMLElement("div"); + document.body.appendChild(rootNode); + document.body.appendChild(appElement); + + Modal.setAppElement(appElement); + + const initialState = ( +
+ + +
+ ); + + ReactDOM.render(initialState, rootNode); + appElement.getAttribute("aria-hidden").should.be.eql("true"); + + const updatedState = ( +
+ + +
+ ); + + ReactDOM.render(updatedState, rootNode); + should(appElement.getAttribute("aria-hidden")).not.be.ok(); + + ReactDOM.unmountComponentAtNode(rootNode); + }); }); // eslint-disable-next-line max-len it("maintains aria-hidden when closed and another modal with ariaHideApp set to true is open", () => { - const rootNode = document.createElement("div"); - document.body.appendChild(rootNode); + withElementCollector(() => { + const rootNode = createHTMLElement("div"); + document.body.appendChild(rootNode); - const appElement = document.createElement("div"); - document.body.appendChild(appElement); + const appElement = createHTMLElement("div"); + document.body.appendChild(appElement); - Modal.setAppElement(appElement); + Modal.setAppElement(appElement); - const initialState = ( -
- - -
- ); + const initialState = ( +
+ + +
+ ); - ReactDOM.render(initialState, rootNode); - appElement.getAttribute("aria-hidden").should.be.eql("true"); + ReactDOM.render(initialState, rootNode); + appElement.getAttribute("aria-hidden").should.be.eql("true"); - const updatedState = ( -
- - -
- ); + const updatedState = ( +
+ + +
+ ); - ReactDOM.render(updatedState, rootNode); - appElement.getAttribute("aria-hidden").should.be.eql("true"); + ReactDOM.render(updatedState, rootNode); + appElement.getAttribute("aria-hidden").should.be.eql("true"); - ReactDOM.unmountComponentAtNode(rootNode); + ReactDOM.unmountComponentAtNode(rootNode); + }); }); // eslint-disable-next-line max-len it("removes aria-hidden when unmounted without close and second modal with ariaHideApp=false is open", () => { - const appElement = document.createElement("div"); - document.body.appendChild(appElement); - Modal.setAppElement(appElement); - - renderModal({ isOpen: true, ariaHideApp: false, id: "test-2-modal-1" }); - should(appElement.getAttribute("aria-hidden")).not.be.ok(); - - renderModal({ isOpen: true, ariaHideApp: true, id: "test-2-modal-2" }); - appElement.getAttribute("aria-hidden").should.be.eql("true"); - - unmountModal(); - should(appElement.getAttribute("aria-hidden")).not.be.ok(); + withElementCollector(() => { + const appElement = createHTMLElement("div"); + document.body.appendChild(appElement); + Modal.setAppElement(appElement); + + const propsA = { isOpen: true, ariaHideApp: false, id: "test-2-modal-1" }; + withModal(propsA, null, () => { + should(appElement.getAttribute("aria-hidden")).not.be.ok(); + }); + + const propsB = { isOpen: true, ariaHideApp: true, id: "test-2-modal-2" }; + withModal(propsB, null, () => { + appElement.getAttribute("aria-hidden").should.be.eql("true"); + }); + + should(appElement.getAttribute("aria-hidden")).not.be.ok(); + }); }); // eslint-disable-next-line max-len it("maintains aria-hidden when unmounted without close and second modal with ariaHideApp=true is open", () => { - const appElement = document.createElement("div"); - document.body.appendChild(appElement); - Modal.setAppElement(appElement); - - renderModal({ isOpen: true, ariaHideApp: true, id: "test-3-modal-1" }); - appElement.getAttribute("aria-hidden").should.be.eql("true"); - - renderModal({ isOpen: true, ariaHideApp: true, id: "test-3-modal-2" }); - appElement.getAttribute("aria-hidden").should.be.eql("true"); - - unmountModal(); - appElement.getAttribute("aria-hidden").should.be.eql("true"); + withElementCollector(() => { + const appElement = createHTMLElement("div"); + document.body.appendChild(appElement); + Modal.setAppElement(appElement); + + const check = (tobe) => appElement.getAttribute("aria-hidden").should.be.eql(tobe); + + const props = { isOpen: true, ariaHideApp: true, id: "test-3-modal-1" }; + withModal(props, null, () => { + check("true"); + withModal({ isOpen: true, ariaHideApp: true, id: "test-3-modal-2" }, null, () => { + check("true"); + }); + check("true"); + }); + should(appElement.getAttribute("aria-hidden")).not.be.ok(); + }); }); it("adds --after-open for animations", () => { - const modal = renderModal({ isOpen: true }); - const rg = /--after-open/i; - rg.test(mcontent(modal).className).should.be.ok(); - rg.test(moverlay(modal).className).should.be.ok(); + const props = { isOpen: true }; + withModal(props, null, modal => { + const rg = /--after-open/i; + rg.test(mcontent(modal).className).should.be.ok(); + rg.test(moverlay(modal).className).should.be.ok(); + }); }); it("adds --before-close for animations", () => { const closeTimeoutMS = 50; - const modal = renderModal({ + const props = { isOpen: true, closeTimeoutMS - }); - modal.portal.closeWithTimeout(); + }; + withModal(props, null, modal => { + modal.portal.closeWithTimeout(); - const rg = /--before-close/i; - rg.test(moverlay(modal).className).should.be.ok(); - rg.test(mcontent(modal).className).should.be.ok(); + const rg = /--before-close/i; + rg.test(moverlay(modal).className).should.be.ok(); + rg.test(mcontent(modal).className).should.be.ok(); - modal.portal.closeWithoutTimeout(); + modal.portal.closeWithoutTimeout(); + }); }); it("should not be open after close with time out and reopen it", () => { - const modal = renderModal({ + const props = { isOpen: true, closeTimeoutMS: 2000, - onRequestClose() {} + onRequestClose() { } + }; + withModal(props, null, modal => { + modal.portal.closeWithTimeout(); + modal.portal.open(); + modal.portal.closeWithoutTimeout(); + modal.portal.state.isOpen.should.not.be.ok(); }); - modal.portal.closeWithTimeout(); - modal.portal.open(); - modal.portal.closeWithoutTimeout(); - modal.portal.state.isOpen.should.not.be.ok(); }); it("verify default prop of shouldCloseOnOverlayClick", () => { - const modal = renderModal({ isOpen: true }); - modal.props.shouldCloseOnOverlayClick.should.be.ok(); + const props = { isOpen: true }; + withModal(props, null, modal => { + modal.props.shouldCloseOnOverlayClick.should.be.ok(); + }); }); it("verify prop of shouldCloseOnOverlayClick", () => { const modalOpts = { isOpen: true, shouldCloseOnOverlayClick: false }; - const modal = renderModal(modalOpts); - modal.props.shouldCloseOnOverlayClick.should.not.be.ok(); + withModal(modalOpts, null, modal => { + modal.props.shouldCloseOnOverlayClick.should.not.be.ok(); + }); }); it("keeps the modal in the DOM until closeTimeoutMS elapses", done => { const closeTimeoutMS = 100; - const modal = renderModal({ isOpen: true, closeTimeoutMS }); - modal.portal.closeWithTimeout(); - - function checkDOM(count) { - const overlay = document.querySelectorAll(".ReactModal__Overlay"); - const content = document.querySelectorAll(".ReactModal__Content"); - overlay.length.should.be.eql(count); - content.length.should.be.eql(count); - } - - // content is still mounted after modal is gone - checkDOM(1); - - setTimeout(() => { - // content is unmounted after specified timeout - checkDOM(0); - done(); - }, closeTimeoutMS); - }); + const props = { isOpen: true, closeTimeoutMS }; + withModal(props, null, modal => { + modal.portal.closeWithTimeout(); - xit("shouldn't throw if forcibly unmounted during mounting", () => { - /* eslint-disable camelcase, react/prop-types */ - class Wrapper extends Component { - constructor(props) { - super(props); - this.state = { error: false }; + function checkDOM(count) { + const overlay = document.querySelectorAll(".ReactModal__Overlay"); + const content = document.querySelectorAll(".ReactModal__Content"); + overlay.length.should.be.eql(count); + content.length.should.be.eql(count); } - unstable_handleError() { - this.setState({ error: true }); - } - render() { - return this.state.error ? null :
{this.props.children}
; - } - } - /* eslint-enable camelcase, react/prop-types */ - - const Throw = () => { - throw new Error("reason"); - }; - const TestCase = () => ( - - - - - ); - - const currentDiv = document.createElement("div"); - document.body.appendChild(currentDiv); - // eslint-disable-next-line react/no-render-return-value - const mount = () => ReactDOM.render(, currentDiv); - mount.should.not.throw(); + // content is still mounted after modal is gone + checkDOM(1); - document.body.removeChild(currentDiv); + setTimeout(() => { + // content is unmounted after specified timeout + checkDOM(0); + done(); + }, closeTimeoutMS); + }); }); it("verify that portalClassName is refreshed on component update", () => { - const node = document.createElement("div"); - let modal = null; - - class App extends Component { - constructor(props) { - super(props); - this.state = { testHasChanged: false }; - } + withElementCollector(() => { + const node = createHTMLElement("div"); + let modal = null; + + class App extends Component { + constructor(props) { + super(props); + this.state = { classModifier: "" }; + } - componentDidMount() { - modal.node.className.should.be.eql("myPortalClass"); + componentDidMount() { + modal.node.className.should.be.eql("portal"); - this.setState({ - testHasChanged: true - }); - } + this.setState({ classModifier: "-modifier" }); + } - componentDidUpdate() { - modal.node.className.should.be.eql("myPortalClass-modifier"); - } + componentDidUpdate() { + modal.node.className.should.be.eql("portal-modifier"); + } - render() { - const portalClassName = - this.state.testHasChanged === true - ? "myPortalClass-modifier" - : "myPortalClass"; - - return ( -
- { - modal = modalComponent; - }} - isOpen - portalClassName={portalClassName} - > - Test - -
- ); + render() { + const { classModifier } = this.state; + const portalClassName = `portal${classModifier}`; + + return ( +
+ { + modal = modalComponent; + }} + isOpen + portalClassName={portalClassName} + > + Test + +
+ ); + } } - } - Modal.setAppElement(node); - ReactDOM.render(, node); + Modal.setAppElement(node); + ReactDOM.render(, node); + ReactDOM.unmountComponentAtNode(node); + }); }); it("use overlayRef and contentRef", () => { let overlay = null; let content = null; - renderModal({ + const props = { isOpen: true, overlayRef: node => (overlay = node), contentRef: node => (content = node) + }; + withModal(props, null, () => { + overlay.should.be.instanceOf(HTMLElement); + content.should.be.instanceOf(HTMLElement); + overlay.classList.contains("ReactModal__Overlay"); + content.classList.contains("ReactModal__Content"); }); - - overlay.should.be.instanceOf(HTMLElement); - content.should.be.instanceOf(HTMLElement); - overlay.classList.contains("ReactModal__Overlay"); - content.classList.contains("ReactModal__Content"); }); }; diff --git a/specs/Modal.style.spec.js b/specs/Modal.style.spec.js index c74d8eea..fdb171ca 100644 --- a/specs/Modal.style.spec.js +++ b/specs/Modal.style.spec.js @@ -1,46 +1,54 @@ /* eslint-env mocha */ import "should"; import Modal from "react-modal"; -import { mcontent, moverlay, renderModal, emptyDOM } from "./helper"; +import { mcontent, moverlay, withModal } from "./helper"; export default () => { - afterEach("Unmount modal", emptyDOM); - it("overrides the default styles when a custom classname is used", () => { - const modal = renderModal({ isOpen: true, className: "myClass" }); - mcontent(modal).style.top.should.be.eql(""); + const props = { isOpen: true, className: "myClass" }; + withModal(props, null, modal => { + mcontent(modal).style.top.should.be.eql(""); + }); }); it("overrides the default styles when using custom overlayClassName", () => { - const modal = renderModal({ - isOpen: true, - overlayClassName: "myOverlayClass" + const overlayClassName = "myOverlayClass"; + const props = { isOpen: true, overlayClassName }; + withModal(props, null, modal => { + moverlay(modal).style.backgroundColor.should.be.eql(""); }); - moverlay(modal).style.backgroundColor.should.be.eql(""); }); it("supports adding style to the modal contents", () => { const style = { content: { width: "20px" } }; - const modal = renderModal({ isOpen: true, style }); - mcontent(modal).style.width.should.be.eql("20px"); + const props = { isOpen: true, style }; + withModal(props, null, modal => { + mcontent(modal).style.width.should.be.eql("20px"); + }); }); it("supports overriding style on the modal contents", () => { const style = { content: { position: "static" } }; - const modal = renderModal({ isOpen: true, style }); - mcontent(modal).style.position.should.be.eql("static"); + const props = { isOpen: true, style }; + withModal(props, null, modal => { + mcontent(modal).style.position.should.be.eql("static"); + }); }); it("supports adding style on the modal overlay", () => { const style = { overlay: { width: "75px" } }; - const modal = renderModal({ isOpen: true, style }); - moverlay(modal).style.width.should.be.eql("75px"); + const props = { isOpen: true, style }; + withModal(props, null, modal => { + moverlay(modal).style.width.should.be.eql("75px"); + }); }); it("supports overriding style on the modal overlay", () => { const style = { overlay: { position: "static" } }; - const modal = renderModal({ isOpen: true, style }); - moverlay(modal).style.position.should.be.eql("static"); + const props = { isOpen: true, style }; + withModal(props, null, modal => { + moverlay(modal).style.position.should.be.eql("static"); + }); }); it("supports overriding the default styles", () => { @@ -49,8 +57,10 @@ export default () => { // check that we can change it const newStyle = previousStyle === "relative" ? "static" : "relative"; Modal.defaultStyles.content.position = newStyle; - const modal = renderModal({ isOpen: true }); - modal.portal.content.style.position.should.be.eql(newStyle); - Modal.defaultStyles.content.position = previousStyle; + const props = { isOpen: true }; + withModal(props, null, modal => { + modal.portal.content.style.position.should.be.eql(newStyle); + Modal.defaultStyles.content.position = previousStyle; + }); }); }; diff --git a/specs/Modal.testability.spec.js b/specs/Modal.testability.spec.js index 4b162ed8..e967628d 100644 --- a/specs/Modal.testability.spec.js +++ b/specs/Modal.testability.spec.js @@ -1,19 +1,13 @@ /* eslint-env mocha */ import ReactDOM from "react-dom"; import sinon from "sinon"; -import { mcontent, renderModal, emptyDOM } from "./helper"; +import { withModal } from "./helper"; export default () => { - afterEach("cleaned up all rendered modals", emptyDOM); - - it("renders as expected, initially", () => { - const modal = renderModal({ isOpen: true }, "hello"); - mcontent(modal).should.be.ok(); - }); - it("allows ReactDOM.createPortal to be overridden in real-time", () => { const createPortalSpy = sinon.spy(ReactDOM, "createPortal"); - renderModal({ isOpen: true }, "hello"); + const props = { isOpen: true }; + withModal(props, "hello"); createPortalSpy.called.should.be.ok(); ReactDOM.createPortal.restore(); }); diff --git a/specs/helper.js b/specs/helper.js index 6c62cdf1..9ff09c84 100644 --- a/specs/helper.js +++ b/specs/helper.js @@ -2,8 +2,78 @@ import React from "react"; import ReactDOM from "react-dom"; import Modal, { bodyOpenClassName } from "../src/components/Modal"; import TestUtils from "react-dom/test-utils"; +import { log as classListLog } from "../src/helpers/classList"; +import { log as focusManagerLog } from "../src/helpers/focusManager"; +import { log as ariaAppLog } from "../src/helpers/ariaAppHider"; +import { log as bodyTrapLog } from "../src/helpers/bodyTrap"; +import { log as portalInstancesLog } from "../src/helpers/portalOpenInstances"; -const divStack = []; +const debug = false; + +let i = 0; + +/** + * This log is used to see if there are leaks in between tests. + */ +export function log(label, spaces) { + if (!debug) return; + + console.log(`${label} -----------------`); + console.log(document.body.children.length); + const logChildren = c => console.log(c.nodeName, c.className, c.id); + document.body.children.forEach(logChildren); + + ariaAppLog(); + bodyTrapLog(); + classListLog(); + focusManagerLog(); + portalInstancesLog(); + + console.log(`end ${label} -----------------` + (!spaces ? '' : ` + + +`)); +} + +let elementPool = []; + +/** + * Every HTMLElement must be requested using this function... + * and inside `withElementCollector`. + */ +export function createHTMLElement(name) { + const e = document.createElement(name); + elementPool[elementPool.length - 1].push(e); + e.className = `element_pool_${name}-${++i}`; + return e; +} + +/** + * Remove every element from its parent and release the pool. + */ +export function drainPool(pool) { + pool.forEach(e => e.parentNode && e.parentNode.removeChild(e)); +} + +/** + * Every HTMLElement must be requested inside this function... + * The reason is that it provides a mechanism that disposes + * all the elements (built with `createHTMLElement`) after a test. + */ +export function withElementCollector(work) { + let r; + let poolIndex = elementPool.length; + elementPool[poolIndex] = []; + try { + r = work(); + } finally { + drainPool(elementPool[poolIndex]); + elementPool = elementPool.slice( + 0, poolIndex + ); + } + return r; +} /** * Polyfill for String.includes on some node versions. @@ -26,15 +96,16 @@ if (!String.prototype.includes) { * Return the class list object from `document.body`. * @return {Array} */ -export const documentBodyClassList = () => document.body.classList; +export const documentClassList = () => document.body.classList; /** * Check if the document.body contains the react modal * open class. * @return {Boolean} */ -export const isBodyWithReactModalOpenClass = (bodyClass = bodyOpenClassName) => - document.body.className.includes(bodyClass); +export const isDocumentWithReactModalOpenClass = ( + bodyClass = bodyOpenClassName +) => document.body.className.includes(bodyClass); /** * Return the class list object from . @@ -141,29 +212,27 @@ export const mouseUpAt = Simulate.mouseUp; */ export const mouseDownAt = Simulate.mouseDown; -export const renderModal = function(props, children, callback) { - const modalProps = { ariaHideApp: false, ...props }; - - const currentDiv = document.createElement("div"); - divStack.push(currentDiv); - document.body.appendChild(currentDiv); - - // eslint-disable-next-line react/no-render-return-value - return ReactDOM.render( - {children}, - currentDiv, - callback - ); -}; - -export const unmountModal = function() { - const currentDiv = divStack.pop(); - ReactDOM.unmountComponentAtNode(currentDiv); - document.body.removeChild(currentDiv); -}; - -export const emptyDOM = () => { - while (divStack.length) { - unmountModal(); - } +export const noop = () => {}; + +/** + * Request a managed modal to run the tests on. + * + */ +export const withModal = function(props, children, test = noop) { + return withElementCollector(() => { + const node = createHTMLElement(); + const modalProps = { ariaHideApp: false, ...props }; + let modal; + try { + ReactDOM.render( + (modal = m)} {...modalProps}> + {children} + , + node + ); + test(modal); + } finally { + ReactDOM.unmountComponentAtNode(node); + } + }); }; diff --git a/src/components/Modal.js b/src/components/Modal.js index cb6bc53d..c064b213 100644 --- a/src/components/Modal.js +++ b/src/components/Modal.js @@ -16,6 +16,8 @@ export const bodyOpenClassName = "ReactModal__Body--open"; const isReact16 = canUseDOM && ReactDOM.createPortal !== undefined; +let createHTMLElement = name => document.createElement(name); + const getCreatePortal = () => isReact16 ? ReactDOM.createPortal @@ -130,7 +132,7 @@ class Modal extends Component { if (!canUseDOM) return; if (!isReact16) { - this.node = document.createElement("div"); + this.node = createHTMLElement("div"); } this.node.className = this.props.portalClassName; @@ -222,7 +224,7 @@ class Modal extends Component { } if (!this.node && isReact16) { - this.node = document.createElement("div"); + this.node = createHTMLElement("div"); } const createPortal = getCreatePortal(); @@ -239,4 +241,8 @@ class Modal extends Component { polyfill(Modal); +if (process.env.NODE_ENV !== "production") { + Modal.setCreateHTMLElement = fn => (createHTMLElement = fn); +} + export default Modal; diff --git a/src/helpers/ariaAppHider.js b/src/helpers/ariaAppHider.js index 2ee47710..f732e5ae 100644 --- a/src/helpers/ariaAppHider.js +++ b/src/helpers/ariaAppHider.js @@ -3,6 +3,33 @@ import { canUseDOM } from "./safeHTMLElement"; let globalElement = null; +/* eslint-disable no-console */ +/* istanbul ignore next */ +export function resetState() { + if (globalElement) { + if (globalElement.removeAttribute) { + globalElement.removeAttribute("aria-hidden"); + } else if (globalElement.length != null) { + globalElement.forEach(element => element.removeAttribute("aria-hidden")); + } else { + document + .querySelectorAll(globalElement) + .forEach(element => element.removeAttribute("aria-hidden")); + } + } + globalElement = null; +} + +/* istanbul ignore next */ +export function log() { + if (process.env.NODE_ENV === "production") return; + const check = globalElement || {}; + console.log("ariaAppHider ----------"); + console.log(check.nodeName, check.className, check.id); + console.log("end ariaAppHider ----------"); +} +/* eslint-enable no-console */ + export function assertNodeList(nodeList, selector) { if (!nodeList || !nodeList.length) { throw new Error( @@ -61,7 +88,3 @@ export function show(appElement) { export function documentNotReadyOrSSRTesting() { globalElement = null; } - -export function resetForTesting() { - globalElement = null; -} diff --git a/src/helpers/bodyTrap.js b/src/helpers/bodyTrap.js index b990823b..5787f6ce 100644 --- a/src/helpers/bodyTrap.js +++ b/src/helpers/bodyTrap.js @@ -5,6 +5,29 @@ let before, after, instances = []; +/* eslint-disable no-console */ +/* istanbul ignore next */ +export function resetState() { + for (let item of [before, after]) { + if (!item) continue; + item.parentNode && item.parentNode.removeChild(item); + } + before = after = null; + instances = []; +} + +/* istanbul ignore next */ +export function log() { + console.log("bodyTrap ----------"); + console.log(instances.length); + for (let item of [before, after]) { + let check = item || {}; + console.log(check.nodeName, check.className, check.id); + } + console.log("edn bodyTrap ----------"); +} +/* eslint-enable no-console */ + function focusContent() { if (instances.length === 0) { if (process.env.NODE_ENV !== "production") { @@ -17,7 +40,7 @@ function focusContent() { } function bodyTrap(eventType, openInstances) { - if (!before || !after) { + if (!before && !after) { before = document.createElement("div"); before.setAttribute("data-react-modal-body-trap", ""); before.style.position = "absolute"; diff --git a/src/helpers/classList.js b/src/helpers/classList.js index e731af7f..c4da5b0e 100644 --- a/src/helpers/classList.js +++ b/src/helpers/classList.js @@ -1,34 +1,56 @@ -const htmlClassList = {}; -const docBodyClassList = {}; +let htmlClassList = {}; +let docBodyClassList = {}; -export function dumpClassLists() { - if (process.env.NODE_ENV !== "production") { - let classes = document.getElementsByTagName("html")[0].className; - let buffer = "Show tracked classes:\n\n"; +/* eslint-disable no-console */ +/* istanbul ignore next */ +function removeClass(at, cls) { + at.classList.remove(cls); +} + +/* istanbul ignore next */ +export function resetState() { + const htmlElement = document.getElementsByTagName("html")[0]; + for (let cls in htmlClassList) { + removeClass(htmlElement, htmlClassList[cls]); + } + + const body = document.body; + for (let cls in docBodyClassList) { + removeClass(body, docBodyClassList[cls]); + } + + htmlClassList = {}; + docBodyClassList = {}; +} + +/* istanbul ignore next */ +export function log() { + if (process.env.NODE_ENV === "production") return; + + let classes = document.getElementsByTagName("html")[0].className; + let buffer = "Show tracked classes:\n\n"; - buffer += ` (${classes}): + buffer += ` (${classes}): `; - for (let x in htmlClassList) { - buffer += ` ${x} ${htmlClassList[x]} + for (let x in htmlClassList) { + buffer += ` ${x} ${htmlClassList[x]} `; - } + } - classes = document.body.className; + classes = document.body.className; - // eslint-disable-next-line max-len - buffer += `\n\ndoc.body (${classes}): + buffer += `\n\ndoc.body (${classes}): `; - for (let x in docBodyClassList) { - buffer += ` ${x} ${docBodyClassList[x]} + for (let x in docBodyClassList) { + buffer += ` ${x} ${docBodyClassList[x]} `; - } + } - buffer += "\n"; + buffer += "\n"; - // eslint-disable-next-line no-console - console.log(buffer); - } + console.log(buffer); } +/* eslint-enable no-console */ /** * Track the number of reference of a class. diff --git a/src/helpers/focusManager.js b/src/helpers/focusManager.js index 96fbc349..acc66c20 100644 --- a/src/helpers/focusManager.js +++ b/src/helpers/focusManager.js @@ -1,9 +1,27 @@ import findTabbable from "../helpers/tabbable"; -const focusLaterElements = []; +let focusLaterElements = []; let modalElement = null; let needToFocus = false; +/* eslint-disable no-console */ +/* istanbul ignore next */ +export function resetState() { + focusLaterElements = []; +} + +/* istanbul ignore next */ +export function log() { + if (process.env.NODE_ENV === "production") return; + console.log("focusManager ----------"); + focusLaterElements.forEach(f => { + const check = f || {}; + console.log(check.nodeName, check.className, check.id); + }); + console.log("end focusManager ----------"); +} +/* eslint-enable no-console */ + export function handleBlur() { needToFocus = true; } diff --git a/src/helpers/portalOpenInstances.js b/src/helpers/portalOpenInstances.js index 5af460da..9879b84b 100644 --- a/src/helpers/portalOpenInstances.js +++ b/src/helpers/portalOpenInstances.js @@ -51,6 +51,21 @@ class PortalOpenInstances { }; } -const portalOpenInstances = new PortalOpenInstances(); +let portalOpenInstances = new PortalOpenInstances(); + +/* eslint-disable no-console */ +/* istanbul ignore next */ +export function log() { + console.log("portalOpenInstances ----------"); + console.log(portalOpenInstances.openInstances.length); + portalOpenInstances.openInstances.forEach(p => console.log(p)); + console.log("end portalOpenInstances ----------"); +} + +/* istanbul ignore next */ +export function resetState() { + portalOpenInstances = new PortalOpenInstances(); +} +/* eslint-enable no-console */ export default portalOpenInstances;