diff --git a/gulpfile.js b/gulpfile.js
index 951986bb6..5bf321a80 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -170,7 +170,7 @@ gulp.task('_buildPuiJs', ['_cleanBuiltPuiJs'], function() {
gulp.task('_buildPuiReactJs', ['_cleanBuiltPuiJs'], function() {
var b = browserify('./src/pivotal-ui/javascripts/pivotal-ui-react.js');
- b.transform(reactify);
+ b.transform(reactify, {es6: true});
return b.bundle()
.pipe(source('./pivotal-ui-react.js'))
diff --git a/karma.config.js b/karma.config.js
index 30184561e..d8fd8454e 100644
--- a/karma.config.js
+++ b/karma.config.js
@@ -7,7 +7,9 @@ module.exports = function(config) {
basePath: './',
browserNoActivityTimeout: 60000,
browserify: {
- transform: ['reactify']
+ transform: [
+ ['reactify', {es6: true}]
+ ]
},
browsers: ['Chrome', 'PhantomJS'],
colors: true,
diff --git a/src/pivotal-ui/components/lists.scss b/src/pivotal-ui/components/lists.scss
index 46e292df5..09761d426 100644
--- a/src/pivotal-ui/components/lists.scss
+++ b/src/pivotal-ui/components/lists.scss
@@ -977,3 +977,73 @@ or you want to vertically align it.
Use [Card lists][list_cards] if you'd like to make a grid of vertically and horizontally aligned cards.
*/
+
+
+/*doc
+---
+title: React Draggable List
+name: react_draggable_list
+categories:
+ - Beta
+---
+
+Creates a draggable list.
+
+The property `onDrop` is a callback when a drop event has completed. Use this
+if you need to make an API call to update the order of some elements.
+
+```jsx_example
+var draggableListDropCallback = function(data) {
+ alert('New item indices order: ' + data);
+};
+```
+
+```react_example
+
+
+ Get me out of here!
+
+
+
+ LOL
+
+
+
+ Can't stop
+
+
+```
+*/
+
+.list-draggable {
+ @include user-select(none);
+
+ .draggable-grip {
+ display: inline-block;
+ visibility: hidden;
+ color: $gray-6;
+ }
+
+ > li {
+ width: 100%;
+
+ &.hover {
+ cursor: pointer;
+
+ .draggable-grip {
+ visibility: visible;
+ }
+ }
+
+ &.grabbed {
+ background-color: $list-draggable-bg;
+ * {
+ visibility: hidden;
+ }
+ }
+
+ &.grabbed .draggable-grip {
+ visibility: hidden;
+ }
+ }
+}
diff --git a/src/pivotal-ui/components/pui-variables.scss b/src/pivotal-ui/components/pui-variables.scss
index 80e69e486..4cb4c69bf 100644
--- a/src/pivotal-ui/components/pui-variables.scss
+++ b/src/pivotal-ui/components/pui-variables.scss
@@ -960,6 +960,8 @@ $list-group-link-color: #555 !default;
$list-group-link-hover-color: $list-group-link-color !default;
$list-group-link-heading-color: #333 !default;
+$list-draggable-bg: $teal-3;
+
// Panels
// -------------------------
diff --git a/src/pivotal-ui/javascripts/components.js b/src/pivotal-ui/javascripts/components.js
index 53c176d09..248b7f02e 100644
--- a/src/pivotal-ui/javascripts/components.js
+++ b/src/pivotal-ui/javascripts/components.js
@@ -70,6 +70,9 @@ module.exports = {
SimpleTabs: require('./tabs.jsx').SimpleTabs,
SimpleAltTabs: require('./tabs.jsx').SimpleAltTabs,
+ DraggableList: require('./draggable-list.js').DraggableList,
+ DraggableListItem: require('./draggable-list.js').DraggableListItem,
+
Dropdown: require('./dropdowns.jsx').Dropdown,
DropdownItem: require('./dropdowns.jsx').DropdownItem,
LinkDropdown: require('./dropdowns.jsx').LinkDropdown,
diff --git a/src/pivotal-ui/javascripts/draggable-list.js b/src/pivotal-ui/javascripts/draggable-list.js
new file mode 100644
index 000000000..7d442d8cc
--- /dev/null
+++ b/src/pivotal-ui/javascripts/draggable-list.js
@@ -0,0 +1,125 @@
+'use strict';
+var React = require('react/addons');
+var _ = require('lodash');
+var cx = React.addons.classSet;
+var {move} = require('./utils');
+var HoverMixin = require('./mixins/hover-mixin');
+
+function preventDefault(e) {
+ e.preventDefault();
+}
+
+var DraggableList = React.createClass({
+ propTypes: {
+ onDrop: React.PropTypes.func
+ },
+
+ getInitialProps: function() {
+ return {
+ onDrop: _.noop
+ };
+ },
+
+ getInitialState: function() {
+ return {
+ itemIndices: _.times(this.props.children.length),
+ draggingId: null
+ };
+ },
+
+ componentWillReceiveProps: function(nextProps) {
+ if (nextProps.children) {
+ this.setState({
+ itemIndices: _.times(nextProps.children.length),
+ draggingId: null
+ });
+ }
+ },
+
+ dragStart: function(draggingId, e) {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.dropEffect = 'move';
+ e.dataTransfer.setData('text/plain', '');
+ setTimeout(function() { this.setState({draggingId}); }.bind(this), 0);
+ },
+
+ dragEnd: function() {
+ this.setState({draggingId: null});
+ },
+
+ dragEnter: function(e) {
+ var {draggingId, itemIndices} = this.state;
+ var endDraggingId = parseInt(e.currentTarget.dataset.draggingId, 10);
+ if (draggingId === null || _.isNaN(endDraggingId)) {
+ return;
+ }
+
+ var startIndex = itemIndices.indexOf(draggingId);
+ var endIndex = itemIndices.indexOf(endDraggingId);
+
+ move(itemIndices, startIndex, endIndex);
+ this.setState({itemIndices});
+ },
+
+ drop: function() {
+ this.props.onDrop(this.state.itemIndices);
+ },
+
+ render: function() {
+ var grabbed, items = [];
+ React.Children.forEach(this.props.children, function(child, draggingId) {
+ grabbed = this.state.draggingId === draggingId;
+ items.push(React.addons.cloneWithProps(child, {grabbed, onDragStart: _.bind(this.dragStart, this, draggingId), onDragEnd: this.dragEnd, onDragEnter: this.dragEnter, onDrop: this.drop, draggingId, key: draggingId}));
+ }, this);
+ var sortedItems = _.map(this.state.itemIndices, i => items[i]);
+ return (
+
+ );
+ }
+});
+
+var DraggableListItem = React.createClass({
+ mixins: [HoverMixin],
+
+ propTypes: {
+ draggingId: React.PropTypes.number,
+ onMouseEnter: React.PropTypes.func,
+ onMouseLeave: React.PropTypes.func,
+ onDragStart: React.PropTypes.func,
+ onDragEnter: React.PropTypes.func,
+ onDragEnd: React.PropTypes.func,
+ onDrop: React.PropTypes.func
+ },
+
+ render: function() {
+ var {hover} = this.state;
+ var {grabbed, onDragStart, onDragEnd, onDragEnter, onDrop, draggingId} = this.props;
+ var {onMouseEnter, onMouseLeave} = this;
+ var className = cx({'list-group-item pln': true, grabbed, hover});
+ var props = {
+ className, onMouseEnter, onMouseLeave, onDragStart, onDragEnd, onDragEnter, onDrop,
+ onDragOver: preventDefault,
+ draggable: !grabbed,
+ 'data-dragging-id': draggingId
+ };
+ return (
+
+
+
+
+ Drag to reorder
+
+
+ {this.props.children}
+
+
+ );
+ }
+});
+
+module.exports = {
+ DraggableList,
+ DraggableListItem
+};
diff --git a/src/pivotal-ui/javascripts/mixins/hover-mixin.js b/src/pivotal-ui/javascripts/mixins/hover-mixin.js
new file mode 100644
index 000000000..ca686bf6c
--- /dev/null
+++ b/src/pivotal-ui/javascripts/mixins/hover-mixin.js
@@ -0,0 +1,19 @@
+'use strict';
+
+var HoverMixin = {
+ getInitialState: function() {
+ return {
+ hover: false
+ };
+ },
+
+ onMouseEnter: function() {
+ this.setState({hover: true});
+ },
+
+ onMouseLeave: function() {
+ this.setState({hover: false});
+ }
+};
+
+module.exports = HoverMixin;
diff --git a/src/pivotal-ui/javascripts/utils.js b/src/pivotal-ui/javascripts/utils.js
index 588b60369..cf68f5d0e 100644
--- a/src/pivotal-ui/javascripts/utils.js
+++ b/src/pivotal-ui/javascripts/utils.js
@@ -10,7 +10,7 @@ var breakpoints = {
xlMin: 1800
};
-module.exports = window.utils = {
+var utils = {
isMinWidthXs: function minWidthXs() {
return isMinWidth(breakpoints.xsMin);
},
@@ -29,9 +29,28 @@ module.exports = window.utils = {
isMinWidthXl: function minWidthXs() {
return isMinWidth(breakpoints.xlMin);
+ },
+
+ move: function(collection, startIndex, endIndex) {
+ while (startIndex < 0) {
+ startIndex += collection.length;
+ }
+ while (endIndex < 0) {
+ endIndex += collection.length;
+ }
+ if (endIndex >= collection.length) {
+ var k = endIndex - collection.length;
+ while ((k--) + 1) {
+ collection.push(undefined);
+ }
+ }
+ collection.splice(endIndex, 0, collection.splice(startIndex, 1)[0]);
+ return collection;
}
};
+module.exports = global.utils = utils;
+
function isMinWidth(width) {
return Modernizr.mq('(min-width: ' + width + 'px)');
}
diff --git a/test/spec/javascripts/draggable_list_spec.js b/test/spec/javascripts/draggable_list_spec.js
new file mode 100644
index 000000000..dec99d0c3
--- /dev/null
+++ b/test/spec/javascripts/draggable_list_spec.js
@@ -0,0 +1,197 @@
+'use strict';
+require('./spec_helper');
+var $ = require('jquery');
+var React = require('react/addons');
+
+function getListItemText() {
+ return $('#container li.list-group-item').map(function() {
+ return $('> span', this).text();
+ }).toArray();
+}
+
+var {DraggableList, DraggableListItem} = require('../../../src/pivotal-ui/javascripts/draggable-list');
+
+describe("DraggableList", function() {
+ var subject, dropSpy;
+
+ beforeEach(function() {
+ jasmine.clock().install();
+ $('').appendTo('body');
+
+ dropSpy = jasmine.createSpy('drop');
+ subject = React.render(
+
+
+ Get me out of here!
+
+
+ LOL
+
+
+ Can't stop
+
+ ,
+ container
+ );
+ });
+
+ afterEach(function() {
+ React.unmountComponentAtNode(container);
+ document.body.removeChild(container);
+ });
+
+ it("renders a list group of all items", function() {
+ expect($('#container ul.list-group')).toHaveClass('list-draggable');
+
+ expect($('#container li.list-group-item .draggable-grip')).toHaveLength(3);
+ expect(getListItemText()).toEqual(['Get me out of here!', 'LOL', 'Can\'t stop']);
+ });
+
+ describe("when the children are changed", function() {
+ beforeEach(function() {
+ subject = React.render(
+
+
+ Get me out of here!
+
+
+ LOL
+
+
+ Can't stop
+
+
+ One more time
+
+ ,
+ container
+ );
+ });
+
+ it("updates the list of itemIndices", function() {
+ expect(subject.state.itemIndices.length).toEqual(4);
+ });
+
+ it("renders the items", function() {
+ expect(getListItemText()).toEqual(['Get me out of here!', 'LOL', 'Can\'t stop', 'One more time']);
+ });
+ });
+
+ describe("when starting to drag an item", function() {
+ var dataTransferSpy;
+ beforeEach(function() {
+ dataTransferSpy = jasmine.createSpyObj('dataTransfer', ['setData', 'getData']);
+ $('#container li.list-group-item').eq(0).simulate('dragStart', {dataTransfer: dataTransferSpy});
+ jasmine.clock().tick(1);
+ });
+
+ it("changes the aria-grabbed attribute to true", function() {
+ expect($('#container ul.list-group .draggable-grip').eq(0)).toHaveAttr('aria-grabbed', 'true');
+ });
+
+ it("calls setData with text/plain so firefox considers the drag to be valid", function() {
+ expect(dataTransferSpy.setData).toHaveBeenCalledWith('text/plain', '');
+ });
+
+ it("hides the item", function() {
+ expect($('#container li.list-group-item').eq(0)).toHaveClass('grabbed');
+ });
+
+ it("is not draggable", function() {
+ expect($('#container li.list-group-item').eq(0)).toHaveAttr('draggable', 'false');
+ });
+
+ describe("when the children are changed", function() {
+ beforeEach(function() {
+ subject = React.render(
+
+
+ Get me out of here!
+
+
+ LOL
+
+
+ Can't stop
+
+
+ One more time
+
+ ,
+ container
+ );
+ });
+
+ it("cancels the drag", function() {
+ expect(subject.state.draggingId).toBe(null);
+ });
+
+ describe("when the drag enter event is triggered", function() {
+ it("does not change the list", function() {
+ var itemIndices = Array.prototype.slice.call(subject.state.itemIndices);
+ $('#container li.list-group-item').eq(1).simulate('dragEnter', {dataTransfer: dataTransferSpy});
+ expect(itemIndices).toEqual(subject.state.itemIndices);
+ });
+ });
+ });
+
+ describe("when drag enter event is triggered", function() {
+ beforeEach(function() {
+ $('#container li.list-group-item').eq(1).simulate('dragEnter', {dataTransfer: dataTransferSpy});
+ });
+
+ it("reorders the list", function() {
+ expect(getListItemText()).toEqual(['LOL', 'Get me out of here!', 'Can\'t stop']);
+ });
+
+ describe("when the drop event is triggered", function() {
+ beforeEach(function() {
+ $('#container li.list-group-item').eq(1).simulate('drop', {dataTransfer: dataTransferSpy});
+ });
+
+ it("calls the drop callback", function() {
+ expect(dropSpy).toHaveBeenCalledWith([1, 0, 2]);
+ });
+ });
+
+
+ describe("when dragging enter event is triggered on the last list item", function() {
+ beforeEach(function() {
+ $('#container li.list-group-item').eq(2).simulate('dragEnter', {dataTransfer: dataTransferSpy});
+ });
+
+ it("reorders the list", function() {
+ var listItemsText = getListItemText();
+ expect(listItemsText).toEqual(['LOL', 'Can\'t stop', 'Get me out of here!']);
+ });
+
+ describe("when the drag is ended", function() {
+ beforeEach(function() {
+ $('#container li.list-group-item').eq(2).simulate('dragEnd', {dataTransfer: dataTransferSpy});
+ });
+
+ it("removes the grabbed class", function() {
+ expect($('#container .grabbed')).not.toExist();
+ });
+
+ describe("when starting to drag another item", function() {
+ beforeEach(function() {
+ $('#container li.list-group-item').eq(2).simulate('dragStart', {dataTransfer: dataTransferSpy});
+ jasmine.clock().tick(1);
+ });
+
+ describe("when dragging enter event is triggered on the first list item", function() {
+ beforeEach(function() {
+ $('#container li.list-group-item').eq(0).simulate('dragEnter', {dataTransfer: dataTransferSpy});
+ });
+
+ it("reorders the list", function() {
+ expect(getListItemText()).toEqual(['Get me out of here!', 'LOL', 'Can\'t stop']);
+ });
+ });
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/test/spec/javascripts/mixins/hover_mixin_spec.js b/test/spec/javascripts/mixins/hover_mixin_spec.js
new file mode 100644
index 000000000..453eb6e67
--- /dev/null
+++ b/test/spec/javascripts/mixins/hover_mixin_spec.js
@@ -0,0 +1,41 @@
+'use strict';
+require('../spec_helper');
+var $ = require('jquery');
+var React = require('react/addons');
+
+var HoverMixin = require('../../../../src/pivotal-ui/javascripts/mixins/hover-mixin');
+
+describe("HoverMixin", function() {
+ var subject;
+ beforeEach(function() {
+ $('').appendTo('body');
+ var Klass = React.createClass({
+ mixins: [HoverMixin],
+ render: function() { return ; }
+ });
+ subject = React.render(, container);
+ });
+
+ afterEach(function() {
+ React.unmountComponentAtNode(container);
+ document.body.removeChild(container);
+ });
+
+ it("initializes the hover state to false", function() {
+ expect(subject.state.hover).toBe(false);
+ });
+
+ describe("when mouse over event is triggered on the component", function() {
+ it("sets the hover state to true", function() {
+ $(subject.getDOMNode()).simulate('mouseOver');
+ expect(subject.state.hover).toBe(true);
+ });
+
+ describe("when the mouse out event is triggered on the component", function() {
+ it("sets the hover state to false", function() {
+ $(subject.getDOMNode()).simulate('mouseOut');
+ expect(subject.state.hover).toBe(false);
+ });
+ });
+ });
+});
diff --git a/test/spec/javascripts/spec_helper.js b/test/spec/javascripts/spec_helper.js
new file mode 100644
index 000000000..e72c91b6f
--- /dev/null
+++ b/test/spec/javascripts/spec_helper.js
@@ -0,0 +1,12 @@
+'use strict';
+var $ = require('jquery');
+var React = require('react/addons');
+var TestUtils = React.addons.TestUtils;
+
+$.fn.simulate = function(eventName) {
+ var args = Array.prototype.slice.call(arguments, 1);
+ $.each(this, function() {
+ TestUtils.SimulateNative[eventName].apply(null, [this].concat(args));
+ });
+ return this;
+};
diff --git a/test/spec/javascripts/utils_spec.js b/test/spec/javascripts/utils_spec.js
new file mode 100644
index 000000000..9539bd59f
--- /dev/null
+++ b/test/spec/javascripts/utils_spec.js
@@ -0,0 +1,11 @@
+var utils = require('../../../src/pivotal-ui/javascripts/utils');
+describe("Utils", function() {
+ describe("#move", function() {
+ it("moves an item at an index in a collection to the specified index", function() {
+ expect(utils.move(['a', 'b', 'c', 'd', 'e'], 0, 4)).toEqual(['b', 'c', 'd', 'e', 'a']);
+ expect(utils.move(['a', 'b', 'c', 'd', 'e'], 4, 0)).toEqual(['e', 'a', 'b', 'c', 'd']);
+ expect(utils.move(['a', 'b', 'c', 'd', 'e'], 0, 2)).toEqual(['b', 'c', 'a', 'd', 'e']);
+ expect(utils.move(['a', 'b', 'c', 'd', 'e'], 3, 1)).toEqual(['a', 'd', 'b', 'c', 'e']);
+ });
+ });
+});