├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .size-limit ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── prettier.config.js ├── rollup.config.js ├── src ├── index.js ├── index.test.js └── supports.js ├── test ├── createDOM.js ├── mocha.opts └── setup.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "modules": false }], 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | ['@babel/plugin-proposal-class-properties', { loose: true }], 8 | ['@babel/plugin-proposal-object-rest-spread', { loose: true }], 9 | ["babel-plugin-transform-react-remove-prop-types", { "mode": "wrap" }], 10 | "babel-plugin-transform-dev-warning" 11 | ], 12 | "env": { 13 | "test": { 14 | "plugins": [ 15 | "@babel/transform-modules-commonjs" 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // So parent files don't get applied 3 | root: true, 4 | globals: { 5 | preval: false, 6 | }, 7 | env: { 8 | es6: true, 9 | browser: true, 10 | node: true, 11 | mocha: true, 12 | }, 13 | extends: ['airbnb'], 14 | parser: 'babel-eslint', 15 | parserOptions: { 16 | ecmaVersion: 7, 17 | sourceType: 'module', 18 | }, 19 | plugins: ['babel', 'jsx-a11y', 'mocha', 'prettier'], 20 | rules: { 21 | 'linebreak-style': 'off', // Don't play nicely with Windows. 22 | 'arrow-body-style': 'off', // Not our taste? 23 | 'arrow-parens': 'off', // Incompatible with prettier 24 | 'object-curly-newline': 'off', // Incompatible with prettier 25 | 'function-paren-newline': 'off', // Incompatible with prettier 26 | indent: 'off', // Incompatible with prettier 27 | 'space-before-function-paren': 'off', // Incompatible with prettier 28 | 'no-confusing-arrow': 'off', // Incompatible with prettier 29 | 'no-mixed-operators': 'off', // Incompatible with prettier 30 | 'consistent-this': ['error', 'self'], 31 | 'max-len': [ 32 | 'error', 33 | 100, 34 | 2, 35 | { 36 | ignoreUrls: true, 37 | }, 38 | ], // airbnb is allowing some edge cases 39 | 'no-console': 'error', // airbnb is using warn 40 | 'no-alert': 'error', // airbnb is using warn 41 | 'no-param-reassign': 'off', // Not our taste? 42 | 'no-prototype-builtins': 'off', // airbnb use error 43 | 'object-curly-spacing': 'off', // use babel plugin rule 44 | 'no-restricted-properties': 'off', // To remove once react-docgen support ** operator. 45 | 'prefer-destructuring': 'off', // To remove once react-docgen support ** operator. 46 | 47 | 'babel/object-curly-spacing': ['error', 'always'], 48 | 49 | 'react/jsx-indent': 'off', // Incompatible with prettier 50 | 'react/jsx-closing-bracket-location': 'off', // Incompatible with prettier 51 | 'react/jsx-wrap-multilines': 'off', // Incompatible with prettier 52 | 'react/jsx-indent-props': 'off', // Incompatible with prettier 53 | 'react/jsx-handler-names': [ 54 | 'error', 55 | { 56 | // airbnb is disabling this rule 57 | eventHandlerPrefix: 'handle', 58 | eventHandlerPropPrefix: 'on', 59 | }, 60 | ], 61 | 'react/require-default-props': 'off', // airbnb use error 62 | 'react/forbid-prop-types': 'off', // airbnb use error 63 | 'react/jsx-filename-extension': ['error', { extensions: ['.js'] }], // airbnb is using .jsx 64 | 'react/no-danger': 'error', // airbnb is using warn 65 | 'react/no-direct-mutation-state': 'error', // airbnb is disabling this rule 66 | 'react/no-find-dom-node': 'off', // I don't know 67 | 'react/no-unused-prop-types': 'off', // Is still buggy 68 | 'react/sort-prop-types': 'error', // airbnb do nothing here. 69 | 'react/default-props-match-prop-types': 'off', // Buggy 70 | 'react/jsx-curly-brace-presence': 'off', // Buggy 71 | 'react/destructuring-assignment': 'off', // Fuck no 72 | 73 | 'mocha/handle-done-callback': 'error', 74 | 'mocha/no-exclusive-tests': 'error', 75 | 'mocha/no-global-tests': 'error', 76 | 'mocha/no-pending-tests': 'error', 77 | 'mocha/no-skipped-tests': 'error', 78 | 79 | 'prettier/prettier': ['error'], 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor 2 | *.sublime-project 3 | *.sublime-workspace 4 | 5 | # Node 6 | node_modules 7 | *.log 8 | 9 | # OSX 10 | .DS_Store 11 | 12 | # Project 13 | dist 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/src/*.js 3 | !/dist/*.js 4 | *.spec.js 5 | -------------------------------------------------------------------------------- /.size-limit: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | name: 'The entry point', 4 | webpack: true, 5 | path: 'dist/react-event-listener.cjs.js', 6 | limit: '1.7 KB' 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | sudo: false 5 | cache: 6 | directories: 7 | - node_modules 8 | script: 9 | - npm run test 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | All notable changes are described on the [Releases](https://github.com/oliviertassinari/react-event-listener/releases) page. 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-event-listener 2 | 3 | > A React component for binding events on the global scope. 4 | 5 | [![npm version](https://img.shields.io/npm/v/react-event-listener.svg?style=flat-square)](https://www.npmjs.com/package/react-event-listener) 6 | [![npm downloads](https://img.shields.io/npm/dm/react-event-listener.svg?style=flat-square)](https://www.npmjs.com/package/react-event-listener) 7 | [![Build Status](https://travis-ci.org/oliviertassinari/react-event-listener.svg?branch=master)](https://travis-ci.org/oliviertassinari/react-event-listener) 8 | 9 | [![Dependencies](https://img.shields.io/david/oliviertassinari/react-event-listener.svg?style=flat-square)](https://david-dm.org/oliviertassinari/react-event-listener) 10 | [![DevDependencies](https://img.shields.io/david/dev/oliviertassinari/react-event-listener.svg?style=flat-square)](https://david-dm.org/oliviertassinari/react-event-listener#info=devDependencies&view=list) 11 | 12 | ## Installation 13 | 14 | ```sh 15 | npm install react-event-listener 16 | ``` 17 | 18 | ## The problem solved 19 | 20 | This module provides a **declarative way** to bind events to a global `EventTarget`. 21 | It's using the React lifecycle to bind and unbind at the right time. 22 | 23 | ## Usage 24 | 25 | ```js 26 | import React, {Component} from 'react'; 27 | import EventListener, {withOptions} from 'react-event-listener'; 28 | 29 | class MyComponent extends Component { 30 | handleResize = () => { 31 | console.log('resize'); 32 | }; 33 | 34 | handleScroll = () => { 35 | console.log('scroll'); 36 | }; 37 | 38 | handleMouseMove = () => { 39 | console.log('mousemove'); 40 | }; 41 | 42 | render() { 43 | return ( 44 |
45 | 50 | 51 |
52 | ); 53 | } 54 | } 55 | ``` 56 | 57 | ### Note on server-side rendering 58 | 59 | When doing server side rendering, `document` and `window` aren't available. 60 | You can use a string as a `target`, or check that they exist before rendering 61 | the component. 62 | 63 | ### Note on performance 64 | 65 | You should avoid passing inline functions for listeners, because this creates a new `Function` instance on every 66 | render, defeating `EventListener`'s `shouldComponentUpdate`, and triggering an update cycle where it removes its old 67 | listeners and adds its new listeners (so that it can stay up-to-date with the props you passed in). 68 | 69 | ### Note on testing 70 | 71 | In [this](https://github.com/facebook/react/issues/5043) issue from React, `TestUtils.Simulate.` methods won't bubble up to `window` or `document`. As a result, you must use [`document.dispatchEvent`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) or simulate event using [native DOM api](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/click). 72 | 73 | See our [test cases](https://github.com/oliviertassinari/react-event-listener/blob/master/src/index.spec.js) for more information. 74 | 75 | ## License 76 | 77 | MIT 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-event-listener", 3 | "version": "0.6.6", 4 | "description": "A React component that allow to bind events on the global scope", 5 | "main": "dist/react-event-listener.cjs.js", 6 | "scripts": { 7 | "build": "rimraf dist && rollup -c && rimraf dist/react-event-listener.esm.js", 8 | "lint": "eslint . && echo \"eslint: no lint errors\"", 9 | "size": "yarn build && size-limit", 10 | "test:unit": "NODE_ENV=test mocha", 11 | "test:watch": "NODE_ENV=test mocha -w", 12 | "test": "yarn lint && yarn test:unit && yarn size", 13 | "prettier": "find . -name \"*.js\" | grep -v -f .eslintignore | xargs prettier --write", 14 | "version": "yarn build && pkgfiles" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/oliviertassinari/react-event-listener.git" 19 | }, 20 | "homepage": "https://github.com/oliviertassinari/react-event-listener", 21 | "keywords": [ 22 | "react", 23 | "event", 24 | "listener", 25 | "binding" 26 | ], 27 | "author": "olivier.tassinari@gmail.com", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/oliviertassinari/react-event-listener/issues" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.0.0", 34 | "@babel/plugin-proposal-class-properties": "^7.0.0", 35 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0", 36 | "@babel/plugin-transform-modules-commonjs": "^7.0.0", 37 | "@babel/plugin-transform-runtime": "^7.2.0", 38 | "@babel/preset-env": "^7.0.0", 39 | "@babel/preset-react": "^7.0.0", 40 | "@babel/register": "^7.0.0", 41 | "babel-eslint": "^9.0.0", 42 | "babel-plugin-transform-dev-warning": "^0.1.0", 43 | "babel-plugin-transform-react-remove-prop-types": "^0.4.9", 44 | "chai": "^4.1.2", 45 | "enzyme": "^3.1.0", 46 | "enzyme-adapter-react-16": "^1.0.1", 47 | "eslint": "^5.0.0", 48 | "eslint-config-airbnb": "^17.0.0", 49 | "eslint-plugin-babel": "^5.0.0", 50 | "eslint-plugin-import": "^2.7.0", 51 | "eslint-plugin-jsx-a11y": "^6.0.0", 52 | "eslint-plugin-mocha": "^5.0.0", 53 | "eslint-plugin-prettier": "^2.3.1", 54 | "eslint-plugin-react": "^7.4.0", 55 | "jsdom": "^12.0.0", 56 | "mocha": "^5.0.0", 57 | "pkgfiles": "^2.3.2", 58 | "prettier": "^1.7.4", 59 | "react": "^16.0.0", 60 | "react-dom": "^16.0.0", 61 | "react-test-renderer": "^16.0.0", 62 | "rimraf": "^2.6.2", 63 | "rollup": "^0.66.2", 64 | "rollup-plugin-babel": "^4.0.0-beta.4", 65 | "rollup-plugin-commonjs": "^9.1.3", 66 | "rollup-plugin-node-resolve": "^3.3.0", 67 | "rollup-plugin-replace": "^2.0.0", 68 | "sinon": "^6.1.2", 69 | "size-limit": "^0.20.0" 70 | }, 71 | "dependencies": { 72 | "@babel/runtime": "^7.2.0", 73 | "prop-types": "^15.6.0", 74 | "warning": "^4.0.1" 75 | }, 76 | "peerDependencies": { 77 | "react": "^16.3.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | bracketSpacing: true, 6 | jsxBracketSameLine: false, 7 | parser: 'babylon', 8 | semi: true, 9 | }; 10 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from 'rollup-plugin-node-resolve'; 2 | import babel from 'rollup-plugin-babel'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import replace from 'rollup-plugin-replace'; 5 | import pkg from './package.json'; 6 | 7 | const input = './src/index.js'; 8 | const external = id => !id.startsWith('.') && !id.startsWith('/'); 9 | 10 | const globals = { 11 | react: 'React', 12 | 'prop-types': 'PropTypes', 13 | }; 14 | 15 | const getBabelOptions = ({ useESModules }) => ({ 16 | exclude: '**/node_modules/**', 17 | runtimeHelpers: true, 18 | plugins: [['@babel/transform-runtime', { useESModules }]], 19 | }); 20 | 21 | export default [ 22 | { 23 | input, 24 | output: { 25 | file: 'dist/react-event-listener.umd.js', 26 | format: 'umd', 27 | exports: 'named', 28 | name: 'ReactEventListener', 29 | globals, 30 | }, 31 | external: Object.keys(globals), 32 | plugins: [ 33 | nodeResolve(), 34 | babel(getBabelOptions({ useESModules: true })), 35 | commonjs({ include: '**/node_modules/**' }), 36 | replace({ 'process.env.NODE_ENV': JSON.stringify('development') }), 37 | ], 38 | }, 39 | { 40 | input, 41 | output: { file: pkg.main, format: 'cjs', exports: 'named' }, 42 | external, 43 | plugins: [babel(getBabelOptions({ useESModules: false }))], 44 | }, 45 | { 46 | input, 47 | output: { file: 'dist/react-event-listener.esm.js', format: 'es', exports: 'named' }, 48 | external, 49 | plugins: [babel(getBabelOptions({ useESModules: true }))], 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import warning from 'warning'; 4 | import { passiveOption } from './supports'; 5 | 6 | const defaultEventOptions = { 7 | capture: false, 8 | passive: false, 9 | }; 10 | 11 | function mergeDefaultEventOptions(options) { 12 | return { ...defaultEventOptions, ...options }; 13 | } 14 | 15 | function getEventListenerArgs(eventName, callback, options) { 16 | const args = [eventName, callback]; 17 | args.push(passiveOption ? options : options.capture); 18 | return args; 19 | } 20 | 21 | function on(target, eventName, callback, options) { 22 | // eslint-disable-next-line prefer-spread 23 | target.addEventListener.apply(target, getEventListenerArgs(eventName, callback, options)); 24 | } 25 | 26 | function off(target, eventName, callback, options) { 27 | // eslint-disable-next-line prefer-spread 28 | target.removeEventListener.apply(target, getEventListenerArgs(eventName, callback, options)); 29 | } 30 | 31 | function forEachListener(props, iteratee) { 32 | const { 33 | children, // eslint-disable-line no-unused-vars 34 | target, // eslint-disable-line no-unused-vars 35 | ...eventProps 36 | } = props; 37 | 38 | Object.keys(eventProps).forEach(name => { 39 | if (name.substring(0, 2) !== 'on') { 40 | return; 41 | } 42 | 43 | const prop = eventProps[name]; 44 | const type = typeof prop; 45 | const isObject = type === 'object'; 46 | const isFunction = type === 'function'; 47 | 48 | if (!isObject && !isFunction) { 49 | return; 50 | } 51 | 52 | const capture = name.substr(-7).toLowerCase() === 'capture'; 53 | let eventName = name.substring(2).toLowerCase(); 54 | eventName = capture ? eventName.substring(0, eventName.length - 7) : eventName; 55 | 56 | if (isObject) { 57 | iteratee(eventName, prop.handler, prop.options); 58 | } else { 59 | iteratee(eventName, prop, mergeDefaultEventOptions({ capture })); 60 | } 61 | }); 62 | } 63 | 64 | export function withOptions(handler, options) { 65 | warning(options, 'react-event-listener: should be specified options in withOptions.'); 66 | 67 | return { 68 | handler, 69 | options: mergeDefaultEventOptions(options), 70 | }; 71 | } 72 | 73 | class EventListener extends React.PureComponent { 74 | componentDidMount() { 75 | this.applyListeners(on); 76 | } 77 | 78 | componentDidUpdate(prevProps) { 79 | this.applyListeners(off, prevProps); 80 | this.applyListeners(on); 81 | } 82 | 83 | componentWillUnmount() { 84 | this.applyListeners(off); 85 | } 86 | 87 | applyListeners(onOrOff, props = this.props) { 88 | const { target } = props; 89 | 90 | if (target) { 91 | let element = target; 92 | 93 | if (typeof target === 'string') { 94 | element = window[target]; 95 | } 96 | 97 | forEachListener(props, onOrOff.bind(null, element)); 98 | } 99 | } 100 | 101 | render() { 102 | return this.props.children || null; 103 | } 104 | } 105 | 106 | EventListener.propTypes = { 107 | /** 108 | * You can provide a single child too. 109 | */ 110 | children: PropTypes.node, 111 | /** 112 | * The DOM target to listen to. 113 | */ 114 | target: PropTypes.oneOfType([PropTypes.object, PropTypes.string]).isRequired, 115 | }; 116 | 117 | export default EventListener; 118 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import { shallow } from 'enzyme'; 6 | import { assert } from 'chai'; 7 | import { spy } from 'sinon'; 8 | import { render, unmountComponentAtNode } from 'react-dom'; 9 | import { Simulate } from 'react-dom/test-utils'; 10 | import EventListener, { withOptions } from './index'; 11 | 12 | describe('EventListener', () => { 13 | describe('prop: children', () => { 14 | it('should work without', () => { 15 | const wrapper = shallow(); 16 | 17 | assert.strictEqual(wrapper.children().length, 0, 'Should work without children'); 18 | }); 19 | 20 | it('should render it', () => { 21 | const wrapper = shallow( 22 | 23 |
Foo
24 |
, 25 | ); 26 | 27 | assert.strictEqual(wrapper.children().length, 1, 'Should render his children.'); 28 | }); 29 | }); 30 | 31 | let node; 32 | beforeEach(() => { 33 | // Pattern from "react-router": http://git.io/vWpfs 34 | node = document.createElement('div'); 35 | document.body.appendChild(node); 36 | }); 37 | 38 | afterEach(() => { 39 | unmountComponentAtNode(node); 40 | node.parentNode.removeChild(node); 41 | }); 42 | 43 | describe('prop: target', () => { 44 | it('should work with a string', () => { 45 | const handleClick = spy(); 46 | 47 | render(, node); 48 | document.body.click(); 49 | assert.strictEqual(handleClick.callCount, 1); 50 | }); 51 | 52 | it('should work with a node', () => { 53 | const handleClick = spy(); 54 | 55 | render(, node); 56 | document.body.click(); 57 | assert.strictEqual(handleClick.callCount, 1); 58 | }); 59 | }); 60 | 61 | [ 62 | { 63 | contextName: 'using Simulate.click(extraNode)', 64 | name: 'should not invoke event listener to document', 65 | invokeFn(extraNode) { 66 | Simulate.click(extraNode); 67 | }, 68 | expectFn(handle) { 69 | assert.strictEqual(handle.callCount, 0); 70 | }, 71 | }, 72 | { 73 | contextName: 'using extraNode.click()', 74 | name: 'should invoke event listener to document', 75 | invokeFn(extraNode) { 76 | extraNode.click(); 77 | }, 78 | expectFn(handle) { 79 | assert.strictEqual(handle.callCount, 1); 80 | }, 81 | }, 82 | ].forEach(({ contextName, name, invokeFn, expectFn }) => { 83 | describe(contextName, () => { 84 | it(name, () => { 85 | class TextComponent extends React.Component { 86 | static propTypes = { 87 | onClick: PropTypes.func, 88 | }; 89 | 90 | handleClick = () => { 91 | this.props.onClick(); 92 | }; 93 | 94 | render() { 95 | return ; 96 | } 97 | } 98 | 99 | const handleClick = spy(); 100 | 101 | render(, node); 102 | 103 | assert.strictEqual(handleClick.callCount, 0); 104 | 105 | const extraNode = document.createElement('button'); 106 | document.body.appendChild(extraNode); 107 | 108 | invokeFn(extraNode); 109 | expectFn(handleClick); 110 | 111 | extraNode.parentNode.removeChild(extraNode); 112 | }); 113 | }); 114 | }); 115 | 116 | describe('when props change', () => { 117 | it('removes old listeners', () => { 118 | const handleClick = spy(); 119 | 120 | render(, node); 121 | render(, node); 122 | 123 | document.body.click(); 124 | assert.strictEqual(handleClick.callCount, 0); 125 | }); 126 | 127 | it('adds new listeners', () => { 128 | const handleClick = spy(); 129 | 130 | render(, node); 131 | 132 | document.body.click(); 133 | assert.strictEqual(handleClick.callCount, 0); 134 | 135 | render(, node); 136 | 137 | document.body.click(); 138 | assert.strictEqual(handleClick.callCount, 1); 139 | }); 140 | 141 | describe('lifecycle', () => { 142 | let extraNode; 143 | 144 | beforeEach(() => { 145 | extraNode = document.createElement('button'); 146 | document.body.appendChild(extraNode); 147 | }); 148 | 149 | afterEach(() => { 150 | extraNode.parentNode.removeChild(extraNode); 151 | }); 152 | 153 | it('removes listeners from old node', () => { 154 | const handleClick = spy(); 155 | 156 | render(, node); 157 | render(, node); 158 | 159 | document.body.click(); 160 | assert.strictEqual(handleClick.callCount, 0); 161 | }); 162 | 163 | it('adds listeners to new node', () => { 164 | const handleClick = spy(); 165 | 166 | render(, node); 167 | render(, node); 168 | document.body.click(); 169 | assert.strictEqual(handleClick.callCount, 1); 170 | }); 171 | }); 172 | 173 | it("doesn't update if props are shallow equal", () => { 174 | const handleClick = () => {}; 175 | const inst = render(, node); 176 | const componentWillUpdate = inst.componentWillUpdate; 177 | let updated = false; 178 | inst.componentWillUpdate = (...args) => { 179 | updated = true; 180 | componentWillUpdate.bind(inst)(...args); 181 | }; 182 | render(, node); 183 | assert.strictEqual(updated, false); 184 | }); 185 | }); 186 | 187 | describe('when using capture phase', () => { 188 | it('attaches listeners with capture', () => { 189 | let button; 190 | const calls = []; 191 | 192 | render( 193 |
194 | calls.push('outer')} /> 195 |
, 203 | node, 204 | ); 205 | 206 | assert.strictEqual(calls.length, 0); 207 | button.click(); 208 | assert.deepEqual(calls, ['outer', 'inner'], 'Should be called in the right order.'); 209 | }); 210 | }); 211 | 212 | describe('when using withOptions helper', () => { 213 | it('should return handler function & event options of merging default values', () => { 214 | const obj = withOptions(() => 'test', {}); 215 | assert.strictEqual(obj.handler(), 'test'); 216 | assert.deepEqual(obj.options, { capture: false, passive: false }); 217 | }); 218 | 219 | it('should work with using withOptions helper', () => { 220 | const handleClick = spy(); 221 | 222 | render(, node); 223 | document.body.click(); 224 | assert.strictEqual(handleClick.callCount, 1); 225 | }); 226 | 227 | it('attaches listeners with capture (withOptions)', () => { 228 | let button = null; 229 | const calls = []; 230 | 231 | render( 232 |
233 | calls.push('outer'), { capture: true })} 236 | /> 237 |
, 245 | node, 246 | ); 247 | 248 | assert.strictEqual(calls.length, 0); 249 | button.click(); 250 | assert.deepEqual(calls, ['outer', 'inner'], 'Should be called in the right order.'); 251 | }); 252 | }); 253 | }); 254 | -------------------------------------------------------------------------------- /src/supports.js: -------------------------------------------------------------------------------- 1 | function defineProperty(object, property, attr) { 2 | return Object.defineProperty(object, property, attr); 3 | } 4 | 5 | // Passive options 6 | // Inspired by https://github.com/Modernizr/Modernizr/blob/master/feature-detects/dom/passiveeventlisteners.js 7 | export const passiveOption = (() => { 8 | let cache = null; 9 | 10 | return (() => { 11 | if (cache !== null) { 12 | return cache; 13 | } 14 | 15 | let supportsPassiveOption = false; 16 | 17 | try { 18 | window.addEventListener( 19 | 'test', 20 | null, 21 | defineProperty({}, 'passive', { 22 | get() { 23 | supportsPassiveOption = true; 24 | }, 25 | }), 26 | ); 27 | } catch (err) { 28 | // 29 | } 30 | 31 | cache = supportsPassiveOption; 32 | 33 | return supportsPassiveOption; 34 | })(); 35 | })(); 36 | 37 | export default {}; 38 | -------------------------------------------------------------------------------- /test/createDOM.js: -------------------------------------------------------------------------------- 1 | const { JSDOM } = require('jsdom'); 2 | const Node = require('jsdom/lib/jsdom/living/node-document-position'); 3 | 4 | // We can use jsdom-global at some point if maintaining these lists is a burden. 5 | const whitelist = ['HTMLElement', 'Performance']; 6 | const blacklist = ['sessionStorage', 'localStorage']; 7 | 8 | function createDOM() { 9 | const dom = new JSDOM('', { pretendToBeVisual: true }); 10 | global.window = dom.window; 11 | global.Node = Node; 12 | global.document = dom.window.document; 13 | global.navigator = { 14 | userAgent: 'node.js', 15 | }; 16 | 17 | Object.keys(dom.window) 18 | .filter(key => !blacklist.includes(key)) 19 | .concat(whitelist) 20 | .forEach(key => { 21 | if (typeof global[key] === 'undefined') { 22 | global[key] = dom.window[key]; 23 | } 24 | }); 25 | } 26 | 27 | module.exports = createDOM; 28 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require @babel/register 2 | --reporter dot 3 | --recursive 4 | test/setup.js 5 | src/{,**/}*.test.js 6 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import createDOM from './createDOM'; 4 | 5 | Enzyme.configure({ adapter: new Adapter() }); 6 | createDOM(); 7 | --------------------------------------------------------------------------------