diff --git a/Carousel/index.js b/Carousel/index.js index 3ee508b..5adb3f3 100644 --- a/Carousel/index.js +++ b/Carousel/index.js @@ -2,47 +2,14 @@ var React = require("react"); var Swipable = require("../Swipable"); -var range = require("../utils/range"); +var CarouselMixin = require("../CarouselMixin"); var getClassName = require("../utils/getClassName"); var setPosition = require("../utils/setPosition").setPosition; -var noop = require("../utils/noop"); module.exports = React.createClass({ displayName: "Carousel", - mixins: [Swipable], - - propTypes: { - baseClass: React.PropTypes.string, - cacheSize: React.PropTypes.number, - embedWidth: React.PropTypes.number, - embedHeight: React.PropTypes.number, - pageIndex: React.PropTypes.number, - previousPageIndex: React.PropTypes.number, - loop: React.PropTypes.bool, - renderEmptyPages: React.PropTypes.bool, - pages: React.PropTypes.arrayOf(React.PropTypes.any).isRequired, - pageView: React.PropTypes.func.isRequired, - onSwiped: React.PropTypes.func, - swipeThreshold: React.PropTypes.number, - swipeCancelThreshold: React.PropTypes.number, - }, - - getDefaultProps: function () { - return { - baseClass: "merry-go-round", - cacheSize: 1, - embedWidth: 0, - embedHeight: 0, - pageIndex: 0, - previousPageIndex: 0, - loop: false, - renderEmptyPages: false, - onSwiped: noop, - swipeThreshold: 10, - swipeCancelThreshold: 10, - }; - }, + mixins: [Swipable, CarouselMixin], isMoving: function () { return this.props.pageIndex !== this.props.previousPageIndex; @@ -56,62 +23,12 @@ module.exports = React.createClass({ }; }, - isIndexWithinBounds: function (index) { - return index >= 0 && index < this.props.pages.length; - }, - - normalizeIndex: function (index) { - if ( !this.props.loop ) { return index; } - - while ( index < 0 ) { - index += this.props.pages.length; - } - - return index % this.props.pages.length; - }, - - isIndexInView: function (index, viewIndex) { - return Math.abs(index - viewIndex) <= this.props.cacheSize; - }, - - calculateBuffers: function () { - var first = Math.min(this.props.previousPageIndex, this.props.pageIndex) - this.props.cacheSize; - var last = Math.max(this.props.previousPageIndex, this.props.pageIndex) + this.props.cacheSize; - var indices = range(first, last + 1); - - return indices.map(function calculateBuffer (index) { - return { - index: index, - pageIndex: this.normalizeIndex(index), - willBeDiscarded: !this.isIndexInView(this.props.pageIndex, index), - isNew: !this.isIndexInView(this.props.previousPageIndex, index), - }; - }.bind(this)); - }, - - renderPages: function () { - - var PageView = this.props.pageView; - var buffers = this.calculateBuffers(); - - return buffers.map(function renderBuffer (buffer) { - var pageView; - - if ( this.props.renderEmptyPages || this.isIndexWithinBounds(buffer.pageIndex) ) { - pageView = PageView({ - page: this.props.pages[buffer.pageIndex], - index: buffer.pageIndex, - willBeDiscarded: buffer.willBeDiscarded, - isNew: buffer.isNew, - }); - } - - return React.DOM.div({ - className: this.props.baseClass + "__page", - key: buffer.index, - style: this.calculatePageStyle(buffer.index), - }, pageView); - }.bind(this)); + renderPage: function (buffer, pageView) { + return React.DOM.div({ + className: this.props.baseClass + "__page", + key: buffer.index, + style: this.calculatePageStyle(buffer.index), + }, pageView); }, calculateSliderStyle: function () { diff --git a/CarouselMixin/index.js b/CarouselMixin/index.js new file mode 100644 index 0000000..a326129 --- /dev/null +++ b/CarouselMixin/index.js @@ -0,0 +1,94 @@ +"use strict"; + +var React = require("react"); +var range = require("../utils/range"); +var noop = require("../utils/noop"); + +var CarouselMixin = { + propTypes: { + baseClass: React.PropTypes.string, + cacheSize: React.PropTypes.number, + embedWidth: React.PropTypes.number, + embedHeight: React.PropTypes.number, + pageIndex: React.PropTypes.number, + previousPageIndex: React.PropTypes.number, + loop: React.PropTypes.bool, + renderEmptyPages: React.PropTypes.bool, + pages: React.PropTypes.arrayOf(React.PropTypes.any).isRequired, + pageView: React.PropTypes.func.isRequired, + onSwiped: React.PropTypes.func, + swipeThreshold: React.PropTypes.number, + swipeCancelThreshold: React.PropTypes.number, + }, + + getDefaultProps: function () { + return { + baseClass: "merry-go-round", + cacheSize: 1, + embedWidth: 0, + embedHeight: 0, + pageIndex: 0, + previousPageIndex: 0, + loop: false, + renderEmptyPages: false, + onSwiped: noop, + swipeThreshold: 10, + swipeCancelThreshold: 10, + }; + }, + + isIndexWithinBounds: function (index) { + return index >= 0 && index < this.props.pages.length; + }, + + normalizeIndex: function (index) { + if ( !this.props.loop ) { return index; } + + while ( index < 0 ) { + index += this.props.pages.length; + } + + return index % this.props.pages.length; + }, + + isIndexInView: function (index, viewIndex) { + return Math.abs(index - viewIndex) <= this.props.cacheSize; + }, + + calculateBuffers: function () { + var first = Math.min(this.props.previousPageIndex, this.props.pageIndex) - this.props.cacheSize; + var last = Math.max(this.props.previousPageIndex, this.props.pageIndex) + this.props.cacheSize; + var indices = range(first, last + 1); + + return indices.map(function calculateBuffer (index) { + return { + index: index, + pageIndex: this.normalizeIndex(index), + willBeDiscarded: !this.isIndexInView(this.props.pageIndex, index), + isNew: !this.isIndexInView(this.props.previousPageIndex, index), + }; + }.bind(this)); + }, + + renderPages: function () { + var PageView = this.props.pageView; + var buffers = this.calculateBuffers(); + + return buffers.map(function renderBuffer (buffer) { + var pageView; + + if ( this.props.renderEmptyPages || this.isIndexWithinBounds(buffer.pageIndex) ) { + pageView = PageView({ + page: this.props.pages[buffer.pageIndex], + index: buffer.pageIndex, + willBeDiscarded: buffer.willBeDiscarded, + isNew: buffer.isNew, + }); + } + + return this.renderPage(buffer, pageView); + }.bind(this)); + }, +}; + +module.exports = CarouselMixin; diff --git a/Fader/index.js b/Fader/index.js new file mode 100644 index 0000000..7acb287 --- /dev/null +++ b/Fader/index.js @@ -0,0 +1,55 @@ +"use strict"; + +var React = require("react"); +var Swipable = require("../Swipable"); +var CarouselMixin = require("../CarouselMixin"); +var getClassName = require("../utils/getClassName"); + +module.exports = React.createClass({ + displayName: "Carousel", + + mixins: [Swipable, CarouselMixin], + + calculatePageStyle: function (index) { + return { + width: this.props.pageWidth + "px", + height: this.props.pageHeight + "px", + opacity: index === this.props.pageIndex ? "1.0" : "0.0", + }; + }, + + renderPage: function (buffer, pageView) { + return React.DOM.div({ + className: this.props.baseClass + "__page", + key: buffer.index, + style: this.calculatePageStyle(buffer.index), + }, pageView); + }, + + calculateStyle: function () { + return { + width: (this.props.width) + "px", + height: (this.props.height) + "px", + left: (-this.props.embedWidth) + "px", + top: (-this.props.embedHeight) + "px", + }; + }, + + render: function () { + if ( this.props.pages.length === 0 ) { + // (jussi-kalliokoski): Nothing to render. Avoids infinite loop in calculateBuffers(). + return React.DOM.div(); + } + + return React.DOM.div({ + className: this.props.baseClass, + onTouchStart: this.handleTouchStart, + onTouchMove: this.handleTouchMove, + onTouchEnd: this.handleTouchEnd, + onTouchCancel: this.handleTouchCancel, + style: this.calculateStyle(), + }, + this.renderPages() + ); + }, +}); diff --git a/README.md b/README.md index 0980b46..e15dfcb 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Courtesy of the test suite that's run on [BrowserStack](https://www.browserstack ## Usage -Merry-go-Round exposes two components, `Carousel` and `Container`: +Merry-go-Round exposes three components, `Carousel`, `Fader`, and `Container`: ### Carousel @@ -70,6 +70,10 @@ var Carousel = require("merry-go-round/Carousel"); * `embedHeight` (Integer, optional, defaults to `0`): The number of pixels to "embed" into the vertically. Basically reverses the container's padding, so that you can have things such as partially revealed pages that come outside the margin. * `renderEmptyPages` (Boolean, optional, defaults to `false`): Whether to render empty pages. This is useful when you want to have special views for pages that don't have any content. +### Fader + +The Fader is otherwise identical to the Carousel component and implements the same API, but instead of vertically sliding the pages, the pages fade in and out. + ### Container The Container is a general purpose mixin for creating containers of the Carousels. It provides some useful functionality such as auto-rotation, marker-based page navigation and passing on the carousel-related events to your controller view. diff --git a/gulpfile.js b/gulpfile.js index ac3e3fb..c947b78 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -11,6 +11,8 @@ var spawn = require("child_process").spawn; var files = [ "./Carousel/index.js", + "./Fader/index.js", + "./CarouselMixin/index.js", "./Container/index.js", "./utils/*.js", "./gulpfile.js", diff --git a/index.js b/index.js index 332259e..70abe87 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ "use strict"; module.exports.Carousel = require("./Carousel"); +module.exports.Fader = require("./Fader"); module.exports.Container = require("./Container"); diff --git a/package.json b/package.json index deb6bce..1a5883c 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,10 @@ "files": [ "index.js", "Carousel", + "Fader", "Container", "Swipable", + "CarouselMixin", "utils" ], "scripts": { diff --git a/test/CarouselSpec.js b/test/CarouselSpec.js index 1b4303a..d255b78 100644 --- a/test/CarouselSpec.js +++ b/test/CarouselSpec.js @@ -44,7 +44,7 @@ describe("Carousel", function () { return [].slice.call(getElementsByClassName(element, "merry-go-round__page")); } - function expectPages (list) { + function expectBufferedPageIndicesToMatch (list) { expectChildrenByClassName("merry-go-round__page", list.length); var actual = getPages().map(function (page) { if ( page.childNodes.length === 0 ) { @@ -133,7 +133,7 @@ describe("Carousel", function () { cacheSize: 3, }); - expectPages([ + expectBufferedPageIndicesToMatch([ null, null, null, @@ -147,7 +147,7 @@ describe("Carousel", function () { it("should default to `1`", function () { prepare(); - expectPages([ + expectBufferedPageIndicesToMatch([ null, 0, 1, @@ -228,7 +228,7 @@ describe("Carousel", function () { }); it("should render all pages between current and previous, plus `cacheSize`", function () { - expectPages([ + expectBufferedPageIndicesToMatch([ null, 0, 1, @@ -252,7 +252,7 @@ describe("Carousel", function () { }); it("should render only pages determined by `cacheSize`", function () { - expectPages([ + expectBufferedPageIndicesToMatch([ 2, 3, null, @@ -315,7 +315,7 @@ describe("Carousel", function () { }); it("should ring buffer the `pages` array", function () { - expectPages([ + expectBufferedPageIndicesToMatch([ 1, 2, 3, @@ -411,7 +411,7 @@ describe("Carousel", function () { }); it("should not do anything", function () { - expect(onSwiped.called).to.not.be.ok(); + expect(onSwiped.called).to.equal(false); }); }); @@ -426,7 +426,7 @@ describe("Carousel", function () { }); it("should not do anything", function () { - expect(onSwiped.called).to.not.be.ok(); + expect(onSwiped.called).to.equal(false); }); }); @@ -440,7 +440,7 @@ describe("Carousel", function () { }); it("should not do anything", function () { - expect(onSwiped.called).to.not.be.ok(); + expect(onSwiped.called).to.equal(false); }); }); @@ -454,7 +454,7 @@ describe("Carousel", function () { }); it("should not do anything", function () { - expect(onSwiped.called).to.not.be.ok(); + expect(onSwiped.called).to.equal(false); }); }); @@ -468,7 +468,7 @@ describe("Carousel", function () { }); it("should not do anything", function () { - expect(onSwiped.called).to.not.be.ok(); + expect(onSwiped.called).to.equal(false); }); }); @@ -490,7 +490,7 @@ describe("Carousel", function () { }); it("should not do anything", function () { - expect(onSwiped.called).to.not.be.ok(); + expect(onSwiped.called).to.equal(false); }); }); }); diff --git a/test/ContainerSpec.js b/test/ContainerSpec.js index 44d0ba3..21598ce 100644 --- a/test/ContainerSpec.js +++ b/test/ContainerSpec.js @@ -281,7 +281,7 @@ describe("Container", function () { }); it("should be started", function () { - expect(container.startAutoRotate.called).to.be.ok(); + expect(container.startAutoRotate.called).to.equal(true); }); }); @@ -292,7 +292,7 @@ describe("Container", function () { }); it("should be stopped", function () { - expect(container.stopAutoRotate.called).to.be.ok(); + expect(container.stopAutoRotate.called).to.equal(true); }); }); @@ -303,7 +303,7 @@ describe("Container", function () { }); it("should be started", function () { - expect(container.startAutoRotate.called).to.be.ok(); + expect(container.startAutoRotate.called).to.equal(true); }); }); @@ -314,7 +314,7 @@ describe("Container", function () { }); it("should be stopped", function () { - expect(container.stopAutoRotate.called).to.be.ok(); + expect(container.stopAutoRotate.called).to.equal(true); }); }); diff --git a/test/FaderSpec.js b/test/FaderSpec.js new file mode 100644 index 0000000..a0bad5e --- /dev/null +++ b/test/FaderSpec.js @@ -0,0 +1,405 @@ +"use strict"; + +var React = require("react/addons"); + +describe("Fader", function () { + var Fader = require("../Fader"); + + var DummyComponent = React.createClass({ + render: function () { + return React.DOM.div({ + className: "dummy", + }, this.props.page.index); + }, + }); + + var pages; + var defaults; + var element; + var component; + + function getElementsByClassName (targetElement, targetClassName) { + if ( targetElement.getElementsByClassName ) { + return targetElement.getElementsByClassName(targetClassName); + } + + // (jussi-kalliokoski): naive shiv for IE8, but works for the purposes of this test. + return [].filter.call(targetElement.getElementsByTagName("div"), function (element) { + var classList = element.className ? element.className.split(/\s+/g) : []; + return classList.some(function (className) { + return targetClassName === className; + }); + }); + } + + function expectChildByClassName (className) { + expect(getElementsByClassName(element, className).length).to.be.above(0); + } + + function expectChildrenByClassName (className, count) { + expect(getElementsByClassName(element, className).length).to.equal(count); + } + + function getPages () { + return [].slice.call(getElementsByClassName(element, "merry-go-round__page")); + } + + function expectBufferedPageIndicesToMatch (list) { + expectChildrenByClassName("merry-go-round__page", list.length); + var actual = getPages().map(function (page) { + if ( page.childNodes.length === 0 ) { + return null; + } + + return getElementsByClassName(page, "dummy")[0].innerHTML; + }); + + expect(actual).to.eql(list); + } + + function prepare (options) { + element = document.createElement("div"); + options = options || {}; + var defaultKeys = Object.keys(defaults); + defaultKeys.forEach(function (key) { + if ( !options.hasOwnProperty(key) ) { + options[key] = defaults[key]; + } + }); + component = Fader(options); + React.renderComponent(component, element); + } + + beforeEach(function () { + pages = [{ + index: 0, + }, { + index: 1, + }, { + index: 2, + }, { + index: 3, + }]; + + defaults = { + pageView: DummyComponent, + pages: pages, + }; + }); + + it("should exist", function () { + expect(Fader).to.be.ok(); + }); + + describe("`baseClass`", function () { + function testBaseClass (baseClass, baseClassProp) { + prepare({ + baseClass: baseClassProp, + }); + + expectChildByClassName(baseClass); + expectChildByClassName(baseClass + "__page"); + } + + it("should be used as the base class", function () { + testBaseClass("foo", "foo"); + }); + + it("should default to `merry-go-round`", function () { + testBaseClass("merry-go-round"); + }); + }); + + describe("`cacheSize`", function () { + it("should determine the number of pages cached around current page", function () { + prepare({ + cacheSize: 3, + }); + + expectBufferedPageIndicesToMatch([ + null, + null, + null, + 0, + 1, + 2, + 3, + ]); + }); + + it("should default to `1`", function () { + prepare(); + + expectBufferedPageIndicesToMatch([ + null, + 0, + 1, + ]); + }); + }); + + describe("pages", function () { + beforeEach(function () { + defaults.pageWidth = 11; + defaults.pageHeight = 13; + }); + + function expectPageOpacities (expected) { + var actual = getPages().map(function (page) { + return parseFloat(page.style.opacity); + }); + + expect(actual).to.eql(expected); + } + + function expectPageSizes (width, height) { + getPages().forEach(function (page) { + expect([page.style.width, page.style.height]).to.eql([width + "px", height + "px"]); + }); + } + + describe("when on first page", function () { + beforeEach(function () { + prepare({ + pageIndex: 0, + previousPageIndex: 0, + }); + }); + + it("the opacities should be correct", function () { + expectPageOpacities([ + 0.0, + 1.0, + 0.0, + ]); + }); + + it("the sizes should match", function () { + expectPageSizes(11, 13); + }); + }); + + describe("when on third page", function () { + beforeEach(function () { + prepare({ + pageIndex: 2, + previousPageIndex: 2, + }); + }); + + it("the opacities should be correct", function () { + expectPageOpacities([ + 0.0, + 1.0, + 0.0, + ]); + }); + }); + }); + + describe("when provided an empty `pages` array", function () { + it("should render as an empty div", function () { + var html = React.renderComponentToStaticMarkup(Fader({ + pages: [], + pageView: DummyComponent, + })); + + expect(html).to.equal("
"); + }); + }); + + describe("when `looping` is enabled", function () { + beforeEach(function () { + defaults.loop = true; + }); + + describe("when buffers overflow the `pages` array", function () { + beforeEach(function () { + prepare({ + cacheSize: 3, + pageIndex: 0, + previousPageIndex: 6, + }); + }); + + it("should ring buffer the `pages` array", function () { + expectBufferedPageIndicesToMatch([ + 1, + 2, + 3, + 0, + 1, + 2, + 3, + 0, + 1, + 2, + 3, + 0, + 1, + ]); + }); + }); + }); + + describe("when swiped", function () { + function createTouches () { + return [].map.call(arguments, function (position, index) { + return { + id: "touch" + index, + pageX: position[0], + pageY: position[1], + }; + }); + } + + function swipe (positions, cancel) { + var carousel = getElementsByClassName(element, "merry-go-round")[0]; + React.addons.TestUtils.Simulate.touchStart(carousel, { + touches: createTouches(positions.shift()), + }); + + positions.forEach(function (position) { + React.addons.TestUtils.Simulate.touchMove(carousel, { + touches: createTouches(position), + }); + }); + + if ( cancel ) { + React.addons.TestUtils.Simulate.touchCancel(carousel, { + touches: [], + }); + } else { + React.addons.TestUtils.Simulate.touchEnd(carousel, { + touches: [], + }); + } + } + + var onSwiped; + beforeEach(function () { + onSwiped = defaults.onSwiped = sinon.spy(); + }); + + describe("left", function () { + beforeEach(function () { + prepare(); + swipe([ + [100, 0], + [0, 0], + ]); + }); + + it("should trigger `onSwiped` event, with positive sign", function () { + expect(onSwiped.lastCall.args[0].sign).to.equal(1); + }); + }); + + describe("right", function () { + beforeEach(function () { + prepare(); + swipe([ + [0, 0], + [100, 0], + ]); + }); + + it("should trigger `onSwiped` event, with negative sign", function () { + expect(onSwiped.lastCall.args[0].sign).to.equal(-1); + }); + }); + + describe("up", function () { + beforeEach(function () { + prepare(); + swipe([ + [0, 100], + [0, 0], + ]); + }); + + it("should not do anything", function () { + expect(onSwiped.called).to.equal(false); + }); + }); + + describe("up, then left", function () { + beforeEach(function () { + prepare(); + swipe([ + [100, 100], + [100, 0], + [0, 0], + ]); + }); + + it("should not do anything", function () { + expect(onSwiped.called).to.equal(false); + }); + }); + + describe("too little", function () { + beforeEach(function () { + prepare(); + swipe([ + [5, 0], + [0, 0], + ]); + }); + + it("should not do anything", function () { + expect(onSwiped.called).to.equal(false); + }); + }); + + describe("without `onSwiped` set", function () { + beforeEach(function () { + prepare({ onSwiped: undefined }); + swipe([ + [100, 0], + [0, 0], + ]); + }); + + it("should not do anything", function () { + expect(onSwiped.called).to.equal(false); + }); + }); + + describe("left and canceled", function () { + beforeEach(function () { + prepare(); + swipe([ + [100, 0], + [0, 0], + ], true); + }); + + it("should not do anything", function () { + expect(onSwiped.called).to.equal(false); + }); + }); + + describe("with multiple fingers", function () { + beforeEach(function () { + var carousel = getElementsByClassName(element, "merry-go-round")[0]; + React.addons.TestUtils.Simulate.touchStart(carousel, { + touches: createTouches([100, 0]), + }); + React.addons.TestUtils.Simulate.touchMove(carousel, { + touches: createTouches([0, 0]), + }); + React.addons.TestUtils.Simulate.touchStart(carousel, { + touches: createTouches([0, 0], [10, 20]), + }); + React.addons.TestUtils.Simulate.touchEnd(carousel, { + touches: [], + }); + }); + + it("should not do anything", function () { + expect(onSwiped.called).to.equal(false); + }); + }); + }); +});