├── .nvmrc ├── .gitignore ├── circle.yml ├── tests ├── helpers │ ├── render-into-app.js │ └── test-setup.js └── index.test.js ├── .nycrc ├── LICENSE ├── package.json ├── README.md └── src └── index.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 6.9.4 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | build/** 3 | coverage/** 4 | .nyc_output/** 5 | 6 | .DS_Store 7 | npm-debug.log* 8 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.9.4 4 | 5 | general: 6 | branches: 7 | ignore: 8 | - gh-pages 9 | -------------------------------------------------------------------------------- /tests/helpers/render-into-app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function () { 4 | 5 | var ReactDOM = require('react-dom'); 6 | 7 | module.exports = function renderIntoApp (component) { 8 | return ReactDOM.render(component, document.getElementById('app')); 9 | }; 10 | 11 | })(); 12 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "lines": 90, 3 | "statements": 90, 4 | "functions": 90, 5 | "branches": 90, 6 | "require": [ 7 | "./tests/helpers/test-setup.js" 8 | ], 9 | "include": [ 10 | "src/**/*.js" 11 | ], 12 | "exclude": [], 13 | "extension": [ 14 | ".js" 15 | ], 16 | "reporter": [ 17 | "lcov", 18 | "text-summary" 19 | ], 20 | "cache": true, 21 | "all": true, 22 | "check-coverage": true, 23 | "sourceMap": true, 24 | "instrument": true, 25 | "report-dir": "./coverage" 26 | } 27 | -------------------------------------------------------------------------------- /tests/helpers/test-setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function () { 4 | 5 | var jsdom = require('jsdom'); 6 | var chai = require('chai'); 7 | var sinonChai = require('sinon-chai'); 8 | 9 | // Jsdom document & window 10 | var doc = jsdom.jsdom('
'); 11 | var win = doc.defaultView; 12 | 13 | // Add to global 14 | global.document = doc; 15 | global.window = win; 16 | 17 | // Add window keys to global window 18 | for (var key in window) { 19 | if (!(key in global)) { 20 | global[key] = window[key]; 21 | } 22 | } 23 | 24 | chai.expect(); 25 | chai.use(sinonChai); 26 | 27 | })(); 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jake 'Sid' Smith - https://github.com/JakeSidSmith/ 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-fastclick", 3 | "version": "3.0.2", 4 | "description": "Fast Touch Events for React", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "mocha": "nyc mocha --bail --recursive 'tests/**/*.test.js'", 8 | "lint-tests": "eslint -c node_modules/eslintrc/.eslintrc-es5-mocha tests/", 9 | "lint-src": "eslint -c node_modules/eslintrc/.eslintrc-es5 src/", 10 | "test": "npm run lint-src && npm run lint-tests && npm run mocha" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/JakeSidSmith/react-fastclick" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "fastclick", 19 | "fast", 20 | "click", 21 | "touch", 22 | "events", 23 | "event", 24 | "mobile" 25 | ], 26 | "author": "Jake 'Sid' Smith", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/JakeSidSmith/react-fastclick/issues" 30 | }, 31 | "homepage": "https://github.com/JakeSidSmith/react-fastclick", 32 | "peerDependencies": { 33 | "react": "*" 34 | }, 35 | "dependencies": {}, 36 | "devDependencies": { 37 | "chai": "=3.5.0", 38 | "eslintrc": "git+https://github.com/JakeSidSmith/eslintrc.git#v0.0.1", 39 | "jsdom": "=8.4.1", 40 | "mocha": "=2.4.5", 41 | "nyc": "=10.1.2", 42 | "react": "=15.4.2", 43 | "react-addons-test-utils": "=15.4.2", 44 | "react-dom": "=15.4.2", 45 | "sinon": "=1.17.3", 46 | "sinon-chai": "=2.8.0" 47 | }, 48 | "engines": { 49 | "node": "*" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Fastclick [![CircleCI](https://circleci.com/gh/JakeSidSmith/react-fastclick.svg?style=svg)](https://circleci.com/gh/JakeSidSmith/react-fastclick) 2 | **Instantly make your desktop / hybrid apps more responsive on touch devices.** 3 | 4 | React Fastclick automatically adds fastclick touch events to elements with onClick attributes (and those that require special functionality, such as inputs) to prevent the delay that occurs on some touch devices. 5 | 6 | ## Installation 7 | 8 | Use npm to install react-fastclick 9 | 10 | ``` 11 | npm install react-fastclick 12 | ``` 13 | 14 | ## Usage 15 | 16 | Initialize `react-fastclick` in your main javascript file before any of your components are created, and you're done. 17 | 18 | Now any calls to onClick or elements with special functionality, such as inputs, will have fast touch events added automatically - no need to write any additional listeners. 19 | 20 | **ES6** 21 | 22 | ```javascript 23 | import initReactFastclick from 'react-fastclick'; 24 | initReactFastclick(); 25 | ``` 26 | 27 | **ES5** 28 | 29 | ```javascript 30 | var initReactFastclick = require('react-fastclick'); 31 | initReactFastclick(); 32 | ``` 33 | 34 | ## Notes 35 | 36 | 1. The event triggered on touch devices is a modified `touchend` event. This means that it may have some keys that are unusual for a click event. 37 | 38 | In order to simulate a click as best as possible, this event is populated with the following keys / values. All positions are taken from the last know touch position. 39 | 40 | ```javascript 41 | { 42 | // Simulate left click 43 | button: 0, 44 | type: 'click', 45 | // Additional key to tell the difference between 46 | // a regular click and a fastclick 47 | fastclick: true, 48 | // From touch positions 49 | clientX, 50 | clientY, 51 | pageX, 52 | pageY, 53 | screenX, 54 | screenY 55 | } 56 | ``` 57 | 58 | 2. On some devices the elements flicker after being touched. This can be prevented by setting the css property `-webkit-tap-highlight-color` to transparent. 59 | Either target `html, body` (to prevent the flickering on all elements) or target the specific element you don't want to flicker e.g. `button`. 60 | 61 | ```css 62 | html, body { 63 | -webkit-tap-highlight-color: transparent; 64 | } 65 | ``` 66 | 67 | ## Support 68 | 69 | React Fastclick 3.x.x has been tested with React 15, but should support older versions also. 70 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | (function () { 4 | var getReactFCInitializer = function (React) { 5 | return function initializeReactFastclick () { 6 | var originalCreateElement = React.createElement; 7 | 8 | // Moved if Math.abs(downX - upX) > MOVE_THRESHOLD; 9 | var MOVE_THRESHOLD = 8; 10 | var TOUCH_DELAY = 1000; 11 | 12 | var touchKeysToStore = [ 13 | 'clientX', 14 | 'clientY', 15 | 'pageX', 16 | 'pageY', 17 | 'screenX', 18 | 'screenY', 19 | 'radiusX', 20 | 'radiusY' 21 | ]; 22 | 23 | var touchEvents = { 24 | downPos: {}, 25 | lastPos: {} 26 | }; 27 | 28 | var isDisabled = function (element) { 29 | if (!element) { 30 | return false; 31 | } 32 | var disabled = element.getAttribute('disabled'); 33 | 34 | return disabled !== false && disabled !== null; 35 | }; 36 | 37 | var focus = function (event, target) { 38 | var myTarget = target || event.currentTarget; 39 | 40 | if (!myTarget || isDisabled(myTarget)) { 41 | return; 42 | } 43 | 44 | myTarget.focus(); 45 | }; 46 | 47 | var handleType = { 48 | input: function (event) { 49 | focus(event); 50 | event.stopPropagation(); 51 | }, 52 | textarea: function (event) { 53 | focus(event); 54 | event.stopPropagation(); 55 | }, 56 | select: function (event) { 57 | focus(event); 58 | event.stopPropagation(); 59 | }, 60 | label: function (event) { 61 | var input; 62 | 63 | var forTarget = event.currentTarget.getAttribute('for'); 64 | 65 | if (forTarget) { 66 | input = document.getElementById(forTarget); 67 | } else { 68 | input = event.currentTarget.querySelectorAll('input, textarea, select')[0]; 69 | } 70 | 71 | if (input) { 72 | focus(event, input); 73 | } 74 | } 75 | }; 76 | 77 | var fakeClickEvent = function (event) { 78 | if (typeof event.persist === 'function') { 79 | event.persist(); 80 | } 81 | 82 | event.fastclick = true; 83 | event.type = 'click'; 84 | event.button = 0; 85 | }; 86 | 87 | var copyTouchKeys = function (touch, target) { 88 | if (typeof target.persist === 'function') { 89 | target.persist(); 90 | } 91 | 92 | if (touch) { 93 | for (var i = 0; i < touchKeysToStore.length; i += 1) { 94 | var key = touchKeysToStore[i]; 95 | target[key] = touch[key]; 96 | } 97 | } 98 | }; 99 | 100 | var noTouchHappened = function () { 101 | return !touchEvents.touched && ( 102 | !touchEvents.lastTouchDate || new Date().getTime() > touchEvents.lastTouchDate + TOUCH_DELAY 103 | ); 104 | }; 105 | 106 | var invalidateIfMoreThanOneTouch = function (event) { 107 | touchEvents.invalid = event.touches && event.touches.length > 1 || touchEvents.invalid; 108 | }; 109 | 110 | var onMouseEvent = function (callback, event) { 111 | var touched = !noTouchHappened(); 112 | 113 | // Prevent mouse events on other elements 114 | if (touched && event.target !== touchEvents.target) { 115 | event.preventDefault(); 116 | } 117 | 118 | // Prevent any mouse events if we touched recently 119 | if (typeof callback === 'function' && !touched) { 120 | callback(event); 121 | } 122 | 123 | if (event.type === 'click') { 124 | touchEvents.invalid = false; 125 | touchEvents.touched = false; 126 | touchEvents.moved = false; 127 | } 128 | }; 129 | 130 | var onTouchStart = function (callback, event) { 131 | touchEvents.invalid = false; 132 | touchEvents.moved = false; 133 | touchEvents.touched = true; 134 | touchEvents.target = event.target; 135 | touchEvents.lastTouchDate = new Date().getTime(); 136 | 137 | copyTouchKeys(event.touches[0], touchEvents.downPos); 138 | copyTouchKeys(event.touches[0], touchEvents.lastPos); 139 | 140 | invalidateIfMoreThanOneTouch(event); 141 | 142 | if (typeof callback === 'function') { 143 | callback(event); 144 | } 145 | }; 146 | 147 | var onTouchMove = function (callback, event) { 148 | touchEvents.touched = true; 149 | touchEvents.lastTouchDate = new Date().getTime(); 150 | 151 | copyTouchKeys(event.touches[0], touchEvents.lastPos); 152 | 153 | invalidateIfMoreThanOneTouch(event); 154 | 155 | if (Math.abs(touchEvents.downPos.clientX - touchEvents.lastPos.clientX) > MOVE_THRESHOLD || 156 | Math.abs(touchEvents.downPos.clientY - touchEvents.lastPos.clientY) > MOVE_THRESHOLD) { 157 | touchEvents.moved = true; 158 | } 159 | 160 | if (typeof callback === 'function') { 161 | callback(event); 162 | } 163 | }; 164 | 165 | var onTouchEnd = function (callback, onClick, type, event) { 166 | touchEvents.touched = true; 167 | touchEvents.lastTouchDate = new Date().getTime(); 168 | 169 | invalidateIfMoreThanOneTouch(event); 170 | 171 | if (typeof callback === 'function') { 172 | callback(event); 173 | } 174 | 175 | if (!touchEvents.invalid && !touchEvents.moved) { 176 | var box = event.currentTarget.getBoundingClientRect(); 177 | 178 | if (touchEvents.lastPos.clientX - (touchEvents.lastPos.radiusX || 0) <= box.right && 179 | touchEvents.lastPos.clientX + (touchEvents.lastPos.radiusX || 0) >= box.left && 180 | touchEvents.lastPos.clientY - (touchEvents.lastPos.radiusY || 0) <= box.bottom && 181 | touchEvents.lastPos.clientY + (touchEvents.lastPos.radiusY || 0) >= box.top) { 182 | 183 | if (!isDisabled(event.currentTarget)) { 184 | if (typeof onClick === 'function') { 185 | copyTouchKeys(touchEvents.lastPos, event); 186 | fakeClickEvent(event); 187 | onClick(event); 188 | } 189 | 190 | if (!event.defaultPrevented && handleType[type]) { 191 | handleType[type](event); 192 | } 193 | } 194 | } 195 | } 196 | }; 197 | 198 | var propsWithFastclickEvents = function (type, props) { 199 | var newProps = {}; 200 | 201 | // Loop over props 202 | for (var key in props) { 203 | // Copy props to newProps 204 | newProps[key] = props[key]; 205 | } 206 | 207 | // Apply our wrapped mouse and touch handlers 208 | newProps.onClick = onMouseEvent.bind(null, props.onClick); 209 | newProps.onMouseDown = onMouseEvent.bind(null, props.onMouseDown); 210 | newProps.onMouseMove = onMouseEvent.bind(null, props.onMouseMove); 211 | newProps.onMouseUp = onMouseEvent.bind(null, props.onMouseUp); 212 | newProps.onTouchStart = onTouchStart.bind(null, props.onTouchStart); 213 | newProps.onTouchMove = onTouchMove.bind(null, props.onTouchMove); 214 | newProps.onTouchEnd = onTouchEnd.bind(null, props.onTouchEnd, props.onClick, type); 215 | 216 | if (typeof Object.freeze === 'function') { 217 | Object.freeze(newProps); 218 | } 219 | 220 | return newProps; 221 | }; 222 | 223 | React.createElement = function () { 224 | // Convert arguments to array 225 | var args = Array.prototype.slice.call(arguments); 226 | 227 | var type = args[0]; 228 | var props = args[1]; 229 | 230 | // Check if basic element & has onClick prop 231 | if (type && typeof type === 'string' && ( 232 | (props && typeof props.onClick === 'function') || handleType[type] 233 | )) { 234 | // Add our own events to props 235 | args[1] = propsWithFastclickEvents(type, props || {}); 236 | } 237 | 238 | // Apply args to original createElement function 239 | return originalCreateElement.apply(null, args); 240 | }; 241 | 242 | if (typeof React.DOM === 'object') { 243 | for (var key in React.DOM) { 244 | React.DOM[key] = React.createElement.bind(null, key); 245 | } 246 | } 247 | }; 248 | }; 249 | 250 | /* istanbul ignore next */ 251 | // Export for commonjs / browserify 252 | if (typeof exports === 'object' && typeof module !== 'undefined') { 253 | var React = require('react'); 254 | module.exports = getReactFCInitializer(React); 255 | // Export for amd / require 256 | } else if (typeof define === 'function' && define.amd) { // eslint-disable-line no-undef 257 | define(['react'], function (ReactAMD) { // eslint-disable-line no-undef 258 | return getReactFCInitializer(ReactAMD); 259 | }); 260 | // Export globally 261 | } else { 262 | var root; 263 | 264 | if (typeof window !== 'undefined') { 265 | root = window; 266 | } else if (typeof global !== 'undefined') { 267 | root = global; 268 | } else if (typeof self !== 'undefined') { 269 | root = self; 270 | } else { 271 | root = this; 272 | } 273 | 274 | root.Reorder = getReactFCInitializer(root.React); 275 | } 276 | })(); 277 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect; 4 | var sinon = require('sinon'); 5 | var spy = sinon.spy; 6 | var stub = sinon.stub; 7 | var TestUtils = require('react-addons-test-utils'); 8 | var renderIntoApp = require('./helpers/render-into-app'); 9 | 10 | describe('react-fastclick', function () { 11 | 12 | var originalCreateElement, fastclickCreateElement; 13 | 14 | function handlerKeyToSimulatedEventKey (key) { 15 | var simulatedEventKey = key.replace(/^on/, ''); 16 | return simulatedEventKey.charAt(0).toLowerCase() + simulatedEventKey.substring(1); 17 | } 18 | 19 | function getBoundingClientRect () { 20 | return { 21 | top: 25, 22 | left: 25, 23 | right: 75, 24 | bottom: 75, 25 | width: 50, 26 | height: 50 27 | }; 28 | } 29 | 30 | var touches = [ 31 | { 32 | clientX: 50, 33 | clientY: 50 34 | } 35 | ]; 36 | 37 | var specialTypes = [ 38 | 'input', 39 | 'textarea', 40 | 'select', 41 | 'label' 42 | ]; 43 | 44 | var additionalProps = { 45 | onClick: function () {}, 46 | onMouseDown: function () {}, 47 | onMouseMove: function () {}, 48 | onMouseUp: function () {}, 49 | onTouchStart: function () {}, 50 | onTouchMove: function () {}, 51 | onTouchEnd: function () {} 52 | }; 53 | 54 | beforeEach(function () { 55 | // Clear module cache 56 | delete require.cache[require.resolve('react')]; 57 | delete require.cache[require.resolve('../src/index')]; 58 | }); 59 | 60 | it('should redefine React.createElement', function () { 61 | originalCreateElement = require('react').createElement; 62 | var theSameCreateElement = require('react').createElement; 63 | 64 | expect(originalCreateElement).to.equal(theSameCreateElement); 65 | 66 | require('../src/index')(); 67 | fastclickCreateElement = require('react').createElement; 68 | 69 | expect(originalCreateElement).not.to.equal(fastclickCreateElement); 70 | }); 71 | 72 | describe('createElement', function () { 73 | 74 | it('should create a regular React element', function () { 75 | var element = fastclickCreateElement('div'); 76 | 77 | expect(element).to.exist; 78 | expect(element.ref).to.be.null; 79 | expect(element.key).to.be.null; 80 | expect(element.type).to.equal('div'); 81 | expect(element.props).to.eql({}); 82 | }); 83 | 84 | it('should add events if it is a special element', function () { 85 | var element; 86 | 87 | for (var i = 0; i < specialTypes.length; i += 1) { 88 | element = fastclickCreateElement(specialTypes[i]); 89 | 90 | for (var key in additionalProps) { 91 | expect(typeof element.props[key]).to.equal('function'); 92 | } 93 | } 94 | }); 95 | 96 | it('should add events if it has an onClick handler', function () { 97 | var element = fastclickCreateElement('div', {onClick: function () {}}); 98 | 99 | for (var key in additionalProps) { 100 | expect(typeof element.props[key]).to.equal('function'); 101 | } 102 | }); 103 | 104 | }); 105 | 106 | describe('mouse events', function () { 107 | 108 | it('should trigger standard mouse event handlers', function () { 109 | var props = { 110 | onMouseDown: spy(), 111 | onMouseMove: spy(), 112 | onMouseUp: spy(), 113 | onClick: spy() 114 | }; 115 | 116 | var node = renderIntoApp(fastclickCreateElement('div', props)); 117 | 118 | for (var key in props) { 119 | var mouseEvent = handlerKeyToSimulatedEventKey(key); 120 | 121 | TestUtils.Simulate[mouseEvent](node); 122 | 123 | expect(props[key]).to.have.been.calledOnce; 124 | } 125 | }); 126 | 127 | it('should not prevent default on same target', function () { 128 | var event = { 129 | target: 'target', 130 | touches: [], 131 | preventDefault: spy() 132 | }; 133 | 134 | var props = { 135 | onTouchStart: spy(), 136 | onTouchMove: spy(), 137 | onTouchEnd: spy(), 138 | onMouseDown: spy(), 139 | onMouseMove: spy(), 140 | onMouseUp: spy(), 141 | onClick: spy() 142 | }; 143 | 144 | var node = renderIntoApp(fastclickCreateElement('div', props)); 145 | 146 | for (var key in props) { 147 | var eventType = handlerKeyToSimulatedEventKey(key); 148 | 149 | TestUtils.Simulate[eventType](node, event); 150 | 151 | if (eventType.indexOf('touch') >= 0) { 152 | expect(props[key]).to.have.been.calledOnce; 153 | } else { 154 | expect(props[key]).not.to.have.been.called; 155 | } 156 | } 157 | 158 | expect(event.preventDefault.callCount).to.equal(0); 159 | }); 160 | 161 | it('should prevent default on different target', function () { 162 | var event1 = { 163 | target: 'target1', 164 | touches: [], 165 | preventDefault: spy() 166 | }; 167 | 168 | var event2 = { 169 | target: 'target2', 170 | touches: [], 171 | preventDefault: spy() 172 | }; 173 | 174 | var props = { 175 | onTouchStart: spy(), 176 | onTouchMove: spy(), 177 | onTouchEnd: spy(), 178 | onMouseDown: spy(), 179 | onMouseMove: spy(), 180 | onMouseUp: spy(), 181 | onClick: spy() 182 | }; 183 | 184 | var node = renderIntoApp(fastclickCreateElement('div', props)); 185 | 186 | for (var key in props) { 187 | var eventType = handlerKeyToSimulatedEventKey(key); 188 | 189 | if (eventType.indexOf('touch') >= 0) { 190 | TestUtils.Simulate[eventType](node, event1); 191 | expect(props[key]).to.have.been.calledOnce; 192 | } else { 193 | TestUtils.Simulate[eventType](node, event2); 194 | expect(props[key]).not.to.have.been.called; 195 | } 196 | } 197 | 198 | expect(event1.preventDefault.callCount).to.equal(0); 199 | expect(event2.preventDefault.callCount).to.equal(4); 200 | }); 201 | 202 | }); 203 | 204 | describe('touch events', function () { 205 | 206 | it('should trigger standard touch event handlers', function () { 207 | var props = { 208 | onClick: function () {}, 209 | onTouchStart: spy(), 210 | onTouchMove: spy(), 211 | onTouchEnd: spy() 212 | }; 213 | 214 | var node = renderIntoApp(fastclickCreateElement('div', props)); 215 | 216 | for (var key in props) { 217 | if (key !== 'onClick') { 218 | var touchEvent = handlerKeyToSimulatedEventKey(key); 219 | 220 | TestUtils.Simulate[touchEvent](node, {touches: [{}]}); 221 | 222 | expect(props[key]).to.have.been.calledOnce; 223 | } 224 | } 225 | }); 226 | 227 | it('should trigger the click handler when a fastclick happens', function () { 228 | var props = { 229 | onClick: spy() 230 | }; 231 | 232 | var node = renderIntoApp(fastclickCreateElement('div', props)); 233 | 234 | var getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 235 | 236 | TestUtils.Simulate.touchStart( 237 | node, 238 | { 239 | type: 'touchstart', 240 | touches: touches 241 | } 242 | ); 243 | 244 | TestUtils.Simulate.touchEnd( 245 | node, 246 | { 247 | type: 'touchend', 248 | touches: null 249 | } 250 | ); 251 | 252 | expect(props.onClick).to.have.been.calledOnce; 253 | 254 | TestUtils.Simulate.click( 255 | node, 256 | { 257 | type: 'click' 258 | } 259 | ); 260 | 261 | expect(props.onClick).to.have.been.calledOnce; 262 | 263 | getBoundingClientRectStub.restore(); 264 | }); 265 | 266 | it('should not trigger the click handler if multiple touches', function () { 267 | var props = { 268 | onClick: spy() 269 | }; 270 | 271 | var node = renderIntoApp(fastclickCreateElement('div', props)); 272 | 273 | var getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 274 | 275 | TestUtils.Simulate.touchStart( 276 | node, 277 | { 278 | type: 'touchstart', 279 | touches: [touches[0], touches[0]] 280 | } 281 | ); 282 | 283 | TestUtils.Simulate.touchEnd( 284 | node, 285 | { 286 | type: 'touchend', 287 | touches: null 288 | } 289 | ); 290 | 291 | expect(props.onClick).not.to.have.been.called; 292 | 293 | TestUtils.Simulate.click( 294 | node, 295 | { 296 | type: 'click' 297 | } 298 | ); 299 | 300 | expect(props.onClick).not.to.have.been.called; 301 | 302 | getBoundingClientRectStub.restore(); 303 | }); 304 | 305 | it('should not trigger the click handler if touch moves', function () { 306 | var props = { 307 | onClick: spy() 308 | }; 309 | 310 | var node = renderIntoApp(fastclickCreateElement('div', props)); 311 | 312 | var getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 313 | 314 | TestUtils.Simulate.touchStart( 315 | node, 316 | { 317 | type: 'touchstart', 318 | touches: touches 319 | } 320 | ); 321 | 322 | TestUtils.Simulate.touchMove( 323 | node, 324 | { 325 | type: 'touchmove', 326 | touches: [ 327 | { 328 | clientX: 60, 329 | clientY: 50 330 | } 331 | ] 332 | } 333 | ); 334 | 335 | TestUtils.Simulate.touchEnd( 336 | node, 337 | { 338 | type: 'touchend', 339 | touches: null 340 | } 341 | ); 342 | 343 | expect(props.onClick).not.to.have.been.called; 344 | 345 | TestUtils.Simulate.click( 346 | node, 347 | { 348 | type: 'click' 349 | } 350 | ); 351 | 352 | expect(props.onClick).not.to.have.been.called; 353 | 354 | getBoundingClientRectStub.restore(); 355 | }); 356 | 357 | it('should not trigger the click handler if touch is outside of the element', function () { 358 | var props = { 359 | onClick: spy() 360 | }; 361 | 362 | var node = renderIntoApp(fastclickCreateElement('div', props)); 363 | 364 | var getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 365 | 366 | TestUtils.Simulate.touchStart( 367 | node, 368 | { 369 | type: 'touchstart', 370 | touches: [ 371 | { 372 | clientX: 80, 373 | clientY: 80 374 | } 375 | ] 376 | } 377 | ); 378 | 379 | TestUtils.Simulate.touchEnd( 380 | node, 381 | { 382 | type: 'touchend', 383 | touches: null 384 | } 385 | ); 386 | 387 | expect(props.onClick).not.to.have.been.called; 388 | 389 | TestUtils.Simulate.click( 390 | node, 391 | { 392 | type: 'click' 393 | } 394 | ); 395 | 396 | expect(props.onClick).not.to.have.been.called; 397 | 398 | getBoundingClientRectStub.restore(); 399 | }); 400 | 401 | }); 402 | 403 | describe('special elements', function () { 404 | 405 | it('should focus inputs, selects, and textareas when a fastclick is triggered', function () { 406 | var node, getBoundingClientRectStub, focusSpy; 407 | 408 | for (var i = 0; i < specialTypes.length; i += 1) { 409 | var type = specialTypes[i]; 410 | 411 | if (type !== 'label') { 412 | node = renderIntoApp(fastclickCreateElement(type)); 413 | 414 | getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 415 | focusSpy = spy(node, 'focus'); 416 | 417 | TestUtils.Simulate.touchStart( 418 | node, 419 | { 420 | type: 'touchstart', 421 | touches: touches 422 | } 423 | ); 424 | 425 | TestUtils.Simulate.touchEnd( 426 | node, 427 | { 428 | type: 'touchend', 429 | touches: null 430 | } 431 | ); 432 | 433 | expect(focusSpy).to.have.been.calledOnce; 434 | 435 | TestUtils.Simulate.click( 436 | node, 437 | { 438 | type: 'click' 439 | } 440 | ); 441 | 442 | expect(focusSpy).to.have.been.calledOnce; 443 | 444 | getBoundingClientRectStub.restore(); 445 | focusSpy.restore(); 446 | } 447 | } 448 | }); 449 | 450 | it('should not focus inputs, selects, and textareas if they are disabled', function () { 451 | var node, getBoundingClientRectStub, focusSpy; 452 | 453 | for (var i = 0; i < specialTypes.length; i += 1) { 454 | var type = specialTypes[i]; 455 | 456 | if (type !== 'label') { 457 | node = renderIntoApp(fastclickCreateElement(type, {disabled: true})); 458 | 459 | getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 460 | focusSpy = spy(node, 'focus'); 461 | 462 | TestUtils.Simulate.touchStart( 463 | node, 464 | { 465 | type: 'touchstart', 466 | touches: touches 467 | } 468 | ); 469 | 470 | TestUtils.Simulate.touchEnd( 471 | node, 472 | { 473 | type: 'touchend', 474 | touches: null 475 | } 476 | ); 477 | 478 | expect(focusSpy).not.to.have.been.called; 479 | 480 | TestUtils.Simulate.click( 481 | node, 482 | { 483 | type: 'click' 484 | } 485 | ); 486 | 487 | expect(focusSpy).not.to.have.been.called; 488 | 489 | getBoundingClientRectStub.restore(); 490 | focusSpy.restore(); 491 | } 492 | } 493 | }); 494 | 495 | it('should focus an input inside a label', function () { 496 | var node = renderIntoApp( 497 | fastclickCreateElement('label', null, fastclickCreateElement('input')) 498 | ); 499 | var label = node; 500 | var input = label.getElementsByTagName('input')[0]; 501 | 502 | var getBoundingClientRectStub = stub(label, 'getBoundingClientRect', getBoundingClientRect); 503 | var focusSpy = spy(input, 'focus'); 504 | 505 | TestUtils.Simulate.touchStart( 506 | label, 507 | { 508 | type: 'touchstart', 509 | touches: touches 510 | } 511 | ); 512 | 513 | TestUtils.Simulate.touchEnd( 514 | label, 515 | { 516 | type: 'touchend', 517 | touches: null 518 | } 519 | ); 520 | 521 | expect(focusSpy).to.have.been.calledOnce; 522 | 523 | TestUtils.Simulate.click( 524 | label, 525 | { 526 | type: 'click' 527 | } 528 | ); 529 | 530 | expect(focusSpy).to.have.been.calledOnce; 531 | 532 | getBoundingClientRectStub.restore(); 533 | focusSpy.restore(); 534 | }); 535 | 536 | it('should not focus a disabled input inside a label', function () { 537 | var node = renderIntoApp( 538 | fastclickCreateElement('label', null, fastclickCreateElement('input', {disabled: true})) 539 | ); 540 | var label = node; 541 | var input = label.getElementsByTagName('input')[0]; 542 | 543 | var getBoundingClientRectStub = stub(label, 'getBoundingClientRect', getBoundingClientRect); 544 | var focusSpy = spy(input, 'focus'); 545 | 546 | TestUtils.Simulate.touchStart( 547 | label, 548 | { 549 | type: 'touchstart', 550 | touches: touches 551 | } 552 | ); 553 | 554 | TestUtils.Simulate.touchEnd( 555 | label, 556 | { 557 | type: 'touchend', 558 | touches: null 559 | } 560 | ); 561 | 562 | expect(focusSpy).not.to.have.been.calledOnce; 563 | 564 | TestUtils.Simulate.click( 565 | label, 566 | { 567 | type: 'click' 568 | } 569 | ); 570 | 571 | expect(focusSpy).not.to.have.been.calledOnce; 572 | 573 | getBoundingClientRectStub.restore(); 574 | focusSpy.restore(); 575 | }); 576 | 577 | it('should focus an input for a label', function () { 578 | var node = renderIntoApp( 579 | fastclickCreateElement( 580 | 'div', 581 | null, 582 | fastclickCreateElement('label', {htmlFor: 'my-input'}), 583 | fastclickCreateElement('input', {id: 'my-input'}) 584 | ) 585 | ); 586 | 587 | var label = node.getElementsByTagName('label')[0]; 588 | var input = node.getElementsByTagName('input')[0]; 589 | 590 | var getBoundingClientRectStub = stub(label, 'getBoundingClientRect', getBoundingClientRect); 591 | var focusSpy = spy(input, 'focus'); 592 | 593 | TestUtils.Simulate.touchStart( 594 | label, 595 | { 596 | type: 'touchstart', 597 | touches: touches 598 | } 599 | ); 600 | 601 | TestUtils.Simulate.touchEnd( 602 | label, 603 | { 604 | type: 'touchend', 605 | touches: null 606 | } 607 | ); 608 | 609 | expect(focusSpy).to.have.been.calledOnce; 610 | 611 | TestUtils.Simulate.click( 612 | label, 613 | { 614 | type: 'click' 615 | } 616 | ); 617 | 618 | expect(focusSpy).to.have.been.calledOnce; 619 | 620 | getBoundingClientRectStub.restore(); 621 | focusSpy.restore(); 622 | }); 623 | 624 | it('should not focus a disabled input for a label', function () { 625 | var node = renderIntoApp( 626 | fastclickCreateElement( 627 | 'div', 628 | null, 629 | fastclickCreateElement('label', {htmlFor: 'my-input'}), 630 | fastclickCreateElement('input', {id: 'my-input', disabled: true}) 631 | ) 632 | ); 633 | 634 | var label = node.getElementsByTagName('label')[0]; 635 | var input = node.getElementsByTagName('input')[0]; 636 | 637 | var getBoundingClientRectStub = stub(label, 'getBoundingClientRect', getBoundingClientRect); 638 | var focusSpy = spy(input, 'focus'); 639 | 640 | TestUtils.Simulate.touchStart( 641 | label, 642 | { 643 | type: 'touchstart', 644 | touches: touches 645 | } 646 | ); 647 | 648 | TestUtils.Simulate.touchEnd( 649 | label, 650 | { 651 | type: 'touchend', 652 | touches: null 653 | } 654 | ); 655 | 656 | expect(focusSpy).not.to.have.been.calledOnce; 657 | 658 | TestUtils.Simulate.click( 659 | label, 660 | { 661 | type: 'click' 662 | } 663 | ); 664 | 665 | expect(focusSpy).not.to.have.been.calledOnce; 666 | 667 | getBoundingClientRectStub.restore(); 668 | focusSpy.restore(); 669 | }); 670 | 671 | it('should gracefully handle no input inside a label', function () { 672 | var node = renderIntoApp(fastclickCreateElement('label')); 673 | 674 | var getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 675 | 676 | TestUtils.Simulate.touchStart( 677 | node, 678 | { 679 | type: 'touchstart', 680 | touches: touches 681 | } 682 | ); 683 | 684 | TestUtils.Simulate.touchEnd( 685 | node, 686 | { 687 | type: 'touchend', 688 | touches: null 689 | } 690 | ); 691 | 692 | TestUtils.Simulate.click( 693 | node, 694 | { 695 | type: 'click' 696 | } 697 | ); 698 | 699 | getBoundingClientRectStub.restore(); 700 | }); 701 | 702 | it('should gracefully handle no input for a label', function () { 703 | var node = renderIntoApp(fastclickCreateElement('label', {htmlFor: 'my-input'})); 704 | 705 | var getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 706 | 707 | TestUtils.Simulate.touchStart( 708 | node, 709 | { 710 | type: 'touchstart', 711 | touches: touches 712 | } 713 | ); 714 | 715 | TestUtils.Simulate.touchEnd( 716 | node, 717 | { 718 | type: 'touchend', 719 | touches: null 720 | } 721 | ); 722 | 723 | TestUtils.Simulate.click( 724 | node, 725 | { 726 | type: 'click' 727 | } 728 | ); 729 | 730 | getBoundingClientRectStub.restore(); 731 | }); 732 | 733 | }); 734 | 735 | describe('events', function () { 736 | 737 | it('should persist events if they are to be mutated', function () { 738 | var props = { 739 | onClick: spy() 740 | }; 741 | var node = renderIntoApp(fastclickCreateElement('div', props)); 742 | 743 | var getBoundingClientRectStub = stub(node, 'getBoundingClientRect', getBoundingClientRect); 744 | var persistSpy = spy(); 745 | 746 | TestUtils.Simulate.touchStart( 747 | node, 748 | { 749 | type: 'touchstart', 750 | touches: touches, 751 | persist: persistSpy 752 | } 753 | ); 754 | 755 | expect(persistSpy).not.to.have.been.called; 756 | 757 | TestUtils.Simulate.touchEnd( 758 | node, 759 | { 760 | type: 'touchend', 761 | touches: null, 762 | persist: persistSpy 763 | } 764 | ); 765 | 766 | // Properties copied onto event from stored positions, and fake click event properties 767 | expect(persistSpy).to.have.been.calledTwice; 768 | persistSpy.reset(); 769 | 770 | TestUtils.Simulate.click( 771 | node, 772 | { 773 | type: 'click', 774 | persist: persistSpy 775 | } 776 | ); 777 | 778 | expect(persistSpy).not.to.have.been.called; 779 | expect(props.onClick).to.have.been.calledOnce; 780 | 781 | getBoundingClientRectStub.restore(); 782 | }); 783 | 784 | }); 785 | 786 | }); 787 | --------------------------------------------------------------------------------