├── .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 | --------------------------------------------------------------------------------