From 9076eb715621d4bce9b864962d714bc0273b00b2 Mon Sep 17 00:00:00 2001 From: Austin Wood Date: Wed, 5 Jul 2017 13:19:54 -0700 Subject: [PATCH] [added] Support using multiple document.body classes * update `bodyOpenClassName` prop to handle adding and removing multiple class names * update String#includes polyfill to work properly * ensure shared classes on `document.body` persist on one modal close if multiple modals are open * create new helper for adding/removing class names from body element * remove unmaintained and obsolete `element-class` library * rename refCount private variable `modals` to `classListMap` * create `get` method on refCount helper for public access to the class list count --- docs/styles/classes.md | 22 +++++++++++++++++----- package.json | 1 - specs/Modal.spec.js | 28 ++++++++++++++++++++++++++++ specs/helper.js | 14 +++++++++++--- src/components/ModalPortal.js | 10 +++------- src/helpers/bodyClassList.js | 20 ++++++++++++++++++++ src/helpers/refCount.js | 25 ++++++++++++++----------- 7 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 src/helpers/bodyClassList.js diff --git a/docs/styles/classes.md b/docs/styles/classes.md index 57283c79..43214245 100644 --- a/docs/styles/classes.md +++ b/docs/styles/classes.md @@ -1,10 +1,22 @@ ### CSS Classes -Sometimes it may be preferable to use CSS classes rather than inline styles. You can use the `className` and `overlayClassName` props to specify a given CSS class for each of those. -You can override the default class that is added to `document.body` when the modal is open by defining a property `bodyOpenClassName`. +Sometimes it may be preferable to use CSS classes rather than inline styles. -It's required that `bodyOpenClassName` must be `constant string`, otherwise we would end up with a complex system to manage which class name -should appear or be removed from `document.body` from which modal (if using multiple modals simultaneously). +You can use the `className` and `overlayClassName` props to specify a given CSS +class for each of those. + +You can override the default class that is added to `document.body` when the +modal is open by defining a property `bodyOpenClassName`. + +It's required that `bodyOpenClassName` must be `constant string`, otherwise we +would end up with a complex system to manage which class name should appear or +be removed from `document.body` from which modal (if using multiple modals +simultaneously). + +`bodyOpenClassName` can support adding multiple classes to `document.body` when +the modal is open. Add as many class names as you desire, delineated by spaces. + +Note: If you provide those props all default styles will not be applied, leaving +all styles under control of the CSS class. -Note: If you provide those props all default styles will not be applied, leaving all styles under control of the CSS class. The `portalClassName` can also be used however there are no styles by default applied diff --git a/package.json b/package.json index 10fd7517..35f98e53 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "webpack-dev-server": "1.11.0" }, "dependencies": { - "element-class": "^0.2.0", "exenv": "1.2.0", "prop-types": "^15.5.10", "react-dom-factories": "^1.0.0" diff --git a/specs/Modal.spec.js b/specs/Modal.spec.js index 272cc45e..e8327c3c 100644 --- a/specs/Modal.spec.js +++ b/specs/Modal.spec.js @@ -271,6 +271,34 @@ describe('State', () => { expect(!isBodyWithReactModalOpenClass()).toBeTruthy(); }); + it('supports adding/removing multiple document.body classes', () => { + renderModal({ + isOpen: true, + bodyOpenClassName: 'A B C' + }); + expect(document.body.classList.contains('A', 'B', 'C')).toBeTruthy(); + unmountModal(); + expect(!document.body.classList.contains('A', 'B', 'C')).toBeTruthy(); + }); + + it('does not remove shared classes if more than one modal is open', () => { + renderModal({ + isOpen: true, + bodyOpenClassName: 'A' + }); + renderModal({ + isOpen: true, + bodyOpenClassName: 'A B' + }); + + expect(isBodyWithReactModalOpenClass('A B')).toBeTruthy(); + unmountModal(); + expect(!isBodyWithReactModalOpenClass('A B')).toBeTruthy(); + expect(isBodyWithReactModalOpenClass('A')).toBeTruthy(); + unmountModal(); + expect(!isBodyWithReactModalOpenClass('A')).toBeTruthy(); + }); + it('should not add classes to document.body for unopened modals', () => { renderModal({ isOpen: true }); expect(isBodyWithReactModalOpenClass()).toBeTruthy(); diff --git a/specs/helper.js b/specs/helper.js index 04efd1c2..0af99a26 100644 --- a/specs/helper.js +++ b/specs/helper.js @@ -8,9 +8,17 @@ const divStack = []; /** * Polyfill for String.includes on some node versions. */ -if (!(String.prototype.hasOwnProperty('includes'))) { - String.prototype.includes = function(item) { - return this.length > 0 && this.split(" ").indexOf(item) !== -1; +if (!String.prototype.includes) { + String.prototype.includes = function(search, start) { + if (typeof start !== 'number') { + start = 0; + } + + if (start + search.length > this.length) { + return false; + } + + return this.indexOf(search, start) !== -1; }; } diff --git a/src/components/ModalPortal.js b/src/components/ModalPortal.js index 75cafd79..61086122 100644 --- a/src/components/ModalPortal.js +++ b/src/components/ModalPortal.js @@ -1,10 +1,10 @@ import React, { Component } from 'react'; import { PropTypes } from 'prop-types'; -import elementClass from 'element-class'; import * as focusManager from '../helpers/focusManager'; import scopeTab from '../helpers/scopeTab'; import * as ariaAppHider from '../helpers/ariaAppHider'; import * as refCount from '../helpers/refCount'; +import * as bodyClassList from '../helpers/bodyClassList'; import SafeHTMLElement from '../helpers/safeHTMLElement'; // so that our CSS is statically analyzable @@ -119,9 +119,8 @@ export default class ModalPortal extends Component { beforeOpen() { const { appElement, ariaHideApp, bodyOpenClassName } = this.props; - refCount.add(bodyOpenClassName); // Add body class - elementClass(document.body).add(bodyOpenClassName); + bodyClassList.add(bodyOpenClassName); // Add aria-hidden to appElement if (ariaHideApp) { ariaAppHider.hide(appElement); @@ -130,11 +129,8 @@ export default class ModalPortal extends Component { beforeClose() { const { appElement, ariaHideApp, bodyOpenClassName } = this.props; - refCount.remove(bodyOpenClassName); // Remove class if no more modals are open - if (refCount.count(bodyOpenClassName) === 0) { - elementClass(document.body).remove(bodyOpenClassName); - } + bodyClassList.remove(bodyOpenClassName); // Reset aria-hidden attribute if all modals have been removed if (ariaHideApp && refCount.totalCount() < 1) { ariaAppHider.show(appElement); diff --git a/src/helpers/bodyClassList.js b/src/helpers/bodyClassList.js new file mode 100644 index 00000000..eea672a2 --- /dev/null +++ b/src/helpers/bodyClassList.js @@ -0,0 +1,20 @@ +import * as refCount from './refCount'; + +export function add (bodyClass) { + // Increment class(es) on refCount tracker and add class(es) to body + bodyClass + .split(' ') + .map(refCount.add) + .forEach(className => document.body.classList.add(className)); +} + +export function remove (bodyClass) { + const classListMap = refCount.get(); + // Decrement class(es) from the refCount tracker + // and remove unused class(es) from body + bodyClass + .split(' ') + .map(refCount.remove) + .filter(className => classListMap[className] === 0) + .forEach(className => document.body.classList.remove(className)); +} diff --git a/src/helpers/refCount.js b/src/helpers/refCount.js index ca5c6752..fe6ade16 100644 --- a/src/helpers/refCount.js +++ b/src/helpers/refCount.js @@ -1,23 +1,26 @@ -const modals = {}; +const classListMap = {}; + +export function get() { + return classListMap; +} export function add(bodyClass) { // Set variable and default if none - if (!modals[bodyClass]) { - modals[bodyClass] = 0; + if (!classListMap[bodyClass]) { + classListMap[bodyClass] = 0; } - modals[bodyClass] += 1; + classListMap[bodyClass] += 1; + return bodyClass; } export function remove(bodyClass) { - if (modals[bodyClass]) { - modals[bodyClass] -= 1; + if (classListMap[bodyClass]) { + classListMap[bodyClass] -= 1; } -} - -export function count(bodyClass) { - return modals[bodyClass]; + return bodyClass; } export function totalCount() { - return Object.keys(modals).reduce((acc, curr) => acc + modals[curr], 0); + return Object.keys(classListMap) + .reduce((acc, curr) => acc + classListMap[curr], 0); }