├── .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 |
42 |
43 | ));
44 | expect(wrapper.children().map((x) => ({ type: x.type(), id: x.prop('id') }))).to.eql([
45 | { type: 'section', id: 'a' },
46 | { type: 'nav', id: 'b' },
47 | ]);
48 | });
49 | });
50 |
51 | describe('#onOutsideClick()', () => {
52 | const target = { parentNode: null };
53 | const event = { target };
54 | beforeEach(() => {
55 | target.parentNode = null;
56 | });
57 |
58 | it('is a noop if `this.childNode` contains `e.target`', () => {
59 | const spy = sinon.spy();
60 | const wrapper = shallow();
61 | const instance = wrapper.instance();
62 |
63 | instance.childNode = {};
64 | target.parentNode = instance.childNode;
65 | expect(contains(instance.childNode, target)).to.equal(true);
66 |
67 | instance.onMouseUp(event);
68 |
69 | expect(spy).to.have.property('callCount', 0);
70 | });
71 |
72 | describe('when `this.childNode` does not contain `e.target`', () => {
73 | it('calls onOutsideClick', () => {
74 | const spy = sinon.spy();
75 | const wrapper = shallow();
76 | const instance = wrapper.instance();
77 |
78 | instance.childNode = {};
79 | expect(contains(instance.childNode, target)).to.equal(false);
80 |
81 | instance.onMouseUp(event);
82 |
83 | expect(spy).to.have.property('callCount', 1);
84 | expect(spy.firstCall.args).to.eql([event]);
85 | });
86 | });
87 | });
88 |
89 | describe.skip('lifecycle methods', () => {
90 | wrap()
91 | .withOverride(() => document, 'attachEvent', () => sinon.stub())
92 | .describe('#componentDidMount', () => {
93 | let addEventListenerStub;
94 | beforeEach(() => {
95 | addEventListenerStub = sinon.stub(document, 'addEventListener');
96 | });
97 |
98 | it('document.addEventListener is called with `click` & onOutsideClick', () => {
99 | const wrapper = mount();
100 | const { onOutsideClick } = wrapper.instance();
101 | expect(addEventListenerStub.calledWith('click', onOutsideClick, true)).to.equal(true);
102 | });
103 |
104 | it('document.attachEvent is called if addEventListener is not available', () => {
105 | document.addEventListener = undefined;
106 |
107 | const wrapper = mount();
108 | const { onOutsideClick } = wrapper.instance();
109 | expect(document.attachEvent.calledWith('onclick', onOutsideClick)).to.equal(true);
110 | });
111 | });
112 |
113 | wrap()
114 | .withOverride(() => document, 'detachEvent', () => sinon.stub())
115 | .describe('#componentWillUnmount', () => {
116 | let removeEventListenerSpy;
117 | beforeEach(() => {
118 | removeEventListenerSpy = sinon.spy(document, 'removeEventListener');
119 | });
120 |
121 | it('document.removeEventListener is called with `click` and props.onOutsideClick', () => {
122 | const wrapper = mount();
123 | const { onOutsideClick } = wrapper.instance();
124 |
125 | wrapper.instance().componentWillUnmount();
126 | expect(removeEventListenerSpy.calledWith('click', onOutsideClick, true)).to.equal(true);
127 | });
128 |
129 | it('document.detachEvent is called if document.removeEventListener is not available', () => {
130 | document.removeEventListener = undefined;
131 |
132 | const wrapper = mount();
133 | const { onOutsideClick } = wrapper.instance();
134 |
135 | wrapper.instance().componentWillUnmount();
136 | expect(document.detachEvent.calledWith('onclick', onOutsideClick)).to.equal(true);
137 | });
138 | });
139 | });
140 |
141 | describe('no zombie event listeners', () => {
142 | wrap()
143 | .withGlobal('document', () => document)
144 | .describe('mocked document', () => {
145 | beforeEach(() => {
146 | sinon.spy(document, 'addEventListener');
147 | sinon.spy(document, 'removeEventListener');
148 | });
149 |
150 | it('calls onOutsideClick only once and with no extra eventListeners', () => {
151 | const spy = sinon.spy();
152 | const wrapper = shallow();
153 | const instance = wrapper.instance();
154 |
155 | instance.onMouseDown();
156 | instance.onMouseDown();
157 | instance.onMouseDown();
158 | instance.onMouseUp();
159 | expect(document.addEventListener).to.have.property('callCount', 3);
160 | expect(document.removeEventListener).to.have.property('callCount', 3);
161 | expect(spy).to.have.property('callCount', 1);
162 | });
163 |
164 | it('removes all eventListeners after componentWillUnmount', () => {
165 | const wrapper = shallow();
166 | const instance = wrapper.instance();
167 |
168 | instance.onMouseDown();
169 | instance.onMouseDown();
170 | instance.onMouseDown();
171 |
172 | wrapper.instance().componentWillUnmount();
173 |
174 | expect(document.addEventListener).to.have.property('callCount', 3);
175 | expect(document.removeEventListener).to.have.property('callCount', 3);
176 | });
177 | });
178 | });
179 | });
180 |
181 | describe('OutsideClickHandler display=inline', () => {
182 | describe('basics', () => {
183 | it('renders a div', () => {
184 | expect(shallow().is('div')).to.equal(true);
185 | });
186 |
187 | it('renders the children it‘s given', () => {
188 | const wrapper = shallow((
189 |
190 |
191 |
192 |
193 | ));
194 | expect(wrapper.children().map((x) => ({ type: x.type(), id: x.prop('id') }))).to.eql([
195 | { type: 'section', id: 'a' },
196 | { type: 'nav', id: 'b' },
197 | ]);
198 | });
199 | });
200 |
201 | describe('#onOutsideClick()', () => {
202 | const target = { parentNode: null };
203 | const event = { target };
204 | beforeEach(() => {
205 | target.parentNode = null;
206 | });
207 |
208 | it('is a noop if `this.childNode` contains `e.target`', () => {
209 | const spy = sinon.spy();
210 | const wrapper = shallow();
211 | const instance = wrapper.instance();
212 |
213 | instance.childNode = {};
214 | target.parentNode = instance.childNode;
215 | expect(contains(instance.childNode, target)).to.equal(true);
216 |
217 | instance.onMouseUp(event);
218 |
219 | expect(spy).to.have.property('callCount', 0);
220 | });
221 |
222 | describe('when `this.childNode` does not contain `e.target`', () => {
223 | it('calls onOutsideClick', () => {
224 | const spy = sinon.spy();
225 | const wrapper = shallow();
226 | const instance = wrapper.instance();
227 |
228 | instance.childNode = {};
229 | expect(contains(instance.childNode, target)).to.equal(false);
230 |
231 | instance.onMouseUp(event);
232 |
233 | expect(spy).to.have.property('callCount', 1);
234 | expect(spy.firstCall.args).to.eql([event]);
235 | });
236 | });
237 | });
238 |
239 | describe.skip('lifecycle methods', () => {
240 | wrap()
241 | .withOverride(() => document, 'attachEvent', () => sinon.stub())
242 | .describe('#componentDidMount', () => {
243 | let addEventListenerStub;
244 | beforeEach(() => {
245 | addEventListenerStub = sinon.stub(document, 'addEventListener');
246 | });
247 |
248 | it('document.addEventListener is called with `click` & onOutsideClick', () => {
249 | const wrapper = mount();
250 | const { onOutsideClick } = wrapper.instance();
251 | expect(addEventListenerStub.calledWith('click', onOutsideClick, true)).to.equal(true);
252 | });
253 |
254 | it('document.attachEvent is called if addEventListener is not available', () => {
255 | document.addEventListener = undefined;
256 |
257 | const wrapper = mount();
258 | const { onOutsideClick } = wrapper.instance();
259 | expect(document.attachEvent.calledWith('onclick', onOutsideClick)).to.equal(true);
260 | });
261 | });
262 |
263 | wrap()
264 | .withOverride(() => document, 'detachEvent', () => sinon.stub())
265 | .describe('#componentWillUnmount', () => {
266 | let removeEventListenerSpy;
267 | beforeEach(() => {
268 | removeEventListenerSpy = sinon.spy(document, 'removeEventListener');
269 | });
270 |
271 | it('document.removeEventListener is called with `click` and props.onOutsideClick', () => {
272 | const wrapper = mount();
273 | const { onOutsideClick } = wrapper.instance();
274 |
275 | wrapper.instance().componentWillUnmount();
276 | expect(removeEventListenerSpy.calledWith('click', onOutsideClick, true)).to.equal(true);
277 | });
278 |
279 | it('document.detachEvent is called if document.removeEventListener is not available', () => {
280 | document.removeEventListener = undefined;
281 |
282 | const wrapper = mount();
283 | const { onOutsideClick } = wrapper.instance();
284 |
285 | wrapper.instance().componentWillUnmount();
286 | expect(document.detachEvent.calledWith('onclick', onOutsideClick)).to.equal(true);
287 | });
288 | });
289 | });
290 | });
291 |
--------------------------------------------------------------------------------
/test/_helpers/enzymeSetup.js:
--------------------------------------------------------------------------------
1 | import configure from 'enzyme-adapter-react-helper';
2 |
3 | configure({ disableLifecycleMethods: true });
4 |
--------------------------------------------------------------------------------
/test/_helpers/restoreSinonStubs.js:
--------------------------------------------------------------------------------
1 | import sinon from 'sinon-sandbox';
2 |
3 | afterEach(() => {
4 | sinon.restore();
5 | });
6 |
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --require babel-register
2 | --compilers js:babel-register,jsx:babel-register
3 | --require airbnb-js-shims
4 | --recursive
5 |
--------------------------------------------------------------------------------