├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .npmrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.js ├── package.json ├── src └── OutsideClickHandler.jsx └── test ├── .eslintrc ├── OutsideClickHandler_test.jsx ├── _helpers ├── enzymeSetup.js └── restoreSinonStubs.js └── mocha.opts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "development": { 4 | "presets": ["airbnb"], 5 | }, 6 | "test": { 7 | "presets": ["airbnb"] 8 | }, 9 | "cjs": { 10 | "presets": ["airbnb"] 11 | }, 12 | "esm": { 13 | "presets": [["airbnb", { "modules": false }]] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | 4 | "extends": [ 5 | "airbnb", 6 | ], 7 | 8 | "env": { 9 | "browser": true, 10 | "node": true 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Only apps should have lockfiles. 61 | npm-shrinkwrap.json 62 | package-lock.json 63 | yarn.lock 64 | 65 | # Build output 66 | build/ 67 | esm/ 68 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Only apps should have lockfiles. 61 | npm-shrinkwrap.json 62 | package-lock.json 63 | yarn.lock 64 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "12" 4 | - "10" 5 | - "8" 6 | - "6" 7 | - "4" 8 | - "iojs" 9 | before_install: 10 | - 'nvm install-latest-npm' 11 | script: 12 | - 'if [ -n "${LINT-}" ]; then npm run lint ; fi' 13 | - 'if [ "${TEST-}" = true ]; then npm run tests-only ; fi' 14 | env: 15 | global: 16 | - TEST=true 17 | matrix: 18 | - REACT=0.14 19 | - REACT=15 20 | - REACT=16 21 | sudo: false 22 | matrix: 23 | fast_finish: true 24 | include: 25 | - node_js: "lts/*" 26 | env: LINT=true TEST=false 27 | allow_failures: 28 | - node_js: "iojs" 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v1.3.0 4 | - [New] `display`: add `contents` (#34) 5 | - [New] `display`: add `inline` (#28) 6 | - [Refactor] Replace `componentWillReceiveProps` with `componentDidUpdate` for a side effect (#36) 7 | - [Deps] update `airbnb-prop-types` 8 | - [Dev Deps] update `enzyme-adapter-react-helper`, `eslint`, `eslint-config-airbnb`, `eslint-plugin-import`, `eslint-plugin-react`, `eslint-plugin-react-with-styles`, `rimraf`, `safe-publish-latest`, `sinon-sandbox`; add `eslint-plugin-react-hooks` 9 | 10 | ## v1.2.4 11 | - [Fix] prevent memory leak if `mousedown` is fired, but `mouseup` isn’t (#20) 12 | - [Deps] update `airbnb-prop-types`, `document.contains` 13 | - [Dev Deps] update `airbnb-js-shims`, `babel-preset-airbnb`, `enzyme`, `enzyme-adapter-react-helper`, `eslint`, `eslint-plugin-import`, `eslint-plugin-react`, `eslint-config-airbnb`, `eslint-plugin-jsx-a11y`, `sinon-sandbox` 14 | 15 | ## v1.2.3 16 | - [Fix] use `document.contains` instead of implicitly requiring polyfills 17 | - [Deps] update `object.values`, `prop-types`, `airbnb-prop-types` 18 | - [Dev Deps] update `airbnb-js-shims`, `chai`, `enzyme`, `enzyme-adapter-react-helper`, `eslint`, `eslint-config-airbnb`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `eslint-plugin-react-with-styles`, `react`, `react-dom`, `rimraf`, `safe-publish-latest` 19 | 20 | ## v1.2.2 21 | - Add .npmignore, fixes ([#6](https://github.com/airbnb/react-outside-click-handler/issues/6)) 22 | 23 | ## v1.2.1 24 | - [Fix] use `object.values` instead of `Object.values` 25 | 26 | ## v1.2.0 27 | - Allow consolidated-events ^2.0.0 ([#5](https://github.com/airbnb/react-outside-click-handler/pull/5)) 28 | 29 | ## v1.1.0 30 | - Add `flex` as a display prop option ([#4](https://github.com/airbnb/react-outside-click-handler/pull/4)) 31 | 32 | ## v1.0.0 33 | - Initial commit 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Airbnb 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-outside-click-handler 2 | 3 | > A React component for handling outside clicks 4 | 5 | ## Installation 6 | 7 | ```shell 8 | $ npm install react-outside-click-handler 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```jsx 14 | import OutsideClickHandler from 'react-outside-click-handler'; 15 | 16 | function MyComponent() { 17 | return ( 18 | { 20 | alert('You clicked outside of this component!!!'); 21 | }} 22 | > 23 | Hello World 24 | 25 | ); 26 | } 27 | ``` 28 | 29 | ## Props 30 | 31 | ### children: `PropTypes.node.isRequired` 32 | 33 | Since the `OutsideClickHandler` specifically handles clicks outside a specific subtree, `children` is expected to be defined. A consumer should also not render the `OutsideClickHandler` in the case that `children` are not defined. 34 | 35 | *Note that if you use a `Portal` (native or `react-portal`) of any sort in the `children`, the `OutsideClickHandler` will not behave as expected.* 36 | 37 | ### onOutsideClick: `PropTypes.func.isRequired` 38 | 39 | The `onOutsideClick` prop is also required as without it, the `OutsideClickHandler` is basically a heavy-weight `
`. It takes the relevant clickevent as an arg and gets triggered when the user clicks anywhere outside of the subtree generated by the DOM node. 40 | 41 | ### disabled: `PropTypes.bool` 42 | 43 | If the `disabled` prop is true, outside clicks will not be registered. This can be utilized to temporarily disable interaction without unmounting/remounting the entire tree. 44 | 45 | ### useCapture: `PropTypes.bool` 46 | 47 | See https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#Event_bubbling_and_capture for more information on event bubbling vs. capture. 48 | 49 | If `useCapture` is true, the event will be registered in the capturing phase and thus, propagated top-down instead of bottom-up as is the default. 50 | 51 | ### display: `PropTypes.oneOf(['block', 'flex', 'inline-block', 'inline', 'contents'])` 52 | 53 | By default, the `OutsideClickHandler` renders a `display: block` `
` to wrap the subtree defined by `children`. If desired, the `display` can be set to `inline-block`, `inline`, `flex`, or `contents` instead. There is no way not to render a wrapping `
`. 54 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | module.exports = require('./build/OutsideClickHandler'); 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-outside-click-handler", 3 | "version": "1.3.0", 4 | "description": "A React component for dealing with clicks outside its subtree", 5 | "main": "index.js", 6 | "scripts": { 7 | "prebuild": "npm run clean", 8 | "build": "npm run build:cjs && npm run build:esm", 9 | "build:cjs": "BABEL_ENV=cjs babel src/ -d build/", 10 | "build:esm": "BABEL_ENV=esm babel src/ -d esm/", 11 | "clean": "rimraf build esm", 12 | "lint": "eslint --ext .js,.jsx src test", 13 | "mocha": "mocha", 14 | "react": "enzyme-adapter-react-install 16", 15 | "pretest": "npm run --silent lint", 16 | "pretests-only": "npm run react", 17 | "tests-only": "npm run mocha --silent test", 18 | "test": "npm run tests-only", 19 | "tag": "git tag v$npm_package_version", 20 | "version:patch": "npm --no-git-tag-version version patch", 21 | "version:minor": "npm --no-git-tag-version version minor", 22 | "version:major": "npm --no-git-tag-version version major", 23 | "preversion": "npm run test && npm run check-changelog && npm run check-only-changelog-changed", 24 | "postversion": "git commit package.json CHANGELOG.md -m \"v$npm_package_version\" && npm run tag && git push && git push --tags && npm publish --registry=https://registry.npmjs.org/", 25 | "prepublish": "in-publish && safe-publish-latest && npm run build || not-in-publish", 26 | "check-changelog": "expr $(git status --porcelain 2>/dev/null| grep \"^\\s*M.*CHANGELOG.md\" | wc -l) >/dev/null || (echo 'Please edit CHANGELOG.md' && exit 1)", 27 | "check-only-changelog-changed": "(expr $(git status --porcelain 2>/dev/null| grep -v \"CHANGELOG.md\" | wc -l) >/dev/null && echo 'Only CHANGELOG.md may have uncommitted changes' && exit 1) || exit 0" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/airbnb/react-outside-click-handler.git" 32 | }, 33 | "author": "Maja Wichrowska ", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/airbnb/react-outside-click-handler/issues" 37 | }, 38 | "homepage": "https://github.com/airbnb/react-outside-click-handler#readme", 39 | "devDependencies": { 40 | "airbnb-js-shims": "^2.2.0", 41 | "babel-cli": "^6.26.0", 42 | "babel-core": "^6.26.3", 43 | "babel-plugin-syntax-jsx": "^6.18.0", 44 | "babel-preset-airbnb": "^2.6.0", 45 | "babel-register": "^6.26.0", 46 | "chai": "^4.2.0", 47 | "enzyme": "^3.10.0", 48 | "enzyme-adapter-react-helper": "^1.3.6", 49 | "eslint": "^6.4.0", 50 | "eslint-config-airbnb": "^18.0.1", 51 | "eslint-plugin-import": "^2.18.2", 52 | "eslint-plugin-jsx-a11y": "^6.2.3", 53 | "eslint-plugin-react": "^7.14.3", 54 | "eslint-plugin-react-hooks": "^2.1.0", 55 | "eslint-plugin-react-with-styles": "^2.2.0", 56 | "in-publish": "^2.0.0", 57 | "mocha": "^3.5.3", 58 | "mocha-wrap": "^2.1.2", 59 | "react": "^0.14 || >=15", 60 | "react-dom": "^0.14 || >=15", 61 | "rimraf": "^2.7.1", 62 | "safe-publish-latest": "^1.1.3", 63 | "sinon": "^5.1.1", 64 | "sinon-sandbox": "^2.0.6" 65 | }, 66 | "dependencies": { 67 | "airbnb-prop-types": "^2.15.0", 68 | "consolidated-events": "^1.1.1 || ^2.0.0", 69 | "document.contains": "^1.0.1", 70 | "object.values": "^1.1.0", 71 | "prop-types": "^15.7.2" 72 | }, 73 | "peerDependencies": { 74 | "react": "^0.14 || >=15", 75 | "react-dom": "^0.14 || >=15" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/OutsideClickHandler.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { forbidExtraProps } from 'airbnb-prop-types'; 5 | import { addEventListener } from 'consolidated-events'; 6 | import objectValues from 'object.values'; 7 | 8 | import contains from 'document.contains'; 9 | 10 | const DISPLAY = { 11 | BLOCK: 'block', 12 | FLEX: 'flex', 13 | INLINE: 'inline', 14 | INLINE_BLOCK: 'inline-block', 15 | CONTENTS: 'contents', 16 | }; 17 | 18 | const propTypes = forbidExtraProps({ 19 | children: PropTypes.node.isRequired, 20 | onOutsideClick: PropTypes.func.isRequired, 21 | disabled: PropTypes.bool, 22 | useCapture: PropTypes.bool, 23 | display: PropTypes.oneOf(objectValues(DISPLAY)), 24 | }); 25 | 26 | const defaultProps = { 27 | disabled: false, 28 | 29 | // `useCapture` is set to true by default so that a `stopPropagation` in the 30 | // children will not prevent all outside click handlers from firing - maja 31 | useCapture: true, 32 | display: DISPLAY.BLOCK, 33 | }; 34 | 35 | export default class OutsideClickHandler extends React.Component { 36 | constructor(...args) { 37 | super(...args); 38 | 39 | this.onMouseDown = this.onMouseDown.bind(this); 40 | this.onMouseUp = this.onMouseUp.bind(this); 41 | this.setChildNodeRef = this.setChildNodeRef.bind(this); 42 | } 43 | 44 | componentDidMount() { 45 | const { disabled, useCapture } = this.props; 46 | 47 | if (!disabled) this.addMouseDownEventListener(useCapture); 48 | } 49 | 50 | componentDidUpdate({ disabled: prevDisabled }) { 51 | const { disabled, useCapture } = this.props; 52 | if (prevDisabled !== disabled) { 53 | if (disabled) { 54 | this.removeEventListeners(); 55 | } else { 56 | this.addMouseDownEventListener(useCapture); 57 | } 58 | } 59 | } 60 | 61 | componentWillUnmount() { 62 | this.removeEventListeners(); 63 | } 64 | 65 | // Use mousedown/mouseup to enforce that clicks remain outside the root's 66 | // descendant tree, even when dragged. This should also get triggered on 67 | // touch devices. 68 | onMouseDown(e) { 69 | const { useCapture } = this.props; 70 | 71 | const isDescendantOfRoot = this.childNode && contains(this.childNode, e.target); 72 | if (!isDescendantOfRoot) { 73 | if (this.removeMouseUp) { 74 | this.removeMouseUp(); 75 | this.removeMouseUp = null; 76 | } 77 | this.removeMouseUp = addEventListener( 78 | document, 79 | 'mouseup', 80 | this.onMouseUp, 81 | { capture: useCapture }, 82 | ); 83 | } 84 | } 85 | 86 | // Use mousedown/mouseup to enforce that clicks remain outside the root's 87 | // descendant tree, even when dragged. This should also get triggered on 88 | // touch devices. 89 | onMouseUp(e) { 90 | const { onOutsideClick } = this.props; 91 | 92 | const isDescendantOfRoot = this.childNode && contains(this.childNode, e.target); 93 | if (this.removeMouseUp) { 94 | this.removeMouseUp(); 95 | this.removeMouseUp = null; 96 | } 97 | 98 | if (!isDescendantOfRoot) { 99 | onOutsideClick(e); 100 | } 101 | } 102 | 103 | setChildNodeRef(ref) { 104 | this.childNode = ref; 105 | } 106 | 107 | addMouseDownEventListener(useCapture) { 108 | this.removeMouseDown = addEventListener( 109 | document, 110 | 'mousedown', 111 | this.onMouseDown, 112 | { capture: useCapture }, 113 | ); 114 | } 115 | 116 | removeEventListeners() { 117 | if (this.removeMouseDown) this.removeMouseDown(); 118 | if (this.removeMouseUp) this.removeMouseUp(); 119 | } 120 | 121 | render() { 122 | const { children, display } = this.props; 123 | 124 | return ( 125 |
133 | {children} 134 |
135 | ); 136 | } 137 | } 138 | 139 | OutsideClickHandler.propTypes = propTypes; 140 | OutsideClickHandler.defaultProps = defaultProps; 141 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true, 4 | }, 5 | "rules": { 6 | "indent": [2, 2, { 7 | "MemberExpression": "off" 8 | }], 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/OutsideClickHandler_test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { expect } from 'chai'; 3 | import sinon from 'sinon-sandbox'; 4 | import { shallow, mount } from 'enzyme'; 5 | import wrap from 'mocha-wrap'; 6 | import contains from 'document.contains'; 7 | import OutsideClickHandler from '../src/OutsideClickHandler'; 8 | 9 | const document = { 10 | addEventListener() {}, 11 | removeEventListener() {}, 12 | }; 13 | 14 | describe('OutsideClickHandler', () => { 15 | describe('basics', () => { 16 | it('renders a div', () => { 17 | expect(shallow().is('div')).to.equal(true); 18 | }); 19 | 20 | it('renders display others than block properly', () => { 21 | const displays = ['flex', 'inline-block', 'contents', 'inline']; 22 | displays.forEach((displayType) => { 23 | const wrapper = shallow(( 24 | 25 | )); 26 | expect(wrapper.prop('style')).to.have.property('display', displayType); 27 | }); 28 | }); 29 | 30 | it('does not add `display` style when using the default `block`', () => { 31 | const wrapper = shallow(( 32 | 33 | )); 34 | expect(wrapper.props()).not.to.have.property('display'); 35 | }); 36 | 37 | it('renders the children it‘s given', () => { 38 | const wrapper = shallow(( 39 | 40 |
41 |