├── .editorconfig
├── .gitignore
├── .jshintrc
├── LICENSE
├── README.md
├── __tests__
├── List.js
├── all.js
├── phantomjs-shims.js
└── sortable-test.js
├── index.js
├── karma.conf.js
└── package.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | ; http://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | indent_style = space
7 | indent_size = 2
8 | end_of_line = lf
9 | charset = utf-8
10 | trim_trailing_whitespace = true
11 | insert_final_newline = true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Directory for instrumented libs generated by jscoverage/JSCover
11 | lib-cov
12 |
13 | # Coverage directory used by tools like istanbul
14 | coverage
15 |
16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
17 | .grunt
18 |
19 | # Compiled binary addons (http://nodejs.org/api/addons.html)
20 | build/Release
21 |
22 | # Dependency directory
23 | # Commenting this out is preferred by some people, see
24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
25 | node_modules
26 |
27 | # Users Environment Variables
28 | .lock-wscript
29 |
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "laxcomma": true
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 hui.liu
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 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | react-sortable-mixin
2 | ====================
3 |
4 | A mixin for React to creat a sortable(drag and move) List Component.
5 | [Demo](http://hulufei.github.io/react-sortable-mixin/demo/)
6 |
7 | ## Install
8 |
9 | `npm install --save-dev react-sortable-mixin`
10 |
11 | ## Usage
12 |
13 | - Define a List Component use `ListMixin` contains Item Components use `ItemMixin`.
14 | - List Component required state `items` to set items' data.
15 | - Item Component required props:
16 | [`key`](http://facebook.github.io/react/docs/reconciliation.html) / `index` / [`movableProps`](http://facebook.github.io/react/docs/transferring-props.html).
17 |
18 | That's it!
19 |
20 | Example code:
21 |
22 | ```javascript
23 | var React = require('react');
24 | var sortable = require('react-sortable-mixin');
25 |
26 | // Item Component use `ItemMixin`
27 | var Item = React.createClass({
28 | mixins: [sortable.ItemMixin],
29 | render: function() {
30 | return
item {this.props.item};
31 | }
32 | });
33 |
34 | // List Component use `ListMixin`
35 | var List = React.createClass({
36 | mixins: [sortable.ListMixin],
37 | componentDidMount: function() {
38 | // Set items' data, key name `items` required
39 | this.setState({ items: this.props.items });
40 | },
41 | render: function() {
42 | var items = this.state.items.map(function(item, i) {
43 | // Required props in Item (key/index/movableProps)
44 | return ;
45 | }, this);
46 |
47 | return ;
48 | }
49 | });
50 |
51 | module.exports = List;
52 | ```
53 |
54 | ## Hook Events (On List)
55 |
56 | - `onMoveBefore`: after `mousedown` before `mousemove`
57 | - `onResorted`: if items not resorted (drop at same position) will not trigger
58 | - `onMoveEnd`
59 |
--------------------------------------------------------------------------------
/__tests__/List.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var sortable = require('../index');
3 |
4 | var Item = React.createClass({
5 | mixins: [sortable.ItemMixin],
6 | render: function() {
7 | return item {this.props.item};
8 | }
9 | });
10 |
11 | var List = React.createClass({
12 | mixins: [sortable.ListMixin],
13 | componentDidMount: function() {
14 | // Set items
15 | this.setState({ items: this.props.items });
16 | },
17 | render: function() {
18 | var items = this.state.items.map(function(item, i) {
19 | return ;
20 | }, this);
21 |
22 | return ;
23 | }
24 | });
25 |
26 | module.exports = List;
27 |
--------------------------------------------------------------------------------
/__tests__/all.js:
--------------------------------------------------------------------------------
1 | require('./phantomjs-shims');
2 |
3 | // require all modules ending in "-test" from the
4 | // current directory and all subdirectories
5 | var testsContext = require.context(".", true, /-test$/);
6 | testsContext.keys().forEach(testsContext);
--------------------------------------------------------------------------------
/__tests__/phantomjs-shims.js:
--------------------------------------------------------------------------------
1 | // https://github.com/facebook/react/blob/master/src/test/phantomjs-shims.js
2 | (function() {
3 |
4 | var Ap = Array.prototype;
5 | var slice = Ap.slice;
6 | var Fp = Function.prototype;
7 |
8 | if (!Fp.bind) {
9 | // PhantomJS doesn't support Function.prototype.bind natively, so
10 | // polyfill it whenever this module is required.
11 | Fp.bind = function(context) {
12 | var func = this;
13 | var args = slice.call(arguments, 1);
14 |
15 | function bound() {
16 | var invokedAsConstructor = func.prototype && (this instanceof func);
17 | return func.apply(
18 | // Ignore the context parameter when invoking the bound function
19 | // as a constructor. Note that this includes not only constructor
20 | // invocations using the new keyword but also calls to base class
21 | // constructors such as BaseClass.call(this, ...) or super(...).
22 | !invokedAsConstructor && context || this,
23 | args.concat(slice.call(arguments))
24 | );
25 | }
26 |
27 | // The bound function must share the .prototype of the unbound
28 | // function so that any object created by one constructor will count
29 | // as an instance of both constructors.
30 | bound.prototype = func.prototype;
31 |
32 | return bound;
33 | };
34 | }
35 |
36 | })();
37 |
--------------------------------------------------------------------------------
/__tests__/sortable-test.js:
--------------------------------------------------------------------------------
1 | var React = require('react/addons');
2 | var TestUtils = React.addons.TestUtils;
3 | var List = require('./List');
4 | var simulant = require('simulant');
5 |
6 | describe('List Sortable', function() {
7 | var items = ['first', 'second', 'third'];
8 | var list, listElem;
9 |
10 | function getIndex(el) {
11 | var children = list.getDOMNode().children;
12 | return Array.prototype.indexOf.call(children, el);
13 | }
14 |
15 | beforeEach(function() {
16 | // Not render into real DOM, so can't `getBoundingClientRect`
17 | list = TestUtils.renderIntoDocument();
18 | // list = React.render(, document.body);
19 | listElem = list.getDOMNode();
20 | });
21 |
22 | it('should render items in order', function() {
23 | var children = listElem.children;
24 | expect(children.length).to.equal(3);
25 | for(var i = 0; i < children.length; i++) {
26 | expect(children[i].innerHTML).to.have.string(items[i]);
27 | }
28 | });
29 |
30 | it('should setup on mousedown', function() {
31 | var firstItem = listElem.children[0];
32 | var secondItem = listElem.children[1];
33 | simulant.fire(firstItem, 'mousedown');
34 | // Should insert placeholder
35 | expect(listElem.children.length).to.equal(4);
36 | expect(firstItem.style.position).to.equal('absolute');
37 | // Index start from 0
38 | expect(getIndex(secondItem)).to.equal(2);
39 | });
40 |
41 | it('should drag and drop in resorted position', function() {
42 | var firstItem = listElem.children[0];
43 | var secondItem = listElem.children[1];
44 |
45 | list.onMoveBefore = sinon.spy();
46 | list.onMoveEnd = sinon.spy();
47 | list.onResorted = sinon.spy();
48 | resortSpy = sinon.spy(list, 'resort');
49 |
50 | simulant.fire(firstItem, 'mousedown');
51 | list.onMoveBefore.should.have.been.calledWith(firstItem);
52 |
53 | simulant.fire(listElem, 'mousemove', { clientX: 100, clientY: 20 });
54 | list.onMoveEnd.should.have.callCount(0);
55 | list.onResorted.should.have.callCount(0);
56 | resortSpy.should.have.callCount(0);
57 |
58 | // Can't move after release
59 | simulant.fire(document, 'mouseup');
60 | list.onMoveEnd.should.have.been.calledOnce;
61 | // list.onResorted.should.have.been.calledOnce;
62 | resortSpy.should.have.been.calledOnce;
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | var ReactDOM = require("react-dom");
2 | var listMixin = {
3 | getInitialState: function() {
4 | return {items: this.props.list || []};
5 | },
6 | componentWillMount: function() {
7 | // Set movable props
8 | // This should transfer to `ItemComponent` in `ListComponent`
9 | this.movableProps = {
10 | bindMove: this.bindMove,
11 | unbindMove: this.unbindMove,
12 | resort: this.resort
13 | };
14 | },
15 | // movedComponent: component to move
16 | // moveElemEvent: mouse event object triggered on moveElem
17 | bindMove: function(movedComponent, moveElemEvent) {
18 | var moveElem = ReactDOM.findDOMNode(movedComponent)
19 | , placeholder = movedComponent.placeholder
20 | , parentPosition = moveElem.parentElement.getBoundingClientRect()
21 | , moveElemPosition = moveElem.getBoundingClientRect()
22 | , viewport = document.body.getBoundingClientRect()
23 | , maxOffset = viewport.right - parentPosition.left - moveElemPosition.width
24 | , offsetX = moveElemEvent.clientX - moveElemPosition.left
25 | , offsetY = moveElemEvent.clientY - moveElemPosition.top;
26 |
27 | // (Keep width) currently manually set in `onMoveBefore` if necessary,
28 | // due to unexpected css box model
29 | // moveElem.style.width = moveElem.offsetWidth + 'px';
30 | moveElem.parentElement.style.position = 'relative';
31 | moveElem.style.position = 'absolute';
32 | moveElem.style.zIndex = '100';
33 | // Keep the initialized position in DOM
34 | moveElem.style.left = (moveElemPosition.left - parentPosition.left) + 'px';
35 | moveElem.style.top = (moveElemPosition.top - parentPosition.top) + 'px';
36 |
37 | // Place here to customize/override styles
38 | if (this.onMoveBefore) {
39 | this.onMoveBefore(moveElem);
40 | }
41 |
42 | this.moveHandler = function(e) {
43 | var left = e.clientX - parentPosition.left - offsetX
44 | , top = e.clientY - parentPosition.top - offsetY
45 | , siblings
46 | , sibling
47 | , compareRect
48 | , i, len;
49 | if (left > maxOffset) {
50 | left = maxOffset;
51 | }
52 | moveElem.style.left = left + 'px';
53 | moveElem.style.top = top + 'px';
54 | // Loop all siblings to find intersected sibling
55 | siblings = moveElem.parentElement.children;
56 | for (i = 0, len = siblings.length; i < len; i++) {
57 | sibling = siblings[i];
58 | if (sibling !== this.intersectItem &&
59 | sibling !== moveElem) {
60 | compareRect = sibling.getBoundingClientRect();
61 | if (e.clientX > compareRect.left &&
62 | e.clientX < compareRect.right &&
63 | e.clientY > compareRect.top &&
64 | e.clientY < compareRect.bottom) {
65 | if (sibling !== placeholder) {
66 | movedComponent.insertPlaceHolder(sibling);
67 | }
68 | this.intersectItem = sibling;
69 | break;
70 | }
71 | }
72 | }
73 | e.stopPropagation();
74 | }.bind(this);
75 |
76 | // Stop move
77 | this.mouseupHandler = function() {
78 | var el = moveElem
79 | , parentElem = el.parentElement
80 | , children = parentElem.children
81 | , newIndex, elIndex;
82 |
83 | newIndex = Array.prototype.indexOf.call(children, placeholder);
84 | elIndex = Array.prototype.indexOf.call(children, el);
85 | // Subtract self
86 | if (newIndex > elIndex) {
87 | newIndex -= 1;
88 | }
89 |
90 | // Clean DOM
91 | el.removeAttribute('style');
92 | parentElem.removeChild(placeholder);
93 |
94 | this.unbindMove();
95 | this.resort(movedComponent.props.index, newIndex);
96 | }.bind(this);
97 |
98 | // To make handler removable, DO NOT `.bind(this)` here, because
99 | // > A new function reference is created after .bind() is called!
100 | if (movedComponent.movable) {
101 | ReactDOM.findDOMNode(this).addEventListener('mousemove', this.moveHandler);
102 | }
103 | // Bind to `document` to be more robust
104 | document.addEventListener('mouseup', this.mouseupHandler);
105 | },
106 | unbindMove: function() {
107 | ReactDOM.findDOMNode(this).removeEventListener('mousemove', this.moveHandler);
108 | document.removeEventListener('mouseup', this.mouseupHandler);
109 | this.intersectItem = null;
110 | if (this.onMoveEnd) {
111 | this.onMoveEnd();
112 | }
113 | },
114 | resort: function(oldPosition, newPosition) {
115 | var items, movedItem;
116 | if (oldPosition !== newPosition) {
117 | items = this.state.items;
118 | // First: remove item from old position
119 | movedItem = items.splice(oldPosition, 1)[0];
120 | // Then add to new position
121 | items.splice(newPosition, 0, movedItem);
122 | this.setState({'items': items});
123 | if (this.onResorted) {
124 | this.onResorted(items);
125 | }
126 | }
127 | }
128 | };
129 |
130 | var itemMixin = {
131 | componentDidMount: function() {
132 | ReactDOM.findDOMNode(this).addEventListener('mousedown', this.moveSetup);
133 | this.setMovable(true);
134 | },
135 | insertPlaceHolder: function(el) {
136 | // Move forward, insert before `el`
137 | // Move afterward, insert after `el`
138 | var parentEl = el.parentElement
139 | , elIndex = Array.prototype.indexOf.call(parentEl.children, el)
140 | , newIndex = Array.prototype.indexOf.call(parentEl.children, this.placeholder);
141 | parentEl.insertBefore(this.placeholder,
142 | newIndex > elIndex ? el : el.nextSibling);
143 | },
144 | createPlaceHolder: function(el) {
145 | el = el || ReactDOM.findDOMNode(this);
146 | this.placeholder = el.cloneNode(true);
147 | this.placeholder.style.opacity = '0';
148 | },
149 | moveSetup: function(e) {
150 | var el = ReactDOM.findDOMNode(this);
151 | this.createPlaceHolder(el);
152 |
153 | this.props.bindMove(this, e);
154 | this.insertPlaceHolder(el);
155 | this.intersectItem = null;
156 | // For nested movable list
157 | e.stopPropagation();
158 | },
159 | setMovable: function(movable) {
160 | this.movable = movable;
161 | }
162 | };
163 |
164 | exports.ListMixin = listMixin;
165 | exports.ItemMixin = itemMixin;
166 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 | // Generated on Fri Nov 21 2014 10:33:53 GMT+0000 (UTC)
3 |
4 | module.exports = function(config) {
5 | config.set({
6 |
7 | // base path that will be used to resolve all patterns (eg. files, exclude)
8 | basePath: '',
9 |
10 |
11 | // frameworks to use
12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
13 | frameworks: ['mocha', 'chai', 'sinon-chai'],
14 |
15 |
16 | // list of files / patterns to load in the browser
17 | files: [
18 | // only specify one entry point, and require all tests in there
19 | '__tests__/all.js',
20 | 'example/index.js'
21 | ],
22 |
23 |
24 | // list of files to exclude
25 | exclude: [
26 | ],
27 |
28 |
29 | // preprocess matching files before serving them to the browser
30 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
31 | preprocessors: {
32 | '__tests__/all.js': ['webpack'],
33 | 'example/index.js': ['webpack']
34 | },
35 |
36 | webpack: {
37 | // karma watches the test entry points
38 | // (you don't need to specify the entry option)
39 | // webpack watches dependencies
40 |
41 | // webpack configuration
42 | // devtool: 'inline-source-map',
43 | module: {
44 | loaders: [
45 | { test: /\.js$/, loader: 'jsx' }
46 | ]
47 | }
48 | },
49 |
50 | webpackServer: {
51 | // webpack-dev-server configuration
52 | // webpack-dev-middleware configuration
53 | // i. e.
54 | noInfo: true
55 | },
56 |
57 | // the port used by the webpack-dev-server
58 | // defaults to "config.port" + 1
59 | webpackPort: 1234,
60 |
61 |
62 | // test results reporter to use
63 | // possible values: 'dots', 'progress'
64 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter
65 | reporters: ['progress'],
66 |
67 |
68 | // web server port
69 | port: 9876,
70 |
71 |
72 | // enable / disable colors in the output (reporters and logs)
73 | colors: true,
74 |
75 |
76 | // level of logging
77 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
78 | logLevel: config.LOG_INFO,
79 |
80 |
81 | // enable / disable watching file and executing tests whenever any file changes
82 | autoWatch: true,
83 |
84 |
85 | // start these browsers
86 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
87 | browsers: ['PhantomJS'],
88 |
89 |
90 | // Continuous Integration mode
91 | // if true, Karma captures browsers, runs the tests and exits
92 | singleRun: false
93 | });
94 | };
95 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-sortable-mixin",
3 | "version": "0.0.1",
4 | "description": "Make list like component sortable",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "./node_modules/karma/bin/karma start"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/hulufei/react-sortable-mixin.git"
12 | },
13 | "keywords": [
14 | "react",
15 | "sortable",
16 | "movable",
17 | "list",
18 | "mixin",
19 | "drag",
20 | "move",
21 | "react-component"
22 | ],
23 | "author": "hulufei",
24 | "license": "MIT",
25 | "bugs": {
26 | "url": "https://github.com/hulufei/react-sortable-mixin/issues"
27 | },
28 | "devDependencies": {
29 | "karma-chai-plugins": "~0.2.3",
30 | "mocha": "~2.0.1",
31 | "karma": "~0.12.25",
32 | "karma-phantomjs-launcher": "~0.1.4",
33 | "karma-mocha": "~0.1.9",
34 | "webpack": "~1.4.13",
35 | "webpack-dev-server": "~1.6.6",
36 | "karma-webpack": "~1.3.1",
37 | "jsx-loader": "~0.12.2",
38 | "simulant": "~0.1.3"
39 | }
40 | }
41 |
--------------------------------------------------------------------------------