diff --git a/docs/README.md b/docs/README.md index 78b936d8..eb5edc67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -69,6 +69,11 @@ import ReactModal from 'react-modal'; See the `Styles` section for more details. */ bodyOpenClassName="ReactModal__Body--open" + /* + String className to be applied to the document.html (must be a constant string). + See the `Styles` section for more details. + */ + htmlOpenClassName="ReactModal__Html--open" /* Boolean indicating if the appElement should be hidden */ diff --git a/examples/basic/app.css b/examples/basic/app.css index 6bcc1092..a83abb6e 100644 --- a/examples/basic/app.css +++ b/examples/basic/app.css @@ -29,3 +29,8 @@ transform: scale(0.5) rotateX(30deg); transition: all 150ms ease-in; } + +.ReactModal__Body--open, +.ReactModal__Html--open { + overflow: hidden; +} diff --git a/specs/Modal.spec.js b/specs/Modal.spec.js index d86624fd..54539c6f 100644 --- a/specs/Modal.spec.js +++ b/specs/Modal.spec.js @@ -6,6 +6,7 @@ import Modal from "react-modal"; import * as ariaAppHider from "react-modal/helpers/ariaAppHider"; import { isBodyWithReactModalOpenClass, + isHtmlWithReactModalOpenClass, contentAttribute, mcontent, moverlay, @@ -253,32 +254,49 @@ export default () => { (document.body.className.indexOf("custom-modal-open") > -1).should.be.ok(); }); - it("don't append class to document.body if modal is not open", () => { + it("supports overriding react modal open class in html.", () => { + renderModal({ isOpen: true, htmlOpenClassName: "custom-modal-open" }); + ( + document + .getElementsByTagName("html")[0] + .className.indexOf("custom-modal-open") > -1 + ).should.be.ok(); + }); + + // eslint-disable-next-line max-len + it("don't append class to document.body and html if modal is not open", () => { renderModal({ isOpen: false }); isBodyWithReactModalOpenClass().should.not.be.ok(); + isHtmlWithReactModalOpenClass().should.not.be.ok(); unmountModal(); }); - it("append class to document.body if modal is open", () => { + it("append class to document.body and html if modal is open", () => { renderModal({ isOpen: true }); isBodyWithReactModalOpenClass().should.be.ok(); + isHtmlWithReactModalOpenClass().should.be.ok(); unmountModal(); }); - it("removes class from document.body when unmounted without closing", () => { + // eslint-disable-next-line max-len + it("removes class from document.body and html when unmounted without closing", () => { renderModal({ isOpen: true }); unmountModal(); isBodyWithReactModalOpenClass().should.not.be.ok(); + isHtmlWithReactModalOpenClass().should.not.be.ok(); }); - it("remove class from document.body when no modals opened", () => { + it("remove class from document.body and html when no modals opened", () => { renderModal({ isOpen: true }); renderModal({ isOpen: true }); isBodyWithReactModalOpenClass().should.be.ok(); + isHtmlWithReactModalOpenClass().should.be.ok(); unmountModal(); isBodyWithReactModalOpenClass().should.be.ok(); + isHtmlWithReactModalOpenClass().should.be.ok(); unmountModal(); isBodyWithReactModalOpenClass().should.not.be.ok(); + isHtmlWithReactModalOpenClass().should.not.be.ok(); }); it("supports adding/removing multiple document.body classes", () => { @@ -328,6 +346,59 @@ export default () => { isBodyWithReactModalOpenClass().should.be.ok(); }); + it("supports adding/removing multiple html classes", () => { + renderModal({ + isOpen: true, + htmlOpenClassName: "A B C" + }); + document + .getElementsByTagName("html")[0] + .classList.contains("A", "B", "C") + .should.be.ok(); + unmountModal(); + document + .getElementsByTagName("html")[0] + .classList.contains("A", "B", "C") + .should.not.be.ok(); + }); + + it("does not remove shared classes if more than one modal is open", () => { + renderModal({ + isOpen: true, + htmlOpenClassName: "A" + }); + renderModal({ + isOpen: true, + htmlOpenClassName: "A B" + }); + + isHtmlWithReactModalOpenClass("A B").should.be.ok(); + unmountModal(); + isHtmlWithReactModalOpenClass("A B").should.not.be.ok(); + isHtmlWithReactModalOpenClass("A").should.be.ok(); + unmountModal(); + isHtmlWithReactModalOpenClass("A").should.not.be.ok(); + }); + + it("should not add classes to html for unopened modals", () => { + renderModal({ isOpen: true }); + isHtmlWithReactModalOpenClass().should.be.ok(); + renderModal({ isOpen: false, htmlOpenClassName: "testHtmlClass" }); + isHtmlWithReactModalOpenClass("testHtmlClass").should.not.be.ok(); + }); + + it("should not remove classes from html if modal is closed", () => { + renderModal({ isOpen: true }); + isHtmlWithReactModalOpenClass().should.be.ok(); + renderModal({ isOpen: false, htmlOpenClassName: "testHtmlClass" }); + renderModal({ isOpen: false }); + isHtmlWithReactModalOpenClass("testHtmlClass").should.not.be.ok(); + isHtmlWithReactModalOpenClass().should.be.ok(); + renderModal({ isOpen: false }); + renderModal({ isOpen: false }); + isHtmlWithReactModalOpenClass().should.be.ok(); + }); + it("additional aria attributes", () => { const modal = renderModal( { isOpen: true, aria: { labelledby: "a" } }, diff --git a/specs/helper.js b/specs/helper.js index 1cf57516..c45e06a0 100644 --- a/specs/helper.js +++ b/specs/helper.js @@ -1,6 +1,9 @@ import React from "react"; import ReactDOM from "react-dom"; -import Modal, { bodyOpenClassName } from "../src/components/Modal"; +import Modal, { + bodyOpenClassName, + htmlOpenClassName +} from "../src/components/Modal"; import TestUtils from "react-dom/test-utils"; const divStack = []; @@ -30,6 +33,14 @@ if (!String.prototype.includes) { export const isBodyWithReactModalOpenClass = (bodyClass = bodyOpenClassName) => document.body.className.includes(bodyClass); +/** + * Check if the html contains the react modal + * open class. + * @return {Boolean} + */ +export const isHtmlWithReactModalOpenClass = (htmlClass = htmlOpenClassName) => + document.getElementsByTagName("html")[0].className.includes(htmlClass); + /** * Returns a rendered dom element by class. * @param {React} element A react instance. diff --git a/src/components/Modal.js b/src/components/Modal.js index 7db54386..6b30796c 100644 --- a/src/components/Modal.js +++ b/src/components/Modal.js @@ -7,6 +7,7 @@ import SafeHTMLElement, { canUseDOM } from "../helpers/safeHTMLElement"; export const portalClassName = "ReactModalPortal"; export const bodyOpenClassName = "ReactModal__Body--open"; +export const htmlOpenClassName = "ReactModal__Html--open"; const isReact16 = ReactDOM.createPortal !== undefined; const createPortal = isReact16 @@ -31,6 +32,7 @@ export default class Modal extends Component { }), portalClassName: PropTypes.string, bodyOpenClassName: PropTypes.string, + htmlOpenClassName: PropTypes.string, className: PropTypes.oneOfType([ PropTypes.string, PropTypes.shape({ @@ -69,6 +71,7 @@ export default class Modal extends Component { isOpen: false, portalClassName, bodyOpenClassName, + htmlOpenClassName, ariaHideApp: true, closeTimeoutMS: 0, shouldFocusAfterRender: true, diff --git a/src/components/ModalPortal.js b/src/components/ModalPortal.js index 5ded7a16..ff47a536 100644 --- a/src/components/ModalPortal.js +++ b/src/components/ModalPortal.js @@ -38,6 +38,7 @@ export default class ModalPortal extends Component { className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), overlayClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), bodyOpenClassName: PropTypes.string, + htmlOpenClassName: PropTypes.string, ariaHideApp: PropTypes.bool, appElement: PropTypes.instanceOf(SafeHTMLElement), onAfterOpen: PropTypes.func, @@ -84,6 +85,13 @@ export default class ModalPortal extends Component { "This may cause unexpected behavior when multiple modals are open." ); } + if (newProps.htmlOpenClassName !== this.props.htmlOpenClassName) { + // eslint-disable-next-line no-console + console.warn( + 'React-Modal: "htmlOpenClassName" prop has been modified. ' + + "This may cause unexpected behavior when multiple modals are open." + ); + } } // Focus only needs to be set once when the modal is being opened if (!this.props.isOpen && newProps.isOpen) { @@ -121,9 +129,15 @@ export default class ModalPortal extends Component { }; beforeOpen() { - const { appElement, ariaHideApp, bodyOpenClassName } = this.props; - // Add body class - bodyClassList.add(bodyOpenClassName); + const { + appElement, + ariaHideApp, + bodyOpenClassName, + htmlOpenClassName + } = this.props; + // Add body and html class + bodyClassList.add(document.body, bodyOpenClassName); + classList.add(document.getElementsByTagName("html")[0], htmlOpenClassName); // Add aria-hidden to appElement if (ariaHideApp) { ariaHiddenInstances += 1; @@ -136,6 +150,10 @@ export default class ModalPortal extends Component { // Remove body class bodyClassList.remove(this.props.bodyOpenClassName); + classList.remove( + document.getElementsByTagName("html")[0], + this.props.htmlOpenClassName + ); // Reset aria-hidden attribute if all modals have been removed if (ariaHideApp && ariaHiddenInstances > 0) { diff --git a/src/helpers/bodyClassList.js b/src/helpers/classList.js similarity index 100% rename from src/helpers/bodyClassList.js rename to src/helpers/classList.js