mobify/scooch

View on GitHub
src/scooch.js

Summary

Maintainability
F
4 days
Test Coverage
(function(factory) {
    if (typeof define === 'function' && define.amd) {
        /*
         In AMD environments, you will need to define an alias
         to your selector engine. i.e. either zepto or jQuery.
         */
        define(['$'], factory);
    } else {
        /*
         Browser globals
         */
        var selectorLibrary = window.Mobify && window.Mobify.$ || window.jQuery || window.Zepto;
        factory(selectorLibrary);
    }
})(function($) {

    var Utils = (function($) {
        var exports = {};
        var ua = navigator.userAgent;
        var has = $.support = $.support || {};

        $.extend($.support, {
            'touch': 'ontouchend' in document
        });

        /**
            Events (either touch or mouse)
        */
        exports.events = (has.touch)
            ? {down: 'touchstart', move: 'touchmove', up: 'touchend'}
            : {down: 'mousedown', move: 'mousemove', up: 'mouseup'};

        /**
            Returns the position of a mouse or touch event in (x, y)
            @function
            @param {Event} touch or mouse event
            @returns {Object} X and Y coordinates
        */
        exports.getCursorPosition = (has.touch)
            ? function(e) {e = e.originalEvent || e; return {x: e.touches[0].clientX, y: e.touches[0].clientY};}
            : function(e) {return {x: e.clientX, y: e.clientY};};


        /**
            Returns prefix property for current browser.
            @param {String} CSS Property Name
            @return {String} Detected CSS Property Name
        */
        exports.getProperty = function(name) {
            var prefixes = ['Webkit', 'Moz', 'O', 'ms', ''];
            var testStyle = document.createElement('div').style;

            for (var i = 0; i < prefixes.length; ++i) {
                if (testStyle[prefixes[i] + name] !== undefined) {
                    return prefixes[i] + name;
                }
            }

            // Not Supported
            return;
        };

        $.extend(has, {
            'transform': !!(exports.getProperty('Transform')),

            // Usage of transform3d on *android* would cause problems for input fields:
            // - https://coderwall.com/p/d5lmba
            // - http://static.trygve-lie.com/bugs/android_input/
            'transform3d': !!(window.WebKitCSSMatrix && 'm11' in new window.WebKitCSSMatrix() && !/android\s+[1-2]/i.test(ua))
        });

        // translateX(element, delta)
        // Moves the element by delta (px)
        var transformProperty = exports.getProperty('Transform');
        if (has.transform3d) {
            exports.translateX = function(element, delta) {
                if (typeof delta === 'number') {
                    delta = delta + 'px';
                }
                element.style[transformProperty] = 'translate3d(' + delta  + ',0,0)';
            };
        } else if (has.transform) {
            exports.translateX = function(element, delta) {
                if (typeof delta === 'number') {
                    delta = delta + 'px';
                }
                element.style[transformProperty] = 'translate(' + delta  + ',0)';
            };
        } else {
            exports.translateX = function(element, delta) {
                if (typeof delta === 'number') {
                    delta = delta + 'px';
                }
                element.style.left = delta;
            };
        }

        // setTransitions
        var transitionProperty = exports.getProperty('Transition');
        var durationProperty = exports.getProperty('TransitionDuration');

        exports.setTransitions = function(element, enable) {
            if (enable) {
                element.style[durationProperty] = '';
            } else {
                element.style[durationProperty] = '0s';
            }
        };

        exports.onTransitionEnd = function($el, callback) {
            $el.one('transitionend webkitTransitionEnd otransitionend MSTransitionEnd', function(e) {
                var $target = $(e.target);
                if ($target.not($el).length === 0) {
                    callback(e);
                }
            });
        };


        // Request Animation Frame
        // courtesy of @paul_irish
        exports.requestAnimationFrame = (function() {
            var prefixed = (window.requestAnimationFrame       ||
                            window.webkitRequestAnimationFrame ||
                            window.mozRequestAnimationFrame    ||
                            window.oRequestAnimationFrame      ||
                            window.msRequestAnimationFrame     ||
                            function(callback) {
                                window.setTimeout(callback, 1000 / 60);
                            });

            var requestAnimationFrame = function() {
                prefixed.apply(window, arguments);
            };

            return requestAnimationFrame;
        })();

        return exports;

    })($);

    var Scooch = (function($, Utils) {
        var defaults = {
            dragRadius: 10,
            moveRadius: 20,
            animate: true,
            autoHideArrows: false,
            rightToLeft: false,
            infinite: false,
            autoplay: false,
            classPrefix: 'm-',
            classNames: {
                outer: 'scooch',
                inner: 'scooch-inner',
                item: 'item',
                center: 'center',
                touch: 'has-touch',
                dragging: 'dragging',
                active: 'active',
                inactive: 'inactive',
                fluid: 'fluid'
            }
        };
        var has = $.support;

        // Constructor
        var Scooch = function(element, options) {
            this.setOptions(options);
            this.initElements(element);

            if (this.options.infinite) {
                // Make infinite
                this.initLoop();
            }
            if (typeof this.options.autoplay === 'object') {
                this.initAutoplay();
            }
            this.initStartElement();

            this.initOffsets();
            this.initAnimation();

            this.bind();
            this.start();

            this._updateCallbacks = [];
        };

        // Expose Defaults
        Scooch.defaults = defaults;

        Scooch.prototype.setOptions = function(opts) {
            var options = this.options || $.extend({}, defaults, opts);

            /* classNames requires a deep copy */
            options.classNames = $.extend({}, options.classNames, opts.classNames || {});

            this.options = options;
        };

        Scooch.prototype.initElements = function(element) {
            this._index = 1;  // 1-based index

            this.element = element;
            this.$element = $(element);
            this.$inner = this.$element.find('.' + this._getClass('inner'));
            this.$items = this.$inner.children();

            this._length = this.$items.length;
            this._alignment = this.$element.hasClass(this._getClass('center')) ? 0.5 : 0;

            this._isFluid = this.$element.hasClass(this._getClass('fluid'));

            // Limits
            this._lockLeft = 1;
            this._lockRight = this._length;
        };

        Scooch.prototype.initStartElement = function() {
            this.$start = this.$inner.children().first();
            this.$current = this.$items.eq(this._index - 1);
        };

        Scooch.prototype.initLoop = function() {
            this._loopPrepend = 2;
            this._loopAppend = 2;

            for (var i = 0; i < this._loopPrepend; i++) {
                var $clone = this.$items.eq(this._length - 1 - i).clone();
                this.$inner.prepend($clone);
            }
            for (var i = 0; i < this._loopAppend; i++) {
                var $clone = this.$items.eq(i).clone();
                this.$inner.append($clone);
            }

            this._lockLeft = this._lockLeft - 1;
            this._lockRight = this._lockRight + 1;

            var self = this;
            this.$element.on('afterSlide', function() {
                var newIndex = self._index;

                // If one of the appended clones, go to the other side of the loop
                if (self._index < 1) {
                    newIndex =  self._length;
                } else if (self._index > self._length) {
                    newIndex =  1;
                } else {
                    return;
                }

                self._index = newIndex;
                self.initStartElement();
                self.start();
            });
        };

        Scooch.prototype.initAutoplay = function() {
            // Using modulus to determine the next correct index. Always use the current
            // index and length in this calculation as the values can change.
            var self = this; // bind reference to this for later
            var moveScooch = function() {
                // scooch's index starts at 1, so increment after the modulus calculation.
                var newIndex = (self._index % self._length) + 1;

                self.move(newIndex);
            };

            if (this.options.autoplay.interval &&
                typeof this.options.autoplay.interval === 'number' &&
                this.options.autoplay.interval > 1
            ) {
                // Always appear to be infinite until user interaction (if applicable)
                var previousAutoHideOption = self.options.autoHideArrows;
                self.options.autoHideArrows = false;

                self.timer = window.setInterval(moveScooch, this.options.autoplay.interval);

                if (typeof this.options.autoplay.cancelOnInteraction === 'boolean' &&
                    this.options.autoplay.cancelOnInteraction
                ) {
                    this.$element.on('touchstart click mouseover', function() {
                        window.clearInterval(self.timer);

                        // Restore autoHideArrows option
                        self.options.autoHideArrows = previousAutoHideOption;
                        if (previousAutoHideOption) {
                            self.hideArrows(self._index); // Refresh arrow states
                        }
                    });
                } else {
                    // If no chance to cancel autoplay, this scooch will be infinite
                    self.initLoop();
                }
            }
        };

        Scooch.prototype.initOffsets = function() {
            this._offsetDrag = 0;
        };

        Scooch.prototype.initAnimation = function() {
            this.animating = false;
            this.dragging = false;
            this._needsUpdate = false;
            this._enableAnimation();
        };


        Scooch.prototype._getClass = function(id) {
            return this.options.classPrefix + this.options.classNames[id];
        };


        Scooch.prototype._enableAnimation = function() {
            if (this.animating) {
                return;
            }

            Utils.setTransitions(this.$inner[0], true);
            this.$inner.removeClass(this._getClass('dragging'));
            this.animating = true;
        };

        Scooch.prototype._disableAnimation = function() {
            if (!this.animating) {
                return;
            }

            Utils.setTransitions(this.$inner[0], false);
            this.$inner.addClass(this._getClass('dragging'));
            this.animating = false;
        };

        Scooch.prototype.start = function() {
            this._disableAnimation();

            this.$element.trigger('beforeSlide', [this._index, this._index]);
            this.$element.trigger('afterSlide', [this._index, this._index]);

            this.update();
        };

        Scooch.prototype.refresh = function() {
            /* Call when number of items has changed (e.g. with AJAX) */
            this.$items = this.$inner.children( '.' + this._getClass('item'));
            this._length = this.$items.length;
            this._lockRight = this.$items.length;
            this.start();
        };

        Scooch.prototype.update = function(callback) {
            if (typeof callback !== 'undefined') {
                this._updateCallbacks.push(callback);
            }

            /* We throttle calls to the real `_update` for efficiency */
            if (this._needsUpdate) {
                return;
            }

            this._needsUpdate = true;

            var self = this;
            Utils.requestAnimationFrame(function() {
                self._update();

                setTimeout(function() {
                    for (var i = 0, _len = self._updateCallbacks.length; i < _len; i++) {
                        self._updateCallbacks[i].call(self);
                    }
                    self._updateCallbacks = [];
                }, 10);
            });
        };

        Scooch.prototype._update = function() {
            if (!this._needsUpdate) {
                return;
            }

            var $current = this.$current;
            var $start = this.$start;
            var currentOffset = $current.prop('offsetLeft') + $current.prop('clientWidth') * this._alignment;
            var startOffset = $start.prop('offsetLeft') + $start.prop('clientWidth') * this._alignment;
            var x = Math.round(-(currentOffset - startOffset) + this._offsetDrag);

            if ($current.prop('offsetParent')) {
                Utils.translateX(this.$inner[0], x);
            }

            this._needsUpdate = false;
        };

        Scooch.prototype.hideArrows = function(nextSlide) {
            this.$element.find('[data-m-slide=prev]').removeClass(this._getClass('inactive'));
            this.$element.find('[data-m-slide=next]').removeClass(this._getClass('inactive'));

            if (nextSlide === 1) {
                this.$element.find('[data-m-slide=prev]').addClass(this._getClass('inactive'));
            }

            if (nextSlide === this._length) {
                this.$element.find('[data-m-slide=next]').addClass(this._getClass('inactive'));
            }
        };

        Scooch.prototype.bind = function() {
            var abs = Math.abs;
            var dragging = false;
            var canceled = false;
            var dragRadius = this.options.dragRadius;
            var xy;
            var dx;
            var dy;
            var dragThresholdMet;
            var self = this;
            var $element = this.$element;
            var $inner = this.$inner;
            var opts = this.options;
            var lockLeft = false;
            var lockRight = false;
            var windowWidth = $(window).width();

            var start = function(e) {
                if (!has.touch) e.preventDefault();

                dragging = true;
                canceled = false;

                xy = Utils.getCursorPosition(e);
                dx = 0;
                dy = 0;
                dragThresholdMet = false;

                // Disable smooth transitions
                self._disableAnimation();

                lockLeft = self._index === self._lockLeft;
                lockRight = self._index === self._lockRight;
            };

            var drag = function(e) {
                if (!dragging || canceled) return;

                var newXY = Utils.getCursorPosition(e);
                var dragLimit = self.$element.width();
                dx = xy.x - newXY.x;
                dy = xy.y - newXY.y;

                if (dragThresholdMet || abs(dx) > abs(dy) && (abs(dx) > dragRadius)) {
                    dragThresholdMet = true;
                    e.preventDefault();

                    if (lockLeft && (dx < 0)) {
                        dx = dx * (-dragLimit) / (dx - dragLimit);
                    } else if (lockRight && (dx > 0)) {
                        dx = dx * (dragLimit) / (dx + dragLimit);
                    }
                    self._offsetDrag = -dx;
                    self.update();
                } else if ((abs(dy) > abs(dx)) && (abs(dy) > dragRadius)) {
                    canceled = true;
                }
            };

            var end = function(e) {
                if (!dragging) {
                    return;
                }

                dragging = false;

                self._enableAnimation();

                if (!canceled && abs(dx) > opts.moveRadius) {
                    // Move to the next slide if necessary
                    if (opts.rightToLeft) {
                        if (dx < 0) {
                            self.next();
                        } else {
                            self.prev();
                        }
                    } else {
                        if (dx > 0) {
                            self.next();
                        } else {
                            self.prev();
                        }
                    }
                } else {
                    // Reset back to regular position
                    self._offsetDrag = 0;
                    self.update();
                }
            };

            var click = function(e) {
                if (dragThresholdMet) e.preventDefault();
            };

            $inner
                .on(Utils.events.down + '.scooch', start)
                .on(Utils.events.move + '.scooch', drag)
                .on(Utils.events.up + '.scooch', end)
                .on('click.scooch', click)
                .on('mouseout.scooch', end);

            $element.on('click', '[data-m-slide]', function(e) {
                e.preventDefault();
                var action = $(this).attr('data-m-slide');
                var index = parseInt(action, 10);

                if (isNaN(index)) {
                    self[action]();
                } else {
                    self.move(index);
                }
            });

            $element.on('afterSlide', function(e, previousSlide, nextSlide) {
                self.$items.eq(previousSlide - 1).removeClass(self._getClass('active'));
                self.$items.eq(nextSlide - 1).addClass(self._getClass('active'));

                self.$element.find('[data-m-slide=\'' + previousSlide + '\']').removeClass(self._getClass('active'));
                self.$element.find('[data-m-slide=\'' + nextSlide + '\']').addClass(self._getClass('active'));

                if (opts.autoHideArrows) { // Hide prev/next arrows when at bounds
                    self.hideArrows(nextSlide);
                }
            });

            $(window).on('resize orientationchange', function(e) {
                // Disable animation for now to avoid seeing
                // the carousel sliding, as it updates its position.
                // Animation will be enabled automatically when you're swiping.
                // Don't update Carousel on window height change
                if (windowWidth === $(window).width()) {
                    return;
                }

                self._disableAnimation();
                windowWidth = $(window).width();
                self.update();
            });

            $element.trigger('beforeSlide', [1, 1]);
            $element.trigger('afterSlide', [1, 1]);
        };

        Scooch.prototype.unbind = function() {
            this.$inner.off();
        };

        Scooch.prototype.destroy = function() {
            this.unbind();
            this.$element.trigger('destroy');
            this.$element.remove();

            // Cleanup
            this.$element = null;
            this.$inner = null;
            this.$start = null;
            this.$current = null;
        };

        Scooch.prototype.move = function(newIndex, opts) {
            var $element = this.$element;
            var $inner = this.$inner;
            var $items = this.$items;
            var $start = this.$start;
            var $current = this.$current;
            var length = this._length;
            var index = this._index;

            opts = $.extend({}, this.options, opts);

            // Bound Values between [1, length];
            if (newIndex < this._lockLeft) {
                newIndex = this._lockLeft;
            } else if (newIndex > this._lockRight) {
                newIndex = this._lockRight;
            }

            // Check if we should animate
            if (opts.animate) {
                this._enableAnimation();
            } else {
                this._disableAnimation();
            }

            // Trigger beforeSlide event
            $element.trigger('beforeSlide', [index, newIndex]);

            // Index must be decremented to convert between 1- and 0-based indexing.
            if (opts.infinite) {
                this.$current = $current = $inner.children().eq(newIndex + this._loopPrepend - 1);
            } else {
                this.$current = $current = $items.eq(newIndex - 1);
            }

            this._offsetDrag = 0;
            this._index = newIndex;

            // Update, re-enable animation if necessary
            if (opts.animate) {
                this.update();
            } else {
                this.update(function() {
                    this._enableAnimation();
                });
            }
            // Trigger afterSlide event
            if (opts.animate) {
                Utils.onTransitionEnd(this.$inner, function() {
                    $element.trigger('afterSlide', [index, newIndex]);
                });
            } else {
                $element.trigger('afterSlide', [index, newIndex]);
            }
        };

        Scooch.prototype.next = function() {
            this.move(this._index + 1);
        };

        Scooch.prototype.prev = function() {
            this.move(this._index - 1);
        };

        return Scooch;

    })($, Utils);

    /**
        jQuery interface to set up a scooch carousel

        @param {String} [action] Action to perform. When no action is passed, the carousel is simply initialized.
        @param {Object} [options] Options passed to the action.
    */
    $.fn.scooch = function(action, options) {
        var initOptions = $.extend({}, $.fn.scooch.defaults);

        // Handle different calling conventions
        if (typeof action === 'object') {
            $.extend(initOptions, action, true);
            options = null;
            action = null;
        }

        options = Array.prototype.slice.apply(arguments);

        this.each(function() {
            var $this = $(this);
            var scooch = this._scooch;

            if (!scooch) {
                scooch = new Scooch(this, initOptions);
            }

            if (action) {
                scooch[action].apply(scooch, options.slice(1));

                if (action === 'destroy') {
                    scooch = null;
                }
            }

            this._scooch = scooch;
        });

        return this;
    };

    $.fn.scooch.defaults = {};

});