reactjs/react-modal

View on GitHub
specs/Modal.spec.js

Summary

Maintainability
F
5 days
Test Coverage
/* eslint-env mocha */
import should from "should";
import React, { Component } from "react";
import ReactDOM from "react-dom";
import Modal from "react-modal";
import {
  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,
  withModal,
  documentClassList,
  withElementCollector,
  createHTMLElement
} from "./helper";

Modal.setCreateHTMLElement(createHTMLElement);

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 props = { isOpen: true };
    withModal(props, "hello", modal => {
      mcontent(modal).should.be.ok();
    });
  });

  it("can be closed initially", () => {
    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 props = {};
    withModal(props, "hello", modal => {
      should(ReactDOM.findDOMNode(modal.portal)).not.be.ok();
    });
  });

  it("has default props", () => {
    withElementCollector(() => {
      // eslint-disable-next-line react/no-render-return-value
      const modal = <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", () => {
    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", () => {
    withElementCollector(() => {
      const el1 = createHTMLElement("div");
      const el2 = createHTMLElement("div");
      const node = createHTMLElement("div");
      ReactDOM.render(<Modal isOpen={true} appElement={[el1, el2]} />, 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", () => {
    withElementCollector(() => {
      const node = createHTMLElement("div");
      Modal.setAppElement(node);
      ReactDOM.render(<Modal isOpen />, node);
      document.body
        .querySelector(".ReactModalPortal")
        .parentNode.should.be.eql(document.body);
      ReactDOM.unmountComponentAtNode(node);
    });
  });

  it("allow setting appElement of type string", () => {
    withElementCollector(() => {
      const node = createHTMLElement("div");
      const appElement = "body";
      Modal.setAppElement(appElement);
      ReactDOM.render(<Modal isOpen />, 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", () => {
    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(<Modal isOpen />, node);
      el1.getAttribute("aria-hidden").should.be.eql("true");
      ReactDOM.unmountComponentAtNode(node);
    });
  });

  it("default parentSelector should be 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 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 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 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...";
    withModal(
      {
        isOpen: true,
        contentLabel: "Special Modal"
      },
      child,
      modal => {
        contentAttribute(modal, "aria-label").should.be.eql("Special Modal");
      }
    );
  });

  it("removes the portal node", () => {
    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;

    function checkDOM(count) {
      const portal = document.querySelectorAll(".ReactModalPortal");
      portal.length.should.be.eql(count);
    }

    const props = { isOpen: true, closeTimeoutMS };
    withModal(props, "hello", () => {
      checkDOM(1);
    });

    setTimeout(() => {
      // content is unmounted after specified timeout
      checkDOM(0);
      done();
    }, closeTimeoutMS);
  });

  it("focuses the modal content by default", () => {
    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", () => {
    withModal(
      { isOpen: true, shouldFocusAfterRender: false },
      null,
      modal => {
        document.activeElement.should.not.be.eql(mcontent(modal));
      }
    );
  });

  it("give back focus to previous element or modal.", done => {
    withModal(
      {
        isOpen: true,
        className: "modal-a",
        onRequestClose: function() { done(); }
      },
      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);
      }
    );
  });

  it("does not steel focus when a descendent is already focused", () => {
    let content;
    const input = (
      <input
        ref={el => {
          el && el.focus();
          content = el;
        }}
      />
    );
    const props = { isOpen: true };
    withModal(props, input, () => {
      document.activeElement.should.be.eql(content);
    });
  });

  it("supports id prop", () => {
    const props = { isOpen: true, id: "id" };
    withModal(props, null, modal => {
      mcontent(modal)
        .id
        .should.be.eql("id");
    });
  });

  it("supports portalClassName", () => {
    const props = {
      isOpen: true,
      portalClassName: "myPortalClass"
    };
    withModal(props, null, modal => {
      modal.node.className.includes("myPortalClass").should.be.ok();
    });
  });

  it("supports custom className", () => {
    const props = { isOpen: true, className: "myClass" };
    withModal(props, null, modal => {
      mcontent(modal)
        .className.includes("myClass")
        .should.be.ok();
    });
  });

  it("supports custom overlayElement", () => {
    const overlayElement = (props, contentElement) => (
      <div {...props} id="custom">
        {contentElement}
      </div>
    );

    const props = { isOpen: true, overlayElement };
    withModal(props, null, modal => {
      const modalOverlay = moverlay(modal);
      modalOverlay.id.should.eql("custom");
    });
  });

  it("supports custom contentElement", () => {
    const contentElement = (props, children) => (
      <div {...props} id="custom">
        {children}
      </div>
    );

    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 props = {
      isOpen: true,
      overlayClassName: "myOverlayClass"
    };
    withModal(props, null, modal => {
      moverlay(modal)
        .className.includes("myOverlayClass")
        .should.be.ok();
    });
  });

  it("overrides content classes with custom object className", () => {
    withElementCollector(() => {
      const props = {
        isOpen: true,
        className: {
          base: "myClass",
          afterOpen: "myClass_after-open",
          beforeClose: "myClass_before-close"
        }
      };
      const node = createHTMLElement("div");
      const modal = ReactDOM.render(<Modal {...props} />, node);
      const request = requestAnimationFrame(() => {
        mcontent(modal).className.should.be.eql("myClass myClass_after-open");
        ReactDOM.unmountComponentAtNode(node);
      });
      cancelAnimationFrame(request);
    });
  });

  it("overrides overlay classes with custom object overlayClassName", () => {
    withElementCollector(() => {
      const props = {
        isOpen: true,
        overlayClassName: {
          base: "myOverlayClass",
          afterOpen: "myOverlayClass_after-open",
          beforeClose: "myOverlayClass_before-close"
        }
      };
      const node = createHTMLElement("div");
      const modal = ReactDOM.render(<Modal {...props} />, node);
      const request = requestAnimationFrame(() => {
        moverlay(modal).className.should.be.eql(
          "myOverlayClass myOverlayClass_after-open"
        );
        ReactDOM.unmountComponentAtNode(node);
      });
      cancelAnimationFrame(request);
    });
  });

  it("supports overriding react modal open class in document.body.", () => {
    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 <html />.", () => {
    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.", () => {
    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.", () => {
    const props = { isOpen: true, bodyOpenClassName: null };
    withModal(props, null, () => {
      documentClassList().should.be.empty();
    });
  });

  it("don't append class to <html /> if modal is closed.", () => {
    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.", () => {
    const props = { isOpen: true };
    withModal(props, null, () => {
      isDocumentWithReactModalOpenClass().should.be.ok();
    });
  });

  it("don't append class to <html /> if not defined.", () => {
    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", () => {
    withModal({ isOpen: true });
    isDocumentWithReactModalOpenClass().should.not.be.ok();
  });

  it("remove class from document.body when no modals opened", () => {
    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", () => {
    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.not.be.ok();
    ;
  });

  it("does not remove shared classes if more than one modal is open", () => {
    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();
    });
    isDocumentWithReactModalOpenClass("A").should.not.be.ok();
  });

  it("should not add classes to document.body for unopened modals", () => {
    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", () => {
    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 <html /> if modal is closed", () => {
    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();
    });
  });

  it("additional aria attributes", () => {
    withModal(
      { isOpen: true, aria: { labelledby: "a" } },
      "hello",
      modal => mcontent(modal)
        .getAttribute("aria-labelledby")
        .should.be.eql("a")
    );
  });

  it("additional data attributes", () => {
    withModal(
      { isOpen: true, data: { background: "green" } },
      "hello",
      modal => mcontent(modal)
        .getAttribute("data-background")
        .should.be.eql("green")
    );
  });

  it("additional testId attribute", () => {
    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(() => ariaAppSetElement(".test")).throw();
  });

  it("removes aria-hidden from appElement when unmounted w/o closing", () => {
    withElementCollector(() => {
      const el = createHTMLElement("div");
      const node = createHTMLElement("div");
      ReactDOM.render(<Modal isOpen appElement={el} />, node);
      el.getAttribute("aria-hidden").should.be.eql("true");
      ReactDOM.unmountComponentAtNode(node);
      const request = requestAnimationFrame(() => {
        should(el.getAttribute('aria-hidden')).not.be.ok();
      });
      cancelAnimationFrame(request);
    });
  });

  // eslint-disable-next-line max-len
  it("removes aria-hidden when closed and another modal with ariaHideApp set to false is open", () => {
    withElementCollector(() => {
      const rootNode = createHTMLElement("div");
      const appElement = createHTMLElement("div");
      document.body.appendChild(rootNode);
      document.body.appendChild(appElement);

      Modal.setAppElement(appElement);

      const initialState = (
        <div>
          <Modal isOpen={true} ariaHideApp={false} id="test-1-modal-1" />
          <Modal isOpen={true} ariaHideApp={true} id="test-1-modal-2" />
        </div>
      );

      ReactDOM.render(initialState, rootNode);
      appElement.getAttribute("aria-hidden").should.be.eql("true");

      const updatedState = (
        <div>
          <Modal isOpen={true} ariaHideApp={false} id="test-1-modal-1" />
          <Modal isOpen={false} ariaHideApp={true} id="test-1-modal-2" />
        </div>
      );

      const request = requestAnimationFrame(() => {
        ReactDOM.render(updatedState, rootNode);
        should(appElement.getAttribute("aria-hidden")).not.be.ok();

        ReactDOM.unmountComponentAtNode(rootNode);
      });
      cancelAnimationFrame(request);
    });
  });

  // eslint-disable-next-line max-len
  it("maintains aria-hidden when closed and another modal with ariaHideApp set to true is open", () => {
    withElementCollector(() => {
      const rootNode = createHTMLElement("div");
      document.body.appendChild(rootNode);

      const appElement = createHTMLElement("div");
      document.body.appendChild(appElement);

      Modal.setAppElement(appElement);

      const initialState = (
        <div>
          <Modal isOpen={true} ariaHideApp={true} id="test-1-modal-1" />
          <Modal isOpen={true} ariaHideApp={true} id="test-1-modal-2" />
        </div>
      );

      ReactDOM.render(initialState, rootNode);
      appElement.getAttribute("aria-hidden").should.be.eql("true");

      const updatedState = (
        <div>
          <Modal isOpen={true} ariaHideApp={true} id="test-1-modal-1" />
          <Modal isOpen={false} ariaHideApp={true} id="test-1-modal-2" />
        </div>
      );

      ReactDOM.render(updatedState, rootNode);
      appElement.getAttribute("aria-hidden").should.be.eql("true");

      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", () => {
    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");
      });

      const request = requestAnimationFrame(() => {
        should(appElement.getAttribute("aria-hidden")).not.be.ok();
      });
      cancelAnimationFrame(request);
    });
  });

  // eslint-disable-next-line max-len
  it("maintains aria-hidden when unmounted without close and second modal with ariaHideApp=true is open", () => {
    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");
      });

      const request = requestAnimationFrame(() => {
        should(appElement.getAttribute("aria-hidden")).not.be.ok();
      });
      cancelAnimationFrame(request);
    });
  });

  it("adds --after-open for animations", () => {
    withElementCollector(() => {
      const rg = /--after-open/i;
      const props = { isOpen: true };
      const node = createHTMLElement("div");
      const modal = ReactDOM.render(<Modal {...props} />, node);
      const request = requestAnimationFrame(() => {
        const contentName = modal.portal.content.className;
        const overlayName = modal.portal.overlay.className;
        rg.test(contentName).should.be.ok();
        rg.test(overlayName).should.be.ok();
        ReactDOM.unmountComponentAtNode(node);
      });
      cancelAnimationFrame(request);
    });
  });

  it("adds --before-close for animations", () => {
    const closeTimeoutMS = 50;
    const props = {
      isOpen: true,
      closeTimeoutMS
    };
    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();

      modal.portal.closeWithoutTimeout();
    });
  });

  it("should not be open after close with time out and reopen it", () => {
    const props = {
      isOpen: true,
      closeTimeoutMS: 2000,
      onRequestClose() { }
    };
    withModal(props, null, modal => {
      modal.portal.closeWithTimeout();
      modal.portal.open();
      modal.portal.closeWithoutTimeout();
      modal.portal.state.isOpen.should.not.be.ok();
    });
  });

  it("verify default prop of shouldCloseOnOverlayClick", () => {
    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 };
    withModal(modalOpts, null, modal => {
      modal.props.shouldCloseOnOverlayClick.should.not.be.ok();
    });
  });

  it("keeps the modal in the DOM until closeTimeoutMS elapses", done => {
    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);
    }
    withElementCollector(() => {
      const closeTimeoutMS = 100;
      const props = { isOpen: true, closeTimeoutMS };
      const node = createHTMLElement("div");
      const modal = ReactDOM.render(<Modal {...props} />, node);

      modal.portal.closeWithTimeout();
      checkDOM(1);

      setTimeout(() => {
        checkDOM(0);
        ReactDOM.unmountComponentAtNode(node);
        done();
      }, closeTimeoutMS);
    });
  });

  it("verify that portalClassName is refreshed on component update", () => {
    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("portal");

          this.setState({ classModifier: "-modifier" });
        }

        componentDidUpdate() {
          modal.node.className.should.be.eql("portal-modifier");
        }

        render() {
          const { classModifier } = this.state;
          const portalClassName = `portal${classModifier}`;

          return (
            <div>
              <Modal
                ref={modalComponent => {
                  modal = modalComponent;
                }}
                isOpen
                portalClassName={portalClassName}
              >
                <span>Test</span>
              </Modal>
            </div>
          );
        }
      }

      Modal.setAppElement(node);
      ReactDOM.render(<App />, node);
      ReactDOM.unmountComponentAtNode(node);
    });
  });

  it("use overlayRef and contentRef", () => {
    let overlay = null;
    let content = null;

    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");
    });
  });
};