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']); + }); + }); +});