Skip to content

Commit 1baebf4

Browse files
Andrew Fullerdiasbruno
Andrew Fuller
authored andcommittedJun 27, 2017
[change] Track open body className appropriately
1 parent 27579ca commit 1baebf4

File tree

9 files changed

+100
-52
lines changed

9 files changed

+100
-52
lines changed
 

‎docs/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import ReactModal from 'react-modal';
5353
*/
5454
className="ReactModal__Content"
5555
/*
56-
String className to be applied to the document.body.
56+
String className to be applied to the document.body (must be a constant string).
5757
See the `Styles` section for more details.
5858
*/
5959
bodyOpenClassName="ReactModal__Body--open"

‎docs/styles/classes.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,9 @@
22

33
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.
44
You can override the default class that is added to `document.body` when the modal is open by defining a property `bodyOpenClassName`.
5+
6+
It's required that `bodyOpenClassName` must be `constant string`, otherwise we would end up with a complex system to manage which class name
7+
should appear or be removed from `document.body` from which modal (if using multiple modals simultaneously).
8+
59
Note: If you provide those props all default styles will not be applied, leaving all styles under control of the CSS class.
610
The `portalClassName` can also be used however there are no styles by default applied

‎specs/Modal.spec.js

+19
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,25 @@ describe('State', () => {
239239
unmountModal();
240240
expect(!isBodyWithReactModalOpenClass()).toBeTruthy();
241241
});
242+
243+
it('should not add classes to document.body for unopened modals', () => {
244+
renderModal({ isOpen: true });
245+
expect(isBodyWithReactModalOpenClass()).toBeTruthy();
246+
renderModal({ isOpen: false, bodyOpenClassName: 'testBodyClass' });
247+
expect(!isBodyWithReactModalOpenClass('testBodyClass')).toBeTruthy()
248+
});
249+
250+
it('should not remove classes from document.body when rendering unopened modal', () => {
251+
renderModal({ isOpen: true });
252+
expect(isBodyWithReactModalOpenClass()).toBeTruthy();
253+
renderModal({ isOpen: false, bodyOpenClassName: 'testBodyClass' });
254+
renderModal({ isOpen: false });
255+
expect(!isBodyWithReactModalOpenClass('testBodyClass')).toBeTruthy()
256+
expect(isBodyWithReactModalOpenClass()).toBeTruthy();
257+
renderModal({ isOpen: false });
258+
renderModal({ isOpen: false });
259+
expect(isBodyWithReactModalOpenClass()).toBeTruthy();
260+
});
242261

