├── .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 | ; 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 | --------------------------------------------------------------------------------