Source: constructs/ui/scrllbr.js

/*!
 * VERSION: 0.0.1
 * DATE: 2019-10-28
 * UPDATES AND DOCS AT: https://chris-moody.github.io/mkr
 *
 * @license copyright 2019 Christopher C. Moody
 *
 *  Permission is hereby granted, free of charge, to any person obtaining a copy of
 *  this software and associated documentation files (the "Software"), to deal in the
 *  Software without restriction, including without limitation the rights to use, copy,
 *  modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
 *  and to permit persons to whom the Software is furnished to do so, subject to the
 *  following conditions:
 *
 *  The above copyright notice and this permission notice shall be included in all
 *  copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
 *  INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
 *  PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 *  HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
 *  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
 *  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 * 
 * @author: Christopher C. Moody, chris@moodydigital.com
 */
 
(function(global, className) {
    var _instances = {}, _count=-1;

    /**
     * @class scrllbr
     * @classdesc Cross-browser custom scrollbar
     * @description Initializes a new scrllbr instance.
     * @param {Object} options - Options used to customize the scrllbr
     * @param {*} [options.parent=document.body] - Element which the scrllbr's contianer element is appended
     * @param {String=} options.id - The id of the instance. Auto-generated when not provided
     * @param {String} [options.type=scrllbr.VERTICAL] - The type of scrllbr to create. Can be scrllbr.VERTICAL, scrllbr.HORIZONTAL or scrllbr.OMNI.
     
     * @param {Object=} options.attr - Attributes to apply to the scrllbr's container element.
     * @param {Object=} options.css - CSS Properties to apply to the scrllbr's container element

     * @param {Object=} options.content - Options used to create the scrllbr's content element
     * @param {String} [options.content.attr.class='scrllbr-content']

     * @param {Object=} options.track - Options used to create the scrollbar track
     * @param {String} [options.track.attr.class='scrllbr-track']

     * @param {Object=} options.thumb - Options used to create scrollbar thumb
     * @param {String} [options.thumb.attr.class='scrllbr-thumb']

     * @requires {@link  scrllbr}
     * @returns {msk} A new scrllbr instance.
    **/
    var scrllbr = function(options) {
        options = options || {};
        _count++;
        var id = this._id = options.id || className+'-'+_count;
        this._parent = mkr.getDefault(options, 'parent', document.body);

        mkr.setDefault(options, 'attr', {});
        mkr.setDefault(options.attr, 'id', id);
        mkr.setDefault(options.attr, 'class', 'mkr-'+className);
        mkr.setDefault(options, 'css', {});

        mkr.setDefault(options, 'track', {});
        mkr.setDefault(options.track, 'attr', {});
        mkr.setDefault(options.track.attr, 'class', 'track');
        mkr.setDefault(options.track, 'css', {});

        mkr.setDefault(options, 'thumb', {});
        mkr.setDefault(options.thumb, 'attr', {});
        mkr.setDefault(options.thumb.attr, 'class', 'thumb');
        mkr.setDefault(options.thumb, 'css', {});
        mkr.setDefault(options.thumb.css, 'height', 100);
        
        this._container = mkr.create('div', {attr:options.attr, css:options.css}, this._parent);
        this._track = mkr.create('div', options.track, this._container);
        this._thumb = mkr.create('div', options.thumb, this._container);
        this._ux = mkr.setDefault(options, 'ux', 'click');
        this._observer = new MutationObserver(this.onMutate.bind(this));
        this._scrolling = false;
        this._scroll = 0;
        this._range = 0;
        this._touchStart = {x:0, y:0};

        this.type = mkr.setDefault(options, 'type', scrllbr.VERTICAL);
        this.target =  mkr.setDefault(options, 'target', null);

        _instances[id] = this;
    };
    
    scrllbr.prototype = {

        /**
         * @name scrllbr#el
         * @public
         * @readonly
         * @type Element
         * @description The container element associated with this instance
        **/
        get el() {return this._container;},

        /**
         * @name scrllbr#range
         * @public
         * @readonly
         * @type Number
         * @description The range of the scrllr thumb in pixels
        **/
        get range() { return this.type === scrllbr.VERTICAL ?
            this._track.clientHeight - this._thumb.clientHeight :
            this._track.clientWidth - this._thumb.clientWidth;
         },
        
        /**
         * @name scrllbr#scroll
         * @public
         * @type Number
         * @description The current scroll position as a number between 0 and 1
        **/
        get scroll() { return this._scroll; },
        set scroll(value) {
            this._scroll = Math.min(1, Math.max(0, value));
            if(this.type === scrllbr.VERTICAL) TweenMax.set(this._thumb, {y:this._scroll*this.range});
            else TweenMax.set(this._thumb, {x:this._scroll*this.range});
        },

        /**
         * @name scrllbr#target
         * @public
         * @type Element
         * @description The element to which the scrllbr is attached
        **/
        get target() {return this._target;},
        set target(value) {
            if(this._target) this.removeListeners();

            this._target = value;
            if(!this._target) return;
            this.addListeners();
            mkr.once(window, 'load', this.refresh, this);
        },

        /**
         * @name scrllbr#type
         * @public
         * @readonly
         * @type String
         * @description Indicates the orientation of the scrllbr instance
        **/
        get type() {return this._type;},
        set type(value) {
            this._type = value;
            if(this._type === scrllbr.VERTICAL) TweenMax.set(this._container, {className:'-=horizontal'});
            else TweenMax.set(this._container, {className:'+=horizontal'});
        },

        /**
         * @function addListeners
         * @memberof scrllbr.prototype
         * @public
         * @description Attach scroll listeners to the target
        **/
        addListeners: function() {
            //this._observer.observe(this._target, {childList:true, subtree:true});
            mkr.on(this._target, 'scroll', this.onScroll, this);

            if(this._ux.indexOf('touch') < 0) {
                mkr.on(this._thumb, 'mousedown', this.startScroll, this);
                mkr.on(this._track, 'mousedown', this.trackScroll, this);
                return;
            }

            mkr.on(this._thumb, 'touchstart', this.startScroll, this);
            mkr.on(this._track, 'touchstart', this.trackScroll, this);
        },

        /**
         * @function removeListeners
         * @memberof scrllbr.prototype
         * @public
         * @description Remove scroll listeners to the target
        **/
        removeListeners: function() {
            this._scrolling = false;
            //this._observer.disconnect();
            mkr.off(this._target, 'scroll', this.onScroll, this);

            if(this._ux.indexOf('touch') < 0) {
                mkr.off(this._thumb, 'mousedown', this.startScroll, this);
                mkr.off(this._track, 'mousedown', this.trackScroll, this);
                mkr.off(document.body, 'mouseup', this.stopScroll, this);
                mkr.off(document.body, 'mouseleave', this.stopScroll, this);
                mkr.off(document.body, 'mousemove', this.updateScroll, this);
                return;
            }

            mkr.off(this._thumb, 'touchstart', this.startScroll, this);
            mkr.off(this._track, 'touchstart', this.trackScroll, this);
            mkr.off(document.body, 'touchend', this.stopScroll, this);
            mkr.off(document.body, 'touchcancel', this.stopScroll, this);
            mkr.off(document.body, 'touchmove', this.updateScroll, this);
        },

        /**
         * @function trackScroll
         * @memberof scrllbr.prototype
         * @public
         * @description Handle track interactions
        **/
        trackScroll: function(e) {
            var delta, dir, sLen, eX, eY, target = this._target,
                x=0, y=0;
            var t = this._container;
            while(t.offsetParent) {
                x += t.offsetLeft;
                y += t.offsetTop;
                t = t.offsetParent;
            }


            if(e.type === 'touchstart') {
                var touch = e.touches[0];
                eX = touch.clientX;
                eY = touch.clientY;
            }
            else {
                eX = e.clientX;
                eY = e.clientY;
            }

            if(this.type === scrllbr.VERTICAL) {
                delta = (eY-y)/this.range - this.scroll;
                dir = Math.abs(delta)/delta;
                sLen = target.scrollHeight - target.clientHeight;
                delta = dir*0.95*(target.clientHeight/sLen);
            }
            else {
                delta = (eX-x)/this.range - this.scroll;
                dir = Math.abs(delta)/delta;
                sLen = target.scrollWidth - target.clientWidth;
                delta = dir*0.95*(target.clientWidth/sLen);
            }

            this.scroll = this._scroll + delta;
            TweenMax.set(this._target, {scrollTo:(this.scroll)*sLen});

            this.startScroll(e);
        },

        /**
         * @function startScroll
         * @memberof scrllbr.prototype
         * @public
         * @description Add listeners to enable manual scrolling
        **/
        startScroll: function(e) {
            this._scrolling = true;
            //console.log('start scroll');
            if(e.type === 'touchstart') {
                var touch = e.touches[0];
                this._touchStart = { 
                    x:touch.clientX,
                    y:touch.clientY
                };
            }

            if(this._ux.indexOf('touch') < 0) {
                mkr.on(document.body, 'mouseup', this.stopScroll, this);
                mkr.on(document.body, 'mouseleave', this.stopScroll, this);
                mkr.on(document.body, 'mousemove', this.updateScroll, this);
                return;
            }

            mkr.on(document.body, 'touchend', this.stopScroll, this);
            mkr.on(document.body, 'touchcancel', this.stopScroll, this);
            mkr.on(document.body, 'touchmove', this.updateScroll, this);
        },

        /**
         * @function stopScroll
         * @memberof scrllbr.prototype
         * @public
         * @description Remove manuak scroll listeners
        **/
        stopScroll: function(e) {
            this._scrolling = false;
            this._touchStart = {x:0, y:0};
            //console.log('stop scroll');
            if(this._ux.indexOf('touch') < 0) {
                mkr.off(document.body, 'mouseup', this.stopScroll, this);
                mkr.off(document.body, 'mouseleave', this.stopScroll, this);
                mkr.off(document.body, 'mousemove', this.updateScroll, this);
                return;
            }

            mkr.off(document.body, 'touchend', this.stopScroll, this);
            mkr.off(document.body, 'touchcancel', this.stopScroll, this);
            mkr.off(document.body, 'touchmove', this.updateScroll, this);
        },

        /**
         * @function updateScroll
         * @memberof scrllbr.prototype
         * @public
         * @description Update tha target scroll position based on user action
        **/
        updateScroll: function(e) {
            var sLen, sPos, delta, touch, target = this._target;
            if(e.type == 'touchmove') {
                touch = e.changedTouches[0];
            }
            if(this.type === scrllbr.VERTICAL) {
                sLen = target.scrollHeight - target.clientHeight;
                if(e.type == 'touchmove') {
                    delta = (touch.clientY-this._touchStart.y)/this.range;
                    this._touchStart.y = touch.clientY;
                }
                else delta = e.movementY/this.range;
            }
            else {
                sLen = target.scrollWidth - target.clientWidth;
                if(e.type == 'touchmove') {
                    delta = (touch.clientX-this._touchStart.x)/this.range;
                    this._touchStart.x = touch.clientX;
                }
                else delta = movementX/this.range;
            }

            this.scroll = this._scroll + delta;
            TweenMax.set(this._target, {scrollTo:(this.scroll+delta)*sLen});
        },

        /**
         * @function onScroll
         * @memberof scrllbr.prototype
         * @public
         * @description Updates thumb position when the target is scrolled
        **/
        onScroll: function(e) {
            if(this._scrolling) return;
            var sLength, sPos, scrollLen, target = this._target;
            if(!target) return;
            if(this.type === scrllbr.VERTICAL) {
                sLength = target.scrollHeight - target.clientHeight;
                sPos = target.scrollTop/sLength;
            }
            else {
                sLength = target.scrollWidth - target.clientWidth;
                sPos = target.scrollLeft/sLength;
            }

             this.scroll = sPos;
        },

        /**
         * @function onMutate
         * @memberof scrllbr.prototype
         * @public
         * @description Updates thumb when the target is mutated
        **/
        onMutate: function(mutationsList, observer) {
            mutationsList.forEach(function(mutation) {
                if (mutation.type === 'childList') {
                    //console.log('A child node has been added or removed.');
                    this.refresh();
                }
            });
            //mkr.on(target, 'scroll', this.onScroll, this);
        },

        /**
         * @function refresh
         * @memberof scrllbr.prototype
         * @private
         * @description Updates the thumb position/size based on the target.
        **/
        refresh: function() {
            var sLength, sPos, sRatio, scrollLen, target = this._target;
            if(!target) return;
            if(this.type === scrllbr.VERTICAL) {
                sLength = target.scrollHeight - target.clientHeight;
                sRatio = target.clientHeight/target.scrollHeight;
                sPos = target.scrollTop/sLength;

                TweenMax.set(this._thumb, {height:sRatio*this._track.clientHeight});
            }
            else {
                sLength = target.scrollWidth - target.clientWidth;
                sRatio = target.clientWidth/target.scrollWidth;
                sPos = target.scrollLeft/sLength;

                TweenMax.set(this._thumb, {width:sRatio*this._track.clientWidth});
            }

            this.scroll = sPos;
        },
    };

    /**
    * @alias scrllbr.VERTICAL
    * @memberof scrllbr
    * @static
    * @readonly
    * @type String
    * @description returns 'vertical'
    **/
    Object.defineProperty(scrllbr, 'VERTICAL', {
        get: function() {
          return 'vertical';
        }
    });

    /**
    * @alias scrllbr.HORIZONTAL
    * @memberof scrllbr
    * @static
    * @readonly
    * @type String
    * @description returns 'horizontal'
    **/
    Object.defineProperty(scrllbr, 'HORIZONTAL', {
        get: function() {
          return 'horizontal';
        }
    });

    /**
    * @function getInstance
    * @memberof scrllbr
    * @static
    * @description returns the scrllbr instance associated with the id
    * @param {String} id - The lookup id
    * @returns {scrllbr} The associate scrllbr, if it exists
    **/
    scrllbr.getInstance = function(id) {
        return _instances[id];
    };

    /**
    * @function getElInstance
    * @memberof scrllbr
    * @static
    * @description returns the scrllbr instance associated with the provided element's id attribute
    * @param {Element} el - The element with the lookup id
    * @returns {scrllbr} The associate scrllbr, if it exists
    **/    
    scrllbr.getElInstance = function(el) {
        return _instances[el.id];
    };

    /**
    * @alias scrllbr.VERSION
    * @memberof scrllbr
    * @static
    * @readonly
    * @type String
    * @description returns scrllbr's version number
    **/
    Object.defineProperty(scrllbr, 'VERSION', {
        get: function() {
          return '0.0.1';
        }
    });

    if(typeof define === 'function' && define.amd){ //AMD
        define(function () { return scrllbr; });
    } else if (typeof module !== 'undefined' && module.exports){ //node
        module.exports = scrllbr;
    } else { //browser
        global[className] = scrllbr;
    }
})(mkr._constructs, 'scrllbr');