├── .babelrc
├── .eslintignore
├── .eslintrc
├── .gitignore
├── .npmignore
├── .travis.yml
├── LICENSE
├── README.md
├── examples
├── smartphone-dashboard
│ ├── Dashboard.react.js
│ ├── index.html
│ ├── index.js
│ └── package.json
└── webpack.config.js
├── package.json
└── src
├── CustomGesture.react.js
├── Draggable.react.js
├── Holdable.react.js
├── Swipeable.react.js
├── TouchHandler.js
├── circleMath.js
├── computeDeltas.js
├── computePositionStyle.js
├── convertToDefaultsObject.js
├── defineHold.js
├── defineSwipe.js
├── gestureLevenshtein.js
├── gestureMoves.js
├── index.js
└── tests
├── CustomGesture.react.spec.js
├── Draggable.react.spec.js
├── Holdable.react.spec.js
├── Swipeable.react.spec.js
├── computeDeltas.spec.js
├── computePositionStyle.spec.js
├── gestureLevenshtein.spec.js
├── helpers.js
├── index.spec.js
└── setup.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "es2015",
4 | "stage-2"
5 | ],
6 | "plugins": [
7 | "transform-react-jsx",
8 | "transform-es2015-modules-commonjs",
9 | "transform-class-properties"
10 | ],
11 | "env": {
12 | "test": {
13 | "plugins": ["rewire"]
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | webpack.config*.js
2 | node_modules
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint-config-airbnb",
3 | "parser": "babel-eslint",
4 | "env": {
5 | "browser": true,
6 | "mocha": true,
7 | },
8 | rules: {
9 | "arrow-body-style": 0, // this is a little overzealous
10 | "no-use-before-define": ["error", "nofunc"],
11 | "space-infix-ops": 0, // enforcement is too broad currently
12 | "quotes": 0,
13 | "no-unused-expressions": ["error", { "allowShortCircuit": true }],
14 | "array-bracket-spacing": 0,
15 | "object-curly-spacing": 0,
16 | "comma-dangle": ["error", "always-multiline"],
17 | "react/sort-comp": 0, // I need to fiddle with this a bit
18 | "react/prop-types": 0 // little buggy right now (https://github.com/yannickcr/eslint-plugin-react/issues/494)
19 | },
20 | "plugins": [
21 | "react"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | lib
2 | node_modules
3 | .DS_Store
4 | npm-debug.log
5 | coverage
6 | build.js*
7 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | coverage
2 | .babelrc
3 | .eslint*
4 | .npmignore
5 | .travis.yml
6 | build.js*
7 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "5"
5 | - "node"
6 |
7 | script:
8 | - npm run lint
9 | - npm test
10 |
11 | after_success:
12 | - npm run test:cov
13 | - npm run test:codecov
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Phil Aquilina
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-Touch
2 | [](https://travis-ci.org/phil303/react-touch)
3 | [](https://codecov.io/github/phil303/react-touch?branch=master)
4 | [](https://www.npmjs.com/package/react-touch)
5 |
6 | React-Touch is a set of wrapper components that handle touch interactions in a more declarative way, abstracting out and giving you hooks into behaviors such as dragging, holding, swiping, and custom gestures. React-Touch also works with mouse events as well.
7 |
8 | Here's a quick example of the API.
9 |
10 | ```jsx
11 | import { Holdable } from 'react-touch';
12 |
13 | ({ holdProgress }) =>
14 |
15 | ```
16 |
17 | ## Try it out
18 | ```
19 | npm install react-touch --save
20 | ```
21 |
22 | ## Demos
23 |
24 | - [Emulating a Smartphone Dashboard](http://phil303.github.io/react-touch/examples/smartphone-dashboard/)
25 |
26 | ## What Does This Library Do?
27 | If you've ever written mobile web software, then you might've found yourself needing the ability to touch drag a component, measure a hold, or react to a swipe or gesture. This library is a set of wrapper components that abstract out the details of those things so you can wrap your components and move on.
28 |
29 | ## API
30 | Exports:
31 |
32 | - [`defineHold`](#defineholdconfig-object)
33 | - [`defineSwipe`](#defineswipeconfig-object)
34 | - [`Holdable`](#holdable-)
35 | - [`Draggable`](#draggable-)
36 | - [`Swipeable`](#swipeable-)
37 | - [`CustomGesture`](#customgesture-)
38 |
39 | ### Helpers
40 |
41 | #### `defineHold(config?: Object)`
42 |
43 | Used in conjuction with `Holdable`, `defineHold` is an optional helper function that creates a configuration for your holdable component. The arguments to it are:
44 |
45 | - `config`: Optional. Object with the following keys:
46 | - `updateEvery`: Optional. Defaults to 250. Units are in milliseconds.
47 | - `holdFor`: Optional. Defaults to 1000. Units are in milliseconds.
48 |
49 | #### Example Usage
50 | ```jsx
51 | const hold = defineHold({updateEvery: 50, holdFor: 500});
52 |
53 |
54 |
55 | ```
56 |
57 | #### `defineSwipe(config?: Object)`
58 |
59 | Used in conjuction with `Swipeable`, `defineSwipe` is an optional helper function that creates a configuration for your swipeable component. The arguments to it are:
60 |
61 | - `config`: Optional. Object with the following keys:
62 | - `swipeDistance`: Optional. Defaults to 100. Units are in pixels.
63 |
64 | #### Example Usage
65 | ```jsx
66 | const swipe = defineSwipe({swipeDistance: 50});
67 |
68 |
69 |
70 | ```
71 |
72 |
73 | ### Components
74 |
75 | #### ` `
76 |
77 | Used to create a component that understands holds. `Holdable` will give you hooks for the progress and the completion of a hold. You can pass a component or a function as its child. Passing a function will gain you access to the hold progress.
78 |
79 | #### Example Usage:
80 | ```jsx
81 |
82 | ({ holdProgress }) =>
83 |
84 | ```
85 |
86 | #### Props
87 | - `onHoldProgress?: Function`
88 | When the hold makes progress, this callback is fired. Update intervals can be adjusted by the `updateEvery` key in the configuration.
89 |
90 | - `onHoldComplete?: Function`
91 | When the hold has completed, this callback is fired. Length of hold can be
92 | adjusted by the `holdFor` key in the configuration.
93 |
94 | #### Callback Argument Keys
95 | - `holdProgress`
96 |
97 | #### ` `
98 |
99 | Used to create a component that can be dragged. `Draggable` requires a `style` prop defining its initial position and will pass updates to the child component via a callback.
100 |
101 | #### Example Usage
102 | ```jsx
103 |
104 | {({translateX, translateY}) => {
105 | return (
106 |
107 |
108 |
109 | );
110 | }}
111 |
112 | ```
113 |
114 | #### Props
115 | - `style: Object` Required. An object that defines the initial position of the draggable component. You can pass any of the following styles to it and they'll be updated and passed back out in the callback with every animation tick.
116 |
117 | - `translateX`
118 | - `translateY`
119 | - `top`
120 | - `left`
121 | - `right`
122 | - `bottom`
123 |
124 | #### Callback Argument Keys
125 | Any of the above keys depending on what you set as your `style`. Additionally:
126 |
127 | - `dx`
128 | - `dy`
129 |
130 | #### ` `
131 |
132 | Used to create a component that understands swipes. `Swipeable` gives you hooks to the swipe directions up, down, left, right, with the swipe threshold being customized using the `defineSwipe` helper.
133 |
134 | #### Example Usage:
135 | ```jsx
136 |
137 |
138 |
139 | ```
140 |
141 | #### Props
142 | - `onSwipeLeft?: Function`
143 | When the swipe threshold has been passed in the left direction, fire this callback.
144 |
145 | - `onSwipeRight?: Function`
146 | When the swipe threshold has been passed in the right direction, fire this callback.
147 |
148 | - `onSwipeDown?: Function`
149 | When the swipe threshold has been passed in the down direction, fire this callback.
150 |
151 | - `onSwipeUp?: Function`
152 | When the swipe threshold has been passed in the up direction, fire this callback.
153 |
154 |
155 | #### ` `
156 |
157 | Used to create a component that understands a customized gesture. Gestures are passed through the config prop. When the gesture is recognized, `onGesture` will fire.
158 |
159 | Gestures are just a combination of discrete linear movements. For instance, a "C" gesture would be composed of a left, down-left, down, down-right, and right. The user doesn't have to do this perfectly, the library will do a distance calculation and fire or not fire the `onGesture` callback based off that. This algorithm is a port of a [Swift library by Didier Brun](https://github.com/didierbrun/DBPathRecognizer).
160 |
161 | #### Example Usage:
162 | ```jsx
163 | import { CustomGesture, moves } from 'react-touch';
164 |
165 | const CIRCLE = [
166 | moves.RIGHT,
167 | moves.DOWNRIGHT,
168 | moves.DOWN,
169 | moves.DOWNLEFT,
170 | moves.LEFT,
171 | moves.UPLEFT,
172 | moves.UP,
173 | moves.UPRIGHT,
174 | moves.RIGHT,
175 | ];
176 |
177 |
178 |
179 |
180 | ```
181 |
182 | #### Props
183 | - `onGesture?: Function`
184 | Callback fired when the gesture is complete.
185 |
186 |
187 | ### Advanced Usage
188 | Want to be able to drag *and* hold a component? You can wrap react-touch components with other react-touch components to achieve this. For example:
189 |
190 | ```jsx
191 | const hold = defineHold({updateEvery: 50, holdFor: 500});
192 | console.log('held out')}>
193 |
194 | {({translateX, translateY, holdProgress}) => {
195 | return (
196 |
197 |
198 |
199 | );
200 | }}
201 |
202 |
203 | ```
204 |
205 | Notice the callback argument keys are the combination of the two parent components. This feature means you don't have to do multiple nested callbacks to achieve the same effect.
206 |
--------------------------------------------------------------------------------
/examples/smartphone-dashboard/Dashboard.react.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Motion, spring } from 'react-motion';
3 | import clamp from 'lodash/clamp';
4 | import shuffle from 'lodash/shuffle';
5 |
6 | import { Draggable, Holdable, Swipeable, CustomGesture, moves } from '../../src/index';
7 |
8 | const BORDER = 10;
9 | const ICON_SIZE = 90;
10 | const PADDING = 20;
11 | const ICONS_PER_ROW = 3;
12 | const CIRCLE = [
13 | moves.RIGHT, moves.DOWNRIGHT, moves.DOWN, moves.DOWNLEFT, moves.LEFT,
14 | moves.UPLEFT, moves.UP, moves.UPRIGHT, moves.RIGHT,
15 | ];
16 |
17 | const _calcPosition = (i, slotSize) => {
18 | return {
19 | left: (i % ICONS_PER_ROW) * slotSize,
20 | marginLeft: (slotSize - ICON_SIZE) / 2 + PADDING,
21 | top: Math.floor(i / ICONS_PER_ROW) * slotSize + PADDING,
22 | };
23 | };
24 |
25 | class Dashboard extends React.Component {
26 | state = { currentPage: 0 };
27 |
28 | _onSwipe(increment) {
29 | const pages = this.props.pages.length;
30 | const currentPage = clamp(this.state.currentPage + increment, 0, pages - 1);
31 | this.setState({ currentPage });
32 | }
33 |
34 | render() {
35 | const { width, height } = this.props;
36 | const slotSize = (width - BORDER * 2 - PADDING * 2) / ICONS_PER_ROW;
37 | return (
38 |
e.preventDefault()}
42 | onTouchMove={e => e.preventDefault()}
43 | >
44 | {this.props.pages.map((icons, idx) => {
45 | const left = (idx - this.state.currentPage) * width;
46 | return (
47 |
48 | {style =>
49 |
50 |
this._onSwipe(increment)}
54 | />
55 |
56 | }
57 |
58 | );
59 | })}
60 |
61 |
62 | This works on desktop web, your browser's device mode, or on your
63 | phone. If the aspect ratio looks off in device mode, refresh.
64 | The icons become draggable if you press and hold one for a second.
65 | If you swipe right, you'll transition to the next screen.
66 | To shuffle the icons, try a clockwise circle gesture.
67 | This whole demo is about 200 lines of code.
68 |
69 |
70 |
71 | );
72 | }
73 | }
74 |
75 | class Page extends React.Component {
76 | state = {
77 | iconsDraggable: false,
78 | icons: this.props.icons,
79 | currentlyDragged: null,
80 | };
81 |
82 | _onHoldComplete() {
83 | this.setState({ iconsDraggable: true });
84 | }
85 |
86 | _onDrag(pos, icon) {
87 | const { slotSize } = this.props;
88 | const i = clamp(Math.floor(pos.top / slotSize), 0, this.state.icons.length);
89 | const j = clamp(Math.floor(pos.left / slotSize), 0, 2);
90 | const idx = i * ICONS_PER_ROW + j;
91 |
92 | const newIcons = [ ...this.state.icons ];
93 | const removeIdx = newIcons.indexOf(icon);
94 | newIcons.splice(removeIdx, 1);
95 | newIcons.splice(idx, 0, icon);
96 |
97 | this.setState({ icons: newIcons, currentlyDragged: { pos, id: icon.id } });
98 | }
99 |
100 | _onDragEnd() {
101 | this.setState({ currentlyDragged: null });
102 | }
103 |
104 | _onGesture() {
105 | this.setState({ icons: shuffle(this.state.icons) });
106 | }
107 |
108 | _onSwipe(increment) {
109 | if (this.state.iconsDraggable) {
110 | return;
111 | }
112 | this.props.onSwipe(increment);
113 | }
114 |
115 | _onTouchEnd() {
116 | if (this.state.iconsDraggable && !this.state.currentlyDragged) {
117 | this.setState({iconsDraggable: false});
118 | }
119 | }
120 |
121 | render() {
122 | return (
123 | this._onGesture()}>
124 | this._onSwipe(1)}
126 | onSwipeRight={() => this._onSwipe(-1)}
127 | >
128 | this._onTouchEnd()}
131 | onTouchEnd={() => this._onTouchEnd()}
132 | >
133 | {!this.state.iconsDraggable ?
134 | this.renderHoldableIcons() : this.renderDraggableIcons()}
135 |
136 |
137 |
138 | );
139 | }
140 |
141 | renderHoldableIcons() {
142 | return this.state.icons.map((icon, i) => {
143 | const style = {
144 | ..._calcPosition(i, this.props.slotSize),
145 | zIndex: 0,
146 | backgroundColor: icon.color,
147 | };
148 | return (
149 | this._onHoldComplete()}>
150 |
151 |
152 | );
153 | });
154 | }
155 |
156 | renderDraggableIcons() {
157 | return this.state.icons.map((icon, i) => {
158 | const { currentlyDragged } = this.state;
159 | let pos, zIndex, className;
160 | if (currentlyDragged && icon.id === currentlyDragged.id) {
161 | const { marginLeft } = _calcPosition(i, this.props.slotSize);
162 | pos = currentlyDragged.pos;
163 | pos.marginLeft = marginLeft;
164 | zIndex = 1;
165 | className = "icon";
166 | } else {
167 | pos = _calcPosition(i, this.props.slotSize);
168 | zIndex = 0;
169 | className = "icon shake";
170 | }
171 | const style = { ...pos, zIndex, backgroundColor: icon.color };
172 |
173 | return (
174 | this._onDrag(newPos, icon)}
178 | onDragEnd={() => this._onDragEnd()}
179 | >
180 |
181 |
182 | );
183 | });
184 | }
185 | }
186 |
187 | export default Dashboard;
188 |
--------------------------------------------------------------------------------
/examples/smartphone-dashboard/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Smartphone Dashboard
7 |
8 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/examples/smartphone-dashboard/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import Dashboard from './Dashboard.react';
4 | import times from 'lodash/times';
5 |
6 | // turn off contextmenu on desktop
7 | document.oncontextmenu = () => false;
8 |
9 | const COLORS = [ '#EF767A', '#456990', '#49BEAA', '#49DCB1', '#EEB868' ];
10 | const MAX_ICONS = 9;
11 | const MIN_ICONS = 6;
12 |
13 | const createIcons = numPages => {
14 | return times(numPages, () => {
15 | const numIcons = Math.round(Math.random() * (MAX_ICONS - MIN_ICONS) + MIN_ICONS);
16 | return times(numIcons, idx => {
17 | return { id: idx, color: COLORS[Math.floor(Math.random() * 10) % COLORS.length] };
18 | });
19 | });
20 | };
21 |
22 | const width = Math.min(window.innerWidth, 450);
23 | const height = Math.min(window.innerHeight, 700);
24 |
25 | ReactDOM.render(
26 | ,
27 | document.getElementById('content')
28 | );
29 |
--------------------------------------------------------------------------------
/examples/smartphone-dashboard/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-touch-dashboard-example",
3 | "version": "0.0.0",
4 | "description": "React-Touch smartphone dashboard example",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/phil303/react-touch"
8 | },
9 | "author": "Phil Aquilina (http://github.com/phil303)",
10 | "license": "MIT",
11 | "bugs": {
12 | "url": "https://github.com/phil303/react-touch/issues"
13 | },
14 | "homepage": "https://github.com/phil303/react-touch",
15 | "dependencies": {
16 | "react": "^15.0",
17 | "lodash": "4.6.1",
18 | "react-motion": "0.4.2"
19 | }
20 | }
21 |
22 |
--------------------------------------------------------------------------------
/examples/webpack.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | var webpack = require('webpack');
3 | var path = require('path');
4 |
5 | process.env.NODE_ENV = process.env.NODE_ENV || 'production';
6 | var port = process.env.PORT || 3000;
7 |
8 | var devtool ='source-map';
9 | var plugins = [
10 | new webpack.DefinePlugin({
11 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
12 | }),
13 | new webpack.optimize.OccurenceOrderPlugin()
14 | ];
15 |
16 | var entries = {
17 | 'smartphone-dashboard': './examples/smartphone-dashboard/index.js',
18 | };
19 |
20 | module.exports = {
21 | devtool: devtool,
22 | entry: entries,
23 | output: {
24 | filename: '[name]/build.js',
25 | path: __dirname,
26 | },
27 | module: {
28 | loaders: [{
29 | test: /\.js$/,
30 | loaders: ['babel-loader'],
31 | exclude: /node_modules/,
32 | }],
33 | resolve: {
34 | extensions: ['', '.js']
35 | },
36 | plugins: plugins,
37 | }
38 | };
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-touch",
3 | "version": "0.4.0",
4 | "description": "React wrapper components that make touch events easy",
5 | "main": "./lib/index.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/phil303/react-touch"
9 | },
10 | "scripts": {
11 | "build": "babel src --out-dir lib",
12 | "watch": "npm run build -- --watch",
13 | "build:examples": "webpack --config examples/webpack.config.js",
14 | "watch:examples": "npm run build:examples -- --watch",
15 | "lint": "eslint src",
16 | "prepublish": "rm -rf lib && npm test && npm run build",
17 | "test": "NODE_ENV=test mocha --compilers js:babel-register src/**/tests/*",
18 | "test:one": "npm test -- -g",
19 | "test:watch": "npm test -- --watch",
20 | "test:cov": "NODE_ENV=test babel-node ./node_modules/.bin/istanbul cover ./node_modules/mocha/bin/_mocha -- 'src/**/tests/*'",
21 | "test:codecov": "cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js"
22 | },
23 | "keywords": [
24 | "react",
25 | "reactjs",
26 | "react-touch",
27 | "touch"
28 | ],
29 | "author": "Phil Aquilina (http://github.com/phil303)",
30 | "license": "MIT",
31 | "bugs": {
32 | "url": "https://github.com/phil303/react-touch/issues"
33 | },
34 | "homepage": "https://github.com/phil303/react-touch",
35 | "peerDependencies": {
36 | "react": "^0.14 || ^15.0"
37 | },
38 | "dependencies": {
39 | "lodash": "4.6.1",
40 | "raf": "3.2.0"
41 | },
42 | "devDependencies": {
43 | "babel-cli": "6.6.4",
44 | "babel-eslint": "6.0.0",
45 | "babel-loader": "6.2.4",
46 | "babel-plugin-transform-class-properties": "6.6.0",
47 | "babel-plugin-transform-react-jsx": "6.4.0",
48 | "babel-plugin-rewire": "^1.0.0-rc-2",
49 | "babel-preset-es2015": "6.6.0",
50 | "babel-preset-stage-2": "6.5.0",
51 | "codecov.io": "0.1.6",
52 | "chai": "3.5.0",
53 | "eslint": "2.4.0",
54 | "eslint-config-airbnb": "6.1.0",
55 | "eslint-plugin-react": "4.2.3",
56 | "istanbul": "1.0.0-alpha.2",
57 | "jsdom": "8.1.0",
58 | "mocha": "2.4.5",
59 | "react": "^0.14",
60 | "react-dom": "^0.14",
61 | "react-addons-test-utils": "^0.14",
62 | "sinon": "1.17.3",
63 | "webpack": "1.12.14"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/CustomGesture.react.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import isFunction from 'lodash/isFunction';
3 | import isArray from 'lodash/isArray';
4 | import merge from 'lodash/merge';
5 |
6 | import TouchHandler from './TouchHandler';
7 | import computeDeltas from './computeDeltas';
8 | import gestureLevenshtein from './gestureLevenshtein';
9 | import convertToDefaultsObject from './convertToDefaultsObject';
10 | import { createSectors, computeSectorIdx } from './circleMath';
11 |
12 | const T = React.PropTypes;
13 |
14 | const INITIAL_STATE = { current: null, moves: [] };
15 | const DEFAULT_CONFIG = { fudgeFactor: 5, minMoves: 8, gesture: "" };
16 |
17 | class CustomGesture extends React.Component {
18 | static propTypes = {
19 | children: T.oneOfType([T.func, T.element]).isRequired,
20 | config: T.oneOfType([T.string, T.array, T.object]).isRequired,
21 | onMouseDown: T.func,
22 | onTouchStart: T.func,
23 | onGesture: T.func,
24 | __passThrough: T.object,
25 | };
26 |
27 | static get defaultProps() {
28 | return {
29 | onGesture: () => {},
30 | config: DEFAULT_CONFIG,
31 | };
32 | }
33 |
34 | constructor(props) {
35 | super(props);
36 | this._state = INITIAL_STATE;
37 | this._sectors = createSectors(); // create a resolution map of sectors
38 |
39 | this._touchHandler = new TouchHandler(
40 | this.handleTouchStart.bind(this),
41 | this.handleTouchMove.bind(this),
42 | this.handleTouchEnd.bind(this),
43 | );
44 | }
45 |
46 | componentWillUnmount() {
47 | this._touchHandler.cancelAnimationFrame();
48 | this._touchHandler.removeListeners();
49 | }
50 |
51 | handleTouchStart(touchPosition) {
52 | // set initial conditions for the touch event
53 | this._state = merge({}, this._state, {current: touchPosition});
54 | }
55 |
56 | handleTouchMove(touchPosition) {
57 | const { current, moves } = this._state;
58 | const { dx, dy } = computeDeltas(current, touchPosition);
59 | const sectorIdx = computeSectorIdx(dx, dy);
60 |
61 | this._state = {
62 | current: { x: current.x + dx, y: current.y + dy },
63 | moves: [ ...moves, this._sectors[sectorIdx] ],
64 | };
65 | }
66 |
67 | handleTouchEnd() {
68 | const { config: _config } = this.props;
69 | const config = convertToDefaultsObject(_config, 'gesture', DEFAULT_CONFIG);
70 |
71 | if (this._state.moves.length < config.minMoves) {
72 | this._resetState();
73 | return;
74 | }
75 |
76 | const gesture = isArray(config.gesture) ? config.gesture.join("") : config.gesture;
77 | const distance = gestureLevenshtein(this._state.moves.join(""), gesture);
78 |
79 | if (distance < config.fudgeFactor) {
80 | this.props.onGesture();
81 | }
82 | this._resetState();
83 | }
84 |
85 | _resetState() {
86 | this._touchHandler.cancelAnimationFrame();
87 | this._state = INITIAL_STATE;
88 | }
89 |
90 | render() {
91 | const { onTouchStart, onMouseDown, children, __passThrough } = this.props;
92 | const child = isFunction(children) ? children(__passThrough) : children;
93 |
94 | return React.cloneElement(React.Children.only(child), {
95 | __passThrough,
96 | ...this._touchHandler.listeners(child, onTouchStart, onMouseDown),
97 | });
98 | }
99 | }
100 |
101 | export default CustomGesture;
102 |
--------------------------------------------------------------------------------
/src/Draggable.react.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import isFunction from 'lodash/isFunction';
3 |
4 | import TouchHandler from './TouchHandler';
5 | import computePositionStyle from './computePositionStyle';
6 | import computeDeltas from './computeDeltas';
7 |
8 |
9 | const T = React.PropTypes;
10 | const ZERO_DELTAS = { dx: 0, dy: 0 };
11 | const DEFAULT_TOUCH = { initial: null, current: null, deltas: ZERO_DELTAS };
12 |
13 |
14 | class Draggable extends React.Component {
15 |
16 | static propTypes = {
17 | children: T.oneOfType([T.func, T.element]).isRequired,
18 | position: T.objectOf(T.oneOfType([T.number, T.object])).isRequired,
19 | onMouseDown: T.func,
20 | onTouchStart: T.func,
21 | onDrag: T.func,
22 | onDragEnd: T.func,
23 | __passThrough: T.object,
24 | };
25 |
26 | constructor(props) {
27 | super(props);
28 | this.state = DEFAULT_TOUCH;
29 | this._touchHandler = new TouchHandler(
30 | this.handleTouchStart.bind(this),
31 | this.handleTouchMove.bind(this),
32 | this.handleTouchEnd.bind(this),
33 | );
34 | }
35 |
36 | passThroughState() {
37 | const { position } = this.props;
38 | const { deltas } = this.state;
39 | const current = computePositionStyle(position, deltas);
40 | return { ...current, ...deltas };
41 | }
42 |
43 | handleTouchStart(touchPosition) {
44 | this.setState({ initial: touchPosition, current: touchPosition });
45 | }
46 |
47 | handleTouchMove(touchPosition) {
48 | const { deltas, current } = this.state;
49 | const touchDeltas = computeDeltas(current, touchPosition);
50 | const componentPosition = computePositionStyle(this.props.position, touchDeltas);
51 | this.props.onDrag && this.props.onDrag(componentPosition);
52 |
53 | const latest = {dx: deltas.dx + touchDeltas.dx, dy: deltas.dy + touchDeltas.dy};
54 | this.setState({ deltas: latest, current: touchPosition });
55 | }
56 |
57 | handleTouchEnd() {
58 | this.props.onDragEnd && this.props.onDragEnd();
59 | }
60 |
61 | render() {
62 | const { onTouchStart, onMouseDown, children, __passThrough } = this.props;
63 | const passThrough = { ...__passThrough, ...this.passThroughState() };
64 | const child = isFunction(children) ? children({ ...passThrough }) : children;
65 |
66 | return React.cloneElement(React.Children.only(child), {
67 | __passThrough: passThrough,
68 | ...this._touchHandler.listeners(child, onTouchStart, onMouseDown),
69 | });
70 | }
71 | }
72 |
73 | export default Draggable;
74 |
--------------------------------------------------------------------------------
/src/Holdable.react.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import isFunction from 'lodash/isFunction';
3 | import merge from 'lodash/merge';
4 | import clamp from 'lodash/clamp';
5 |
6 | import defineHold from './defineHold';
7 | import TouchHandler from './TouchHandler';
8 |
9 |
10 | const T = React.PropTypes;
11 | const DEFAULT_HOLD = { initial: null, current: null, duration: 0 };
12 |
13 | class Holdable extends React.Component {
14 |
15 | static propTypes = {
16 | children: T.oneOfType([T.func, T.element]).isRequired,
17 | onHoldProgress: T.func,
18 | onHoldComplete: T.func,
19 | onMouseDown: T.func,
20 | onTouchStart: T.func,
21 | config: T.object,
22 | __passThrough: T.object,
23 | };
24 |
25 | static get defaultProps() {
26 | return {
27 | onHoldProgress: () => {},
28 | onHoldComplete: () => {},
29 | config: defineHold(),
30 | };
31 | }
32 |
33 | constructor(props) {
34 | super(props);
35 | this.state = DEFAULT_HOLD;
36 | this._startHoldProgress = null;
37 | this._startHoldComplete = null;
38 | this._clearHoldProgressTimer = null;
39 | this._clearHoldCompleteTimer = null;
40 |
41 | this._touchHandler = new TouchHandler(
42 | this.handleTouchStart.bind(this),
43 | this.handleTouchMove.bind(this),
44 | this.handleTouchEnd.bind(this),
45 | );
46 | }
47 |
48 | _resetTouch() {
49 | this.setState(DEFAULT_HOLD);
50 | }
51 |
52 | _clearTimers() {
53 | // successful hold completes will null these out
54 | this._clearHoldProgressTimer && this._clearHoldProgressTimer();
55 | this._clearHoldCompleteTimer && this._clearHoldCompleteTimer();
56 | }
57 |
58 | componentDidMount() {
59 | const { onHoldProgress, onHoldComplete, config } = this.props;
60 |
61 | this._startHoldProgress = config.holdProgress(onHoldProgress);
62 | this._startHoldComplete = config.holdComplete(onHoldComplete);
63 | }
64 |
65 | componentWillUnmount() {
66 | this._touchHandler.removeListeners();
67 | this._clearTimers();
68 | }
69 |
70 | passThroughState() {
71 | return { holdProgress: this.state.duration };
72 | }
73 |
74 | handleTouchStart() {
75 | // set initial conditions for the touch event
76 | const initial = Date.now();
77 | this.setState(merge({}, this.state, { initial, current: initial }));
78 |
79 | this._clearHoldProgressTimer = this._startHoldProgress(holdLength => {
80 | const current = Date.now();
81 | const _duration = (current - this.state.initial) / holdLength;
82 | const duration = clamp(_duration, 0, 1);
83 | this.setState(merge({}, this.state, { current, duration }));
84 |
85 | if (duration === 1) {
86 | // edge case: setTimeout ensures onholdComplete has a chance to fire
87 | setTimeout(() => this._clearTimers());
88 | }
89 | });
90 | this._clearHoldCompleteTimer = this._startHoldComplete();
91 | }
92 |
93 | handleTouchMove() {
94 | this._clearTimers();
95 | }
96 |
97 | handleTouchEnd() {
98 | this._clearTimers();
99 | this._resetTouch();
100 | }
101 |
102 | render() {
103 | const { onTouchStart, onMouseDown, children, __passThrough } = this.props;
104 | const passThrough = { ...__passThrough, ...this.passThroughState() };
105 | const child = isFunction(children) ? children({ ...passThrough }) : children;
106 |
107 | return React.cloneElement(React.Children.only(child), {
108 | __passThrough: passThrough,
109 | ...this._touchHandler.listeners(child, onTouchStart, onMouseDown),
110 | });
111 | }
112 | }
113 |
114 | export default Holdable;
115 |
--------------------------------------------------------------------------------
/src/Swipeable.react.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import isFunction from 'lodash/isFunction';
3 | import merge from 'lodash/merge';
4 |
5 | import TouchHandler from './TouchHandler';
6 | import defineSwipe from './defineSwipe';
7 |
8 |
9 | const T = React.PropTypes;
10 | const DIRECTIONS = ['Left', 'Right', 'Up', 'Down'];
11 | const ZERO_DELTAS = { dx: 0, dy: 0 };
12 | const DEFAULT_STATE = { initial: null, current: null, deltas: ZERO_DELTAS };
13 |
14 | class Swipeable extends React.Component {
15 |
16 | static propTypes = {
17 | children: T.oneOfType([T.func, T.element]).isRequired,
18 | config: T.object,
19 | onMouseDown: T.func,
20 | onTouchStart: T.func,
21 | __passThrough: T.object,
22 | };
23 |
24 | static get defaultProps() {
25 | return { config: defineSwipe() };
26 | }
27 |
28 | constructor(props) {
29 | super(props);
30 |
31 | this.state = DEFAULT_STATE;
32 | this._handlerFired = {};
33 | this._touchHandler = new TouchHandler(
34 | this.handleTouchStart.bind(this),
35 | this.handleTouchMove.bind(this),
36 | this.handleTouchEnd.bind(this),
37 | );
38 | }
39 |
40 | componentWillUnmount() {
41 | this._touchHandler.cancelAnimationFrame();
42 | this._touchHandler.removeListeners();
43 | }
44 |
45 | passThroughState() {
46 | return { ...this.state.deltas };
47 | }
48 |
49 | handleTouchStart(touchPosition) {
50 | this.setState(merge({}, this.state, {
51 | initial: touchPosition,
52 | current: touchPosition,
53 | }));
54 | }
55 |
56 | handleTouchMove(touchPosition) {
57 | this.setState(merge({}, this.state, { current: touchPosition }));
58 |
59 | DIRECTIONS.forEach(direction => {
60 | const name = `onSwipe${direction}`;
61 | const handler = this.props[name];
62 | if (handler && !this._handlerFired[name]) {
63 | this.props.config[name](touchPosition, this.state.initial, () => {
64 | this._handlerFired[name] = true;
65 | handler();
66 | });
67 | }
68 | });
69 | }
70 |
71 | handleTouchEnd() {
72 | this._resetState();
73 | }
74 |
75 | _resetState() {
76 | this._touchHandler.cancelAnimationFrame();
77 | this._handlerFired = {};
78 | this.setState(merge({}, this.state, DEFAULT_STATE));
79 | }
80 |
81 | render() {
82 | const { onTouchStart, onMouseDown, children, __passThrough } = this.props;
83 | const passThrough = { ...__passThrough, ...this.passThroughState() };
84 | const child = isFunction(children) ? children({ ...passThrough }) : children;
85 |
86 | return React.cloneElement(React.Children.only(child), {
87 | __passThrough: passThrough,
88 | ...this._touchHandler.listeners(child, onTouchStart, onMouseDown),
89 | });
90 | }
91 | }
92 |
93 | export default Swipeable;
94 |
--------------------------------------------------------------------------------
/src/TouchHandler.js:
--------------------------------------------------------------------------------
1 | import raf from 'raf';
2 |
3 | const extractPosition = callback => (evt, ...args) => {
4 | let nativeEvent = evt;
5 | if (!(evt instanceof window.Event)) {
6 | nativeEvent = evt.nativeEvent;
7 | }
8 |
9 | let touchPosition = null;
10 | if (nativeEvent.touches && nativeEvent.touches.length) {
11 | const touch = nativeEvent.touches[0];
12 | touchPosition = { x: touch.clientX, y: touch.clientY };
13 | } else if (nativeEvent.clientX && nativeEvent.clientY) {
14 | touchPosition = { x: nativeEvent.clientX, y: nativeEvent.clientY };
15 | }
16 | return callback(touchPosition, evt, ...args);
17 | };
18 |
19 |
20 | class TouchHandler {
21 | constructor(onTouchStart, onTouchMove, onTouchEnd) {
22 | // in the event both touch and click handlers can fire (e.g., chrome device
23 | // mode), only add one set of handlers
24 | this._listenersAdded = false;
25 | this._currentAnimationFrame = null;
26 |
27 | // delegated to callbacks
28 | this._onTouchStart = onTouchStart;
29 | this._onTouchMove = onTouchMove;
30 | this._onTouchEnd = onTouchEnd;
31 |
32 | this._handleTouchStart = extractPosition(this._handleTouchStart.bind(this));
33 | this._handleMouseDown = extractPosition(this._handleMouseDown.bind(this));
34 |
35 | this._handleTouchMove = extractPosition(this._handleTouchMove.bind(this));
36 | this._handleTouchEnd = extractPosition(this._handleTouchEnd.bind(this));
37 | }
38 |
39 | listeners(child, onTouchStart, onMouseDown) {
40 | return {
41 | onTouchStart: evt => this._handleTouchStart(evt, child, onTouchStart),
42 | onMouseDown: evt => this._handleMouseDown(evt, child, onMouseDown),
43 | };
44 | }
45 |
46 | removeListeners() {
47 | this._listenersAdded = false;
48 | document.removeEventListener('touchmove', this._handleTouchMove);
49 | document.removeEventListener('touchend', this._handleTouchEnd);
50 | document.removeEventListener('touchcancel', this._handleTouchEnd);
51 | document.removeEventListener('mousemove', this._handleTouchMove);
52 | document.removeEventListener('mouseup', this._handleTouchEnd);
53 | }
54 |
55 | cancelAnimationFrame() {
56 | raf.cancel(this._currentAnimationFrame);
57 | this._currentAnimationFrame = null;
58 | }
59 |
60 | _addTouchListeners() {
61 | this._listenersAdded = true;
62 | document.addEventListener('touchmove', this._handleTouchMove);
63 | document.addEventListener('touchend', this._handleTouchEnd);
64 | document.addEventListener('touchcancel', this._handleTouchEnd);
65 | }
66 |
67 | _addMouseListeners() {
68 | this._listenersAdded = true;
69 | document.addEventListener('mousemove', this._handleTouchMove);
70 | document.addEventListener('mouseup', this._handleTouchEnd);
71 | }
72 |
73 | _handleTouchStart(touchPosition, synthEvent, child, onTouchStart) {
74 | if (this._listenersAdded) return;
75 | this._addTouchListeners();
76 |
77 | child.props.onTouchStart && child.props.onTouchStart(synthEvent);
78 | onTouchStart && onTouchStart(synthEvent);
79 | this._onTouchStart(touchPosition);
80 | }
81 |
82 | _handleMouseDown(touchPosition, synthEvent, child, onMouseDown) {
83 | if (this._listenersAdded) return;
84 | this._addMouseListeners();
85 |
86 | child.props.onMouseDown && child.props.onMouseDown(synthEvent);
87 | onMouseDown && onMouseDown(synthEvent);
88 | this._onTouchStart(touchPosition);
89 | }
90 |
91 | _handleTouchMove(touchPosition) {
92 | if (!this._currentAnimationFrame) {
93 | this._currentAnimationFrame = raf(() => {
94 | this._currentAnimationFrame = null;
95 | this._onTouchMove(touchPosition);
96 | });
97 | }
98 | }
99 |
100 | _handleTouchEnd(touchPosition) {
101 | this.cancelAnimationFrame();
102 | this.removeListeners();
103 | this._onTouchEnd(touchPosition);
104 | }
105 | }
106 |
107 | export default TouchHandler;
108 |
--------------------------------------------------------------------------------
/src/circleMath.js:
--------------------------------------------------------------------------------
1 | import range from 'lodash/range';
2 |
3 | const DIRECTIONS = 8;
4 | const RESOLUTION = 128;
5 | const CIRCLE_RADS = Math.PI * 2;
6 | const SECTOR_RADS = CIRCLE_RADS / DIRECTIONS;
7 | const STEP = CIRCLE_RADS / RESOLUTION;
8 |
9 | export const sectorDistance = (a, b) => {
10 | const dist = Math.abs(parseInt(a, 10) - parseInt(b, 10));
11 | return dist > DIRECTIONS / 2 ? DIRECTIONS - dist : dist;
12 | };
13 |
14 | export const createSectors = () => {
15 | return range(0, CIRCLE_RADS + STEP, STEP).map(angle => {
16 | return Math.floor(angle / SECTOR_RADS);
17 | });
18 | };
19 |
20 | export const computeSectorIdx = (dx, dy) => {
21 | // Our sectors range from vertical to diagonal to horizontal. We want them
22 | // to range "around" those things. Using the "up" sector as an example,
23 | // relative to the vertical line representing the up direction we want the
24 | // sector to range between -1/16th to +1/16th of the circle around that
25 | // line. We can simplify the math by just adding 1/16th to our given angle.
26 | let angle = Math.atan2(dy, dx) + SECTOR_RADS / 2;
27 | if (angle < 0) {
28 | angle += CIRCLE_RADS;
29 | }
30 | // since we're dealing with floating point calculations here, floor
31 | // anything that comes out of the calculation back to the sectorIdx.
32 | return Math.floor(angle / CIRCLE_RADS * RESOLUTION);
33 | };
34 |
--------------------------------------------------------------------------------
/src/computeDeltas.js:
--------------------------------------------------------------------------------
1 | const computeDeltas = (oldPosition, newPosition) => {
2 | const { x: oldX, y: oldY } = oldPosition;
3 | const { x: newX, y: newY } = newPosition;
4 | return { dx: newX - oldX, dy: newY - oldY };
5 | };
6 |
7 | export default computeDeltas;
8 |
--------------------------------------------------------------------------------
/src/computePositionStyle.js:
--------------------------------------------------------------------------------
1 | const DIRECTIVES = [
2 | ['left', 'dx', add],
3 | ['top', 'dy', add],
4 | ['bottom', 'dy', subtract],
5 | ['right', 'dx', subtract],
6 | ['translateX', 'dx', add],
7 | ['translateY', 'dy', add],
8 | ];
9 |
10 | const computePositionStyle = (currentStyle, deltas) => {
11 | return DIRECTIVES.reduce((style, directive) => {
12 | const [name, deltaType, operation] = directive;
13 | if (currentStyle[name] !== undefined) {
14 | // eslint-disable-next-line no-param-reassign
15 | style[name] = operation(currentStyle[name], deltas[deltaType]);
16 | }
17 | return style;
18 | }, {});
19 | };
20 |
21 | function add(a, b) {
22 | return a + b;
23 | }
24 |
25 | function subtract(a, b) {
26 | return add(a, -b);
27 | }
28 |
29 |
30 | export default computePositionStyle;
31 |
--------------------------------------------------------------------------------
/src/convertToDefaultsObject.js:
--------------------------------------------------------------------------------
1 | import isArray from 'lodash/isArray';
2 | import isObject from 'lodash/isObject';
3 |
4 | const convertToDefaultsObject = (value, mainKey='main', defaultValues={}) => {
5 | if (isArray(value) || !isObject(value)) {
6 | return { ...defaultValues, [mainKey]: value };
7 | }
8 | return { ...defaultValues, ...value };
9 | };
10 |
11 | export default convertToDefaultsObject;
12 |
--------------------------------------------------------------------------------
/src/defineHold.js:
--------------------------------------------------------------------------------
1 | const DEFAULT_INTERVAL = 250;
2 | const DEFAULT_HOLD_LENGTH = 1000;
3 |
4 | const defineHold = (config={}) => {
5 | const updateInterval = config.updateEvery || DEFAULT_INTERVAL;
6 | const holdLength = config.holdFor || DEFAULT_HOLD_LENGTH;
7 |
8 | return {
9 | holdProgress: callback => updateState => {
10 | const holdDownTimer = setInterval(() => {
11 | callback();
12 | updateState(holdLength);
13 | }, updateInterval);
14 | return () => clearInterval(holdDownTimer);
15 | },
16 | holdComplete: callback => () => {
17 | const holdReleaseTimer = setTimeout(callback, holdLength);
18 | return () => clearTimeout(holdReleaseTimer);
19 | },
20 | };
21 | };
22 |
23 | export default defineHold;
24 |
--------------------------------------------------------------------------------
/src/defineSwipe.js:
--------------------------------------------------------------------------------
1 | const DEFAULT_SWIPE_DISTANCE = 100;
2 |
3 |
4 | const defineSwipe = (config={}) => {
5 | // TODO: add swipe velocity back in
6 | const swipeDistance = config.swipeDistance || DEFAULT_SWIPE_DISTANCE;
7 |
8 | return {
9 | onSwipeLeft: (current, initial, callback) => {
10 | if (-(current.x - initial.x) >= swipeDistance) {
11 | callback();
12 | }
13 | },
14 | onSwipeRight: (current, initial, callback) => {
15 | if ((current.x - initial.x) >= swipeDistance) {
16 | callback();
17 | }
18 | },
19 | onSwipeUp: (current, initial, callback) => {
20 | if (-(current.y - initial.y) >= swipeDistance) {
21 | callback();
22 | }
23 | },
24 | onSwipeDown: (current, initial, callback) => {
25 | if ((current.y - initial.y) >= swipeDistance) {
26 | callback();
27 | }
28 | },
29 | };
30 | };
31 |
32 | export default defineSwipe;
33 |
--------------------------------------------------------------------------------
/src/gestureLevenshtein.js:
--------------------------------------------------------------------------------
1 | import times from 'lodash/times';
2 | import { sectorDistance } from './circleMath';
3 |
4 | const BIG_NUM = 10000;
5 |
6 |
7 | const gestureLevenshtein = (a, b) => {
8 | if (a.length === 0 || b.length === 0) {
9 | return BIG_NUM;
10 | }
11 |
12 | // create a levenshtein matrix
13 | const levMatrix = times(b.length + 1, () => {
14 | return times(a.length + 1, () => 0);
15 | });
16 | // make the first row and the first side column a big number
17 | for (let j=1; j<=a.length; j++) {
18 | levMatrix[0][j] = BIG_NUM;
19 | }
20 | for (let i=1; i<=b.length; i++) {
21 | levMatrix[i][0] = BIG_NUM;
22 | }
23 |
24 | // now compute the cells in the levenshtein matrix
25 | for (let i=1; i<=b.length; i++) {
26 | for (let j=1; j<=a.length; j++) {
27 | const cost = sectorDistance(a[j-1], b[i-1]);
28 | levMatrix[i][j] = Math.min(
29 | cost + levMatrix[i-1][j],
30 | cost + levMatrix[i][j-1],
31 | cost + levMatrix[i-1][j-1]
32 | );
33 | }
34 | }
35 | return levMatrix[b.length][a.length];
36 | };
37 |
38 | export default gestureLevenshtein;
39 |
--------------------------------------------------------------------------------
/src/gestureMoves.js:
--------------------------------------------------------------------------------
1 | const moves = {
2 | RIGHT: 0,
3 | DOWNRIGHT: 1,
4 | DOWN: 2,
5 | DOWNLEFT: 3,
6 | LEFT: 4,
7 | UPLEFT: 5,
8 | UP: 6,
9 | UPRIGHT: 7,
10 | };
11 |
12 | export default moves;
13 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { default as Draggable } from './Draggable.react';
2 | export { default as Holdable } from './Holdable.react';
3 | export { default as Swipeable } from './Swipeable.react';
4 | export { default as CustomGesture } from './CustomGesture.react';
5 | export { default as defineHold } from './defineHold';
6 | export { default as defineSwipe } from './defineSwipe';
7 | export { default as moves } from './gestureMoves';
8 |
--------------------------------------------------------------------------------
/src/tests/CustomGesture.react.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import sinon from 'sinon';
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import TestUtils from 'react-addons-test-utils';
6 | import times from 'lodash/times';
7 |
8 | import { documentEvent, renderComponent, createFakeRaf, nativeTouch } from './helpers';
9 | import moves from '../gestureMoves';
10 | import CustomGesture from '../CustomGesture.react';
11 | import TouchHandler from '../TouchHandler';
12 |
13 | /* eslint-disable no-unused-expressions */
14 |
15 | const renderCustomGesture = renderComponent(CustomGesture);
16 | const fakeRaf = createFakeRaf();
17 |
18 | describe("CustomGesture", () => {
19 | beforeEach(() => TouchHandler.__Rewire__('raf', fakeRaf));
20 | afterEach(() => TouchHandler.__ResetDependency__('raf'));
21 |
22 | it("should fire 'onGesture' with a qualifying gesture", () => {
23 | const alpha = [
24 | moves.DOWNRIGHT,
25 | moves.RIGHT,
26 | moves.UPRIGHT,
27 | moves.UP,
28 | moves.UPLEFT,
29 | moves.LEFT,
30 | moves.DOWNLEFT,
31 | ];
32 | const spy = sinon.spy();
33 | const component = renderCustomGesture({
34 | onGesture: spy,
35 | config: alpha,
36 | });
37 | TestUtils.Simulate.touchStart(
38 | ReactDOM.findDOMNode(component),
39 | {nativeEvent: nativeTouch(200, 300)}
40 | );
41 | documentEvent('touchmove', nativeTouch(210, 310)); // down-right
42 | fakeRaf.step();
43 | documentEvent('touchmove', nativeTouch(220, 320)); // down-right
44 | fakeRaf.step();
45 | documentEvent('touchmove', nativeTouch(230, 320)); // right
46 | fakeRaf.step();
47 | documentEvent('touchmove', nativeTouch(240, 310)); // up-right
48 | fakeRaf.step();
49 | documentEvent('touchmove', nativeTouch(240, 300)); // up
50 | fakeRaf.step();
51 | documentEvent('touchmove', nativeTouch(230, 290)); // up-left
52 | fakeRaf.step();
53 | documentEvent('touchmove', nativeTouch(220, 290)); // left
54 | fakeRaf.step();
55 | documentEvent('touchmove', nativeTouch(210, 300)); // down-left
56 | fakeRaf.step();
57 | documentEvent('touchmove', nativeTouch(200, 310)); // down-left
58 | fakeRaf.step();
59 | documentEvent('touchend');
60 | expect(spy.calledOnce).to.be.true;
61 | });
62 |
63 | it("should reset the state when touch is ended", () => {
64 | const component = renderCustomGesture();
65 | TestUtils.Simulate.touchStart(
66 | ReactDOM.findDOMNode(component),
67 | {nativeEvent: nativeTouch(200, 300)}
68 | );
69 | times(10, i => {
70 | documentEvent('touchmove', nativeTouch(200 + i * 20, 300));
71 | fakeRaf.step();
72 | });
73 | documentEvent('touchend');
74 | expect(component._state).to.eql({ current: null, moves: [] });
75 | });
76 |
77 | it("should reset the state when touch is ended even when there are no moves", () => {
78 | const component = renderCustomGesture();
79 | TestUtils.Simulate.touchStart(
80 | ReactDOM.findDOMNode(component),
81 | {nativeEvent: nativeTouch(200, 300)}
82 | );
83 | documentEvent('touchend');
84 | expect(component._state).to.eql({ current: null, moves: [] });
85 | });
86 |
87 | it("should render its child as its only output", () => {
88 | const renderer = TestUtils.createRenderer();
89 | renderer.render(
90 |
91 |
92 |
93 | );
94 | const output = renderer.getRenderOutput();
95 | expect(output.type).to.be.equal('div');
96 | });
97 |
98 | it("should pass the correct props to its child", () => {
99 | const renderer = TestUtils.createRenderer();
100 | renderer.render(
101 |
102 |
103 |
104 | );
105 | const output = renderer.getRenderOutput();
106 | expect(output.props).to.have.keys(['__passThrough', 'onMouseDown', 'onTouchStart']);
107 | });
108 |
109 | it("should remove listeners when the component unmounts", () => {
110 | const container = document.createElement('div');
111 | const spy = sinon.spy();
112 | const component = ReactDOM.render(
113 |
114 |
115 | ,
116 | container
117 | );
118 | TestUtils.Simulate.touchStart(
119 | ReactDOM.findDOMNode(component),
120 | {nativeEvent: nativeTouch(200, 300)}
121 | );
122 | times(9, i => {
123 | documentEvent('touchmove', nativeTouch(200 + i * 20, 300));
124 | fakeRaf.step();
125 | });
126 | ReactDOM.unmountComponentAtNode(container);
127 | documentEvent('touchend');
128 | fakeRaf.step();
129 | expect(spy.notCalled).to.be.true;
130 | });
131 | });
132 |
--------------------------------------------------------------------------------
/src/tests/Draggable.react.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import sinon from 'sinon';
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import TestUtils from 'react-addons-test-utils';
6 |
7 | import { documentEvent, renderComponent, createFakeRaf, nativeTouch } from './helpers';
8 | import Draggable from '../Draggable.react';
9 | import TouchHandler from '../TouchHandler';
10 |
11 | /* eslint-disable no-unused-expressions */
12 |
13 | const renderDraggable = renderComponent(Draggable);
14 | const fakeRaf = createFakeRaf();
15 |
16 | describe("Draggable", () => {
17 | beforeEach(() => TouchHandler.__Rewire__('raf', fakeRaf));
18 | afterEach(() => TouchHandler.__ResetDependency__('raf'));
19 |
20 | it("should pass the 'translate' position updates to the callback child", () => {
21 | let update;
22 | const draggable = TestUtils.renderIntoDocument(
23 |
24 | {({ translateX, translateY }) => {
25 | update = { translateX, translateY };
26 | return
;
27 | }}
28 |
29 | );
30 | TestUtils.Simulate.touchStart(
31 | ReactDOM.findDOMNode(draggable),
32 | {nativeEvent: nativeTouch(200, 300)}
33 | );
34 | documentEvent('touchmove', nativeTouch(220, 280));
35 | fakeRaf.step();
36 | expect(update).to.eql({translateX: 170, translateY: 130});
37 | });
38 |
39 | it("should pass the absolute position updates to the callback child", () => {
40 | let update;
41 | const draggable = TestUtils.renderIntoDocument(
42 |
43 | {({ top, left, right, bottom }) => {
44 | update = { top, left, right, bottom };
45 | return
;
46 | }}
47 |
48 | );
49 | TestUtils.Simulate.touchStart(
50 | ReactDOM.findDOMNode(draggable),
51 | {nativeEvent: nativeTouch(200, 300)}
52 | );
53 | documentEvent('touchmove', nativeTouch(220, 280));
54 | fakeRaf.step();
55 | expect(update).to.eql({left: 170, top: 130, bottom: 30, right: 0});
56 | });
57 |
58 | it("should call the 'onDrag' callback on touchmove events", () => {
59 | const initial = {translateX: 100, translateY: 100};
60 | const spy = sinon.spy();
61 | const draggable = renderDraggable({position: initial, onDrag: spy});
62 | TestUtils.Simulate.touchStart(
63 | ReactDOM.findDOMNode(draggable),
64 | {nativeEvent: nativeTouch(200, 300)}
65 | );
66 | documentEvent('touchmove', nativeTouch(220, 280));
67 | fakeRaf.step();
68 | expect(spy.calledOnce).to.be.true;
69 | });
70 |
71 | it("should pass the updated positions to the 'onDrag' callback", () => {
72 | const initial = {translateX: 100, translateY: 100};
73 | const spy = sinon.spy();
74 | const draggable = renderDraggable({position: initial, onDrag: spy});
75 | TestUtils.Simulate.touchStart(
76 | ReactDOM.findDOMNode(draggable),
77 | {nativeEvent: nativeTouch(200, 300)}
78 | );
79 | documentEvent('touchmove', nativeTouch(220, 280));
80 | fakeRaf.step();
81 | expect(spy.calledWith({translateX: 120, translateY: 80})).to.be.true;
82 | });
83 |
84 | it("should pass the delta updates to the callback child", () => {
85 | let update;
86 | const draggable = TestUtils.renderIntoDocument(
87 |
88 | {({ dx, dy }) => {
89 | update = { dx, dy };
90 | return
;
91 | }}
92 |
93 | );
94 | TestUtils.Simulate.touchStart(
95 | ReactDOM.findDOMNode(draggable),
96 | {nativeEvent: nativeTouch(200, 300)}
97 | );
98 | documentEvent('touchmove', nativeTouch(220, 280));
99 | fakeRaf.step();
100 | expect(update).to.eql({dx: 20, dy: -20});
101 | });
102 |
103 | it("should call 'onDragEnd' on touchend", () => {
104 | const initial = {translateX: 100, translateY: 100};
105 | const spy = sinon.spy();
106 | const draggable = renderDraggable({position: initial, onDragEnd: spy});
107 | TestUtils.Simulate.touchStart(
108 | ReactDOM.findDOMNode(draggable),
109 | {nativeEvent: nativeTouch(200, 300)}
110 | );
111 | documentEvent('touchend');
112 | expect(spy.calledOnce).to.be.true;
113 | });
114 |
115 | it("should render its child as its only output", () => {
116 | const renderer = TestUtils.createRenderer();
117 | renderer.render(
118 |
119 |
120 |
121 | );
122 | const output = renderer.getRenderOutput();
123 | expect(output.type).to.be.equal('div');
124 | });
125 |
126 | it("should pass the correct props to its child", () => {
127 | const renderer = TestUtils.createRenderer();
128 | renderer.render(
129 |
130 |
131 |
132 | );
133 | const output = renderer.getRenderOutput();
134 | expect(output.props).to.have.keys(['__passThrough', 'onMouseDown', 'onTouchStart']);
135 | });
136 | });
137 |
--------------------------------------------------------------------------------
/src/tests/Holdable.react.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import sinon from 'sinon';
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import TestUtils from 'react-addons-test-utils';
6 |
7 | import { documentEvent, renderComponent } from './helpers';
8 | import Holdable from '../Holdable.react';
9 | import defineHold from '../defineHold';
10 |
11 | /* eslint-disable no-unused-expressions */
12 |
13 | let clock;
14 | const renderHoldable = renderComponent(Holdable);
15 |
16 | describe("Holdable", () => {
17 | beforeEach(() => {
18 | clock = sinon.useFakeTimers();
19 | });
20 |
21 | afterEach(() => {
22 | clock.restore();
23 | });
24 |
25 | it("should pass updates to callback child as 'holdProgress'", () => {
26 | const progressUpdates = [];
27 | const holdable = TestUtils.renderIntoDocument(
28 |
29 | {({ holdProgress }) => {
30 | progressUpdates.push(holdProgress);
31 | return
;
32 | }}
33 |
34 | );
35 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
36 | clock.tick(250);
37 | expect(progressUpdates).to.be.lengthOf(3);
38 | clock.tick(250);
39 | expect(progressUpdates[3]).to.be.above(progressUpdates[2]);
40 | });
41 |
42 | it("should fire a callback 'onHoldProgress' when progress is made", () => {
43 | const spy = sinon.spy();
44 | const holdable = renderHoldable({onHoldProgress: spy});
45 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
46 | clock.tick(1000);
47 | expect(spy.callCount).to.be.equal(4);
48 | });
49 |
50 | it("should fire a callback 'onHoldComplete' after hold is completed", () => {
51 | const spy = sinon.spy();
52 | const holdable = renderHoldable({onHoldComplete: spy});
53 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
54 | clock.tick(1500);
55 | expect(spy.calledOnce).to.be.true;
56 | });
57 |
58 | it("should stop firing 'onHoldProgress' when touch is moved", () => {
59 | const spy = sinon.spy();
60 | const holdable = renderHoldable({onHoldProgress: spy});
61 |
62 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
63 | clock.tick(250);
64 | expect(spy.calledOnce).to.be.true;
65 | documentEvent('touchmove');
66 | clock.tick(250);
67 | expect(spy.calledOnce).to.be.true;
68 | });
69 |
70 | it("should not fire 'onHoldComplete' when touch is moved", () => {
71 | const spy = sinon.spy();
72 | const holdable = renderHoldable({onHoldComplete: spy});
73 |
74 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
75 | clock.tick(250);
76 | documentEvent('touchmove');
77 | expect(spy.notCalled).to.be.true;
78 | clock.tick(1000);
79 | expect(spy.notCalled).to.be.true;
80 | });
81 |
82 | it("should stop firing 'onHoldProgress' when touch is released", () => {
83 | const spy = sinon.spy();
84 | const holdable = renderHoldable({onHoldProgress: spy});
85 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
86 | clock.tick(250);
87 | documentEvent('touchend');
88 | clock.tick(250);
89 | expect(spy.calledOnce).to.be.true;
90 | });
91 |
92 | it("should not fire 'onHoldComplete' when touch is released", () => {
93 | const spy = sinon.spy();
94 | const holdable = renderHoldable({onHoldComplete: spy});
95 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
96 | clock.tick(250);
97 | documentEvent('touchend');
98 | clock.tick(1000);
99 | expect(spy.notCalled).to.be.true;
100 | });
101 |
102 | it("should reset the state when touch is ended", () => {
103 | const holdable = renderHoldable();
104 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
105 | documentEvent('touchend');
106 | expect(holdable.state).to.eql({initial: null, current: null, duration: 0});
107 | });
108 |
109 | it("should alter its progress updates when 'updateEvery' is used", () => {
110 | const spy = sinon.spy();
111 | const config = defineHold({updateEvery: 50});
112 | const holdable = renderHoldable({ onHoldProgress: spy, config });
113 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
114 |
115 | expect(spy.notCalled).to.be.true;
116 | clock.tick(50);
117 | expect(spy.calledOnce).to.be.true;
118 | clock.tick(50);
119 | expect(spy.calledTwice).to.be.true;
120 | clock.tick(50);
121 | expect(spy.calledThrice).to.be.true;
122 | });
123 |
124 |
125 | it("should alter its hold length when 'holdFor' is used", () => {
126 | const spy = sinon.spy();
127 | const config = defineHold({holdFor: 500});
128 | const holdable = renderHoldable({ onHoldComplete: spy, config });
129 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
130 |
131 | clock.tick(250);
132 | expect(spy.notCalled).to.be.true;
133 | clock.tick(500);
134 | expect(spy.calledOnce).to.be.true;
135 | clock.tick(500);
136 | expect(spy.calledOnce).to.be.true;
137 | });
138 |
139 | it("should render its child as its only output", () => {
140 | const renderer = TestUtils.createRenderer();
141 | renderer.render(
);
142 | const output = renderer.getRenderOutput();
143 | expect(output.type).to.be.equal('div');
144 | });
145 |
146 | it("should pass the correct props to its child", () => {
147 | const renderer = TestUtils.createRenderer();
148 | renderer.render(
);
149 | const output = renderer.getRenderOutput();
150 | expect(output.props).to.have.keys(['__passThrough', 'onMouseDown', 'onTouchStart']);
151 | });
152 |
153 | it("should remove timers and listeners when the component unmounts", () => {
154 | const container = document.createElement('div');
155 | const spy = sinon.spy();
156 | const holdable = ReactDOM.render(
157 |
158 |
159 | ,
160 | container
161 | );
162 | TestUtils.Simulate.touchStart(ReactDOM.findDOMNode(holdable));
163 | ReactDOM.unmountComponentAtNode(container);
164 | clock.tick(250);
165 | expect(spy.notCalled).to.be.true;
166 | });
167 | });
168 |
--------------------------------------------------------------------------------
/src/tests/Swipeable.react.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import sinon from 'sinon';
3 | import React from 'react';
4 | import ReactDOM from 'react-dom';
5 | import TestUtils from 'react-addons-test-utils';
6 | import omitBy from 'lodash/omitBy';
7 | import isNull from 'lodash/isNull';
8 |
9 | import { documentEvent, renderComponent, createFakeRaf, nativeTouch } from './helpers';
10 | import Swipeable from '../Swipeable.react';
11 | import defineSwipe from '../defineSwipe';
12 | import TouchHandler from '../TouchHandler';
13 |
14 | /* eslint-disable no-unused-expressions */
15 |
16 | const renderSwipeable = renderComponent(Swipeable);
17 | const fakeRaf = createFakeRaf();
18 |
19 | const testSwipeDirection = (callback, failPos, successPos, config=null) => {
20 | const spy = sinon.spy();
21 | const props = omitBy({ [callback]: spy, config }, isNull);
22 | const swipeable = renderSwipeable(props);
23 | TestUtils.Simulate.touchStart(
24 | ReactDOM.findDOMNode(swipeable),
25 | {nativeEvent: nativeTouch(200, 300)}
26 | );
27 | documentEvent('touchmove', { touches: [failPos] });
28 | fakeRaf.step();
29 | expect(spy.calledOnce).to.be.false;
30 | documentEvent('touchmove', { touches: [successPos] });
31 | fakeRaf.step();
32 | expect(spy.calledOnce).to.be.true;
33 | };
34 |
35 | describe("Swipeable", () => {
36 | beforeEach(() => TouchHandler.__Rewire__('raf', fakeRaf));
37 | afterEach(() => TouchHandler.__ResetDependency__('raf'));
38 |
39 | it("should fire 'onSwipeLeft' when swiped left", () => {
40 | testSwipeDirection('onSwipeLeft', { clientX: 101 }, { clientX: 100 });
41 | });
42 |
43 | it("should fire 'onSwipeRight' when swiped right", () => {
44 | testSwipeDirection('onSwipeRight', { clientX: 299 }, { clientX: 300 });
45 | });
46 |
47 | it("should fire 'onSwipeUp' when swiped up", () => {
48 | testSwipeDirection('onSwipeUp', { clientY: 201 }, { clientY: 200 });
49 | });
50 |
51 | it("should fire 'onSwipeDown' when swiped down", () => {
52 | testSwipeDirection('onSwipeDown', { clientY: 399 }, { clientY: 400 });
53 | });
54 |
55 | it("should reset the state when touch is ended", () => {
56 | const swipeable = renderSwipeable();
57 | TestUtils.Simulate.touchStart(
58 | ReactDOM.findDOMNode(swipeable),
59 | {nativeEvent: nativeTouch(200, 300)}
60 | );
61 | documentEvent('touchend');
62 | expect(swipeable.state).to.eql(
63 | {initial: null, current: null, deltas: { dx: 0, dy: 0 }}
64 | );
65 | });
66 |
67 | it("should alter its distance threshold when 'swipeDistance is used", () => {
68 | const config = defineSwipe({swipeDistance: 75});
69 | testSwipeDirection('onSwipeLeft', { clientX: 126 }, { clientX: 125 }, config);
70 | });
71 |
72 | it("should render its child as its only output", () => {
73 | const renderer = TestUtils.createRenderer();
74 | renderer.render(
);
75 | const output = renderer.getRenderOutput();
76 | expect(output.type).to.be.equal('div');
77 | });
78 |
79 | it("should pass the correct props to its child", () => {
80 | const renderer = TestUtils.createRenderer();
81 | renderer.render(
);
82 | const output = renderer.getRenderOutput();
83 | expect(output.props).to.have.keys(['__passThrough', 'onMouseDown', 'onTouchStart']);
84 | });
85 |
86 | it("should remove listeners when the component unmounts", () => {
87 | const container = document.createElement('div');
88 | const spy = sinon.spy();
89 | const swipeable = ReactDOM.render(
90 |
91 |
92 | ,
93 | container
94 | );
95 | TestUtils.Simulate.touchStart(
96 | ReactDOM.findDOMNode(swipeable),
97 | {nativeEvent: nativeTouch(200, 300)}
98 | );
99 | ReactDOM.unmountComponentAtNode(container);
100 | documentEvent('touchmove', nativeTouch(100, 300));
101 | fakeRaf.step();
102 | expect(spy.notCalled).to.be.true;
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/src/tests/computeDeltas.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 |
3 | import computeDeltas from '../computeDeltas';
4 |
5 | describe("computeDeltas", () => {
6 | it("should return a dx and a dy of 0 when the positions are the same", () => {
7 | const position = {x: 100, y: 100};
8 | expect(computeDeltas(position, position)).to.eql({dx: 0, dy: 0});
9 | });
10 |
11 | it("should return a negative dx when the position moves left", () => {
12 | const initial = {x: 100, y: 100};
13 | const current = {x: 80, y: 100};
14 | expect(computeDeltas(initial, current)).to.eql({dx: -20, dy: 0});
15 | });
16 |
17 | it("should return a positve dx when the position moves right", () => {
18 | const initial = {x: 100, y: 100};
19 | const current = {x: 120, y: 100};
20 | expect(computeDeltas(initial, current)).to.eql({dx: 20, dy: 0});
21 | });
22 |
23 | it("should return a positive dy when the position moves down", () => {
24 | const initial = {x: 100, y: 100};
25 | const current = {x: 100, y: 120};
26 | expect(computeDeltas(initial, current)).to.eql({dx: 0, dy: 20});
27 | });
28 |
29 | it("should return a negative dy when the position moves up", () => {
30 | const initial = {x: 100, y: 100};
31 | const current = {x: 100, y: 80};
32 | expect(computeDeltas(initial, current)).to.eql({dx: 0, dy: -20});
33 | });
34 |
35 | it("should return non-zero deltas when the position moves in two directions", () => {
36 | const initial = {x: 100, y: 100};
37 | const current = {x: 120, y: 120};
38 | expect(computeDeltas(initial, current)).to.eql({dx: 20, dy: 20});
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/tests/computePositionStyle.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 |
3 | import computePositionStyle from '../computePositionStyle';
4 |
5 | describe("computePositionStyle", () => {
6 | it("should output any keys that you used as inputs", () => {
7 | const styles = { left: 100, right: 30, translateX: 100 };
8 | const deltas = { dx: 20, dy: 10 };
9 | const keys = ['left', 'right', 'translateX'];
10 | expect(computePositionStyle(styles, deltas)).to.have.all.keys(keys);
11 | });
12 |
13 | it("should not skip 0 values", () => {
14 | const styles = { left: 0 };
15 | const deltas = { dx: 20 };
16 | expect(computePositionStyle(styles, deltas)).to.eql({left: 20});
17 | });
18 |
19 | it("should should correctly increment 'left'", () => {
20 | const styles = { left: 100 };
21 | const deltas = { dx: 20 };
22 | expect(computePositionStyle(styles, deltas)).to.eql({left: 120});
23 | });
24 |
25 | it("should should correctly increment 'right'", () => {
26 | const styles = { right: 100 };
27 | const deltas = { dx: 20 };
28 | expect(computePositionStyle(styles, deltas)).to.eql({right: 80});
29 | });
30 |
31 | it("should should correctly increment 'top'", () => {
32 | const styles = { top: 100 };
33 | const deltas = { dy: 20 };
34 | expect(computePositionStyle(styles, deltas)).to.eql({top: 120});
35 | });
36 |
37 | it("should should correctly increment 'bottom'", () => {
38 | const styles = { bottom: 100 };
39 | const deltas = { dy: 20 };
40 | expect(computePositionStyle(styles, deltas)).to.eql({bottom: 80});
41 | });
42 |
43 | it("should should correctly increment 'translateX'", () => {
44 | const styles = { translateX: 100 };
45 | const deltas = { dx: 20 };
46 | expect(computePositionStyle(styles, deltas)).to.eql({translateX: 120});
47 | });
48 |
49 | it("should should correctly increment 'translateY'", () => {
50 | const styles = { translateY: 100 };
51 | const deltas = { dy: 20 };
52 | expect(computePositionStyle(styles, deltas)).to.eql({translateY: 120});
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/src/tests/gestureLevenshtein.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 |
3 | import gestureLevenshtein from '../gestureLevenshtein';
4 | import moves from '../gestureMoves';
5 |
6 |
7 | const UPCARET = [ moves.UPRIGHT, moves.DOWNRIGHT ];
8 | const CIRCLE = [
9 | moves.RIGHT,
10 | moves.DOWNRIGHT,
11 | moves.DOWN,
12 | moves.DOWNLEFT,
13 | moves.LEFT,
14 | moves.UPLEFT,
15 | moves.UP,
16 | moves.UPRIGHT,
17 | moves.RIGHT,
18 | ];
19 |
20 |
21 | describe("gestureLevenshtein", () => {
22 | it("should return a big number when an argument is an empty string", () => {
23 | expect(gestureLevenshtein("", "1")).to.equal(10000);
24 | expect(gestureLevenshtein("1", "")).to.equal(10000);
25 | });
26 |
27 | it("should return 0 when the arguments match", () => {
28 | expect(gestureLevenshtein(UPCARET, UPCARET)).to.equal(0);
29 | expect(gestureLevenshtein(CIRCLE, CIRCLE)).to.equal(0);
30 | });
31 |
32 | it("should return 0 when the arguments 'collapse' to be equal", () => {
33 | // Collapse here meaning if we were to manually remove consecutive
34 | // duplicates, the below value would collapse into "71".
35 | const humanUpCaret = "777777771111111";
36 | expect(gestureLevenshtein(UPCARET, humanUpCaret)).to.equal(0);
37 |
38 | const humanCircle = "0111122222233445555666700000";
39 | expect(gestureLevenshtein(CIRCLE, humanCircle)).to.equal(0);
40 | });
41 |
42 | it("should return correct values when arguments don't match", () => {
43 | const humanUpCaret = "77777777001111111";
44 | expect(gestureLevenshtein(UPCARET, humanUpCaret)).to.equal(2);
45 |
46 | const humanCircle = "0001121112222122233344445555665666777770";
47 | expect(gestureLevenshtein(CIRCLE, humanCircle)).to.equal(3);
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/tests/helpers.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import TestUtils from 'react-addons-test-utils';
3 |
4 | export const documentEvent = (eventName, props={}) => {
5 | const evt = Object.assign(document.createEvent("HTMLEvents"), props);
6 | evt.initEvent(eventName, true, true);
7 | document.dispatchEvent(evt);
8 | };
9 |
10 | export const renderComponent = component => {
11 | const _component = React.createFactory(component);
12 | return props => TestUtils.renderIntoDocument(_component(props,
));
13 | };
14 |
15 | export const nativeTouch = (x, y) => ({touches: [{ clientX: x, clientY: y }]});
16 |
17 | export const createFakeRaf = () => {
18 | const FRAME_LENGTH = 1000 / 60; // assume 60fps for now
19 |
20 | let callbacks = [];
21 | let time = 0;
22 | let id = 0;
23 |
24 | const raf = callback => {
25 | id += 1;
26 | callbacks.push({ callback, id });
27 | return id;
28 | };
29 |
30 | raf.cancel = cancelId => {
31 | callbacks = callbacks.filter(item => item.id !== cancelId);
32 | };
33 |
34 | raf.step = (steps=1) => {
35 | for (let i = 0; i < steps; i++) {
36 | time += FRAME_LENGTH;
37 | // eslint-disable-next-line no-loop-func
38 | callbacks.forEach(({ callback }) => callback(time));
39 | callbacks = [];
40 | }
41 | };
42 |
43 | return raf;
44 | };
45 |
--------------------------------------------------------------------------------
/src/tests/index.spec.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 |
3 | import * as index from '../index';
4 |
5 | const EXPORTS = [
6 | 'Draggable',
7 | 'Holdable',
8 | 'Swipeable',
9 | 'CustomGesture',
10 | 'defineHold',
11 | 'defineSwipe',
12 | 'moves',
13 | ];
14 |
15 | describe("index.js", () => {
16 | it("should have the correct exports", () => {
17 | expect(index).to.have.all.keys(EXPORTS);
18 | });
19 |
20 | it("should not have any extra exports", () => {
21 | expect(Object.keys(index)).to.have.lengthOf(EXPORTS.length);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/tests/setup.js:
--------------------------------------------------------------------------------
1 | import { jsdom } from 'jsdom';
2 |
3 | global.document = jsdom('');
4 | global.window = document.defaultView;
5 |
--------------------------------------------------------------------------------