From 9076eb715621d4bce9b864962d714bc0273b00b2 Mon Sep 17 00:00:00 2001
From: Austin Wood <i@austinwood.me>
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);
 }