/** * Flexible Carousel * @module Ink.UI.Carousel_1 * @version 1 */ Ink.createModule('Ink.UI.Carousel', '1', ['Ink.UI.Common_1', 'Ink.Dom.Event_1', 'Ink.Dom.Css_1', 'Ink.Dom.Element_1', 'Ink.UI.Pagination_1', 'Ink.Dom.Browser_1', 'Ink.Dom.Selector_1'], function(Common, InkEvent, Css, InkElement, Pagination, Browser/*, Selector*/) { 'use strict'; /* * TODO: * keyboardSupport */ function limitRange(n, min, max) { return Math.min(max, Math.max(min, n)); } var requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || function (cb) {return setTimeout(cb, 1000 / 30); }; /** * @class Ink.UI.Carousel_1 * @constructor * * @param {String|DOMElement} selector DOM element or element id * @param {Object} [options] Carousel Options * @param {Integer} [options.autoAdvance] Milliseconds to wait before auto-advancing pages. Set to 0 to disable auto-advance. Defaults to 0. * @param {String} [options.axis] Axis of the carousel. Set to 'y' for a vertical carousel. Defaults to 'x'. * @param {Number} [options.initialPage] Initial index page of the carousel. Defaults to 0. * @param {Boolean} [options.spaceAfterLastSlide=true] If there are not enough slides to fill the full width of the last page, leave white space. Defaults to `true`. * @param {Boolean} [options.swipe] Enable swipe support if available. Defaults to true. * @param {Mixed} [options.pagination] Either an ul element to add pagination markup to or an `Ink.UI.Pagination` instance to use. * @param {Function} [options.onChange] Callback to be called when the page changes. * * @sample Ink_UI_Carousel_1.html */ function Carousel() { Common.BaseUIComponent.apply(this, arguments); } Carousel._name = 'Carousel_1'; Carousel._optionDefinition = { autoAdvance: ['Integer', 0], axis: ['String', 'x'], initialPage: ['Integer', 0], spaceAfterLastSlide: ['Boolean', true], hideLast: ['Boolean', false], // [3.1.0] Deprecate "center". It is only needed when things are of unknown widths. center: ['Boolean', false], keyboardSupport:['Boolean', false], pagination: ['String', null], onChange: ['Function', null], onInit: ['Function', function () {}], swipe: ['Boolean', true] // TODO exponential swipe // TODO specify break point for next page when moving finger }; Carousel.prototype = { _init: function () { this._handlers = { paginationChange: Ink.bindMethod(this, '_onPaginationChange'), windowResize: InkEvent.throttle(Ink.bindMethod(this, 'refit'), 200) }; InkEvent.observe(window, 'resize', this._handlers.windowResize); this._isY = (this._options.axis === 'y'); var ulEl = Ink.s('ul.stage', this._element); this._ulEl = ulEl; InkElement.removeTextNodeChildren(ulEl); if (this._options.pagination == null) { this._currentPage = this._options.initialPage; } this.refit(); // recalculate this._numPages if (this._isY) { // Override white-space: no-wrap which is only necessary to make sure horizontal stuff stays horizontal, but breaks stuff intended to be vertical. this._ulEl.style.whiteSpace = 'normal'; } if (this._options.swipe) { InkEvent.observe(this._element, 'touchstart', Ink.bindMethod(this, '_onTouchStart')); InkEvent.observe(this._element, 'touchmove', Ink.bindMethod(this, '_onTouchMove')); InkEvent.observe(this._element, 'touchend', Ink.bindMethod(this, '_onTouchEnd')); } this._setUpPagination(); this._setUpAutoAdvance(); this._setUpHider(); this._options.onInit.call(this, this); }, /** * Repositions elements around. * Measure the carousel once again, adjusting the involved elements' sizes. This is called automatically when the window resizes, in order to cater for changes from responsive media queries, for instance. * * @method refit * @public */ refit: function() { var _isY = this._isY; var size = function (elm, perpendicular) { if (!elm) { return 0; } if (!perpendicular) { return InkElement.outerDimensions(elm)[_isY ? 1 : 0]; } else { return InkElement.outerDimensions(elm)[_isY ? 0 : 1]; } }; this._liEls = Ink.ss('li.slide', this._ulEl); var numSlides = this._liEls.length; var contRect = this._ulEl.getBoundingClientRect(); this._ctnLength = _isY ? contRect.bottom - contRect.top : contRect.right - contRect.left; this._elLength = size(this._liEls[0]); this._slidesPerPage = Math.floor( this._ctnLength / this._elLength ) || 1; if (!isFinite(this._slidesPerPage)) { this._slidesPerPage = 1; } var numPages = Math.ceil( numSlides / this._slidesPerPage ); var numPagesChanged = this._numPages !== numPages; this._numPages = numPages; this._deltaLength = this._slidesPerPage * this._elLength; this._center(); this._updateHider(); this._IE7(); if (this._pagination && numPagesChanged) { this._pagination.setSize(this._numPages); } this.setPage(limitRange(this.getPage(), 0, this._numPages)); }, _setUpPagination: function () { if (this._options.pagination) { if (Common.isDOMElement(this._options.pagination) || typeof this._options.pagination === 'string') { // if dom element or css selector string... this._pagination = new Pagination(this._options.pagination, { size: this._numPages, onChange: this._handlers.paginationChange }); } else { // assumes instantiated pagination this._pagination = this._options.pagination; this._pagination._options.onChange = this._handlers.paginationChange; this._pagination.setSize(this._numPages); } this._pagination.setCurrent(this._options.initialPage || 0); } else { this._currentPage = this._options.initialPage || 0; } }, _setUpAutoAdvance: function () { if (!this._options.autoAdvance) { return; } var self = this; setTimeout(function autoAdvance() { self.nextPage(true /* wrap */); setTimeout(autoAdvance, self._options.autoAdvance); }, this._options.autoAdvance); }, _setUpHider: function () { if (this._options.hideLast) { var hiderEl = InkElement.create('div', { className: 'hider', insertBottom: this._element }); hiderEl.style.position = 'absolute'; hiderEl.style[ this._isY ? 'left' : 'top' ] = '0'; // fix to top.. hiderEl.style[ this._isY ? 'right' : 'bottom' ] = '0'; // and bottom... hiderEl.style[ this._isY ? 'bottom' : 'right' ] = '0'; // and move to the end. this._hiderEl = hiderEl; } }, // [3.1.0] Deprecate this already _center: function() { if (!this._options.center) { return; } var gap = Math.floor( (this._ctnLength - (this._elLength * this._slidesPerPage) ) / 2 ); var pad; if (this._isY) { pad = [gap, 'px 0']; } else { pad = ['0 ', gap, 'px']; } this._ulEl.style.padding = pad.join(''); }, // [3.1.0] Deprecate this already _updateHider: function() { if (!this._hiderEl) { return; } if (this.getPage() === 0) { var gap = Math.floor( this._ctnLength - (this._elLength * this._slidesPerPage) ); if (this._options.center) { gap /= 2; } this._hiderEl.style[ this._isY ? 'height' : 'width' ] = gap + 'px'; } else { this._hiderEl.style[ this._isY ? 'height' : 'width' ] = '0px'; } }, /** * Refits elements for IE7 because it doesn't support inline-block. * * @method _IE7 * @private */ _IE7: function () { if (Browser.IE && '' + Browser.version.split('.')[0] === '7') { // var numPages = this._numPages; var slides = Ink.ss('li.slide', this._ulEl); var stl = function (prop, val) {slides[i].style[prop] = val; }; for (var i = 0, len = slides.length; i < len; i++) { stl('position', 'absolute'); stl(this._isY ? 'top' : 'left', (i * this._elLength) + 'px'); } } }, _onTouchStart: function (event) { if (event.touches.length > 1) { return; } this._swipeData = { x: InkEvent.pointerX(event), y: InkEvent.pointerY(event) }; var ulRect = this._ulEl.getBoundingClientRect(); this._swipeData.firstUlPos = ulRect[this._isY ? 'top' : 'left']; this._swipeData.inUlX = this._swipeData.x - ulRect.left; this._swipeData.inUlY = this._swipeData.y - ulRect.top; setTransitionProperty(this._ulEl, 'none'); this._touchMoveIsFirstTouchMove = true; }, _onTouchMove: function (event) { if (event.touches.length > 1) { return; /* multitouch event, not my problem. */ } var pointerX = InkEvent.pointerX(event); var pointerY = InkEvent.pointerY(event); var deltaY = Math.abs(pointerY - this._swipeData.y); var deltaX = Math.abs(pointerX - this._swipeData.x); if (this._touchMoveIsFirstTouchMove) { this._touchMoveIsFirstTouchMove = undefined; this._scrolling = this._isY ? deltaX > deltaY : deltaY > deltaX ; if (!this._scrolling) { this._onAnimationFrame(); } } if (!this._scrolling && this._swipeData) { InkEvent.stopDefault(event); this._swipeData.pointerPos = this._isY ? pointerY : pointerX; } }, _onAnimationFrame: function () { var swipeData = this._swipeData; if (!swipeData || this._scrolling || this._touchMoveIsFirstTouchMove) { return; } var elRect = this._element.getBoundingClientRect(); var newPos; if (!this._isY) { newPos = swipeData.pointerPos - swipeData.inUlX - elRect.left; } else { newPos = swipeData.pointerPos - swipeData.inUlY - elRect.top; } this._ulEl.style[this._isY ? 'top' : 'left'] = newPos + 'px'; requestAnimationFrame(Ink.bindMethod(this, '_onAnimationFrame')); }, _onTouchEnd: function (event) { if (this._swipeData && this._swipeData.pointerPos && !this._scrolling && !this._touchMoveIsFirstTouchMove) { var snapToNext = 0.1; // swipe 10% of the way to change page var relProgress = this._swipeData.firstUlPos - this._ulEl.getBoundingClientRect()[this._isY ? 'top' : 'left']; var curPage = this.getPage(); // How many pages were advanced? May be fractional. var progressInPages = relProgress / this._elLength / this._slidesPerPage; // Have we advanced enough to change page? if (Math.abs(progressInPages) > snapToNext) { curPage += Math[ relProgress < 0 ? 'floor' : 'ceil' ](progressInPages); } // If something used to calculate progressInPages was zero, we get NaN here. if (!isNaN(curPage)) { this.setPage(curPage); } InkEvent.stopDefault(event); } setTransitionProperty(this._ulEl, null /* transition: left, top */); this._swipeData = null; this._touchMoveIsFirstTouchMove = undefined; this._scrolling = undefined; }, _onPaginationChange: function(pgn) { this._setPage(pgn.getCurrent()); }, /** * Gets the current page index * @method getPage * @return The current page number **/ getPage: function () { if (this._pagination) { return this._pagination.getCurrent(); } else { return this._currentPage || 0; } }, /** * Sets the current page index * @method setPage * @param {Number} page Index of the destination page. * @param {Boolean} [wrap] Flag to activate circular counting. **/ setPage: function (page, wrap) { if (wrap) { // Pages outside the range [0..this._numPages] are wrapped. page = page % this._numPages; if (page < 0) { page = this._numPages - page; } } page = limitRange(page, 0, this._numPages - 1); if (this._pagination) { this._pagination.setCurrent(page); // _setPage is called by pagination because it listens to its Change event. } else { this._setPage(page); } }, _setPage: function (page) { var _lengthToGo = page * this._deltaLength; var isLastPage = page === (this._numPages - 1); if (!this._options.spaceAfterLastSlide && isLastPage && page > 0) { var _itemsInLastPage = this._liEls.length - (page * this._slidesPerPage); if(_itemsInLastPage < this._slidesPerPage) { _lengthToGo = ((page - 1) * this._deltaLength) + (_itemsInLastPage * this._elLength); } } this._ulEl.style[ this._isY ? 'top' : 'left'] = ['-', _lengthToGo, 'px'].join(''); if (this._options.onChange) { this._options.onChange.call(this, page); } this._currentPage = page; this._updateHider(); }, /** * Goes to the next page * @method nextPage * @param {Boolean} [wrap] Flag to loop from last page to first page. **/ nextPage: function (wrap) { this.setPage(this.getPage() + 1, wrap); }, /** * Goes to the previous page * @method previousPage * @param {Boolean} [wrap] Flag to loop from first page to last page. **/ previousPage: function (wrap) { this.setPage(this.getPage() - 1, wrap); }, /** * Returns how many slides fit into a page * @method getSlidesPerPage * @return {Number} The number of slides per page * @public */ getSlidesPerPage: function() { return this._slidesPerPage; }, /** * Get the amount of pages in the carousel. * @method getTotalPages * @return {Number} The number of pages * @public */ getTotalPages: function() { return this._numPages; }, /** * Get the stage element (your UL with the class ".stage"). * @method getStageElm * @public * @return {DOMElement} Stage element **/ getStageElm: function() { return this._ulEl; }, /** * Get a list of your slides (elements with the ".slide" class inside your stage) * @method getSlidesList * @return {DOMElement[]} Array containing the slides. * @public */ getSlidesList: function() { return this._liEls; }, /** * Get the total number of slides * @method getTotalSlides * @return {Number} The number of slides * @public */ getTotalSlides: function() { return this.getSlidesList().length; } }; function setTransitionProperty(el, newTransition) { el.style.transitionProperty = el.style.oTransitionProperty = el.style.msTransitionProperty = el.style.mozTransitionProperty = el.style.webkitTransitionProperty = newTransition; } Common.createUIComponent(Carousel); return Carousel; });