From 0c6d9660ce220a66d898355132f9fb2f843338e4 Mon Sep 17 00:00:00 2001 From: Bruno Dias Date: Mon, 19 Feb 2018 23:17:21 -0300 Subject: [PATCH] [added] htmlOpenClassName will follow the same rules like... bodyOpenClassName. --- docs/README.md | 1 + docs/styles/classes.md | 17 ++++- specs/Modal.spec.js | 89 +++++++------------------ specs/helper.js | 22 +++++-- src/components/Modal.js | 2 - src/components/ModalPortal.js | 41 ++++++++---- src/helpers/classList.js | 120 +++++++++++++++++++++++++++------- 7 files changed, 183 insertions(+), 109 deletions(-) diff --git a/docs/README.md b/docs/README.md index eb5edc67..cce49269 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,6 +71,7 @@ import ReactModal from 'react-modal'; bodyOpenClassName="ReactModal__Body--open" /* String className to be applied to the document.html (must be a constant string). + This attribute is `null` by default. See the `Styles` section for more details. */ htmlOpenClassName="ReactModal__Html--open" diff --git a/docs/styles/classes.md b/docs/styles/classes.md index e48f2d9b..a7263bcb 100644 --- a/docs/styles/classes.md +++ b/docs/styles/classes.md @@ -38,7 +38,7 @@ any styles applied using these default classes will not override the default styles as they would if specified using the `className` or `overlayClassName` props. -#### For the document body +#### For the document.body and html tag You can override the default class that is added to `document.body` when the modal is open by defining a property `bodyOpenClassName`. @@ -61,6 +61,21 @@ non-default `bodyOpenClassName`), you could use the following CSS: } ``` +You can define a class to be added to the html tag, using the `htmlOpenClassName` +attribute, which can be helpeful to stop the page to scroll to the top when open +a modal. + +This attribute follows the same rules as `bodyOpenClassName`, it must be a *constant string*; + +Here is an example that can help preventing this behavior: + +```CSS +.ReactModal__Body--open, +.ReactModal__Html--open { + overflow: hidden; +} +``` + #### For the entire portal To specify a class to be applied to the entire portal, you may use the diff --git a/specs/Modal.spec.js b/specs/Modal.spec.js index 54539c6f..d96196f1 100644 --- a/specs/Modal.spec.js +++ b/specs/Modal.spec.js @@ -7,6 +7,7 @@ import * as ariaAppHider from "react-modal/helpers/ariaAppHider"; import { isBodyWithReactModalOpenClass, isHtmlWithReactModalOpenClass, + htmlClassList, contentAttribute, mcontent, moverlay, @@ -254,46 +255,45 @@ export default () => { (document.body.className.indexOf("custom-modal-open") > -1).should.be.ok(); }); - it("supports overriding react modal open class in html.", () => { + it("supports setting react modal open class in .", () => { renderModal({ isOpen: true, htmlOpenClassName: "custom-modal-open" }); - ( - document - .getElementsByTagName("html")[0] - .className.indexOf("custom-modal-open") > -1 - ).should.be.ok(); + isHtmlWithReactModalOpenClass("custom-modal-open").should.be.ok(); }); // eslint-disable-next-line max-len - it("don't append class to document.body and html if modal is not open", () => { + it("don't append class to document.body if modal is closed.", () => { renderModal({ isOpen: false }); isBodyWithReactModalOpenClass().should.not.be.ok(); + }); + + it("don't append class to if modal is closed.", () => { + renderModal({ isOpen: false, htmlOpenClassName: "custom-modal-open" }); isHtmlWithReactModalOpenClass().should.not.be.ok(); - unmountModal(); }); - it("append class to document.body and html if modal is open", () => { + it("append class to document.body if modal is open.", () => { renderModal({ isOpen: true }); isBodyWithReactModalOpenClass().should.be.ok(); - isHtmlWithReactModalOpenClass().should.be.ok(); - unmountModal(); + }); + + it("don't append class to if not defined.", () => { + renderModal({ isOpen: true }); + htmlClassList().should.be.empty(); }); // eslint-disable-next-line max-len - it("removes class from document.body and html when unmounted without closing", () => { + it("removes class from document.body when unmounted without closing", () => { renderModal({ isOpen: true }); unmountModal(); isBodyWithReactModalOpenClass().should.not.be.ok(); - isHtmlWithReactModalOpenClass().should.not.be.ok(); }); - it("remove class from document.body and html when no modals opened", () => { + it("remove class from document.body 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(); @@ -346,57 +346,18 @@ 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({ + 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: "A B" + htmlOpenClassName: "testHtmlClass" }); - - 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 }); + modalA.portal.close(); + isHtmlWithReactModalOpenClass("testHtmlClass").should.be.ok(); + modalB.portal.close(); + isHtmlWithReactModalOpenClass().should.not.be.ok(); renderModal({ isOpen: false }); - isHtmlWithReactModalOpenClass().should.be.ok(); }); it("additional aria attributes", () => { diff --git a/specs/helper.js b/specs/helper.js index c45e06a0..6c62cdf1 100644 --- a/specs/helper.js +++ b/specs/helper.js @@ -1,9 +1,6 @@ import React from "react"; import ReactDOM from "react-dom"; -import Modal, { - bodyOpenClassName, - htmlOpenClassName -} from "../src/components/Modal"; +import Modal, { bodyOpenClassName } from "../src/components/Modal"; import TestUtils from "react-dom/test-utils"; const divStack = []; @@ -25,6 +22,12 @@ if (!String.prototype.includes) { }; } +/** + * Return the class list object from `document.body`. + * @return {Array} + */ +export const documentBodyClassList = () => document.body.classList; + /** * Check if the document.body contains the react modal * open class. @@ -33,13 +36,20 @@ if (!String.prototype.includes) { export const isBodyWithReactModalOpenClass = (bodyClass = bodyOpenClassName) => document.body.className.includes(bodyClass); +/** + * Return the class list object from . + * @return {Array} + */ +export const htmlClassList = () => + document.getElementsByTagName("html")[0].classList; + /** * Check if the html contains the react modal * open class. * @return {Boolean} */ -export const isHtmlWithReactModalOpenClass = (htmlClass = htmlOpenClassName) => - document.getElementsByTagName("html")[0].className.includes(htmlClass); +export const isHtmlWithReactModalOpenClass = htmlClass => + htmlClassList().contains(htmlClass); /** * Returns a rendered dom element by class. diff --git a/src/components/Modal.js b/src/components/Modal.js index 6b30796c..e893de96 100644 --- a/src/components/Modal.js +++ b/src/components/Modal.js @@ -7,7 +7,6 @@ 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 @@ -71,7 +70,6 @@ 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 ff47a536..7a9f7c81 100644 --- a/src/components/ModalPortal.js +++ b/src/components/ModalPortal.js @@ -3,7 +3,7 @@ import PropTypes from "prop-types"; import * as focusManager from "../helpers/focusManager"; import scopeTab from "../helpers/scopeTab"; import * as ariaAppHider from "../helpers/ariaAppHider"; -import * as bodyClassList from "../helpers/bodyClassList"; +import * as classList from "../helpers/classList"; import SafeHTMLElement from "../helpers/safeHTMLElement"; // so that our CSS is statically analyzable @@ -132,13 +132,19 @@ export default class ModalPortal extends Component { const { appElement, ariaHideApp, - bodyOpenClassName, - htmlOpenClassName + htmlOpenClassName, + bodyOpenClassName } = this.props; - // Add body and html class - bodyClassList.add(document.body, bodyOpenClassName); - classList.add(document.getElementsByTagName("html")[0], htmlOpenClassName); - // Add aria-hidden to appElement + + // Add classes. + classList.add(document.body, bodyOpenClassName); + + htmlOpenClassName && + classList.add( + document.getElementsByTagName("html")[0], + htmlOpenClassName + ); + if (ariaHideApp) { ariaHiddenInstances += 1; ariaAppHider.hide(appElement); @@ -146,14 +152,21 @@ export default class ModalPortal extends Component { } afterClose = () => { - const { appElement, ariaHideApp } = this.props; + const { + appElement, + ariaHideApp, + htmlOpenClassName, + bodyOpenClassName + } = this.props; - // Remove body class - bodyClassList.remove(this.props.bodyOpenClassName); - classList.remove( - document.getElementsByTagName("html")[0], - this.props.htmlOpenClassName - ); + // Remove classes. + classList.remove(document.body, bodyOpenClassName); + + htmlOpenClassName && + classList.remove( + document.getElementsByTagName("html")[0], + htmlOpenClassName + ); // Reset aria-hidden attribute if all modals have been removed if (ariaHideApp && ariaHiddenInstances > 0) { diff --git a/src/helpers/classList.js b/src/helpers/classList.js index dab78df6..e731af7f 100644 --- a/src/helpers/classList.js +++ b/src/helpers/classList.js @@ -1,35 +1,111 @@ -const classListMap = {}; +const htmlClassList = {}; +const docBodyClassList = {}; -const addClassToMap = className => { - // Set variable and default if none - if (!classListMap[className]) { - classListMap[className] = 0; +export function dumpClassLists() { + if (process.env.NODE_ENV !== "production") { + let classes = document.getElementsByTagName("html")[0].className; + let buffer = "Show tracked classes:\n\n"; + + buffer += ` (${classes}): +`; + for (let x in htmlClassList) { + buffer += ` ${x} ${htmlClassList[x]} +`; + } + + classes = document.body.className; + + // eslint-disable-next-line max-len + buffer += `\n\ndoc.body (${classes}): +`; + for (let x in docBodyClassList) { + buffer += ` ${x} ${docBodyClassList[x]} +`; + } + + buffer += "\n"; + + // eslint-disable-next-line no-console + console.log(buffer); + } +} + +/** + * Track the number of reference of a class. + * @param {object} poll The poll to receive the reference. + * @param {string} className The class name. + * @return {string} + */ +const incrementReference = (poll, className) => { + if (!poll[className]) { + poll[className] = 0; } - classListMap[className] += 1; + poll[className] += 1; return className; }; -const removeClassFromMap = className => { - if (classListMap[className]) { - classListMap[className] -= 1; +/** + * Drop the reference of a class. + * @param {object} poll The poll to receive the reference. + * @param {string} className The class name. + * @return {string} + */ +const decrementReference = (poll, className) => { + if (poll[className]) { + poll[className] -= 1; } return className; }; -const add = bodyClass => { - bodyClass - .split(" ") - .map(addClassToMap) - .forEach(className => document.body.classList.add(className)); +/** + * Track a class and add to the given class list. + * @param {Object} classListRef A class list of an element. + * @param {Object} poll The poll to be used. + * @param {Array} classes The list of classes to be tracked. + */ +const trackClass = (classListRef, poll, classes) => { + classes.forEach(className => { + incrementReference(poll, className); + classListRef.add(className); + }); }; -const remove = bodyClass => { - // Remove unused class(es) from body - bodyClass - .split(" ") - .map(removeClassFromMap) - .filter(className => classListMap[className] === 0) - .forEach(className => document.body.classList.remove(className)); +/** + * Untrack a class and remove from the given class list if the reference + * reaches 0. + * @param {Object} classListRef A class list of an element. + * @param {Object} poll The poll to be used. + * @param {Array} classes The list of classes to be untracked. + */ +const untrackClass = (classListRef, poll, classes) => { + classes.forEach(className => { + decrementReference(poll, className); + poll[className] === 0 && classListRef.remove(className); + }); }; -export { add, remove }; +/** + * Public inferface to add classes to the document.body. + * @param {string} bodyClass The class string to be added. + * It may contain more then one class + * with ' ' as separator. + */ +export const add = (element, classString) => + trackClass( + element.classList, + element.nodeName.toLowerCase() == "html" ? htmlClassList : docBodyClassList, + classString.split(" ") + ); + +/** + * Public inferface to remove classes from the document.body. + * @param {string} bodyClass The class string to be added. + * It may contain more then one class + * with ' ' as separator. + */ +export const remove = (element, classString) => + untrackClass( + element.classList, + element.nodeName.toLowerCase() == "html" ? htmlClassList : docBodyClassList, + classString.split(" ") + );