├── .babelrc
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── index.js
├── package.json
├── src
└── index.jsx
└── test
├── compiler.js
└── index.jsx
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react", "es2015"]
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Directory for instrumented libs generated by jscoverage/JSCover
11 | lib-cov
12 |
13 | # Coverage directory used by tools like istanbul
14 | coverage
15 |
16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
17 | .grunt
18 |
19 | # node-waf configuration
20 | .lock-wscript
21 |
22 | # Compiled binary addons (http://nodejs.org/api/addons.html)
23 | build/Release
24 |
25 | # Dependency directory
26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
27 | node_modules
28 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "6"
4 | script:
5 | - npm run test
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Bob Lauer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # An ES6-friendly on-click-outside React component.
2 |
3 | This is a React component that can be used to listen for clicks outside of a given component. As an example, you may need to hide a menu when a user clicks elsewhere on the page.
4 |
5 | This component was created specifically to support ES6-style React components. If you want to use a mixin instead, I would recommend the [react-onclickoutside](https://github.com/Pomax/react-onclickoutside) mixin.
6 |
7 | ## Installation
8 |
9 | ```
10 | npm install react-onclickout --save
11 | ```
12 |
13 | ## React Version Support
14 |
15 | For React `0.14` or later, use version `2.x` of this package. For React `0.13` or earlier, use version `1.x` of this package.
16 |
17 | ## Usage
18 |
19 | There are two ways to use this component.
20 |
21 | ### As a wrapper component
22 |
23 | ```jsx
24 | const ClickOutHandler = require('react-onclickout');
25 |
26 | class ExampleComponent extends React.Component {
27 |
28 | onClickOut(e) {
29 | alert('user clicked outside of the component!');
30 | }
31 |
32 | render() {
33 | return (
34 |
35 | Click outside of me!
36 |
37 | );
38 | }
39 | }
40 | ```
41 |
42 | ### As a base component
43 |
44 | ```jsx
45 | const ClickOutComponent = require('react-onclickout');
46 |
47 | class ExampleComponent extends ClickOutComponent {
48 |
49 | onClickOut(e) {
50 | alert('user clicked outside of the component!');
51 | }
52 |
53 | render() {
54 | return (
55 |
Click outside of me!
56 | );
57 | }
58 | }
59 | ```
60 |
61 | ## Ignoring Elements
62 |
63 | There are times when you may want to ignore certain elements that were clicked outside of the target component. You can handle such a scenario by inspecting the event passed to your `onClickOut` method handler.
64 |
65 | ```jsx
66 | const ClickOutHandler = require('react-onclickout');
67 |
68 | class ExampleComponent extends React.Component {
69 |
70 | onClickOut(e) {
71 | if (hasClass(e.target, 'ignore-me')) return;
72 | alert('user clicked outside of the component!');
73 | }
74 |
75 | render() {
76 | return (
77 |
78 | Click outside of me!
79 |
80 | );
81 | }
82 | }
83 | ```
84 |
85 | That's pretty much it. Pull requests are more than welcome!
86 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
4 |
5 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
6 |
7 | function _possibleConstructorReturn(self, call) { if (!self) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return call && (typeof call === "object" || typeof call === "function") ? call : self; }
8 |
9 | function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }
10 |
11 | var React = require('react');
12 | var ReactDOM = require('react-dom');
13 |
14 | var ClickOutComponent = function (_React$Component) {
15 | _inherits(ClickOutComponent, _React$Component);
16 |
17 | function ClickOutComponent() {
18 | _classCallCheck(this, ClickOutComponent);
19 |
20 | return _possibleConstructorReturn(this, (ClickOutComponent.__proto__ || Object.getPrototypeOf(ClickOutComponent)).call(this));
21 | }
22 |
23 | _createClass(ClickOutComponent, [{
24 | key: 'componentDidMount',
25 | value: function componentDidMount() {
26 | var self = this;
27 | var elTouchIsClick = true;
28 | var documentTouchIsClick = true;
29 | var el = ReactDOM.findDOMNode(this);
30 |
31 | self.__documentTouchStarted = function (e) {
32 | el.removeEventListener('click', self.__elementClicked);
33 | document.removeEventListener('click', self.__documentClicked);
34 | };
35 |
36 | self.__documentTouchMoved = function (e) {
37 | documentTouchIsClick = false;
38 | };
39 |
40 | self.__documentTouchEnded = function (e) {
41 | if (documentTouchIsClick) self.__documentClicked(e);
42 | documentTouchIsClick = true;
43 | };
44 |
45 | self.__documentClicked = function (e) {
46 | if ((e.__clickedElements || []).indexOf(el) !== -1) return;
47 |
48 | var clickOutHandler = self.onClickOut || self.props.onClickOut;
49 | if (!clickOutHandler) {
50 | return console.warn('onClickOut is not defined.');
51 | }
52 |
53 | clickOutHandler.call(self, e);
54 | };
55 |
56 | self.__elementTouchMoved = function (e) {
57 | elTouchIsClick = false;
58 | };
59 |
60 | self.__elementTouchEnded = function (e) {
61 | if (elTouchIsClick) self.__elementClicked(e);
62 | elTouchIsClick = true;
63 | };
64 |
65 | self.__elementClicked = function (e) {
66 | e.__clickedElements = e.__clickedElements || [];
67 | e.__clickedElements.push(el);
68 | };
69 |
70 | setTimeout(function () {
71 | if (self.__unmounted) return;
72 | self.toggleListeners('addEventListener');
73 | }, 0);
74 | }
75 | }, {
76 | key: 'toggleListeners',
77 | value: function toggleListeners(listenerMethod) {
78 | var el = ReactDOM.findDOMNode(this);
79 |
80 | el[listenerMethod]('touchmove', this.__elementTouchMoved);
81 | el[listenerMethod]('touchend', this.__elementTouchEnded);
82 | el[listenerMethod]('click', this.__elementClicked);
83 |
84 | document[listenerMethod]('touchstart', this.__documentTouchStarted);
85 | document[listenerMethod]('touchmove', this.__documentTouchMoved);
86 | document[listenerMethod]('touchend', this.__documentTouchEnded);
87 | document[listenerMethod]('click', this.__documentClicked);
88 | }
89 | }, {
90 | key: 'componentWillUnmount',
91 | value: function componentWillUnmount() {
92 | this.toggleListeners('removeEventListener');
93 | this.__unmounted = true;
94 | }
95 | }, {
96 | key: 'render',
97 | value: function render() {
98 | return Array.isArray(this.props.children) ? React.createElement(
99 | 'div',
100 | null,
101 | this.props.children
102 | ) : React.Children.only(this.props.children);
103 | }
104 | }]);
105 |
106 | return ClickOutComponent;
107 | }(React.Component);
108 |
109 | module.exports = ClickOutComponent;
110 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-onclickout",
3 | "version": "2.0.7",
4 | "description": "An ES6-friendly on-click-outside React component.",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "babel src/index.jsx --out-file index.js",
8 | "test": "npm run build && mocha --compilers .jsx:test/compiler.js test/*.jsx",
9 | "prepublish": "npm run build"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/boblauer/react-onclickout.git"
14 | },
15 | "keywords": [
16 | "react",
17 | "onclick",
18 | "onclickout",
19 | "onclickoutside"
20 | ],
21 | "author": "Bob Lauer ",
22 | "license": "MIT",
23 | "bugs": {
24 | "url": "https://github.com/boblauer/react-onclickout/issues"
25 | },
26 | "homepage": "https://github.com/boblauer/react-onclickout#readme",
27 | "peerDependencies": {
28 | "react": "^15.x || ^16.x",
29 | "react-dom": "^15.x || ^16.x"
30 | },
31 | "devDependencies": {
32 | "babel-cli": "^6.26.0",
33 | "babel-preset-es2015": "^6.24.1",
34 | "babel-preset-react": "^6.24.1",
35 | "babel-register": "^6.26.0",
36 | "jsdom": "^11.3.0",
37 | "mocha": "^2.2.5",
38 | "react": "^16.x",
39 | "react-dom": "^16.x",
40 | "sinon": "^1.17.2"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const React = require('react');
4 | const ReactDOM = require('react-dom');
5 |
6 | class ClickOutComponent extends React.Component {
7 |
8 | constructor() {
9 | super();
10 | }
11 |
12 | componentDidMount() {
13 | let self = this;
14 | let elTouchIsClick = true;
15 | let documentTouchIsClick = true;
16 | let el = ReactDOM.findDOMNode(this);
17 |
18 | self.__documentTouchStarted = function(e) {
19 | el.removeEventListener('click', self.__elementClicked);
20 | document.removeEventListener('click', self.__documentClicked);
21 | }
22 |
23 | self.__documentTouchMoved = function(e) {
24 | documentTouchIsClick = false;
25 | };
26 |
27 | self.__documentTouchEnded = function(e) {
28 | if (documentTouchIsClick) self.__documentClicked(e);
29 | documentTouchIsClick = true;
30 | };
31 |
32 | self.__documentClicked = function(e) {
33 | if ((e.__clickedElements || []).indexOf(el) !== -1) return;
34 |
35 | let clickOutHandler = self.onClickOut || self.props.onClickOut;
36 | if (!clickOutHandler) {
37 | return console.warn('onClickOut is not defined.');
38 | }
39 |
40 | clickOutHandler.call(self, e);
41 | };
42 |
43 | self.__elementTouchMoved = function(e) {
44 | elTouchIsClick = false;
45 | };
46 |
47 | self.__elementTouchEnded = function(e) {
48 | if (elTouchIsClick) self.__elementClicked(e);
49 | elTouchIsClick = true;
50 | };
51 |
52 | self.__elementClicked = function(e) {
53 | e.__clickedElements = e.__clickedElements || [];
54 | e.__clickedElements.push(el);
55 | };
56 |
57 | setTimeout(function() {
58 | if (self.__unmounted) return;
59 | self.toggleListeners('addEventListener');
60 | }, 0);
61 | }
62 |
63 | toggleListeners(listenerMethod) {
64 | let el = ReactDOM.findDOMNode(this);
65 |
66 | el[listenerMethod]('touchmove', this.__elementTouchMoved);
67 | el[listenerMethod]('touchend', this.__elementTouchEnded);
68 | el[listenerMethod]('click', this.__elementClicked);
69 |
70 | document[listenerMethod]('touchstart', this.__documentTouchStarted);
71 | document[listenerMethod]('touchmove', this.__documentTouchMoved);
72 | document[listenerMethod]('touchend', this.__documentTouchEnded);
73 | document[listenerMethod]('click', this.__documentClicked);
74 | }
75 |
76 | componentWillUnmount() {
77 | this.toggleListeners('removeEventListener');
78 | this.__unmounted = true;
79 | }
80 |
81 | render() {
82 | return Array.isArray(this.props.children) ?
83 | {this.props.children}
:
84 | React.Children.only(this.props.children);
85 | }
86 | }
87 |
88 | module.exports = ClickOutComponent
89 |
--------------------------------------------------------------------------------
/test/compiler.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('babel-register')({
4 | only: /test/
5 | });
6 |
--------------------------------------------------------------------------------
/test/index.jsx:
--------------------------------------------------------------------------------
1 | var assert = require('assert')
2 | , jsdom = require('jsdom')
3 | , React = require('react')
4 | , ReactDOM = require('react-dom')
5 | , sinon = require('sinon')
6 | , ClickOutWrapper = require('../index.js')
7 | , clickedOutCount = 0
8 | ;
9 |
10 | var JSDOM = jsdom.JSDOM;
11 |
12 | function incrementClickedOutCount(count) {
13 | if (typeof count !== 'number') count = 1;
14 | clickedOutCount += count;
15 | }
16 |
17 | beforeEach(() => {
18 | clickedOutCount = 0;
19 | });
20 |
21 | describe('ClickOutWrapper', function () {
22 | var container;
23 |
24 | beforeEach(function() {
25 | sinon.stub(global, 'setTimeout', function(cb) {
26 | cb();
27 | });
28 | });
29 |
30 | afterEach(function() {
31 | if (setTimeout.restore) {
32 | setTimeout.restore();
33 | }
34 | });
35 |
36 | beforeEach(function () {
37 | clickedOutCount = 0;
38 | var dom = new JSDOM('
');
39 | global.document = dom.window.document;
40 | global.window = dom.window;
41 | container = global.document.querySelector('#container');
42 | });
43 |
44 | it('works as a wrapper component', function() {
45 | ReactDOM.render(
46 |
47 | Click in!
48 | , container
49 | );
50 |
51 | appendClickOutArea(container);
52 |
53 | testClicks();
54 | });
55 |
56 | it('works as a wrapper component with multiple children', function() {
57 | ReactDOM.render(
58 |
59 | Click in!
60 | Click in!
61 | , container
62 | );
63 |
64 | appendClickOutArea(container);
65 |
66 | testClicks();
67 | })
68 |
69 | it('works with multiple instances at once', function() {
70 | ReactDOM.render(
71 |
72 |
73 | Click in!
74 |
75 |
76 | Click in!
77 |
78 |
, container
79 | );
80 |
81 | appendClickOutArea(container);
82 |
83 | testMultipleInstanceClicks();
84 | });
85 |
86 | it('works when adding the click-out component on click of the page', function(done) {
87 | var hideWasCalled = false;
88 |
89 | class Component extends React.Component {
90 | constructor() {
91 | super();
92 | this.state = { modalVisible: false }
93 | }
94 |
95 | showModal() {
96 | this.setState({ modalVisible: true });
97 | }
98 |
99 | hideModal() {
100 | hideWasCalled = true;
101 | this.setState({ modalVisible: false });
102 | }
103 |
104 | render() {
105 | var el = this.state.modalVisible ?
106 | I am a modal! :
107 | Show Modal! ;
108 |
109 | return el;
110 | }
111 | }
112 |
113 | ReactDOM.render(React.createElement(Component), container);
114 | setTimeout.restore();
115 | setTimeout(function() {
116 | simulateClick(container.querySelector('button'));
117 | assert(!hideWasCalled);
118 | done();
119 | }, 1);
120 | });
121 |
122 | it('cleans up handlers as a wrapper component', function() {
123 | ReactDOM.render(
124 |
125 | Click in!
126 | , container
127 | );
128 |
129 | appendClickOutArea(container);
130 |
131 | testClicks(function() {
132 | var unmounted = ReactDOM.unmountComponentAtNode(container);
133 | assert.equal(unmounted, true);
134 |
135 | appendClickOutArea(container);
136 |
137 | testUnmountedClicks();
138 | });
139 | });
140 |
141 | it('works as a base class', function() {
142 | class Component extends ClickOutWrapper {
143 | onClickOut() {
144 | incrementClickedOutCount();
145 | }
146 |
147 | render() {
148 | return Click in! ;
149 | }
150 | }
151 |
152 | ReactDOM.render(React.createElement(Component), container);
153 | appendClickOutArea(container);
154 |
155 | testClicks();
156 | });
157 |
158 | it('does not fire a click on itself when it is nested inside another click-out component', function() {
159 | ReactDOM.render(
160 |
161 |
162 | Click in!
163 |
164 | Click in!
165 |
166 |
167 |
, container
168 | );
169 |
170 | var outerClickArea = document.querySelector('.outer-click-area')
171 | , innerClickArea = document.querySelector('.inner-click-area')
172 | , prevCount = clickedOutCount
173 | ;
174 |
175 | simulateClick(outerClickArea);
176 | assert.equal(clickedOutCount, prevCount + 2);
177 |
178 | simulateClick(innerClickArea);
179 | assert.equal(clickedOutCount, prevCount + 2);
180 | })
181 |
182 | it('cleans up as a base component', function() {
183 | class Component extends ClickOutWrapper {
184 | onClickOut() {
185 | incrementClickedOutCount();
186 | }
187 |
188 | render() {
189 | return Click in! ;
190 | }
191 | }
192 |
193 | ReactDOM.render(React.createElement(Component), container);
194 | appendClickOutArea(container);
195 |
196 | testClicks();
197 | var unmounted = ReactDOM.unmountComponentAtNode(container);
198 | assert.equal(unmounted, true);
199 |
200 | appendClickOutArea(container);
201 |
202 | testUnmountedClicks();
203 | });
204 |
205 | it('works when a touchstart and touchend have been fired without a touchmove inbetween', function() {
206 | ReactDOM.render(
207 |
208 | Click in!
209 | , container
210 | );
211 |
212 | appendClickOutArea(container);
213 |
214 | testValidTouches();
215 | });
216 |
217 | it('does not register a click when a touchdrag event is fired inbetween touchstart and touchend', function() {
218 | ReactDOM.render(
219 |
220 | Click in!
221 | , container
222 | );
223 |
224 | appendClickOutArea(container);
225 |
226 | testInvalidTouches();
227 | });
228 | });
229 |
230 | function testClicks() {
231 | var clickIn = document.querySelector('.click-in')
232 | , clickOut = document.querySelector('.click-out')
233 | , prevCount = clickedOutCount
234 | ;
235 |
236 | simulateClick(clickIn);
237 | assert.equal(clickedOutCount, prevCount);
238 |
239 | simulateClick(clickOut);
240 | assert.equal(clickedOutCount, prevCount + 1);
241 | }
242 |
243 | function testValidTouches() {
244 | var clickIn = document.querySelector('.click-in')
245 | , clickOut = document.querySelector('.click-out')
246 | ;
247 |
248 | simulateTouchEvent(clickIn, 'touchstart');
249 | simulateTouchEvent(clickIn, 'touchend');
250 | assert.equal(clickedOutCount, 0);
251 |
252 | simulateTouchEvent(clickOut, 'touchstart');
253 | simulateTouchEvent(clickOut, 'touchend');
254 | assert.equal(clickedOutCount, 1);
255 | }
256 |
257 | function testInvalidTouches() {
258 | var clickIn = document.querySelector('.click-in')
259 | , clickOut = document.querySelector('.click-out')
260 | , prevCount = clickedOutCount
261 | ;
262 |
263 | simulateTouchEvent(clickIn, 'touchstart');
264 | simulateTouchEvent(clickIn, 'touchmove');
265 | simulateTouchEvent(clickIn, 'touchend');
266 | assert.equal(clickedOutCount, 0);
267 |
268 | simulateTouchEvent(clickOut, 'touchstart');
269 | simulateTouchEvent(clickOut, 'touchmove');
270 | simulateTouchEvent(clickOut, 'touchend');
271 | assert.equal(clickedOutCount, 0);
272 | }
273 |
274 | function testMultipleInstanceClicks() {
275 | var clickIn1 = document.querySelector('.click-in')
276 | , clickIn2 = document.querySelector('.click-in-2')
277 | , clickOut = document.querySelector('.click-out')
278 | , prevCount = clickedOutCount
279 | ;
280 |
281 | simulateClick(clickIn1);
282 | assert.equal(clickedOutCount, prevCount + 1);
283 |
284 | simulateClick(clickIn2);
285 | assert.equal(clickedOutCount, prevCount + 2);
286 |
287 | simulateClick(clickOut);
288 | assert.equal(clickedOutCount, prevCount + 4);
289 | }
290 |
291 | function testUnmountedClicks() {
292 | var clickOut = document.querySelector('.click-out')
293 | , prevCount = clickedOutCount
294 | ;
295 |
296 | simulateClick(clickOut);
297 | assert.equal(clickedOutCount, prevCount);
298 | }
299 |
300 | function appendClickOutArea(parent) {
301 | var span = document.createElement('span');
302 | span.className = 'click-out';
303 |
304 | parent.appendChild(span);
305 | }
306 |
307 | function simulateClick(el) {
308 | var clickEvent = document.createEvent('MouseEvents');
309 | clickEvent.initEvent('click', true, true);
310 | el.dispatchEvent(clickEvent);
311 | }
312 |
313 | function simulateTouchEvent(el, eventType) {
314 | var touchEvent = document.createEvent('TouchEvent');
315 | touchEvent.initEvent(eventType, true, true);
316 | el.dispatchEvent(touchEvent);
317 | }
318 |
--------------------------------------------------------------------------------