243262
it('adding/removing aria-hidden without an appElement will try to fallback to document.body', () => {
244263
ariaAppHider.documentNotReadyOrSSRTesting();

‎specs/helper.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import ReactDOM from 'react-dom';
3-
import Modal from '../src/components/Modal';
3+
import Modal, { bodyOpenClassName } from '../src/components/Modal';
44
import TestUtils from 'react-dom/test-utils';
55

66
const divStack = [];
@@ -19,8 +19,8 @@ if (!(String.prototype.hasOwnProperty('includes'))) {
1919
* open class.
2020
* @return {Boolean}
2121
*/
22-
export const isBodyWithReactModalOpenClass = () =>
23-
document.body.className.includes('ReactModal__Body--open');
22+
export const isBodyWithReactModalOpenClass = (bodyClass = bodyOpenClassName) =>
23+
document.body.className.includes(bodyClass);
2424

2525
/**
2626
* Returns a rendered dom element by class.
@@ -109,6 +109,7 @@ export const renderModal = function(props, children, callback) {
109109
const currentDiv = document.createElement('div');
110110
divStack.push(currentDiv);
111111
document.body.appendChild(currentDiv);
112+
112113
return ReactDOM.render(
113114
<Modal {...props}>{children}</Modal>
114115
, currentDiv, callback);

‎src/components/Modal.js

+7-32
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import React, { Component } from 'react';
22
import ReactDOM from 'react-dom';
33
import PropTypes from 'prop-types';
4-
import ExecutionEnvironment from 'exenv';
5-
import elementClass from 'element-class';
64
import ModalPortal from './ModalPortal';
75
import * as ariaAppHider from '../helpers/ariaAppHider';
8-
import * as refCount from '../helpers/refCount';
6+
import SafeHTMLElement from '../helpers/safeHTMLElement';
97

10-
const EE = ExecutionEnvironment;
11-
const renderSubtreeIntoContainer = ReactDOM.unstable_renderSubtreeIntoContainer;
8+
export const portalClassName = 'ReactModalPortal';
9+
export const bodyOpenClassName = 'ReactModal__Body--open';
1210

13-
const SafeHTMLElement = EE.canUseDOM ? window.HTMLElement : {};
11+
const renderSubtreeIntoContainer = ReactDOM.unstable_renderSubtreeIntoContainer;
1412

1513
function getParentElement(parentSelector) {
1614
return parentSelector();
@@ -62,8 +60,8 @@ export default class Modal extends Component {
6260

6361
static defaultProps = {
6462
isOpen: false,
65-
portalClassName: 'ReactModalPortal',
66-
bodyOpenClassName: 'ReactModal__Body--open',
63+
portalClassName,
64+
bodyOpenClassName,
6765
ariaHideApp: true,
6866
closeTimeoutMS: 0,
6967
shouldCloseOnOverlayClick: true,
@@ -99,10 +97,9 @@ export default class Modal extends Component {
9997
this.node = document.createElement('div');
10098
this.node.className = this.props.portalClassName;
10199

102-
if (this.props.isOpen) refCount.add(this);
103-
104100
const parent = getParentElement(this.props.parentSelector);
105101
parent.appendChild(this.node);
102+
106103
this.renderPortal(this.props);
107104
}
108105

@@ -111,8 +108,6 @@ export default class Modal extends Component {
111108
// Stop unnecessary renders if modal is remaining closed
112109
if (!this.props.isOpen && !isOpen) return;
113110

114-
if (isOpen) refCount.add(this);
115-
if (!isOpen) refCount.remove(this);
116111
const currentParent = getParentElement(this.props.parentSelector);
117112
const newParent = getParentElement(newProps.parentSelector);
118113

@@ -133,12 +128,6 @@ export default class Modal extends Component {
133128
componentWillUnmount() {
134129
if (!this.node) return;
135130

136-
refCount.remove(this);
137-
138-
if (this.props.ariaHideApp) {
139-
ariaAppHider.show(this.props.appElement);
140-
}
141-
142131
const state = this.portal.state;
143132
const now = Date.now();
144133
const closesAt = state.isOpen && this.props.closeTimeoutMS
@@ -160,23 +149,9 @@ export default class Modal extends Component {
160149
ReactDOM.unmountComponentAtNode(this.node);
161150
const parent = getParentElement(this.props.parentSelector);
162151
parent.removeChild(this.node);
163-
164-
if (refCount.count() === 0) {
165-
elementClass(document.body).remove(this.props.bodyOpenClassName);
166-
}
167152
}
168153

169154
renderPortal = props => {
170-
if (props.isOpen || refCount.count() > 0) {
171-
elementClass(document.body).add(this.props.bodyOpenClassName);
172-
} else {
173-
elementClass(document.body).remove(this.props.bodyOpenClassName);
174-
}
175-
176-
if (props.ariaHideApp) {
177-
ariaAppHider.toggle(props.isOpen, props.appElement);
178-
}
179-
180155
this.portal = renderSubtreeIntoContainer(this, (
181156
<ModalPortal defaultStyles={Modal.defaultStyles} {...props} />
182157
), this.node);

‎src/components/ModalPortal.js

+43
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import React, { Component } from 'react';
22
import { PropTypes } from 'prop-types';
3+
import elementClass from 'element-class';
34
import * as focusManager from '../helpers/focusManager';
45
import scopeTab from '../helpers/scopeTab';
6+
import * as ariaAppHider from '../helpers/ariaAppHider';
7+
import * as refCount from '../helpers/refCount';
8+
import SafeHTMLElement from '../helpers/safeHTMLElement';
59

610
// so that our CSS is statically analyzable
711
const CLASS_NAMES = {
@@ -38,6 +42,9 @@ export default class ModalPortal extends Component {
3842
PropTypes.string,
3943
PropTypes.object
4044
]),
45+
bodyOpenClassName: PropTypes.string,
46+
ariaHideApp: PropTypes.bool,
47+
appElement: PropTypes.instanceOf(SafeHTMLElement),
4148
onAfterOpen: PropTypes.func,
4249
onRequestClose: PropTypes.func,
4350
closeTimeoutMS: PropTypes.number,
@@ -67,6 +74,15 @@ export default class ModalPortal extends Component {
6774
}
6875

6976
componentWillReceiveProps(newProps) {
77+
if (process.env.NODE_ENV !== "production") {
78+
if (newProps.bodyOpenClassName !== this.props.bodyOpenClassName) {
79+
// eslint-disable-next-line no-console
80+
console.warn(
81+
'React-Modal: "bodyOpenClassName" prop has been modified. ' +
82+
'This may cause unexpected behavior when multiple modals are open.'
83+
);
84+
}
85+
}
7086
// Focus only needs to be set once when the modal is being opened
7187
if (!this.props.isOpen && newProps.isOpen) {
7288
this.setFocusAfterRender(true);
@@ -84,6 +100,7 @@ export default class ModalPortal extends Component {
84100
}
85101

86102
componentWillUnmount() {
103+
this.beforeClose();
87104
clearTimeout(this.closeTimer);
88105
}
89106

@@ -99,12 +116,37 @@ export default class ModalPortal extends Component {
99116
this.content = content;
100117
}
101118

119+
beforeOpen() {
120+
const { appElement, ariaHideApp, bodyOpenClassName } = this.props;
121+
refCount.add(bodyOpenClassName);
122+
// Add body class
123+
elementClass(document.body).add(bodyOpenClassName);
124+
// Add aria-hidden to appElement
125+
if (ariaHideApp) {
126+
ariaAppHider.hide(appElement);
127+
}
128+
}
129+
130+
beforeClose() {
131+
const { appElement, ariaHideApp, bodyOpenClassName } = this.props;
132+
refCount.remove(bodyOpenClassName);
133+
// Remove class if no more modals are open
134+
if (refCount.count(bodyOpenClassName) === 0) {
135+
elementClass(document.body).remove(bodyOpenClassName);
136+
}
137+
// Reset aria-hidden attribute if all modals have been removed
138+
if (ariaHideApp && refCount.totalCount() < 1) {
139+
ariaAppHider.show(appElement);
140+
}
141+
}
142+
102143
afterClose = () => {
103144
focusManager.returnFocus();
104145
focusManager.teardownScopedFocus();
105146
}
106147

107148
open = () => {
149+
this.beforeOpen();
108150
if (this.state.afterOpen && this.state.beforeClose) {
109151
clearTimeout(this.closeTimer);
110152
this.setState({ beforeClose: false });
@@ -122,6 +164,7 @@ export default class ModalPortal extends Component {
122164
}
123165

124166
close = () => {
167+
this.beforeClose();
125168
if (this.props.closeTimeoutMS > 0) {
126169
this.closeWithTimeout();
127170
} else {

‎src/helpers/ariaAppHider.js

-5
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,6 @@ export function show(appElement) {
4848
(appElement || globalElement).removeAttribute('aria-hidden');
4949
}
5050

51-
export function toggle(shouldHide, appElement) {
52-
const apply = shouldHide ? hide : show;
53-
apply(appElement);
54-
}
55-
5651
export function documentNotReadyOrSSRTesting() {
5752
globalElement = null;
5853
}

‎src/helpers/refCount.js

+15-11
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
1-
const modals = [];
1+
const modals = {};
22

3-
export function add(element) {
4-
if (modals.indexOf(element) === -1) {
5-
modals.push(element);
3+
export function add(bodyClass) {
4+
// Set variable and default if none
5+
if (!modals[bodyClass]) {
6+
modals[bodyClass] = 0;
67
}
8+
modals[bodyClass] += 1;
79
}
810

9-
export function remove(element) {
10-
const index = modals.indexOf(element);
11-
if (index === -1) {
12-
return;
11+
export function remove(bodyClass) {
12+
if (modals[bodyClass]) {
13+
modals[bodyClass] -= 1;
1314
}
14-
modals.splice(index, 1);
1515
}
1616

17-
export function count() {
18-
return modals.length;
17+
export function count(bodyClass) {
18+
return modals[bodyClass];
19+
}
20+
21+
export function totalCount() {
22+
return Object.keys(modals).reduce((acc, curr) => acc + modals[curr], 0);
1923
}

‎src/helpers/safeHTMLElement.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import ExecutionEnvironment from 'exenv';
2+
3+
const EE = ExecutionEnvironment;
4+
5+
const SafeHTMLElement = EE.canUseDOM ? window.HTMLElement : {};
6+
7+
export default SafeHTMLElement;

0 commit comments

Comments
 (0)
Please sign in to comment.