├── .gitignore ├── src ├── intro.js ├── outro.js ├── jquery.plugin.js ├── events.js ├── utils.js ├── globals.js ├── scrollbar.js └── optiscroll.js ├── .editorconfig ├── bower.json ├── LICENSE ├── package.json ├── index.d.ts ├── dist ├── optiscroll.css ├── optiscroll.min.js ├── jquery.optiscroll.min.js ├── optiscroll.js └── jquery.optiscroll.js ├── test ├── index.html ├── test.base.js ├── test.api.js ├── test.events.js ├── test.scrollbars.js └── resources │ ├── qunit.css │ └── qunit.js ├── .eslintrc ├── scss └── optiscroll.scss ├── Gruntfile.js ├── index.html └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /.idea/ 3 | -------------------------------------------------------------------------------- /src/intro.js: -------------------------------------------------------------------------------- 1 | ;(function ( window, document, Math, undefined ) { 2 | 'use strict'; 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{package.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/outro.js: -------------------------------------------------------------------------------- 1 | // AMD export 2 | if(typeof define == 'function' && define.amd) { 3 | define(function(){ 4 | return Optiscroll; 5 | }); 6 | } 7 | 8 | // commonjs export 9 | if(typeof module !== 'undefined' && module.exports) { 10 | module.exports = Optiscroll; 11 | } 12 | 13 | window.Optiscroll = Optiscroll; 14 | 15 | })(window, document, Math); 16 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optiscroll", 3 | "version": "3.2.1", 4 | "homepage": "https://github.com/albertogasparin/Optiscroll", 5 | "authors": [ 6 | "Alberto Gasparin " 7 | ], 8 | "description": "Custom scrollbars for modern webapps", 9 | "main": [ 10 | "dis/optiscroll.css", 11 | "dist/optiscroll.js" 12 | ], 13 | "moduleType": [ 14 | "amd", 15 | "globals" 16 | ], 17 | "keywords": [ 18 | "scrollbar", "scroll", "scrollTo", "scrollIntoView" 19 | ], 20 | "license": "MIT", 21 | "ignore": [ 22 | "**/.*", 23 | "node_modules", 24 | "bower_components", 25 | "test", 26 | "tests", 27 | "Gruntfile.js", 28 | "demo" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Alberto Gasparin 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/jquery.plugin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * jQuery plugin 3 | * create instance of Optiscroll 4 | * and when called again you can call functions 5 | * or change instance settings 6 | * 7 | * ``` 8 | * $(el).optiscroll({ options }) 9 | * $(el).optiscroll('method', arg) 10 | * ``` 11 | */ 12 | 13 | (function ($) { 14 | 15 | var pluginName = 'optiscroll'; 16 | 17 | $.fn[pluginName] = function(options) { 18 | var method, args; 19 | 20 | if(typeof options === 'string') { 21 | args = Array.prototype.slice.call(arguments); 22 | method = args.shift(); 23 | } 24 | 25 | return this.each(function() { 26 | var $el = $(this); 27 | var inst = $el.data(pluginName); 28 | 29 | // start new optiscroll instance 30 | if(!inst) { 31 | inst = new window.Optiscroll(this, options || {}); 32 | $el.data(pluginName, inst); 33 | } 34 | // allow exec method on instance 35 | else if(inst && typeof method === 'string') { 36 | inst[method].apply(inst, args); 37 | if(method === 'destroy') { 38 | $el.removeData(pluginName); 39 | } 40 | } 41 | }); 42 | }; 43 | 44 | }(jQuery || Zepto)); 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optiscroll", 3 | "author": "Alberto Gasparin", 4 | "description": "Custom scrollbars for modern webapps", 5 | "keywords": [ 6 | "scrollbars", 7 | "user interface" 8 | ], 9 | "version": "3.2.1", 10 | "homepage": "https://github.com/albertogasparin/optiscroll", 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/albertogasparin/optiscroll.git" 14 | }, 15 | "scripts": { 16 | "prepublish": "grunt build", 17 | "test": "grunt test" 18 | }, 19 | "devDependencies": { 20 | "eslint": "^3.0.0", 21 | "grunt": "^1.0.0", 22 | "grunt-autoprefixer": "^3.0.3", 23 | "grunt-bump": "^0.8.0", 24 | "grunt-contrib-concat": "^1.0.0", 25 | "grunt-contrib-connect": "^1.0.0", 26 | "grunt-contrib-jshint": "^1.0.0", 27 | "grunt-contrib-uglify": "^3.0.0", 28 | "grunt-contrib-watch": "^1.0.0", 29 | "grunt-sass": "^2.0.0", 30 | "load-grunt-tasks": "^3.0.0", 31 | "uglify-js": "^3.0.0" 32 | }, 33 | "browser": "dist/optiscroll.js", 34 | "main": "dist/optiscroll.js", 35 | "typings": "./index.d.ts", 36 | "licence": "MIT", 37 | "bugs": { 38 | "url": "http://github.com/albertogasparin/optiscroll/issues" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare function optiscroll(element: Element, options: optiscroll.OptiscrollOptions): optiscroll.Instance; 2 | 3 | declare namespace optiscroll { 4 | 5 | class Instance { 6 | constructor(element: Element, options: OptiscrollOptions) 7 | 8 | init(): void; 9 | 10 | bind(): void; 11 | 12 | update(): void; 13 | 14 | scrollTo(destX: number, destY: number, duration: number): void; 15 | 16 | animateScroll(startX: number, endX: number, startY: number, endY: number, duration: number): void; 17 | 18 | scrollIntoView(elem: Element, duration: number, delta: number): void; 19 | 20 | destroy(): void; 21 | 22 | fireCustomEvent(eventName: string): void; 23 | } 24 | 25 | interface OptiscrollOptions { 26 | preventParentScroll?: boolean; 27 | forceScrollbars?: boolean; 28 | scrollStopDelay?: number; 29 | maxTrackSize?: number; 30 | minTrackSize?: number; 31 | draggableTracks?: boolean; 32 | autoUpdate?: boolean; 33 | classPrefix?: string; 34 | wrapContent?: boolean; 35 | rtl?: boolean; 36 | } 37 | 38 | interface GlobalSettings { 39 | scrollMinUpdateInterval: number; 40 | checkFrequency: number; 41 | pauseCheck: boolean; 42 | } 43 | 44 | const defaults: OptiscrollOptions; 45 | 46 | const globalSettings: GlobalSettings; 47 | } 48 | 49 | export = optiscroll; 50 | -------------------------------------------------------------------------------- /dist/optiscroll.css: -------------------------------------------------------------------------------- 1 | .optiscroll{position:relative;overflow:auto;-webkit-overflow-scrolling:touch}.optiscroll.is-enabled{overflow:hidden}.optiscroll.is-enabled>.optiscroll-content{position:absolute;top:0;left:0;right:0;bottom:0;z-index:1;overflow:scroll;-webkit-overflow-scrolling:touch}.optiscroll-v,.optiscroll-h{position:absolute;visibility:hidden;z-index:2;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.optiscroll-v{right:0}.optiscroll-h{bottom:0}.optiscroll.has-vtrack>.optiscroll-v,.optiscroll.has-htrack>.optiscroll-h{visibility:visible}.optiscroll.is-rtl>.optiscroll-v{left:0;right:auto}.optiscroll-vtrack,.optiscroll-htrack{display:block;position:absolute;opacity:1;-ms-transform:translate(0%, 0%);transform:translate(0%, 0%);transition:height 0.2s ease 0s, width 0.2s ease 0s, opacity 0.2s ease 0s;box-sizing:border-box}.optiscroll-v{top:4px;bottom:4px;width:0}.optiscroll-h{left:4px;right:4px;height:0}.optiscroll.has-vtrack.has-htrack>.optiscroll-v{bottom:8px}.optiscroll.has-vtrack.has-htrack>.optiscroll-h{right:8px}.optiscroll-vtrack,.optiscroll-htrack{background:rgba(0,0,0,0.3);border-radius:2px;box-shadow:0 0 1px #FFF;opacity:0}.optiscroll-vtrack{width:3px;right:4px}.optiscroll-htrack{height:3px;bottom:4px}.optiscroll:hover>.optiscroll-v .optiscroll-vtrack,.optiscroll:hover>.optiscroll-h .optiscroll-htrack{opacity:1}.optiscroll.has-vtrack.has-htrack.is-rtl>.optiscroll-h{right:4px;left:8px}.optiscroll.is-rtl>.optiscroll-v .optiscroll-vtrack{right:auto;left:4px} 2 | -------------------------------------------------------------------------------- /src/events.js: -------------------------------------------------------------------------------- 1 | var Events = { 2 | 3 | scroll: function (ev) { 4 | 5 | if (!G.pauseCheck) { 6 | this.fireCustomEvent('scrollstart'); 7 | } 8 | G.pauseCheck = true; 9 | 10 | this.scrollbars.v.update(); 11 | this.scrollbars.h.update(); 12 | 13 | this.fireCustomEvent('scroll'); 14 | 15 | clearTimeout(this.cache.timerStop); 16 | this.cache.timerStop = setTimeout(Events.scrollStop.bind(this), this.settings.scrollStopDelay); 17 | }, 18 | 19 | 20 | touchstart: function (ev) { 21 | G.pauseCheck = false; 22 | this.scrollbars.v.update(); 23 | this.scrollbars.h.update(); 24 | 25 | Events.wheel.call(this, ev); 26 | }, 27 | 28 | 29 | touchend: function (ev) { 30 | // prevents touchmove generate scroll event to call 31 | // scrollstop while the page is still momentum scrolling 32 | clearTimeout(this.cache.timerStop); 33 | }, 34 | 35 | 36 | scrollStop: function () { 37 | this.fireCustomEvent('scrollstop'); 38 | G.pauseCheck = false; 39 | }, 40 | 41 | 42 | wheel: function (ev) { 43 | var cache = this.cache, 44 | cacheV = cache.v, 45 | cacheH = cache.h, 46 | preventScroll = this.settings.preventParentScroll && G.isTouch; 47 | 48 | window.cancelAnimationFrame(this.scrollAnimation); 49 | 50 | if(preventScroll && cacheV.enabled && cacheV.percent % 100 === 0) { 51 | this.scrollEl.scrollTop = cacheV.percent ? (cache.scrollH - cache.clientH - 1) : 1; 52 | } 53 | if(preventScroll && cacheH.enabled && cacheH.percent % 100 === 0) { 54 | this.scrollEl.scrollLeft = cacheH.percent ? (cache.scrollW - cache.clientW - 1) : 1; 55 | } 56 | }, 57 | 58 | 59 | }; 60 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Optiscroll Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 45 | 46 | 47 |
48 | 49 |
50 | 51 |
52 |
53 | 54 | 55 |
56 | 57 |
58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /test/test.base.js: -------------------------------------------------------------------------------- 1 | /* eslint-env qunit */ 2 | /* globals os:true */ 3 | 4 | module('Basics', { 5 | setup: function() { 6 | Optiscroll.globalSettings.checkFrequency = 300; 7 | os = new window.Optiscroll(document.querySelector('#os')); 8 | }, teardown: function() { 9 | os.destroy(); 10 | Optiscroll.G.instances.length = 0; 11 | os = null; 12 | }, 13 | }); 14 | 15 | 16 | test('It should be initialized', function () { 17 | equal(typeof os, 'object'); 18 | // check DOM elements 19 | equal(os.element.childNodes.length, 3); 20 | equal(os.scrollEl.childNodes.length, 7); 21 | // check globals 22 | equal(Optiscroll.G.instances.length, 1); 23 | ok(Optiscroll.G.checkTimer); 24 | }); 25 | 26 | 27 | asyncTest('Optiscroll should be destroyed', function () { 28 | expect(5); 29 | os.destroy(); 30 | 31 | setTimeout(function () { 32 | // check DOM elements style 33 | ok(!os.scrollEl); 34 | equal(os.element.childNodes.length, 7); 35 | equal(os.element.className.indexOf('is-enabled'), -1); 36 | // check globals 37 | equal(Optiscroll.G.instances.length, 0); 38 | equal(Optiscroll.G.checkTimer, null); 39 | start(); 40 | }, 1000); 41 | }); 42 | 43 | 44 | asyncTest('Optiscroll should auto update itself', function () { 45 | expect(4); 46 | os.element.style.width = '300px'; 47 | os.element.style.height = '300px'; 48 | 49 | setTimeout(function () { 50 | equal(os.cache.clientW, 300); 51 | equal(os.cache.clientH, 300); 52 | equal(os.cache.scrollW, 300); 53 | equal(os.cache.scrollH, 300); 54 | 55 | start(); 56 | }, 700); 57 | }); 58 | 59 | 60 | asyncTest('Optiscroll should auto destroy itself', function () { 61 | expect(2); 62 | os.element.parentNode.removeChild(os.element); 63 | 64 | setTimeout(function () { 65 | // check globals 66 | equal(Optiscroll.G.instances.length, 0); 67 | equal(Optiscroll.G.checkTimer, null); 68 | start(); 69 | }, 1000); 70 | }); 71 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | var Utils = { 2 | 3 | hideNativeScrollbars: function (scrollEl, isRtl) { 4 | var size = G.scrollbarSpec.width, 5 | scrollElStyle = scrollEl.style; 6 | if(size === 0) { 7 | // hide Webkit/touch scrollbars 8 | var time = Date.now(); 9 | scrollEl.setAttribute('data-scroll', time); 10 | return Utils.addCssRule('[data-scroll="' + time + '"]::-webkit-scrollbar', 'display:none;width:0;height:0;'); 11 | } else { 12 | scrollElStyle[isRtl ? 'left' : 'right'] = -size + 'px'; 13 | scrollElStyle.bottom = -size + 'px'; 14 | return true; 15 | } 16 | }, 17 | 18 | 19 | addCssRule: function (selector, rules) { 20 | var styleSheet = document.getElementById('scroll-sheet'); 21 | if(!styleSheet) { 22 | styleSheet = document.createElement('style'); 23 | styleSheet.id = 'scroll-sheet'; 24 | styleSheet.appendChild(document.createTextNode('')); // WebKit hack 25 | document.head.appendChild(styleSheet); 26 | } 27 | try { 28 | styleSheet.sheet.insertRule(selector + ' {' + rules + '}', 0); 29 | return true; 30 | } catch (e) { return; } 31 | }, 32 | 33 | 34 | createWrapper: function (element, className) { 35 | var wrapper = document.createElement('div'), 36 | child; 37 | while(child = element.childNodes[0]) { 38 | wrapper.appendChild(child); 39 | } 40 | return element.appendChild(wrapper); 41 | }, 42 | 43 | 44 | // Global height checker 45 | // looped to listen element changes 46 | checkLoop: function () { 47 | 48 | if(!G.instances.length) { 49 | G.checkTimer = null; 50 | return; 51 | } 52 | 53 | if(!G.pauseCheck) { // check size only if not scrolling 54 | _invoke(G.instances, 'update'); 55 | } 56 | 57 | if(GS.checkFrequency) { 58 | G.checkTimer = setTimeout(function () { 59 | Utils.checkLoop(); 60 | }, GS.checkFrequency); 61 | } 62 | }, 63 | 64 | 65 | // easeOutCubic function 66 | easingFunction: function (t) { 67 | return (--t) * t * t + 1; 68 | }, 69 | 70 | 71 | }; 72 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | rules: 2 | # 3 | # Coding helpers rules 4 | # 5 | strict: 6 | - 2 7 | - never 8 | max-depth: 9 | - 1 10 | - 5 11 | max-nested-callbacks: 12 | - 1 13 | - 4 14 | # 15 | # Avoid JS bad parts 16 | # 17 | no-use-before-define: 18 | - 1 19 | - nofunc 20 | no-unused-vars: 21 | - 1 22 | - 23 | vars: all 24 | args: none 25 | no-console: 26 | - 1 27 | no-alert: 28 | - 1 29 | no-debugger: 30 | - 1 31 | no-dupe-args: 32 | - 2 33 | no-dupe-keys: 34 | - 2 35 | no-duplicate-case: 36 | - 2 37 | no-func-assign: 38 | - 2 39 | no-inner-declarations: 40 | - 2 41 | no-unreachable: 42 | - 2 43 | no-redeclare: 44 | - 2 45 | no-shadow: 46 | - 2 47 | no-undef: 48 | - 2 49 | no-undefined: 50 | - 0 51 | valid-typeof: 52 | - 2 53 | no-loop-func: 54 | - 1 55 | # 56 | # Punctation rules 57 | # 58 | semi: 59 | - 2 60 | - always 61 | comma-dangle: 62 | - 1 63 | - always-multiline 64 | quotes: 65 | - 1 66 | - single 67 | linebreak-style: 68 | - 2 69 | - unix 70 | no-extra-semi: 71 | - 2 72 | curly: 73 | - 2 74 | # 75 | # Spacing rules 76 | # 77 | indent: 78 | - 1 79 | - 2 80 | - 81 | SwitchCase: 1 82 | VariableDeclarator: 83 | var: 2 84 | let: 2 85 | const: 3 86 | no-irregular-whitespace: 87 | - 2 88 | no-multi-spaces: 89 | - 1 90 | key-spacing: 91 | - 1 92 | - 93 | beforeColon: false 94 | afterColon: true 95 | space-infix-ops: 96 | - 1 97 | object-curly-spacing: 98 | - 1 99 | - always 100 | - 101 | objectsInObjects: true 102 | arraysInObjects: true 103 | space-in-parens: 104 | - 1 105 | - never 106 | eol-last: 107 | - 1 108 | 109 | 110 | env: 111 | es6: false 112 | node: false 113 | browser: true 114 | 115 | globals: 116 | Optiscroll: true 117 | G: true 118 | GS: true 119 | toggleClass: true 120 | _extend: true 121 | _invoke: true 122 | _throttle: true 123 | Scrollbar: true 124 | Utils: true 125 | Events: true 126 | jQuery: true 127 | Zepto: true 128 | -------------------------------------------------------------------------------- /test/test.api.js: -------------------------------------------------------------------------------- 1 | /* eslint-env qunit */ 2 | /* globals os:true */ 3 | 4 | module('Public APIs', { 5 | setup: function() { 6 | os = new window.Optiscroll(document.querySelector('#os'), { autoUpdate: false }); 7 | }, teardown: function() { 8 | os.destroy(); 9 | os = null; 10 | }, 11 | }); 12 | 13 | 14 | asyncTest('It should scrollTo(value, value, 0)', function () { 15 | expect(2); 16 | 17 | os.scrollEl.scrollLeft = 0; 18 | os.scrollEl.scrollTop = 0; 19 | os.scrollTo(50, 100, 0); 20 | 21 | setTimeout(function() { 22 | equal(os.scrollEl.scrollLeft, 50); 23 | equal(os.scrollEl.scrollTop, 100); 24 | start(); 25 | }, 50); 26 | }); 27 | 28 | 29 | asyncTest('It should scrollTo(edgeName, false)', function() { 30 | expect(2); 31 | 32 | os.scrollEl.scrollLeft = 0; 33 | os.scrollEl.scrollTop = 100; 34 | os.scrollTo('right', false); 35 | 36 | setTimeout(function() { 37 | equal(os.scrollEl.scrollLeft, 100); 38 | equal(os.scrollEl.scrollTop, 100); 39 | start(); 40 | }, 300); 41 | }); 42 | 43 | 44 | asyncTest('It should scrollTo(false, value, time)', function() { 45 | expect(2); 46 | 47 | os.scrollEl.scrollLeft = 50; 48 | os.scrollEl.scrollTop = 50; 49 | os.scrollTo(false, 0, 500); 50 | 51 | setTimeout(function() { 52 | equal(os.scrollEl.scrollLeft, 50); 53 | equal(os.scrollEl.scrollTop, 0); 54 | start(); 55 | }, 550); 56 | }); 57 | 58 | 59 | asyncTest('It should scrollIntoView(selector) from top/left', function () { 60 | expect(2); 61 | 62 | os.scrollEl.scrollLeft = 0; 63 | os.scrollEl.scrollTop = 0; 64 | os.scrollIntoView('.test-child'); 65 | 66 | setTimeout(function() { 67 | equal(os.scrollEl.scrollLeft, 10); 68 | equal(os.scrollEl.scrollTop, 10); 69 | start(); 70 | }, 100); 71 | }); 72 | 73 | 74 | asyncTest('It should scrollIntoView(node, time) from bottom/right', function () { 75 | expect(2); 76 | 77 | os.scrollEl.scrollLeft = 100; 78 | os.scrollEl.scrollTop = 100; 79 | os.scrollIntoView(os.element.querySelector('.test-child'), 100); 80 | 81 | setTimeout(function() { 82 | equal(os.scrollEl.scrollLeft, 90); 83 | equal(os.scrollEl.scrollTop, 90); 84 | start(); 85 | }, 150); 86 | }); 87 | 88 | 89 | asyncTest('It should scrollIntoView(selector, time, delta)', function () { 90 | expect(2); 91 | 92 | os.scrollEl.scrollLeft = 0; 93 | os.scrollEl.scrollTop = 0; 94 | os.scrollIntoView('.test-child', 100, 10); 95 | 96 | setTimeout(function() { 97 | equal(os.scrollEl.scrollLeft, 20); 98 | equal(os.scrollEl.scrollTop, 20); 99 | start(); 100 | }, 150); 101 | }); 102 | -------------------------------------------------------------------------------- /src/globals.js: -------------------------------------------------------------------------------- 1 | 2 | // Global variables 3 | var G = Optiscroll.G = { 4 | isTouch: 'ontouchstart' in window, 5 | cssTransition: cssTest('transition'), 6 | cssTransform: cssTest('transform'), 7 | scrollbarSpec: getScrollbarSpec(), 8 | passiveEvent: getPassiveSupport(), 9 | 10 | instances: [], 11 | checkTimer: null, 12 | pauseCheck: false, 13 | }; 14 | 15 | 16 | // Get scrollbars width, thanks Google Closure Library 17 | function getScrollbarSpec () { 18 | var htmlEl = document.documentElement, 19 | outerEl, innerEl, width = 0, rtl = 1; // IE is reverse 20 | 21 | outerEl = document.createElement('div'); 22 | outerEl.style.cssText = 'overflow:scroll;width:50px;height:50px;position:absolute;left:-100px;direction:rtl'; 23 | 24 | innerEl = document.createElement('div'); 25 | innerEl.style.cssText = 'width:100px;height:100px'; 26 | 27 | outerEl.appendChild(innerEl); 28 | htmlEl.appendChild(outerEl); 29 | width = outerEl.offsetWidth - outerEl.clientWidth; 30 | if (outerEl.scrollLeft > 0) { 31 | rtl = 0; // webkit is default 32 | } else { 33 | outerEl.scrollLeft = 1; 34 | if (outerEl.scrollLeft === 0) { 35 | rtl = -1; // firefox is negative 36 | } 37 | } 38 | htmlEl.removeChild(outerEl); 39 | 40 | return { width: width, rtl: rtl }; 41 | } 42 | 43 | 44 | function getPassiveSupport () { 45 | var passive = false; 46 | var options = Object.defineProperty({}, 'passive', { 47 | get: function () { passive = true; }, 48 | }); 49 | window.addEventListener('test', null, options); 50 | return passive ? { capture: false, passive: true } : false; 51 | } 52 | 53 | 54 | // Detect css3 support, thanks Modernizr 55 | function cssTest (prop) { 56 | var ucProp = prop.charAt(0).toUpperCase() + prop.slice(1), 57 | el = document.createElement('test'), 58 | props = [prop, 'Webkit' + ucProp]; 59 | 60 | for (var i in props) { 61 | if(el.style[props[i]] !== undefined) { return props[i]; } 62 | } 63 | return ''; 64 | } 65 | 66 | 67 | 68 | function toggleClass (el, value, bool) { 69 | var classes = el.className.split(/\s+/), 70 | index = classes.indexOf(value); 71 | 72 | if(bool) { 73 | ~index || classes.push(value); 74 | } else { 75 | ~index && classes.splice(index, 1); 76 | } 77 | 78 | el.className = classes.join(' '); 79 | } 80 | 81 | 82 | 83 | 84 | function _extend (dest, src, merge) { 85 | for(var key in src) { 86 | if(!src.hasOwnProperty(key) || dest[key] !== undefined && merge) { 87 | continue; 88 | } 89 | dest[key] = src[key]; 90 | } 91 | return dest; 92 | } 93 | 94 | 95 | function _invoke (collection, fn, args) { 96 | var i, j; 97 | if(collection.length) { 98 | for(i = 0, j = collection.length; i < j; i++) { 99 | collection[i][fn].apply(collection[i], args); 100 | } 101 | } else { 102 | for (i in collection) { 103 | collection[i][fn].apply(collection[i], args); 104 | } 105 | } 106 | } 107 | 108 | function _throttle(fn, threshhold) { 109 | var last, deferTimer; 110 | return function () { 111 | var context = this, 112 | now = Date.now(), 113 | args = arguments; 114 | if (last && now < last + threshhold) { 115 | // hold on to it 116 | clearTimeout(deferTimer); 117 | deferTimer = setTimeout(function () { 118 | last = now; 119 | fn.apply(context, args); 120 | }, threshhold); 121 | } else { 122 | last = now; 123 | fn.apply(context, args); 124 | } 125 | }; 126 | } 127 | 128 | -------------------------------------------------------------------------------- /test/test.events.js: -------------------------------------------------------------------------------- 1 | /* eslint-env qunit */ 2 | /* globals os:true */ 3 | 4 | module('Custom events', { 5 | setup: function() { 6 | os = new window.Optiscroll(document.querySelector('#os'), { autoUpdate: false }); 7 | }, teardown: function() { 8 | os.destroy(); 9 | os = null; 10 | }, 11 | }); 12 | 13 | 14 | asyncTest('It should fire scrollstart', function () { 15 | expect(4); 16 | 17 | os.scrollEl.scrollTop = 0; 18 | 19 | os.element.addEventListener('scrollstart', function (ev) { 20 | equal(ev.type, 'scrollstart'); 21 | equal(ev.detail.scrollTop, 0); 22 | equal(ev.detail.scrollBottom, 100); 23 | equal(ev.detail.scrollbarV.percent, 0); 24 | 25 | start(); 26 | }); 27 | 28 | setTimeout(function () { 29 | os.scrollEl.scrollTop = 20; 30 | }, 50); 31 | 32 | }); 33 | 34 | 35 | asyncTest('It should fire scrollstop', function () { 36 | expect(4); 37 | 38 | os.element.addEventListener('scrollstop', function (ev) { 39 | equal(ev.type, 'scrollstop'); 40 | equal(ev.detail.scrollTop, 50); 41 | equal(ev.detail.scrollBottom, 50); 42 | equal(ev.detail.scrollbarV.percent, 50); 43 | start(); 44 | }); 45 | 46 | os.scrollEl.scrollTop = 50; 47 | }); 48 | 49 | 50 | asyncTest('It should fire scrollreachtop', function () { 51 | expect(4); 52 | 53 | os.scrollEl.scrollTop = 50; 54 | os.update(); 55 | 56 | os.element.addEventListener('scrollreachtop', function (ev) { 57 | equal(ev.type, 'scrollreachtop'); 58 | equal(ev.detail.scrollTop, 0); 59 | equal(ev.detail.scrollBottom, 100); 60 | equal(ev.detail.scrollbarV.percent, 0); 61 | start(); 62 | }); 63 | 64 | setTimeout(function() { 65 | os.scrollEl.scrollTop = 0; 66 | }, 50); 67 | 68 | }); 69 | 70 | 71 | asyncTest('It should fire scrollreachbottom', function () { 72 | expect(4); 73 | 74 | os.scrollEl.scrollTop = 50; 75 | os.update(); 76 | 77 | os.element.addEventListener('scrollreachbottom', function (ev) { 78 | equal(ev.type, 'scrollreachbottom'); 79 | equal(ev.detail.scrollTop, 100); 80 | equal(ev.detail.scrollBottom, 0); 81 | equal(ev.detail.scrollbarV.percent, 100); 82 | start(); 83 | }); 84 | 85 | setTimeout(function() { 86 | os.scrollEl.scrollTop = 100; 87 | }, 50); 88 | 89 | }); 90 | 91 | 92 | asyncTest('It should fire scrollreachleft', function () { 93 | expect(4); 94 | 95 | os.scrollEl.scrollLeft = 50; 96 | os.update(); 97 | 98 | os.element.addEventListener('scrollreachleft', function (ev) { 99 | equal(ev.type, 'scrollreachleft'); 100 | equal(ev.detail.scrollLeft, 0); 101 | equal(ev.detail.scrollRight, 100); 102 | equal(ev.detail.scrollbarH.percent, 0); 103 | start(); 104 | }); 105 | 106 | setTimeout(function() { 107 | os.scrollEl.scrollLeft = 0; 108 | }, 50); 109 | 110 | }); 111 | 112 | 113 | asyncTest('It should fire scrollreachright', function () { 114 | expect(4); 115 | 116 | os.scrollEl.scrollLeft = 50; 117 | os.update(); 118 | 119 | os.element.addEventListener('scrollreachright', function (ev) { 120 | equal(ev.type, 'scrollreachright'); 121 | equal(ev.detail.scrollLeft, 100); 122 | equal(ev.detail.scrollRight, 0); 123 | equal(ev.detail.scrollbarH.percent, 100); 124 | start(); 125 | }); 126 | 127 | setTimeout(function() { 128 | os.scrollEl.scrollLeft = 100; 129 | }, 50); 130 | 131 | }); 132 | 133 | 134 | asyncTest('It should fire scrollreachedge', function () { 135 | expect(7); 136 | 137 | os.scrollEl.scrollLeft = 20; 138 | 139 | var listener = function (ev) { 140 | equal(ev.type, 'scrollreachedge'); 141 | equal(ev.detail.scrollTop, 100); 142 | equal(ev.detail.scrollBottom, 0); 143 | equal(ev.detail.scrollLeft, 20); 144 | equal(ev.detail.scrollRight, 80); 145 | equal(ev.detail.scrollbarV.percent, 100); 146 | equal(ev.detail.scrollbarH.percent, 20); 147 | os.element.removeEventListener('scrollreachedge', listener); 148 | start(); 149 | }; 150 | 151 | os.element.addEventListener('scrollreachedge', listener); 152 | 153 | setTimeout(function() { 154 | os.scrollEl.scrollTop = 100; 155 | }, 50); 156 | }); 157 | 158 | 159 | asyncTest('It should fire sizechange', function () { 160 | expect(2); 161 | 162 | os.element.addEventListener('sizechange', function (ev) { 163 | equal(ev.type, 'sizechange'); 164 | equal(ev.detail.clientHeight, 150); 165 | start(); 166 | }); 167 | 168 | os.element.style.height = '150px'; 169 | os.update(); 170 | }); 171 | -------------------------------------------------------------------------------- /scss/optiscroll.scss: -------------------------------------------------------------------------------- 1 | 2 | /* Set these values before importing optiscroll.scss 3 | * to override the defaults 4 | */ 5 | 6 | $optiscroll-namespace: 'optiscroll' !default; 7 | $optiscroll-classPrefix: $optiscroll-namespace + '-' !default; 8 | 9 | $optiscroll-forceScrollbarV: false !default; 10 | $optiscroll-forceScrollbarH: false !default; 11 | $optiscroll-supportRtl: true !default; 12 | $optiscroll-defaultStyle: true !default; 13 | 14 | 15 | /************************************** 16 | * Optiscroll container base style 17 | */ 18 | 19 | .#{$optiscroll-namespace} { 20 | position: relative; 21 | overflow: auto; 22 | -webkit-overflow-scrolling: touch; 23 | 24 | &.is-enabled { overflow: hidden; } 25 | } 26 | 27 | .#{$optiscroll-namespace}.is-enabled > .#{$optiscroll-classPrefix}content { 28 | position: absolute; 29 | top: 0; 30 | left: 0; 31 | right: 0; 32 | bottom: 0; 33 | z-index: 1; 34 | overflow: scroll; 35 | -webkit-overflow-scrolling: touch; 36 | } 37 | 38 | 39 | /************************************** 40 | * Optiscroll scrollbars base style 41 | */ 42 | 43 | .#{$optiscroll-classPrefix}v, 44 | .#{$optiscroll-classPrefix}h { 45 | position: absolute; 46 | visibility: hidden; 47 | z-index: 2; 48 | user-select: none; 49 | } 50 | 51 | .#{$optiscroll-classPrefix}v { right: 0; } 52 | .#{$optiscroll-classPrefix}h { bottom: 0; } 53 | 54 | .#{$optiscroll-namespace}.has-vtrack > .#{$optiscroll-classPrefix}v, 55 | .#{$optiscroll-namespace}.has-htrack > .#{$optiscroll-classPrefix}h { 56 | visibility: visible; 57 | } 58 | 59 | @if $optiscroll-supportRtl { 60 | .#{$optiscroll-namespace}.is-rtl > .#{$optiscroll-classPrefix}v { 61 | left: 0; 62 | right: auto; 63 | } 64 | } 65 | 66 | 67 | /************************************** 68 | * Optiscroll tracks base style 69 | */ 70 | 71 | .#{$optiscroll-classPrefix}vtrack, 72 | .#{$optiscroll-classPrefix}htrack { 73 | display: block; 74 | position: absolute; 75 | opacity: 1; 76 | transform: translate(0%, 0%); 77 | transition: height 0.2s ease 0s, width 0.2s ease 0s, opacity 0.2s ease 0s; 78 | box-sizing: border-box; 79 | } 80 | 81 | 82 | 83 | /************************************** 84 | * Prevent parent scroll 85 | * even when content is not longer enough. 86 | */ 87 | 88 | @if $optiscroll-forceScrollbarV { 89 | .#{$optiscroll-classPrefix}content:before { 90 | content: ''; 91 | position: absolute; 92 | top: 0; 93 | left: 0; 94 | width: 1px; 95 | height: 100%; 96 | border-bottom: 2px solid transparent; 97 | box-sizing: content-box; 98 | } 99 | } 100 | 101 | 102 | @if $optiscroll-forceScrollbarH { 103 | .#{$optiscroll-classPrefix}content:before { 104 | content: ''; 105 | position: absolute; 106 | top: 0; 107 | left: 0; 108 | width: 100%; 109 | height: 1px; 110 | border-right: 2px solid transparent; 111 | box-sizing: content-box; 112 | } 113 | } 114 | 115 | 116 | 117 | /************************************** 118 | * DEFAULT STYLE 119 | **************************************/ 120 | 121 | @if $optiscroll-defaultStyle { 122 | 123 | /*** 124 | *** Scrollbars style ***/ 125 | 126 | .#{$optiscroll-classPrefix}v { 127 | top: 4px; 128 | bottom: 4px; 129 | width: 0; 130 | } 131 | 132 | .#{$optiscroll-classPrefix}h { 133 | left: 4px; 134 | right: 4px; 135 | height: 0; 136 | } 137 | 138 | /* Avoid overapping while both scrollbars are enabled */ 139 | .#{$optiscroll-namespace}.has-vtrack.has-htrack > .#{$optiscroll-classPrefix}v { 140 | bottom: 8px; 141 | } 142 | .#{$optiscroll-namespace}.has-vtrack.has-htrack > .#{$optiscroll-classPrefix}h { 143 | right: 8px; 144 | } 145 | 146 | /*** 147 | *** Tracks style ***/ 148 | 149 | .#{$optiscroll-classPrefix}vtrack, 150 | .#{$optiscroll-classPrefix}htrack { 151 | background: rgba(#000, 0.3); 152 | border-radius: 2px; 153 | box-shadow: 0 0 1px #FFF; 154 | opacity: 0; 155 | } 156 | 157 | .#{$optiscroll-classPrefix}vtrack { 158 | width: 3px; 159 | right: 4px; 160 | } 161 | 162 | .#{$optiscroll-classPrefix}htrack { 163 | height: 3px; 164 | bottom: 4px; 165 | } 166 | 167 | .#{$optiscroll-namespace}:hover { 168 | & > .#{$optiscroll-classPrefix}v .#{$optiscroll-classPrefix}vtrack, 169 | & > .#{$optiscroll-classPrefix}h .#{$optiscroll-classPrefix}htrack { 170 | opacity: 1; 171 | } 172 | } 173 | 174 | @if $optiscroll-supportRtl { 175 | 176 | .#{$optiscroll-namespace}.has-vtrack.has-htrack.is-rtl > .#{$optiscroll-classPrefix}h { 177 | right: 4px; 178 | left: 8px; 179 | } 180 | 181 | .#{$optiscroll-namespace}.is-rtl > .#{$optiscroll-classPrefix}v .#{$optiscroll-classPrefix}vtrack { 182 | right: auto; 183 | left: 4px; 184 | } 185 | 186 | } 187 | 188 | } 189 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*global exports:true, require:true */ 2 | module.exports = exports = function(grunt) { 3 | 'use strict'; 4 | 5 | grunt.initConfig({ 6 | 7 | pkg: grunt.file.readJSON('package.json'), 8 | 9 | connect: { 10 | server: { 11 | options: { 12 | port: 8181, 13 | hostname: '*' 14 | } 15 | } 16 | }, 17 | 18 | concat: { 19 | options: { 20 | banner: "/*!\n"+ 21 | "* Optiscroll.js v<%= pkg.version %>\n"+ 22 | "* https://github.com/albertogasparin/Optiscroll/\n"+ 23 | "* \n"+ 24 | "* @copyright <%= grunt.template.today('yyyy') %> Alberto Gasparin\n"+ 25 | "* @license Released under MIT LICENSE\n"+ 26 | "*/\n\n", 27 | separator: "\n\n", 28 | }, 29 | nojquery: { 30 | src: [ 31 | 'src/intro.js', 32 | 'src/optiscroll.js', 33 | 'src/events.js', 34 | 'src/scrollbar.js', 35 | 'src/utils.js', 36 | 'src/globals.js', 37 | 'src/outro.js', 38 | ], 39 | dest: 'dist/optiscroll.js' 40 | }, 41 | jquery: { 42 | options: { banner: "" }, 43 | src: [ 44 | 'dist/optiscroll.js', 45 | 'src/jquery.plugin.js' 46 | ], 47 | dest: 'dist/jquery.optiscroll.js' 48 | } 49 | }, 50 | jshint: { 51 | options: { 52 | jshintrc: '.jshintrc' 53 | }, 54 | // source: ['src/*.js', 'src/**/*.js'] 55 | source: ['dist/optiscroll.js'] 56 | }, 57 | uglify: { 58 | dist: { 59 | options: { 60 | preserveComments: 'some', 61 | compress: { 62 | sequences: true, 63 | dead_code: true, 64 | conditionals: true, 65 | booleans: true, 66 | unused: true, 67 | if_return: true, 68 | join_vars: true, 69 | drop_console: true 70 | } 71 | }, 72 | files: { 73 | 'dist/optiscroll.min.js': ['dist/optiscroll.js'], 74 | 'dist/jquery.optiscroll.min.js': ['dist/jquery.optiscroll.js'] 75 | } 76 | } 77 | }, 78 | 79 | sass: { 80 | dist: { 81 | options: { 82 | outputStyle: 'compressed' 83 | }, 84 | files: { 85 | 'dist/optiscroll.css': ['scss/optiscroll.scss'] 86 | } 87 | } 88 | }, 89 | 90 | autoprefixer: { 91 | options: { 92 | browsers: ['last 3 versions', 'IE 11'] 93 | }, 94 | dist: { 95 | files: { 96 | 'dist/optiscroll.css': ['dist/optiscroll.css'] 97 | } 98 | } 99 | }, 100 | 101 | watch: { 102 | build: { 103 | files: ['src/*.js', 'src/**/*.js'], 104 | tasks: ['build'] 105 | }, 106 | css: { 107 | files: ['scss/*.scss'], 108 | tasks: ['sass', 'autoprefixer'] 109 | }, 110 | grunt: { 111 | files: [ 112 | 'Gruntfile.js' 113 | ] 114 | } 115 | }, 116 | 117 | bump: { 118 | options: { 119 | files: ['package.json','bower.json'], 120 | updateConfigs: ['pkg'], 121 | commit: true, 122 | commitMessage: 'Release v%VERSION%', 123 | commitFiles: ['package.json','bower.json','dist/optiscroll.min.js','dist/optiscroll.js','dist/jquery.optiscroll.min.js','dist/jquery.optiscroll.js','dist/optiscroll.css'], 124 | createTag: true, 125 | tagName: 'v%VERSION%', 126 | tagMessage: 'Version %VERSION%', 127 | push: false, 128 | // pushTo: 'upstream', 129 | gitDescribeOptions: '--tags --always --abbrev=1 --dirty=-d' 130 | } 131 | }, 132 | }); 133 | 134 | require('load-grunt-tasks')(grunt); 135 | 136 | grunt.registerTask('default', ['connect', 'build', 'watch']); 137 | grunt.registerTask('build', ['concat', 'uglify', 'sass', 'autoprefixer']); 138 | 139 | grunt.registerTask('test', ['build', 'connect']); 140 | 141 | // For version bumps you need to run the following three commands 142 | // - grunt bump-only:minor 143 | // - grunt buold 144 | // - grunt bump-commit 145 | // Or one of these tasks 146 | 147 | // Version bumb v1.0.0 => v1.0.1 148 | grunt.registerTask('bump-patch', ['bump-only:patch', 'build', 'bump-commit']); 149 | // Version bumb v1.0.0 => v1.1.0 150 | grunt.registerTask('bump-minor', ['bump-only:minor', 'build', 'bump-commit']); 151 | // Version bumb v1.0.0 => v2.0.0 152 | grunt.registerTask('bump-major', ['bump-only:major', 'build', 'bump-commit']); 153 | }; 154 | -------------------------------------------------------------------------------- /test/test.scrollbars.js: -------------------------------------------------------------------------------- 1 | /* eslint-env qunit */ 2 | /* globals os:true, $, Syn */ 3 | 4 | module('Scrollbars', { 5 | setup: function() { 6 | os = new window.Optiscroll(document.querySelector('#os'), { autoUpdate: false }); 7 | // force scrollbars creation and setup 8 | if(!os.element.querySelector('.optiscroll-v')) { 9 | // create 10 | os.scrollbars.v.create(); 11 | os.scrollbars.h.create(); 12 | // reset data 13 | os.cache.v.size = 0; 14 | os.cache.h.size = 0; 15 | os.scrollbars.v.toggle(false); 16 | os.scrollbars.h.toggle(false); 17 | 18 | os.scrollbars.v.update(); 19 | os.scrollbars.h.update(); 20 | } 21 | 22 | }, teardown: function() { 23 | os.destroy(); 24 | os = null; 25 | }, 26 | }); 27 | 28 | 29 | test('It should create scrollbars', function () { 30 | // internal scrollbar intances 31 | ok(os.scrollbars.v, 'Vertical scrollbar instance created'); 32 | ok(os.scrollbars.h, 'Horizontal scrollbar instance created'); 33 | // DOM elements 34 | ok(os.element.querySelector('.optiscroll-v'), 'Vertical scrollbar element created'); 35 | ok(os.element.querySelector('.optiscroll-h'), 'Horizontal scrollbar element created'); 36 | 37 | // Classes 38 | notEqual(os.element.className.indexOf('has-vtrack'), -1); 39 | notEqual(os.element.className.indexOf('has-htrack'), -1); 40 | }); 41 | 42 | test('It should set the track size', function () { 43 | var vTrack = os.element.querySelector('.optiscroll-vtrack'), 44 | hTrack = os.element.querySelector('.optiscroll-htrack'); 45 | // size 46 | equal(os.cache.v.size, 0.5); 47 | equal(vTrack.style.height, '50%'); 48 | equal(os.cache.h.size, 0.5); 49 | equal(hTrack.style.width, '50%'); 50 | }); 51 | 52 | 53 | asyncTest('It should move the tracks on scroll', function () { 54 | var vTrack = os.element.querySelector('.optiscroll-vtrack'), 55 | hTrack = os.element.querySelector('.optiscroll-htrack'); 56 | 57 | os.scrollEl.scrollTop = 100; 58 | os.scrollEl.scrollLeft = 50; 59 | 60 | setTimeout(function () { 61 | equal(os.cache.v.position, 0.5); 62 | equal(os.cache.v.percent, 100); 63 | equal(os.cache.h.position, 0.25); 64 | equal(os.cache.h.percent, 50); 65 | equal(vTrack.style[Optiscroll.G.cssTransform], 'translate(0%, 100%)'); 66 | equal(hTrack.style[Optiscroll.G.cssTransform], 'translate(50%, 0%)'); 67 | start(); 68 | }, 100); 69 | 70 | }); 71 | 72 | 73 | asyncTest('Vertical track should be draggable', function () { 74 | expect(4); 75 | var vTrack = os.element.querySelector('.optiscroll-vtrack'); 76 | 77 | os.scrollEl.scrollTop = 0; 78 | 79 | window.syn.drag('+0 +25', vTrack, function () { 80 | setTimeout(function () { 81 | equal(os.scrollEl.scrollTop, 50); 82 | equal(os.cache.v.position, 0.25); 83 | equal(os.cache.v.percent, 50); 84 | equal(vTrack.style[Optiscroll.G.cssTransform], 'translate(0%, 50%)'); 85 | start(); 86 | }, 50); 87 | }); 88 | 89 | }); 90 | 91 | asyncTest('Horizontal track should be draggable', function () { 92 | expect(4); 93 | var hTrack = os.element.querySelector('.optiscroll-htrack'); 94 | 95 | os.scrollEl.scrollLeft = 0; 96 | 97 | window.syn.drag('+25 +0', hTrack, function () { 98 | setTimeout(function () { 99 | equal(os.scrollEl.scrollLeft, 50); 100 | equal(os.cache.h.position, 0.25); 101 | equal(os.cache.h.percent, 50); 102 | equal(hTrack.style[Optiscroll.G.cssTransform], 'translate(50%, 0%)'); 103 | start(); 104 | }, 500); // wait for scrollStop to fire 105 | }); 106 | }); 107 | 108 | 109 | asyncTest('It should update tracks on size change', function () { 110 | expect(4); 111 | 112 | os.scrollEl.scrollTop = 100; 113 | os.scrollEl.scrollLeft = 10; 114 | os.element.style.width = '150px'; 115 | os.element.style.height = '150px'; 116 | 117 | setTimeout(function () { 118 | os.update(); 119 | }); 120 | 121 | setTimeout(function () { 122 | equal(os.cache.v.size, 0.75); 123 | equal(os.cache.v.percent, 100); 124 | equal(os.cache.h.size, 0.75); 125 | equal(os.cache.h.percent, 20); 126 | start(); 127 | }, 100); 128 | 129 | }); 130 | 131 | 132 | asyncTest('It should update tracks on content change', function () { 133 | expect(2); 134 | 135 | var content = document.querySelector('.test'); 136 | content.style.width = '400px'; 137 | content.style.height = '400px'; 138 | 139 | setTimeout(function () { 140 | os.update(); 141 | }); 142 | 143 | setTimeout(function () { 144 | equal(os.cache.v.size, 0.25); 145 | equal(os.cache.h.size, 0.25); 146 | start(); 147 | }, 100); 148 | 149 | }); 150 | 151 | 152 | asyncTest('Vertical track should be disabled if no scroll', function () { 153 | expect(4); 154 | 155 | os.element.style.height = '300px'; 156 | os.update(); 157 | 158 | setTimeout(function () { 159 | equal(os.cache.v.enabled, false); 160 | equal(os.cache.v.size, 1); 161 | equal(os.cache.v.percent, 0); 162 | equal(os.element.className.indexOf('vtrack-on'), -1); 163 | start(); 164 | }, 50); 165 | }); 166 | 167 | 168 | asyncTest('Horizontal track should be disabled if no scroll', function () { 169 | expect(4); 170 | 171 | os.element.style.width = '300px'; 172 | os.update(); 173 | 174 | setTimeout(function () { 175 | equal(os.cache.h.enabled, false); 176 | equal(os.cache.h.size, 1); 177 | equal(os.cache.h.percent, 0); 178 | equal(os.element.className.indexOf('htrack-on'), -1); 179 | start(); 180 | }, 50); 181 | 182 | }); 183 | 184 | -------------------------------------------------------------------------------- /test/resources/qunit.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * QUnit 1.14.0 3 | * http://qunitjs.com/ 4 | * 5 | * Copyright 2013 jQuery Foundation and other contributors 6 | * Released under the MIT license 7 | * http://jquery.org/license 8 | * 9 | * Date: 2014-01-31T16:40Z 10 | */ 11 | 12 | /** Font Family and Sizes */ 13 | 14 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult { 15 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; 16 | } 17 | 18 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 19 | #qunit-tests { font-size: smaller; } 20 | 21 | 22 | /** Resets */ 23 | 24 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter { 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | 30 | /** Header */ 31 | 32 | #qunit-header { 33 | padding: 0.5em 0 0.5em 1em; 34 | 35 | color: #8699A4; 36 | background-color: #0D3349; 37 | 38 | font-size: 1.5em; 39 | line-height: 1em; 40 | font-weight: 400; 41 | 42 | border-radius: 5px 5px 0 0; 43 | } 44 | 45 | #qunit-header a { 46 | text-decoration: none; 47 | color: #C2CCD1; 48 | } 49 | 50 | #qunit-header a:hover, 51 | #qunit-header a:focus { 52 | color: #FFF; 53 | } 54 | 55 | #qunit-testrunner-toolbar label { 56 | display: inline-block; 57 | padding: 0 0.5em 0 0.1em; 58 | } 59 | 60 | #qunit-banner { 61 | height: 5px; 62 | } 63 | 64 | #qunit-testrunner-toolbar { 65 | padding: 0.5em 0 0.5em 2em; 66 | color: #5E740B; 67 | background-color: #EEE; 68 | overflow: hidden; 69 | } 70 | 71 | #qunit-userAgent { 72 | padding: 0.5em 0 0.5em 2.5em; 73 | background-color: #2B81AF; 74 | color: #FFF; 75 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 76 | } 77 | 78 | #qunit-modulefilter-container { 79 | float: right; 80 | } 81 | 82 | /** Tests: Pass/Fail */ 83 | 84 | #qunit-tests { 85 | list-style-position: inside; 86 | } 87 | 88 | #qunit-tests li { 89 | padding: 0.4em 0.5em 0.4em 2.5em; 90 | border-bottom: 1px solid #FFF; 91 | list-style-position: inside; 92 | } 93 | 94 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running { 95 | display: none; 96 | } 97 | 98 | #qunit-tests li strong { 99 | cursor: pointer; 100 | } 101 | 102 | #qunit-tests li a { 103 | padding: 0.5em; 104 | color: #C2CCD1; 105 | text-decoration: none; 106 | } 107 | #qunit-tests li a:hover, 108 | #qunit-tests li a:focus { 109 | color: #000; 110 | } 111 | 112 | #qunit-tests li .runtime { 113 | float: right; 114 | font-size: smaller; 115 | } 116 | 117 | .qunit-assert-list { 118 | margin-top: 0.5em; 119 | padding: 0.5em; 120 | 121 | background-color: #FFF; 122 | 123 | border-radius: 5px; 124 | } 125 | 126 | .qunit-collapsed { 127 | display: none; 128 | } 129 | 130 | #qunit-tests table { 131 | border-collapse: collapse; 132 | margin-top: 0.2em; 133 | } 134 | 135 | #qunit-tests th { 136 | text-align: right; 137 | vertical-align: top; 138 | padding: 0 0.5em 0 0; 139 | } 140 | 141 | #qunit-tests td { 142 | vertical-align: top; 143 | } 144 | 145 | #qunit-tests pre { 146 | margin: 0; 147 | white-space: pre-wrap; 148 | word-wrap: break-word; 149 | } 150 | 151 | #qunit-tests del { 152 | background-color: #E0F2BE; 153 | color: #374E0C; 154 | text-decoration: none; 155 | } 156 | 157 | #qunit-tests ins { 158 | background-color: #FFCACA; 159 | color: #500; 160 | text-decoration: none; 161 | } 162 | 163 | /*** Test Counts */ 164 | 165 | #qunit-tests b.counts { color: #000; } 166 | #qunit-tests b.passed { color: #5E740B; } 167 | #qunit-tests b.failed { color: #710909; } 168 | 169 | #qunit-tests li li { 170 | padding: 5px; 171 | background-color: #FFF; 172 | border-bottom: none; 173 | list-style-position: inside; 174 | } 175 | 176 | /*** Passing Styles */ 177 | 178 | #qunit-tests li li.pass { 179 | color: #3C510C; 180 | background-color: #FFF; 181 | border-left: 10px solid #C6E746; 182 | } 183 | 184 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 185 | #qunit-tests .pass .test-name { color: #366097; } 186 | 187 | #qunit-tests .pass .test-actual, 188 | #qunit-tests .pass .test-expected { color: #999; } 189 | 190 | #qunit-banner.qunit-pass { background-color: #C6E746; } 191 | 192 | /*** Failing Styles */ 193 | 194 | #qunit-tests li li.fail { 195 | color: #710909; 196 | background-color: #FFF; 197 | border-left: 10px solid #EE5757; 198 | white-space: pre; 199 | } 200 | 201 | #qunit-tests > li:last-child { 202 | border-radius: 0 0 5px 5px; 203 | } 204 | 205 | #qunit-tests .fail { color: #000; background-color: #EE5757; } 206 | #qunit-tests .fail .test-name, 207 | #qunit-tests .fail .module-name { color: #000; } 208 | 209 | #qunit-tests .fail .test-actual { color: #EE5757; } 210 | #qunit-tests .fail .test-expected { color: #008000; } 211 | 212 | #qunit-banner.qunit-fail { background-color: #EE5757; } 213 | 214 | 215 | /** Result */ 216 | 217 | #qunit-testresult { 218 | padding: 0.5em 0.5em 0.5em 2.5em; 219 | 220 | color: #2B81AF; 221 | background-color: #D2E0E6; 222 | 223 | border-bottom: 1px solid #FFF; 224 | } 225 | #qunit-testresult .module-name { 226 | font-weight: 700; 227 | } 228 | 229 | /** Fixture */ 230 | 231 | #qunit-fixture { 232 | position: absolute; 233 | top: -10000px; 234 | left: -10000px; 235 | width: 1000px; 236 | height: 1000px; 237 | } 238 | -------------------------------------------------------------------------------- /src/scrollbar.js: -------------------------------------------------------------------------------- 1 | var Scrollbar = function (which, instance) { 2 | 3 | var isVertical = (which === 'v'), 4 | parentEl = instance.element, 5 | scrollEl = instance.scrollEl, 6 | settings = instance.settings, 7 | cache = instance.cache, 8 | scrollbarCache = cache[which] = {}, 9 | 10 | sizeProp = isVertical ? 'H' : 'W', 11 | clientSize = 'client' + sizeProp, 12 | scrollSize = 'scroll' + sizeProp, 13 | scrollProp = isVertical ? 'scrollTop' : 'scrollLeft', 14 | evSuffixes = isVertical ? ['top','bottom'] : ['left','right'], 15 | evTypesMatcher = /^(mouse|touch|pointer)/, 16 | 17 | rtlMode = G.scrollbarSpec.rtl, 18 | enabled = false, 19 | scrollbarEl = null, 20 | trackEl = null; 21 | 22 | var events = { 23 | dragData: null, 24 | 25 | dragStart: function (ev) { 26 | ev.preventDefault(); 27 | var evData = ev.touches ? ev.touches[0] : ev; 28 | events.dragData = { x: evData.pageX, y: evData.pageY, scroll: scrollEl[scrollProp] }; 29 | events.bind(true, ev.type.match(evTypesMatcher)[1]); 30 | }, 31 | 32 | dragMove: function (ev) { 33 | var evData = ev.touches ? ev.touches[0] : ev, 34 | dragMode = settings.rtl && rtlMode === 1 && !isVertical ? -1 : 1, 35 | delta, deltaRatio; 36 | 37 | ev.preventDefault(); 38 | delta = isVertical ? evData.pageY - events.dragData.y : evData.pageX - events.dragData.x; 39 | deltaRatio = delta / cache[clientSize]; 40 | 41 | scrollEl[scrollProp] = events.dragData.scroll + deltaRatio * cache[scrollSize] * dragMode; 42 | }, 43 | 44 | dragEnd: function (ev) { 45 | events.dragData = null; 46 | events.bind(false, ev.type.match(evTypesMatcher)[1]); 47 | }, 48 | 49 | bind: function (on, type) { 50 | var method = (on ? 'add' : 'remove') + 'EventListener', 51 | moveEv = type + 'move', 52 | upEv = type + (type === 'touch' ? 'end' : 'up'); 53 | 54 | document[method](moveEv, events.dragMove); 55 | document[method](upEv, events.dragEnd); 56 | document[method](type + 'cancel', events.dragEnd); 57 | }, 58 | 59 | }; 60 | 61 | return { 62 | 63 | 64 | toggle: function (bool) { 65 | enabled = bool; 66 | 67 | if(trackEl) { 68 | toggleClass(parentEl, 'has-' + which + 'track', enabled); 69 | } 70 | 71 | // expose enabled 72 | scrollbarCache.enabled = enabled; 73 | }, 74 | 75 | 76 | create: function () { 77 | scrollbarEl = document.createElement('div'); 78 | trackEl = document.createElement('b'); 79 | 80 | scrollbarEl.className = settings.classPrefix + which; 81 | trackEl.className = settings.classPrefix + which + 'track'; 82 | scrollbarEl.appendChild(trackEl); 83 | parentEl.appendChild(scrollbarEl); 84 | 85 | if(settings.draggableTracks) { 86 | var evTypes = window.PointerEvent ? ['pointerdown'] : ['touchstart', 'mousedown']; 87 | evTypes.forEach(function (evType) { 88 | trackEl.addEventListener(evType, events.dragStart); 89 | }); 90 | } 91 | }, 92 | 93 | 94 | update: function () { 95 | var newSize, oldSize, 96 | newDim, newRelPos, deltaPos; 97 | 98 | // if scrollbar is disabled and no scroll 99 | if(!enabled && cache[clientSize] === cache[scrollSize]) { 100 | return; 101 | } 102 | 103 | newDim = this.calc(); 104 | newSize = newDim.size; 105 | oldSize = scrollbarCache.size; 106 | newRelPos = (1 / newSize) * newDim.position * 100; 107 | deltaPos = Math.abs(newDim.position - (scrollbarCache.position || 0)) * cache[clientSize]; 108 | 109 | if(newSize === 1 && enabled) { 110 | this.toggle(false); 111 | } 112 | 113 | if(newSize < 1 && !enabled) { 114 | this.toggle(true); 115 | } 116 | 117 | if(trackEl && enabled) { 118 | this.style(newRelPos, deltaPos, newSize, oldSize); 119 | } 120 | 121 | // update cache values 122 | scrollbarCache = _extend(scrollbarCache, newDim); 123 | 124 | if(enabled) { 125 | this.fireEdgeEv(); 126 | } 127 | 128 | }, 129 | 130 | 131 | style: function (newRelPos, deltaPos, newSize, oldSize) { 132 | if(newSize !== oldSize) { 133 | trackEl.style[ isVertical ? 'height' : 'width' ] = newSize * 100 + '%'; 134 | if (settings.rtl && !isVertical) { 135 | trackEl.style.marginRight = (1 - newSize) * 100 + '%'; 136 | } 137 | } 138 | trackEl.style[G.cssTransform] = 'translate(' + 139 | (isVertical ? '0%,' + newRelPos + '%' : newRelPos + '%' + ',0%') 140 | + ')'; 141 | }, 142 | 143 | 144 | calc: function () { 145 | var position = scrollEl[scrollProp], 146 | viewS = cache[clientSize], 147 | scrollS = cache[scrollSize], 148 | sizeRatio = viewS / scrollS, 149 | sizeDiff = scrollS - viewS, 150 | positionRatio, percent; 151 | 152 | if(sizeRatio >= 1 || !scrollS) { // no scrollbars needed 153 | return { position: 0, size: 1, percent: 0 }; 154 | } 155 | if (!isVertical && settings.rtl && rtlMode) { 156 | position = sizeDiff - position * rtlMode; 157 | } 158 | 159 | percent = 100 * position / sizeDiff; 160 | 161 | // prevent overscroll effetcs (negative percent) 162 | // and keep 1px tolerance near the edges 163 | if(position <= 1) { percent = 0; } 164 | if(position >= sizeDiff - 1) { percent = 100; } 165 | 166 | // Capped size based on min/max track percentage 167 | sizeRatio = Math.max(sizeRatio, settings.minTrackSize / 100); 168 | sizeRatio = Math.min(sizeRatio, settings.maxTrackSize / 100); 169 | 170 | positionRatio = (1 - sizeRatio) * (percent / 100); 171 | 172 | return { position: positionRatio, size: sizeRatio, percent: percent }; 173 | }, 174 | 175 | 176 | fireEdgeEv: function () { 177 | var percent = scrollbarCache.percent; 178 | 179 | if(scrollbarCache.was !== percent && percent % 100 === 0) { 180 | instance.fireCustomEvent('scrollreachedge'); 181 | instance.fireCustomEvent('scrollreach' + evSuffixes[percent / 100]); 182 | } 183 | 184 | scrollbarCache.was = percent; 185 | }, 186 | 187 | 188 | remove: function () { 189 | // remove parent custom classes 190 | this.toggle(false); 191 | // remove elements 192 | if(scrollbarEl) { 193 | scrollbarEl.parentNode.removeChild(scrollbarEl); 194 | scrollbarEl = null; 195 | } 196 | }, 197 | 198 | }; 199 | 200 | }; 201 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Optiscroll.js 9 | 10 | 11 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | 34 |

Optiscroll

35 |

36 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut id nibh dictum ex ultrices pellentesque non eget eros. Sed pellentesque in lorem ac porttitor. Phasellus consectetur risus eu quam pellentesque, at malesuada odio hendrerit. Morbi vitae commodo lectus. In hac habitasse platea dictumst. Quisque placerat arcu ac risus pretium, a efficitur elit hendrerit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. 37 |

38 | 39 |

Nested

40 | 41 |
42 |
43 |

44 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut id nibh dictum ex ultrices pellentesque non eget eros. Sed pellentesque in lorem ac porttitor. Phasellus consectetur risus eu quam pellentesque, at malesuada odio hendrerit. Morbi vitae commodo lectus. In hac habitasse platea dictumst. Quisque placerat arcu ac risus pretium, a efficitur elit hendrerit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. 45 |

46 |

47 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut id nibh dictum ex ultrices pellentesque non eget eros. Sed pellentesque in lorem ac porttitor. Phasellus consectetur risus eu quam pellentesque, at malesuada odio hendrerit. Morbi vitae commodo lectus. In hac habitasse platea dictumst. 48 |

49 | Lorem ipsum dolor sit amet 50 |
51 |
52 |
53 |
54 |

55 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut id nibh dictum ex ultrices pellentesque non eget eros. Sed pellentesque in lorem ac porttitor. Phasellus consectetur risus eu quam pellentesque, at malesuada odio hendrerit. Morbi vitae commodo lectus. In hac habitasse platea dictumst. Quisque placerat arcu ac risus pretium, a efficitur elit hendrerit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. 56 |

57 |

58 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut id nibh dictum ex ultrices pellentesque non eget eros. Sed pellentesque in lorem ac porttitor. Phasellus consectetur risus eu quam pellentesque, at malesuada odio hendrerit. Morbi vitae commodo lectus. In hac habitasse platea dictumst. 59 |

60 | Lorem ipsum dolor sit amet 61 |
62 |
63 | 64 | 65 |

Lorem

66 |

67 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut id nibh dictum ex ultrices pellentesque non eget eros. Sed pellentesque in lorem ac porttitor.
68 | Phasellus consectetur risus eu quam pellentesque, at malesuada odio hendrerit. Morbi vitae commodo lectus. In hac habitasse platea dictumst. Quisque placerat arcu ac risus pretium, a efficitur elit hendrerit. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. 69 |

70 |

71 | Nunc pulvinar, orci et lacinia malesuada, quam augue auctor libero, et egestas neque leo sit amet dolor. Quisque rhoncus vulputate tortor, in ultricies neque fringilla in. Aenean placerat purus vitae rhoncus tincidunt. Nullam varius erat nec fringilla porta. Sed at mi ac ipsum ultrices gravida. Duis quis auctor dolor. Cras tempus, tellus ut sodales consequat, velit elit pulvinar est, vitae venenatis odio felis ut augue. Phasellus rutrum justo ac leo tincidunt, convallis elementum magna malesuada. Nulla eget turpis a metus molestie auctor. Quisque sollicitudin ultrices elementum. Suspendisse tempor scelerisque fermentum. Phasellus dictum massa quis est sagittis, quis placerat sapien ullamcorper. Curabitur enim ex, bibendum at scelerisque a, congue et nunc. Aenean tincidunt sodales leo, vel lacinia neque posuere facilisis. Ut rhoncus egestas nunc, ac bibendum mi efficitur vitae. Curabitur tincidunt nibh eget nisi dictum, blandit dignissim ex maximus. 72 |

73 |

74 | Etiam tempor imperdiet molestie. Phasellus imperdiet eros sed elit cursus, ut luctus quam varius. Nulla et suscipit ipsum. Proin hendrerit tellus sit amet lacinia euismod. Integer sit amet blandit velit. Donec eu maximus ex. Pellentesque sodales magna in rhoncus tempor. Sed tincidunt risus id turpis tristique, aliquet congue erat suscipit. Aenean ligula arcu, venenatis a tristique in, elementum eget tellus. 75 |

76 | 77 | 94 | 95 |
96 | 97 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /dist/optiscroll.min.js: -------------------------------------------------------------------------------- 1 | !function(E,y,C,o){"use strict";var i=function t(e,i){return new t.Instance(e,i||{})},c=i.globalSettings={scrollMinUpdateInterval:25,checkFrequency:1e3,pauseCheck:!1};i.defaults={preventParentScroll:!1,forceScrollbars:!1,scrollStopDelay:300,maxTrackSize:95,minTrackSize:5,draggableTracks:!0,autoUpdate:!0,classPrefix:"optiscroll-",wrapContent:!0,rtl:!1},(i.Instance=function(t,e){this.element=t,this.settings=S(S({},i.defaults),e||{}),"boolean"!=typeof e.rtl&&(this.settings.rtl="rtl"===E.getComputedStyle(t).direction),this.cache={},this.init()}).prototype={init:function(){var t=this.element,e=this.settings,i=!1,l=this.scrollEl=e.wrapContent?u.createWrapper(t):t.firstElementChild;w(l,e.classPrefix+"content",!0),w(t,"is-enabled"+(e.rtl?" is-rtl":""),!0),this.scrollbars={v:s("v",this),h:s("h",this)},(T.scrollbarSpec.width||e.forceScrollbars)&&(i=u.hideNativeScrollbars(l,e.rtl)),i&&h(this.scrollbars,"create"),T.isTouch&&e.preventParentScroll&&w(t,e.classPrefix+"prevent",!0),this.update(),this.bind(),e.autoUpdate&&T.instances.push(this),e.autoUpdate&&!T.checkTimer&&u.checkLoop()},bind:function(){var l,s,n,r,t=this.listeners={},e=this.scrollEl;for(var i in t.scroll=(l=a.scroll.bind(this),s=c.scrollMinUpdateInterval,function(){var t=this,e=Date.now(),i=arguments;n&&e startX) { endX = rightEdge; } 233 | 234 | if(topEdge < startY) { endY = topEdge; } 235 | if(bottomEdge > startY) { endY = bottomEdge; } 236 | 237 | // animate 238 | this.animateScroll(startX, endX, startY, endY, +duration); 239 | }, 240 | 241 | 242 | 243 | 244 | animateScroll: function (startX, endX, startY, endY, duration) { 245 | var self = this, 246 | scrollEl = this.scrollEl, 247 | startTime = Date.now(); 248 | 249 | if(endX === startX && endY === startY) { 250 | return; 251 | } 252 | 253 | if(duration === 0) { 254 | scrollEl.scrollLeft = endX; 255 | scrollEl.scrollTop = endY; 256 | return; 257 | } 258 | 259 | if(isNaN(duration)) { // undefined or auto 260 | // 500px in 430ms, 1000px in 625ms, 2000px in 910ms 261 | duration = Math.pow(Math.max(Math.abs(endX - startX), Math.abs(endY - startY)), 0.54) * 15; 262 | } 263 | 264 | (function animate () { 265 | var time = Math.min(1, ((Date.now() - startTime) / duration)), 266 | easedTime = Utils.easingFunction(time); 267 | 268 | if(endY !== startY) { 269 | scrollEl.scrollTop = ~~(easedTime * (endY - startY)) + startY; 270 | } 271 | if(endX !== startX) { 272 | scrollEl.scrollLeft = ~~(easedTime * (endX - startX)) + startX; 273 | } 274 | 275 | self.scrollAnimation = time < 1 ? window.requestAnimationFrame(animate) : null; 276 | }()); 277 | }, 278 | 279 | 280 | 281 | 282 | destroy: function () { 283 | var self = this, 284 | element = this.element, 285 | scrollEl = this.scrollEl, 286 | listeners = this.listeners, 287 | child; 288 | 289 | if(!this.scrollEl) { return; } 290 | 291 | // unbind events 292 | for (var ev in listeners) { 293 | scrollEl.removeEventListener(ev, listeners[ev]); 294 | } 295 | 296 | // remove scrollbars elements 297 | _invoke(this.scrollbars, 'remove'); 298 | 299 | // unwrap content 300 | if (this.settings.wrapContent) { 301 | while(child = scrollEl.childNodes[0]) { 302 | element.insertBefore(child, scrollEl); 303 | } 304 | element.removeChild(scrollEl); 305 | this.scrollEl = null; 306 | } 307 | 308 | // remove classes 309 | toggleClass(element, this.settings.classPrefix + 'prevent', false); 310 | toggleClass(element, 'is-enabled', false); 311 | 312 | // defer instance removal from global array 313 | // to not affect checkLoop _invoke 314 | window.requestAnimationFrame(function () { 315 | var index = G.instances.indexOf(self); 316 | if (index > -1) { 317 | G.instances.splice(index, 1); 318 | } 319 | }); 320 | }, 321 | 322 | 323 | 324 | 325 | fireCustomEvent: function (eventName) { 326 | var cache = this.cache, 327 | sH = cache.scrollH, sW = cache.scrollW, 328 | eventData; 329 | 330 | eventData = { 331 | // scrollbars data 332 | scrollbarV: _extend({}, cache.v), 333 | scrollbarH: _extend({}, cache.h), 334 | 335 | // scroll position 336 | scrollTop: cache.v.position * sH, 337 | scrollLeft: cache.h.position * sW, 338 | scrollBottom: (1 - cache.v.position - cache.v.size) * sH, 339 | scrollRight: (1 - cache.h.position - cache.h.size) * sW, 340 | 341 | // element size 342 | scrollWidth: sW, 343 | scrollHeight: sH, 344 | clientWidth: cache.clientW, 345 | clientHeight: cache.clientH, 346 | }; 347 | 348 | var event; 349 | if (typeof CustomEvent === 'function') { 350 | event = new CustomEvent(eventName, { detail: eventData }); 351 | } else { // IE does not support CustomEvent 352 | event = document.createEvent('CustomEvent'); 353 | event.initCustomEvent(eventName, false, false, eventData); 354 | } 355 | this.element.dispatchEvent(event); 356 | }, 357 | 358 | }; 359 | 360 | 361 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Optiscroll 2 | 3 | Optiscroll is an tiny (9kB min / **3.9kB gzip**) and highly optimized custom scrollbar library for modern web apps. 4 | 5 | Optiscroll aims to be as light as possible in order to not affect the performance of your webapp. Optiscroll does **not** replace the scrolling logic with Javascript. It only hides native scrollbars and allows you to style the fake scrollbars as you like. Moreover, Optiscroll adds custom events and methods to extend browser scroll functionalities. 6 | 7 | 8 | 9 | ## Features 10 | 11 | - Lightweight and without dependencies 12 | - Highly optimized 13 | - Vertical and horizontal scrollbars support 14 | - Both `ltr` and `rtl` text direction support (with smart detection) 15 | - Nested scrollbars support 16 | - Custom events 17 | - Animated `scrollTo` and `scrollIntoView` 18 | - Auto update on content/scroll area change 19 | - Integrated page bounce fix for iOS 20 | - Optional jQuery plugin 21 | 22 | 23 | 24 | ## Browser support 25 | 26 | Optiscroll works in **all modern browsers** (IE11 and above). Keep in mind that if Optiscroll does not work your web page will still fallback to default scrollbars. 27 | 28 | 29 | 30 | # How to use Optiscroll 31 | 32 | ## Installation 33 | 34 | Grab `optiscroll.min.js` (or `jquery.optiscroll.min.js`) from `dist` folder or: 35 | 36 | ```sh 37 | bower install optiscroll --save 38 | # or 39 | npm install optiscroll --save 40 | ``` 41 | 42 | ## Basic usage 43 | 44 | Include Optiscroll library and stylesheet 45 | 46 | ```html 47 | 48 | 49 | 50 | 51 | 52 | ``` 53 | 54 | Optiscroll automatically wraps your content with a scrollable element, but if you need more control you can create your own element and set `wrapContent: false`. 55 | 56 | ```html 57 |
58 | 59 | My content 60 |
61 | ``` 62 | 63 | Initialize it in your JS code 64 | 65 | ```js 66 | // plain JS version 67 | var element = document.querySelector('#scroll') 68 | var myOptiscrollInstance = new Optiscroll(element); 69 | 70 | // jQuery plugin 71 | $('#scroll').optiscroll() 72 | ``` 73 | 74 | 75 | 76 | ## Instance options 77 | 78 | | Option name | Default | Purpose 79 | |-------------|---------|---------- 80 | | preventParentScroll | false | Mobile only, prevents scrolling parent container (or body) when scroll reach top or bottom (known as iOS page bounce fix). 81 | | forceScrollbars | false | Use custom scrollbars also on iOS, Android and OSX (w/ trackpad) 82 | | scrollStopDelay | 300 (ms) | Time before presuming that the scroll is ended, after which `scrollstop` event is fired 83 | | maxTrackSize | 95 (%) | Maximum size (width or height) of the track 84 | | minTrackSize | 5 (%) | Minimum size (width or height) of the track 85 | | draggableTracks | true | Allow scroll through tracks dragging 86 | | autoUpdate | true | Scrollbars will be automatically updated on size or content changes 87 | | classPrefix | 'optiscroll-' | Custom class prefix for optiscroll elements 88 | | wrapContent | true | Optiscroll will generate an element to wrap your content. If not, the first child will be used 89 | | rtl | false | Optiscroll will automatically detect if the content is rtl, however you can force it if the detection fails 90 | 91 | Examples: 92 | 93 | ```js 94 | // change min and max track size - plain JS version 95 | var myOptiscrollInstance = new Optiscroll(element, { maxTrackSize: 50, minTrackSize: 20 }); 96 | 97 | // Force scrollbars on touch devices - jQuery plugin 98 | $('#scroll').optiscroll({ forceScrollbars: true }); 99 | ``` 100 | 101 | 102 | 103 | 104 | ## Instance methods 105 | 106 | ### scrollTo ( destX, destY [, duration] ) 107 | 108 | Scroll to a specific point with a nice animation. If you need to scroll a single axis then set the opposite axis destination to `false`. 109 | By default the duration is calculated based on the distance (eg: 500px in 700ms, 1000px in 1080ms, 2000px in 1670ms, ...). Alternatively you can set your fixed duration in milliseconds. 110 | 111 | | Arguments | Allowed values 112 | |-----------|---------------- 113 | | destX | number (px), `left`, `right`, `false` 114 | | destY | number (px), `top`, `bottom`, `false` 115 | | duration | number (ms), `auto` 116 | 117 | Examples: 118 | 119 | ```js 120 | // scroll vertically by 500px (scroll duration will be auto) - plain JS version 121 | myOptiscrollInstance.scrollTo(false, 500); 122 | 123 | /* The jQuery plugin allows you to call a method in two ways */ 124 | 125 | // scroll horizontally to right in 100ms 126 | $('#scroll').data('optiscroll').scrollTo('right', false, 100); 127 | 128 | // scroll horizontally by 500px and vertically to bottom with 'auto' duration 129 | $('#scroll').optiscroll('scrollTo', 500, 'bottom', 'auto'); 130 | ``` 131 | 132 | 133 | 134 | ### scrollIntoView (elem [, duration, delta]) 135 | 136 | Scrolls the element into view. The alignment will be driven by the nearest edge. By default the duration is calculated based on the distance (eg: 500px in 700ms, 1000px in 1080ms, 2000px in 1670ms, ...). `delta` is the optional distance in px from the edge. Per edge distances can be defined. 137 | 138 | | Arguments | Allowed values 139 | |-----------|---------------- 140 | | element | DOM node, jQuery element, string (selector) 141 | | duration | number (ms), `auto` 142 | | delta | number (px), object with `top`, `left`, `right`, `bottom` numbers 143 | 144 | Examples: 145 | 146 | ```js 147 | // scrolls element with id anchor-1 into view (scroll duration will be auto) - plain JS version 148 | myOptiscrollInstance.scrollIntoView('#anchor-1'); 149 | 150 | /* The jQuery plugin allows you to call a method in two ways */ 151 | 152 | // scrolls jQuery element into view in 500ms and with a distance from the edges of 20px 153 | var $el = $('.my-element').last(); 154 | $('#scroll').data('optiscroll').scrollIntoView($el, 500, 20); 155 | 156 | // scrolls jQuery element into view with a custom bottom and right distance 157 | $('#scroll').optiscroll('scrollIntoView', $el, 'auto', { bottom: 20, right: 30 }); 158 | ``` 159 | 160 | 161 | ### update () 162 | 163 | Optiscroll caches some DOM properties (like `scrollHeight`, `clientHeight`, ...) in order to avoid quering the DOM (and trigger a layout) each time the user scrolls. Usually the `update` method is called by an internal timer (see the `checkFrequency` global option). So you should not care about it. However if you have disabled the auto update feature for an instance (via the `autoUpdate` option) or globally (via the `checkFrequency` option), you have to call the `update` method in your code. 164 | 165 | 166 | ### destroy () 167 | 168 | If you want to remove Optiscroll, this method will clean up the class names, unbind all events and remove the scrollbar elements. Anyway, Optiscroll is clever enough to destroy itself automatically if its element is removed from the DOM (so it avoids memory leaks). 169 | 170 | ```js 171 | myOptiscrollInstance.destroy(); 172 | 173 | /* The jQuery plugin should be destroyed this way 174 | * so it handles removing its internal reference properly */ 175 | 176 | $('#scroll').optiscroll('destroy'); 177 | ``` 178 | 179 | 180 | ## Instance events 181 | 182 | Each instance will fire a set of custom events after user interaction. Each event will include a `detail` property with some useful data about the scrolled element. 183 | 184 | | Event name | Fired when... 185 | |-------------------|------------------- 186 | | sizechange | changes `clientWidth`/`clientHeight` of the optiscroll element, or changes `scrollWidth`/`scrollHeight` of the scroll area 187 | | scrollstart | the user starts scrolling 188 | | scroll | the user scrolls. This event is already throttled, fired accordingly with the `scrollMinUpdateInterval` value. 189 | | scrollstop | the user stops scrolling. The wait time before firing this event is defined by the `scrollStopDelay` option 190 | | scrollreachedge | the user scrolls to any edge (top/left/right/bottom) 191 | | scrollreachtop | the user scrolls to top 192 | | scrollreachbottom | the user scrolls to bottom 193 | | scrollreachleft | the user scrolls to left 194 | | scrollreachright | the user scrolls to right 195 | 196 | #### Detail object attributes 197 | 198 | | Name | Purpose 199 | |--------------|---------- 200 | | scrollbar{V/H}.percent | Percentage scrolled (value between 0-100) 201 | | scrollbar{V/H}.position | Position (ratio) of the scrollbar from top/left (value between 0-1) 202 | | scrollbar{V/H}.size | Height/width (ratio) of the scrollbar (value between 0-1) 203 | | scrollTop | Pixels scrolled from top 204 | | scrollLeft | Pixels scrolled from left 205 | | scrollBottom | Pixels scrolled from bottom 206 | | scrollRight | Pixels scrolled from right 207 | | scrollWidth | Total scrollable width (px) 208 | | scrollHeight | Total scrollable height (px) 209 | | clientWidth | Width of the scrollable element 210 | | clientHeight | Height of the scrollable element 211 | 212 | Examples: 213 | 214 | ```js 215 | // plain JS listener 216 | document.querySelector('#scroll').addEventListener('scrollreachtop', function (ev) { 217 | console.log(ev.type) // outputs 'scrollreachtop' 218 | console.log(ev.detail.scrollTop) // outputs scroll distance from top 219 | console.log(ev.detail.scrollbarV.percent) // outputs vertical scrolled % 220 | }); 221 | 222 | // jQuery listener 223 | $('#scroll').on('scrollstop', function (ev) { 224 | console.log(ev.type) // outputs 'scrollstop' 225 | console.log(ev.detail.scrollBottom) // outputs scroll distance from bottom 226 | console.log(ev.detail.scrollbarH.percent) // outputs horizontal scrolled % 227 | }); 228 | ``` 229 | 230 | 231 | 232 | ## Global options 233 | 234 | | Option name | Default | Purpose 235 | |-------------|---------|---------- 236 | | scrollMinUpdateInterval | 25 (ms) | By default the scrollbars position is updated up to 40 times per second. By increasing this time the scroll tracks will be updated less frequently. The smallest interval is 16, which means scroll tracks are updated up to 60 times per second. 237 | | checkFrequency | 1000 (ms) | How often scroll areas are checked for size or content changes. To disable the timer (and stop all scrollbars to auto update) set this value to 0. 238 | 239 | Examples: 240 | 241 | ```js 242 | // set the scrollbar update interval to 30 FPS 243 | Optiscroll.globalSettings.scrollMinUpdateInterval = 1000 / 30; 244 | // disable auto update for all Optiscroll instances 245 | Optiscroll.globalSettings.checkFrequency = 0; 246 | ``` 247 | 248 | 249 | ## SCSS styling options 250 | 251 | If you want more control over the styling, you can set these SCSS variables before including `scss/optiscroll.scss` in your `.scss` file: 252 | 253 | ```scss 254 | $optiscroll-namespace: 'optiscroll'; // custom css class namespace 255 | $optiscroll-classPrefix: $optiscroll-namespace + '-'; // same as js classPrefix option 256 | 257 | $optiscroll-forceScrollbarV: false; // css trick to force vertical scrollbars 258 | $optiscroll-forceScrollbarH: false; // css trick to force horizontal scrollbars 259 | $optiscroll-supportRtl: true; // enable/disable rules for rtl support 260 | $optiscroll-defaultStyle: true; // enable/disable default styling 261 | ``` 262 | 263 | 264 | 265 | ## Known limitations 266 | 267 | - `forceScrollbars` is not 100% reliable on iOS Safari (due to on-the-fly style changes), it is ignored on Firefox Mac w/ trackpad ([see FF bug](https://bugzilla.mozilla.org/show_bug.cgi?id=926294)) and on older versions of Chrome/Safari this setting will hide scrollbars also on child scrollable elements. 268 | 269 | - On iOS/Android, custom events (and scrollbars if `forceScrollbars: true`) are fired/updated whenever browser fires the scroll event. 270 | 271 | 272 | 273 | ## Why still timers to check size/content changes? 274 | 275 | Even if there are clever tricks to detect an element size change (eg iframes) there is still no reliable way to detect overflow changes (the event is Firefox only and Chrome has deprecated it). So, timers are still the most performing solution because they allow a more fine grained control. 276 | 277 | 278 | ## Running Tests 279 | 280 | Optiscroll is designed to run in the browser so the tests explicitly require 281 | a browser environment instead of any JavaScript environment (i.e. node.js). 282 | You can simply load test/index.html in any browser to run all the tests. 283 | 284 | 285 | ## Upgrading 286 | 287 | #### From v2 to v3 288 | No changes should be required, just dropped IE < 11 support and added bunch of new features. 289 | 290 | #### From v1 to v2 291 | - `classPrefix` option no longer adds `-` to the namespace, so it allows you to pick your favourite separator (or no separator at all) for `.optiscroll*` elements. 292 | - Optiscroll now automatically wraps inner content. So, remove `.optiscroll-content` from your html (behaviour customisable on v3). 293 | - Styles organisation got a major overhaul, so my suggestion is to [go and have a look](https://github.com/albertogasparin/Optiscroll/blob/master/scss/optiscroll.scss). 294 | 295 | 296 | 297 | ## History & Changelog 298 | 299 | [Check Github Releases page](https://github.com/albertogasparin/Optiscroll/releases) 300 | 301 | 302 | 303 | # License 304 | 305 | This program is free software; it is distributed under an 306 | [MIT License](https://github.com/albertogasparin/Optiscroll/blob/master/LICENSE). 307 | 308 | --- 309 | 310 | Copyright (c) 2017 Alberto Gasparin 311 | Initially developed at [Wilson Fletcher design studio](http://wilsonfletcher.com/) 312 | ([Contributors](https://github.com/albertogasparin/Optiscroll/graphs/contributors)). 313 | -------------------------------------------------------------------------------- /dist/optiscroll.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Optiscroll.js v3.2.1 3 | * https://github.com/albertogasparin/Optiscroll/ 4 | * 5 | * @copyright 2018 Alberto Gasparin 6 | * @license Released under MIT LICENSE 7 | */ 8 | 9 | ;(function ( window, document, Math, undefined ) { 10 | 'use strict'; 11 | 12 | 13 | 14 | /** 15 | * Optiscroll, use this to create instances 16 | * ``` 17 | * var scrolltime = new Optiscroll(element); 18 | * ``` 19 | */ 20 | var Optiscroll = function Optiscroll(element, options) { 21 | return new Optiscroll.Instance(element, options || {}); 22 | }; 23 | 24 | 25 | 26 | var GS = Optiscroll.globalSettings = { 27 | scrollMinUpdateInterval: 1000 / 40, // 40 FPS 28 | checkFrequency: 1000, 29 | pauseCheck: false, 30 | }; 31 | 32 | Optiscroll.defaults = { 33 | preventParentScroll: false, 34 | forceScrollbars: false, 35 | scrollStopDelay: 300, 36 | maxTrackSize: 95, 37 | minTrackSize: 5, 38 | draggableTracks: true, 39 | autoUpdate: true, 40 | classPrefix: 'optiscroll-', 41 | wrapContent: true, 42 | rtl: false, 43 | }; 44 | 45 | 46 | 47 | Optiscroll.Instance = function (element, options) { 48 | // instance variables 49 | this.element = element; 50 | this.settings = _extend(_extend({}, Optiscroll.defaults), options || {}); 51 | if (typeof options.rtl !== 'boolean') { 52 | this.settings.rtl = window.getComputedStyle(element).direction === 'rtl'; 53 | } 54 | this.cache = {}; 55 | 56 | this.init(); 57 | }; 58 | 59 | 60 | 61 | Optiscroll.Instance.prototype = { 62 | 63 | 64 | init: function () { 65 | var element = this.element, 66 | settings = this.settings, 67 | shouldCreateScrollbars = false; 68 | 69 | var scrollEl = this.scrollEl = settings.wrapContent 70 | ? Utils.createWrapper(element) 71 | : element.firstElementChild; 72 | 73 | toggleClass(scrollEl, settings.classPrefix + 'content', true); 74 | toggleClass(element, 'is-enabled' + (settings.rtl ? ' is-rtl' : ''), true); 75 | 76 | // initialize scrollbars 77 | this.scrollbars = { 78 | v: Scrollbar('v', this), 79 | h: Scrollbar('h', this), 80 | }; 81 | 82 | // create DOM scrollbars only if they have size or if it's forced 83 | if(G.scrollbarSpec.width || settings.forceScrollbars) { 84 | shouldCreateScrollbars = Utils.hideNativeScrollbars(scrollEl, settings.rtl); 85 | } 86 | 87 | if(shouldCreateScrollbars) { 88 | _invoke(this.scrollbars, 'create'); 89 | } 90 | 91 | if(G.isTouch && settings.preventParentScroll) { 92 | toggleClass(element, settings.classPrefix + 'prevent', true); 93 | } 94 | 95 | // calculate scrollbars 96 | this.update(); 97 | 98 | // bind container events 99 | this.bind(); 100 | 101 | // add instance to global array for timed check 102 | if(settings.autoUpdate) { 103 | G.instances.push(this); 104 | } 105 | 106 | // start the timed check if it is not already running 107 | if(settings.autoUpdate && !G.checkTimer) { 108 | Utils.checkLoop(); 109 | } 110 | 111 | }, 112 | 113 | 114 | 115 | bind: function () { 116 | var listeners = this.listeners = {}, 117 | scrollEl = this.scrollEl; 118 | 119 | // scroll event binding 120 | listeners.scroll = _throttle(Events.scroll.bind(this), GS.scrollMinUpdateInterval); 121 | 122 | if(G.isTouch) { 123 | listeners.touchstart = Events.touchstart.bind(this); 124 | listeners.touchend = Events.touchend.bind(this); 125 | } 126 | 127 | // Safari does not support wheel event 128 | listeners.mousewheel = listeners.wheel = Events.wheel.bind(this); 129 | 130 | for (var ev in listeners) { 131 | scrollEl.addEventListener(ev, listeners[ev], G.passiveEvent); 132 | } 133 | 134 | }, 135 | 136 | 137 | 138 | 139 | update: function () { 140 | var scrollEl = this.scrollEl, 141 | cache = this.cache, 142 | oldcH = cache.clientH, 143 | sH = scrollEl.scrollHeight, 144 | cH = scrollEl.clientHeight, 145 | sW = scrollEl.scrollWidth, 146 | cW = scrollEl.clientWidth; 147 | 148 | if(sH !== cache.scrollH || cH !== cache.clientH || 149 | sW !== cache.scrollW || cW !== cache.clientW) { 150 | 151 | cache.scrollH = sH; 152 | cache.clientH = cH; 153 | cache.scrollW = sW; 154 | cache.clientW = cW; 155 | 156 | // only fire if cache was defined 157 | if(oldcH !== undefined) { 158 | 159 | // if the element is no more in the DOM 160 | if(sH === 0 && cH === 0 && !document.body.contains(this.element)) { 161 | this.destroy(); 162 | return false; 163 | } 164 | 165 | this.fireCustomEvent('sizechange'); 166 | } 167 | 168 | // this will update the scrollbar 169 | // and check if bottom is reached 170 | _invoke(this.scrollbars, 'update'); 171 | } 172 | }, 173 | 174 | 175 | 176 | 177 | /** 178 | * Animate scrollTo 179 | */ 180 | scrollTo: function (destX, destY, duration) { 181 | var cache = this.cache, 182 | startX, startY, endX, endY; 183 | 184 | G.pauseCheck = true; 185 | // force update 186 | this.update(); 187 | 188 | startX = this.scrollEl.scrollLeft; 189 | startY = this.scrollEl.scrollTop; 190 | 191 | endX = +destX; 192 | if(destX === 'left') { endX = 0; } 193 | if(destX === 'right') { endX = cache.scrollW - cache.clientW; } 194 | if(destX === false) { endX = startX; } 195 | 196 | endY = +destY; 197 | if(destY === 'top') { endY = 0; } 198 | if(destY === 'bottom') { endY = cache.scrollH - cache.clientH; } 199 | if(destY === false) { endY = startY; } 200 | 201 | // animate 202 | this.animateScroll(startX, endX, startY, endY, +duration); 203 | 204 | }, 205 | 206 | 207 | 208 | scrollIntoView: function (elem, duration, delta) { 209 | var scrollEl = this.scrollEl, 210 | eDim, sDim, 211 | leftEdge, topEdge, rightEdge, bottomEdge, 212 | offsetX, offsetY, 213 | startX, startY, endX, endY; 214 | 215 | G.pauseCheck = true; 216 | // force update 217 | this.update(); 218 | 219 | if(typeof elem === 'string') { // selector 220 | elem = scrollEl.querySelector(elem); 221 | } else if(elem.length && elem.jquery) { // jquery element 222 | elem = elem[0]; 223 | } 224 | 225 | if(typeof delta === 'number') { // same delta for all 226 | delta = { top: delta, right: delta, bottom: delta, left: delta }; 227 | } 228 | 229 | delta = delta || {}; 230 | eDim = elem.getBoundingClientRect(); 231 | sDim = scrollEl.getBoundingClientRect(); 232 | 233 | startX = endX = scrollEl.scrollLeft; 234 | startY = endY = scrollEl.scrollTop; 235 | offsetX = startX + eDim.left - sDim.left; 236 | offsetY = startY + eDim.top - sDim.top; 237 | 238 | leftEdge = offsetX - (delta.left || 0); 239 | topEdge = offsetY - (delta.top || 0); 240 | rightEdge = offsetX + eDim.width - this.cache.clientW + (delta.right || 0); 241 | bottomEdge = offsetY + eDim.height - this.cache.clientH + (delta.bottom || 0); 242 | 243 | if(leftEdge < startX) { endX = leftEdge; } 244 | if(rightEdge > startX) { endX = rightEdge; } 245 | 246 | if(topEdge < startY) { endY = topEdge; } 247 | if(bottomEdge > startY) { endY = bottomEdge; } 248 | 249 | // animate 250 | this.animateScroll(startX, endX, startY, endY, +duration); 251 | }, 252 | 253 | 254 | 255 | 256 | animateScroll: function (startX, endX, startY, endY, duration) { 257 | var self = this, 258 | scrollEl = this.scrollEl, 259 | startTime = Date.now(); 260 | 261 | if(endX === startX && endY === startY) { 262 | return; 263 | } 264 | 265 | if(duration === 0) { 266 | scrollEl.scrollLeft = endX; 267 | scrollEl.scrollTop = endY; 268 | return; 269 | } 270 | 271 | if(isNaN(duration)) { // undefined or auto 272 | // 500px in 430ms, 1000px in 625ms, 2000px in 910ms 273 | duration = Math.pow(Math.max(Math.abs(endX - startX), Math.abs(endY - startY)), 0.54) * 15; 274 | } 275 | 276 | (function animate () { 277 | var time = Math.min(1, ((Date.now() - startTime) / duration)), 278 | easedTime = Utils.easingFunction(time); 279 | 280 | if(endY !== startY) { 281 | scrollEl.scrollTop = ~~(easedTime * (endY - startY)) + startY; 282 | } 283 | if(endX !== startX) { 284 | scrollEl.scrollLeft = ~~(easedTime * (endX - startX)) + startX; 285 | } 286 | 287 | self.scrollAnimation = time < 1 ? window.requestAnimationFrame(animate) : null; 288 | }()); 289 | }, 290 | 291 | 292 | 293 | 294 | destroy: function () { 295 | var self = this, 296 | element = this.element, 297 | scrollEl = this.scrollEl, 298 | listeners = this.listeners, 299 | child; 300 | 301 | if(!this.scrollEl) { return; } 302 | 303 | // unbind events 304 | for (var ev in listeners) { 305 | scrollEl.removeEventListener(ev, listeners[ev]); 306 | } 307 | 308 | // remove scrollbars elements 309 | _invoke(this.scrollbars, 'remove'); 310 | 311 | // unwrap content 312 | if (this.settings.wrapContent) { 313 | while(child = scrollEl.childNodes[0]) { 314 | element.insertBefore(child, scrollEl); 315 | } 316 | element.removeChild(scrollEl); 317 | this.scrollEl = null; 318 | } 319 | 320 | // remove classes 321 | toggleClass(element, this.settings.classPrefix + 'prevent', false); 322 | toggleClass(element, 'is-enabled', false); 323 | 324 | // defer instance removal from global array 325 | // to not affect checkLoop _invoke 326 | window.requestAnimationFrame(function () { 327 | var index = G.instances.indexOf(self); 328 | if (index > -1) { 329 | G.instances.splice(index, 1); 330 | } 331 | }); 332 | }, 333 | 334 | 335 | 336 | 337 | fireCustomEvent: function (eventName) { 338 | var cache = this.cache, 339 | sH = cache.scrollH, sW = cache.scrollW, 340 | eventData; 341 | 342 | eventData = { 343 | // scrollbars data 344 | scrollbarV: _extend({}, cache.v), 345 | scrollbarH: _extend({}, cache.h), 346 | 347 | // scroll position 348 | scrollTop: cache.v.position * sH, 349 | scrollLeft: cache.h.position * sW, 350 | scrollBottom: (1 - cache.v.position - cache.v.size) * sH, 351 | scrollRight: (1 - cache.h.position - cache.h.size) * sW, 352 | 353 | // element size 354 | scrollWidth: sW, 355 | scrollHeight: sH, 356 | clientWidth: cache.clientW, 357 | clientHeight: cache.clientH, 358 | }; 359 | 360 | var event; 361 | if (typeof CustomEvent === 'function') { 362 | event = new CustomEvent(eventName, { detail: eventData }); 363 | } else { // IE does not support CustomEvent 364 | event = document.createEvent('CustomEvent'); 365 | event.initCustomEvent(eventName, false, false, eventData); 366 | } 367 | this.element.dispatchEvent(event); 368 | }, 369 | 370 | }; 371 | 372 | 373 | 374 | 375 | var Events = { 376 | 377 | scroll: function (ev) { 378 | 379 | if (!G.pauseCheck) { 380 | this.fireCustomEvent('scrollstart'); 381 | } 382 | G.pauseCheck = true; 383 | 384 | this.scrollbars.v.update(); 385 | this.scrollbars.h.update(); 386 | 387 | this.fireCustomEvent('scroll'); 388 | 389 | clearTimeout(this.cache.timerStop); 390 | this.cache.timerStop = setTimeout(Events.scrollStop.bind(this), this.settings.scrollStopDelay); 391 | }, 392 | 393 | 394 | touchstart: function (ev) { 395 | G.pauseCheck = false; 396 | this.scrollbars.v.update(); 397 | this.scrollbars.h.update(); 398 | 399 | Events.wheel.call(this, ev); 400 | }, 401 | 402 | 403 | touchend: function (ev) { 404 | // prevents touchmove generate scroll event to call 405 | // scrollstop while the page is still momentum scrolling 406 | clearTimeout(this.cache.timerStop); 407 | }, 408 | 409 | 410 | scrollStop: function () { 411 | this.fireCustomEvent('scrollstop'); 412 | G.pauseCheck = false; 413 | }, 414 | 415 | 416 | wheel: function (ev) { 417 | var cache = this.cache, 418 | cacheV = cache.v, 419 | cacheH = cache.h, 420 | preventScroll = this.settings.preventParentScroll && G.isTouch; 421 | 422 | window.cancelAnimationFrame(this.scrollAnimation); 423 | 424 | if(preventScroll && cacheV.enabled && cacheV.percent % 100 === 0) { 425 | this.scrollEl.scrollTop = cacheV.percent ? (cache.scrollH - cache.clientH - 1) : 1; 426 | } 427 | if(preventScroll && cacheH.enabled && cacheH.percent % 100 === 0) { 428 | this.scrollEl.scrollLeft = cacheH.percent ? (cache.scrollW - cache.clientW - 1) : 1; 429 | } 430 | }, 431 | 432 | 433 | }; 434 | 435 | 436 | var Scrollbar = function (which, instance) { 437 | 438 | var isVertical = (which === 'v'), 439 | parentEl = instance.element, 440 | scrollEl = instance.scrollEl, 441 | settings = instance.settings, 442 | cache = instance.cache, 443 | scrollbarCache = cache[which] = {}, 444 | 445 | sizeProp = isVertical ? 'H' : 'W', 446 | clientSize = 'client' + sizeProp, 447 | scrollSize = 'scroll' + sizeProp, 448 | scrollProp = isVertical ? 'scrollTop' : 'scrollLeft', 449 | evSuffixes = isVertical ? ['top','bottom'] : ['left','right'], 450 | evTypesMatcher = /^(mouse|touch|pointer)/, 451 | 452 | rtlMode = G.scrollbarSpec.rtl, 453 | enabled = false, 454 | scrollbarEl = null, 455 | trackEl = null; 456 | 457 | var events = { 458 | dragData: null, 459 | 460 | dragStart: function (ev) { 461 | ev.preventDefault(); 462 | var evData = ev.touches ? ev.touches[0] : ev; 463 | events.dragData = { x: evData.pageX, y: evData.pageY, scroll: scrollEl[scrollProp] }; 464 | events.bind(true, ev.type.match(evTypesMatcher)[1]); 465 | }, 466 | 467 | dragMove: function (ev) { 468 | var evData = ev.touches ? ev.touches[0] : ev, 469 | dragMode = settings.rtl && rtlMode === 1 && !isVertical ? -1 : 1, 470 | delta, deltaRatio; 471 | 472 | ev.preventDefault(); 473 | delta = isVertical ? evData.pageY - events.dragData.y : evData.pageX - events.dragData.x; 474 | deltaRatio = delta / cache[clientSize]; 475 | 476 | scrollEl[scrollProp] = events.dragData.scroll + deltaRatio * cache[scrollSize] * dragMode; 477 | }, 478 | 479 | dragEnd: function (ev) { 480 | events.dragData = null; 481 | events.bind(false, ev.type.match(evTypesMatcher)[1]); 482 | }, 483 | 484 | bind: function (on, type) { 485 | var method = (on ? 'add' : 'remove') + 'EventListener', 486 | moveEv = type + 'move', 487 | upEv = type + (type === 'touch' ? 'end' : 'up'); 488 | 489 | document[method](moveEv, events.dragMove); 490 | document[method](upEv, events.dragEnd); 491 | document[method](type + 'cancel', events.dragEnd); 492 | }, 493 | 494 | }; 495 | 496 | return { 497 | 498 | 499 | toggle: function (bool) { 500 | enabled = bool; 501 | 502 | if(trackEl) { 503 | toggleClass(parentEl, 'has-' + which + 'track', enabled); 504 | } 505 | 506 | // expose enabled 507 | scrollbarCache.enabled = enabled; 508 | }, 509 | 510 | 511 | create: function () { 512 | scrollbarEl = document.createElement('div'); 513 | trackEl = document.createElement('b'); 514 | 515 | scrollbarEl.className = settings.classPrefix + which; 516 | trackEl.className = settings.classPrefix + which + 'track'; 517 | scrollbarEl.appendChild(trackEl); 518 | parentEl.appendChild(scrollbarEl); 519 | 520 | if(settings.draggableTracks) { 521 | var evTypes = window.PointerEvent ? ['pointerdown'] : ['touchstart', 'mousedown']; 522 | evTypes.forEach(function (evType) { 523 | trackEl.addEventListener(evType, events.dragStart); 524 | }); 525 | } 526 | }, 527 | 528 | 529 | update: function () { 530 | var newSize, oldSize, 531 | newDim, newRelPos, deltaPos; 532 | 533 | // if scrollbar is disabled and no scroll 534 | if(!enabled && cache[clientSize] === cache[scrollSize]) { 535 | return; 536 | } 537 | 538 | newDim = this.calc(); 539 | newSize = newDim.size; 540 | oldSize = scrollbarCache.size; 541 | newRelPos = (1 / newSize) * newDim.position * 100; 542 | deltaPos = Math.abs(newDim.position - (scrollbarCache.position || 0)) * cache[clientSize]; 543 | 544 | if(newSize === 1 && enabled) { 545 | this.toggle(false); 546 | } 547 | 548 | if(newSize < 1 && !enabled) { 549 | this.toggle(true); 550 | } 551 | 552 | if(trackEl && enabled) { 553 | this.style(newRelPos, deltaPos, newSize, oldSize); 554 | } 555 | 556 | // update cache values 557 | scrollbarCache = _extend(scrollbarCache, newDim); 558 | 559 | if(enabled) { 560 | this.fireEdgeEv(); 561 | } 562 | 563 | }, 564 | 565 | 566 | style: function (newRelPos, deltaPos, newSize, oldSize) { 567 | if(newSize !== oldSize) { 568 | trackEl.style[ isVertical ? 'height' : 'width' ] = newSize * 100 + '%'; 569 | if (settings.rtl && !isVertical) { 570 | trackEl.style.marginRight = (1 - newSize) * 100 + '%'; 571 | } 572 | } 573 | trackEl.style[G.cssTransform] = 'translate(' + 574 | (isVertical ? '0%,' + newRelPos + '%' : newRelPos + '%' + ',0%') 575 | + ')'; 576 | }, 577 | 578 | 579 | calc: function () { 580 | var position = scrollEl[scrollProp], 581 | viewS = cache[clientSize], 582 | scrollS = cache[scrollSize], 583 | sizeRatio = viewS / scrollS, 584 | sizeDiff = scrollS - viewS, 585 | positionRatio, percent; 586 | 587 | if(sizeRatio >= 1 || !scrollS) { // no scrollbars needed 588 | return { position: 0, size: 1, percent: 0 }; 589 | } 590 | if (!isVertical && settings.rtl && rtlMode) { 591 | position = sizeDiff - position * rtlMode; 592 | } 593 | 594 | percent = 100 * position / sizeDiff; 595 | 596 | // prevent overscroll effetcs (negative percent) 597 | // and keep 1px tolerance near the edges 598 | if(position <= 1) { percent = 0; } 599 | if(position >= sizeDiff - 1) { percent = 100; } 600 | 601 | // Capped size based on min/max track percentage 602 | sizeRatio = Math.max(sizeRatio, settings.minTrackSize / 100); 603 | sizeRatio = Math.min(sizeRatio, settings.maxTrackSize / 100); 604 | 605 | positionRatio = (1 - sizeRatio) * (percent / 100); 606 | 607 | return { position: positionRatio, size: sizeRatio, percent: percent }; 608 | }, 609 | 610 | 611 | fireEdgeEv: function () { 612 | var percent = scrollbarCache.percent; 613 | 614 | if(scrollbarCache.was !== percent && percent % 100 === 0) { 615 | instance.fireCustomEvent('scrollreachedge'); 616 | instance.fireCustomEvent('scrollreach' + evSuffixes[percent / 100]); 617 | } 618 | 619 | scrollbarCache.was = percent; 620 | }, 621 | 622 | 623 | remove: function () { 624 | // remove parent custom classes 625 | this.toggle(false); 626 | // remove elements 627 | if(scrollbarEl) { 628 | scrollbarEl.parentNode.removeChild(scrollbarEl); 629 | scrollbarEl = null; 630 | } 631 | }, 632 | 633 | }; 634 | 635 | }; 636 | 637 | 638 | var Utils = { 639 | 640 | hideNativeScrollbars: function (scrollEl, isRtl) { 641 | var size = G.scrollbarSpec.width, 642 | scrollElStyle = scrollEl.style; 643 | if(size === 0) { 644 | // hide Webkit/touch scrollbars 645 | var time = Date.now(); 646 | scrollEl.setAttribute('data-scroll', time); 647 | return Utils.addCssRule('[data-scroll="' + time + '"]::-webkit-scrollbar', 'display:none;width:0;height:0;'); 648 | } else { 649 | scrollElStyle[isRtl ? 'left' : 'right'] = -size + 'px'; 650 | scrollElStyle.bottom = -size + 'px'; 651 | return true; 652 | } 653 | }, 654 | 655 | 656 | addCssRule: function (selector, rules) { 657 | var styleSheet = document.getElementById('scroll-sheet'); 658 | if(!styleSheet) { 659 | styleSheet = document.createElement('style'); 660 | styleSheet.id = 'scroll-sheet'; 661 | styleSheet.appendChild(document.createTextNode('')); // WebKit hack 662 | document.head.appendChild(styleSheet); 663 | } 664 | try { 665 | styleSheet.sheet.insertRule(selector + ' {' + rules + '}', 0); 666 | return true; 667 | } catch (e) { return; } 668 | }, 669 | 670 | 671 | createWrapper: function (element, className) { 672 | var wrapper = document.createElement('div'), 673 | child; 674 | while(child = element.childNodes[0]) { 675 | wrapper.appendChild(child); 676 | } 677 | return element.appendChild(wrapper); 678 | }, 679 | 680 | 681 | // Global height checker 682 | // looped to listen element changes 683 | checkLoop: function () { 684 | 685 | if(!G.instances.length) { 686 | G.checkTimer = null; 687 | return; 688 | } 689 | 690 | if(!G.pauseCheck) { // check size only if not scrolling 691 | _invoke(G.instances, 'update'); 692 | } 693 | 694 | if(GS.checkFrequency) { 695 | G.checkTimer = setTimeout(function () { 696 | Utils.checkLoop(); 697 | }, GS.checkFrequency); 698 | } 699 | }, 700 | 701 | 702 | // easeOutCubic function 703 | easingFunction: function (t) { 704 | return (--t) * t * t + 1; 705 | }, 706 | 707 | 708 | }; 709 | 710 | 711 | 712 | // Global variables 713 | var G = Optiscroll.G = { 714 | isTouch: 'ontouchstart' in window, 715 | cssTransition: cssTest('transition'), 716 | cssTransform: cssTest('transform'), 717 | scrollbarSpec: getScrollbarSpec(), 718 | passiveEvent: getPassiveSupport(), 719 | 720 | instances: [], 721 | checkTimer: null, 722 | pauseCheck: false, 723 | }; 724 | 725 | 726 | // Get scrollbars width, thanks Google Closure Library 727 | function getScrollbarSpec () { 728 | var htmlEl = document.documentElement, 729 | outerEl, innerEl, width = 0, rtl = 1; // IE is reverse 730 | 731 | outerEl = document.createElement('div'); 732 | outerEl.style.cssText = 'overflow:scroll;width:50px;height:50px;position:absolute;left:-100px;direction:rtl'; 733 | 734 | innerEl = document.createElement('div'); 735 | innerEl.style.cssText = 'width:100px;height:100px'; 736 | 737 | outerEl.appendChild(innerEl); 738 | htmlEl.appendChild(outerEl); 739 | width = outerEl.offsetWidth - outerEl.clientWidth; 740 | if (outerEl.scrollLeft > 0) { 741 | rtl = 0; // webkit is default 742 | } else { 743 | outerEl.scrollLeft = 1; 744 | if (outerEl.scrollLeft === 0) { 745 | rtl = -1; // firefox is negative 746 | } 747 | } 748 | htmlEl.removeChild(outerEl); 749 | 750 | return { width: width, rtl: rtl }; 751 | } 752 | 753 | 754 | function getPassiveSupport () { 755 | var passive = false; 756 | var options = Object.defineProperty({}, 'passive', { 757 | get: function () { passive = true; }, 758 | }); 759 | window.addEventListener('test', null, options); 760 | return passive ? { capture: false, passive: true } : false; 761 | } 762 | 763 | 764 | // Detect css3 support, thanks Modernizr 765 | function cssTest (prop) { 766 | var ucProp = prop.charAt(0).toUpperCase() + prop.slice(1), 767 | el = document.createElement('test'), 768 | props = [prop, 'Webkit' + ucProp]; 769 | 770 | for (var i in props) { 771 | if(el.style[props[i]] !== undefined) { return props[i]; } 772 | } 773 | return ''; 774 | } 775 | 776 | 777 | 778 | function toggleClass (el, value, bool) { 779 | var classes = el.className.split(/\s+/), 780 | index = classes.indexOf(value); 781 | 782 | if(bool) { 783 | ~index || classes.push(value); 784 | } else { 785 | ~index && classes.splice(index, 1); 786 | } 787 | 788 | el.className = classes.join(' '); 789 | } 790 | 791 | 792 | 793 | 794 | function _extend (dest, src, merge) { 795 | for(var key in src) { 796 | if(!src.hasOwnProperty(key) || dest[key] !== undefined && merge) { 797 | continue; 798 | } 799 | dest[key] = src[key]; 800 | } 801 | return dest; 802 | } 803 | 804 | 805 | function _invoke (collection, fn, args) { 806 | var i, j; 807 | if(collection.length) { 808 | for(i = 0, j = collection.length; i < j; i++) { 809 | collection[i][fn].apply(collection[i], args); 810 | } 811 | } else { 812 | for (i in collection) { 813 | collection[i][fn].apply(collection[i], args); 814 | } 815 | } 816 | } 817 | 818 | function _throttle(fn, threshhold) { 819 | var last, deferTimer; 820 | return function () { 821 | var context = this, 822 | now = Date.now(), 823 | args = arguments; 824 | if (last && now < last + threshhold) { 825 | // hold on to it 826 | clearTimeout(deferTimer); 827 | deferTimer = setTimeout(function () { 828 | last = now; 829 | fn.apply(context, args); 830 | }, threshhold); 831 | } else { 832 | last = now; 833 | fn.apply(context, args); 834 | } 835 | }; 836 | } 837 | 838 | 839 | 840 | // AMD export 841 | if(typeof define == 'function' && define.amd) { 842 | define(function(){ 843 | return Optiscroll; 844 | }); 845 | } 846 | 847 | // commonjs export 848 | if(typeof module !== 'undefined' && module.exports) { 849 | module.exports = Optiscroll; 850 | } 851 | 852 | window.Optiscroll = Optiscroll; 853 | 854 | })(window, document, Math); 855 | -------------------------------------------------------------------------------- /dist/jquery.optiscroll.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Optiscroll.js v3.2.1 3 | * https://github.com/albertogasparin/Optiscroll/ 4 | * 5 | * @copyright 2018 Alberto Gasparin 6 | * @license Released under MIT LICENSE 7 | */ 8 | 9 | ;(function ( window, document, Math, undefined ) { 10 | 'use strict'; 11 | 12 | 13 | 14 | /** 15 | * Optiscroll, use this to create instances 16 | * ``` 17 | * var scrolltime = new Optiscroll(element); 18 | * ``` 19 | */ 20 | var Optiscroll = function Optiscroll(element, options) { 21 | return new Optiscroll.Instance(element, options || {}); 22 | }; 23 | 24 | 25 | 26 | var GS = Optiscroll.globalSettings = { 27 | scrollMinUpdateInterval: 1000 / 40, // 40 FPS 28 | checkFrequency: 1000, 29 | pauseCheck: false, 30 | }; 31 | 32 | Optiscroll.defaults = { 33 | preventParentScroll: false, 34 | forceScrollbars: false, 35 | scrollStopDelay: 300, 36 | maxTrackSize: 95, 37 | minTrackSize: 5, 38 | draggableTracks: true, 39 | autoUpdate: true, 40 | classPrefix: 'optiscroll-', 41 | wrapContent: true, 42 | rtl: false, 43 | }; 44 | 45 | 46 | 47 | Optiscroll.Instance = function (element, options) { 48 | // instance variables 49 | this.element = element; 50 | this.settings = _extend(_extend({}, Optiscroll.defaults), options || {}); 51 | if (typeof options.rtl !== 'boolean') { 52 | this.settings.rtl = window.getComputedStyle(element).direction === 'rtl'; 53 | } 54 | this.cache = {}; 55 | 56 | this.init(); 57 | }; 58 | 59 | 60 | 61 | Optiscroll.Instance.prototype = { 62 | 63 | 64 | init: function () { 65 | var element = this.element, 66 | settings = this.settings, 67 | shouldCreateScrollbars = false; 68 | 69 | var scrollEl = this.scrollEl = settings.wrapContent 70 | ? Utils.createWrapper(element) 71 | : element.firstElementChild; 72 | 73 | toggleClass(scrollEl, settings.classPrefix + 'content', true); 74 | toggleClass(element, 'is-enabled' + (settings.rtl ? ' is-rtl' : ''), true); 75 | 76 | // initialize scrollbars 77 | this.scrollbars = { 78 | v: Scrollbar('v', this), 79 | h: Scrollbar('h', this), 80 | }; 81 | 82 | // create DOM scrollbars only if they have size or if it's forced 83 | if(G.scrollbarSpec.width || settings.forceScrollbars) { 84 | shouldCreateScrollbars = Utils.hideNativeScrollbars(scrollEl, settings.rtl); 85 | } 86 | 87 | if(shouldCreateScrollbars) { 88 | _invoke(this.scrollbars, 'create'); 89 | } 90 | 91 | if(G.isTouch && settings.preventParentScroll) { 92 | toggleClass(element, settings.classPrefix + 'prevent', true); 93 | } 94 | 95 | // calculate scrollbars 96 | this.update(); 97 | 98 | // bind container events 99 | this.bind(); 100 | 101 | // add instance to global array for timed check 102 | if(settings.autoUpdate) { 103 | G.instances.push(this); 104 | } 105 | 106 | // start the timed check if it is not already running 107 | if(settings.autoUpdate && !G.checkTimer) { 108 | Utils.checkLoop(); 109 | } 110 | 111 | }, 112 | 113 | 114 | 115 | bind: function () { 116 | var listeners = this.listeners = {}, 117 | scrollEl = this.scrollEl; 118 | 119 | // scroll event binding 120 | listeners.scroll = _throttle(Events.scroll.bind(this), GS.scrollMinUpdateInterval); 121 | 122 | if(G.isTouch) { 123 | listeners.touchstart = Events.touchstart.bind(this); 124 | listeners.touchend = Events.touchend.bind(this); 125 | } 126 | 127 | // Safari does not support wheel event 128 | listeners.mousewheel = listeners.wheel = Events.wheel.bind(this); 129 | 130 | for (var ev in listeners) { 131 | scrollEl.addEventListener(ev, listeners[ev], G.passiveEvent); 132 | } 133 | 134 | }, 135 | 136 | 137 | 138 | 139 | update: function () { 140 | var scrollEl = this.scrollEl, 141 | cache = this.cache, 142 | oldcH = cache.clientH, 143 | sH = scrollEl.scrollHeight, 144 | cH = scrollEl.clientHeight, 145 | sW = scrollEl.scrollWidth, 146 | cW = scrollEl.clientWidth; 147 | 148 | if(sH !== cache.scrollH || cH !== cache.clientH || 149 | sW !== cache.scrollW || cW !== cache.clientW) { 150 | 151 | cache.scrollH = sH; 152 | cache.clientH = cH; 153 | cache.scrollW = sW; 154 | cache.clientW = cW; 155 | 156 | // only fire if cache was defined 157 | if(oldcH !== undefined) { 158 | 159 | // if the element is no more in the DOM 160 | if(sH === 0 && cH === 0 && !document.body.contains(this.element)) { 161 | this.destroy(); 162 | return false; 163 | } 164 | 165 | this.fireCustomEvent('sizechange'); 166 | } 167 | 168 | // this will update the scrollbar 169 | // and check if bottom is reached 170 | _invoke(this.scrollbars, 'update'); 171 | } 172 | }, 173 | 174 | 175 | 176 | 177 | /** 178 | * Animate scrollTo 179 | */ 180 | scrollTo: function (destX, destY, duration) { 181 | var cache = this.cache, 182 | startX, startY, endX, endY; 183 | 184 | G.pauseCheck = true; 185 | // force update 186 | this.update(); 187 | 188 | startX = this.scrollEl.scrollLeft; 189 | startY = this.scrollEl.scrollTop; 190 | 191 | endX = +destX; 192 | if(destX === 'left') { endX = 0; } 193 | if(destX === 'right') { endX = cache.scrollW - cache.clientW; } 194 | if(destX === false) { endX = startX; } 195 | 196 | endY = +destY; 197 | if(destY === 'top') { endY = 0; } 198 | if(destY === 'bottom') { endY = cache.scrollH - cache.clientH; } 199 | if(destY === false) { endY = startY; } 200 | 201 | // animate 202 | this.animateScroll(startX, endX, startY, endY, +duration); 203 | 204 | }, 205 | 206 | 207 | 208 | scrollIntoView: function (elem, duration, delta) { 209 | var scrollEl = this.scrollEl, 210 | eDim, sDim, 211 | leftEdge, topEdge, rightEdge, bottomEdge, 212 | offsetX, offsetY, 213 | startX, startY, endX, endY; 214 | 215 | G.pauseCheck = true; 216 | // force update 217 | this.update(); 218 | 219 | if(typeof elem === 'string') { // selector 220 | elem = scrollEl.querySelector(elem); 221 | } else if(elem.length && elem.jquery) { // jquery element 222 | elem = elem[0]; 223 | } 224 | 225 | if(typeof delta === 'number') { // same delta for all 226 | delta = { top: delta, right: delta, bottom: delta, left: delta }; 227 | } 228 | 229 | delta = delta || {}; 230 | eDim = elem.getBoundingClientRect(); 231 | sDim = scrollEl.getBoundingClientRect(); 232 | 233 | startX = endX = scrollEl.scrollLeft; 234 | startY = endY = scrollEl.scrollTop; 235 | offsetX = startX + eDim.left - sDim.left; 236 | offsetY = startY + eDim.top - sDim.top; 237 | 238 | leftEdge = offsetX - (delta.left || 0); 239 | topEdge = offsetY - (delta.top || 0); 240 | rightEdge = offsetX + eDim.width - this.cache.clientW + (delta.right || 0); 241 | bottomEdge = offsetY + eDim.height - this.cache.clientH + (delta.bottom || 0); 242 | 243 | if(leftEdge < startX) { endX = leftEdge; } 244 | if(rightEdge > startX) { endX = rightEdge; } 245 | 246 | if(topEdge < startY) { endY = topEdge; } 247 | if(bottomEdge > startY) { endY = bottomEdge; } 248 | 249 | // animate 250 | this.animateScroll(startX, endX, startY, endY, +duration); 251 | }, 252 | 253 | 254 | 255 | 256 | animateScroll: function (startX, endX, startY, endY, duration) { 257 | var self = this, 258 | scrollEl = this.scrollEl, 259 | startTime = Date.now(); 260 | 261 | if(endX === startX && endY === startY) { 262 | return; 263 | } 264 | 265 | if(duration === 0) { 266 | scrollEl.scrollLeft = endX; 267 | scrollEl.scrollTop = endY; 268 | return; 269 | } 270 | 271 | if(isNaN(duration)) { // undefined or auto 272 | // 500px in 430ms, 1000px in 625ms, 2000px in 910ms 273 | duration = Math.pow(Math.max(Math.abs(endX - startX), Math.abs(endY - startY)), 0.54) * 15; 274 | } 275 | 276 | (function animate () { 277 | var time = Math.min(1, ((Date.now() - startTime) / duration)), 278 | easedTime = Utils.easingFunction(time); 279 | 280 | if(endY !== startY) { 281 | scrollEl.scrollTop = ~~(easedTime * (endY - startY)) + startY; 282 | } 283 | if(endX !== startX) { 284 | scrollEl.scrollLeft = ~~(easedTime * (endX - startX)) + startX; 285 | } 286 | 287 | self.scrollAnimation = time < 1 ? window.requestAnimationFrame(animate) : null; 288 | }()); 289 | }, 290 | 291 | 292 | 293 | 294 | destroy: function () { 295 | var self = this, 296 | element = this.element, 297 | scrollEl = this.scrollEl, 298 | listeners = this.listeners, 299 | child; 300 | 301 | if(!this.scrollEl) { return; } 302 | 303 | // unbind events 304 | for (var ev in listeners) { 305 | scrollEl.removeEventListener(ev, listeners[ev]); 306 | } 307 | 308 | // remove scrollbars elements 309 | _invoke(this.scrollbars, 'remove'); 310 | 311 | // unwrap content 312 | if (this.settings.wrapContent) { 313 | while(child = scrollEl.childNodes[0]) { 314 | element.insertBefore(child, scrollEl); 315 | } 316 | element.removeChild(scrollEl); 317 | this.scrollEl = null; 318 | } 319 | 320 | // remove classes 321 | toggleClass(element, this.settings.classPrefix + 'prevent', false); 322 | toggleClass(element, 'is-enabled', false); 323 | 324 | // defer instance removal from global array 325 | // to not affect checkLoop _invoke 326 | window.requestAnimationFrame(function () { 327 | var index = G.instances.indexOf(self); 328 | if (index > -1) { 329 | G.instances.splice(index, 1); 330 | } 331 | }); 332 | }, 333 | 334 | 335 | 336 | 337 | fireCustomEvent: function (eventName) { 338 | var cache = this.cache, 339 | sH = cache.scrollH, sW = cache.scrollW, 340 | eventData; 341 | 342 | eventData = { 343 | // scrollbars data 344 | scrollbarV: _extend({}, cache.v), 345 | scrollbarH: _extend({}, cache.h), 346 | 347 | // scroll position 348 | scrollTop: cache.v.position * sH, 349 | scrollLeft: cache.h.position * sW, 350 | scrollBottom: (1 - cache.v.position - cache.v.size) * sH, 351 | scrollRight: (1 - cache.h.position - cache.h.size) * sW, 352 | 353 | // element size 354 | scrollWidth: sW, 355 | scrollHeight: sH, 356 | clientWidth: cache.clientW, 357 | clientHeight: cache.clientH, 358 | }; 359 | 360 | var event; 361 | if (typeof CustomEvent === 'function') { 362 | event = new CustomEvent(eventName, { detail: eventData }); 363 | } else { // IE does not support CustomEvent 364 | event = document.createEvent('CustomEvent'); 365 | event.initCustomEvent(eventName, false, false, eventData); 366 | } 367 | this.element.dispatchEvent(event); 368 | }, 369 | 370 | }; 371 | 372 | 373 | 374 | 375 | var Events = { 376 | 377 | scroll: function (ev) { 378 | 379 | if (!G.pauseCheck) { 380 | this.fireCustomEvent('scrollstart'); 381 | } 382 | G.pauseCheck = true; 383 | 384 | this.scrollbars.v.update(); 385 | this.scrollbars.h.update(); 386 | 387 | this.fireCustomEvent('scroll'); 388 | 389 | clearTimeout(this.cache.timerStop); 390 | this.cache.timerStop = setTimeout(Events.scrollStop.bind(this), this.settings.scrollStopDelay); 391 | }, 392 | 393 | 394 | touchstart: function (ev) { 395 | G.pauseCheck = false; 396 | this.scrollbars.v.update(); 397 | this.scrollbars.h.update(); 398 | 399 | Events.wheel.call(this, ev); 400 | }, 401 | 402 | 403 | touchend: function (ev) { 404 | // prevents touchmove generate scroll event to call 405 | // scrollstop while the page is still momentum scrolling 406 | clearTimeout(this.cache.timerStop); 407 | }, 408 | 409 | 410 | scrollStop: function () { 411 | this.fireCustomEvent('scrollstop'); 412 | G.pauseCheck = false; 413 | }, 414 | 415 | 416 | wheel: function (ev) { 417 | var cache = this.cache, 418 | cacheV = cache.v, 419 | cacheH = cache.h, 420 | preventScroll = this.settings.preventParentScroll && G.isTouch; 421 | 422 | window.cancelAnimationFrame(this.scrollAnimation); 423 | 424 | if(preventScroll && cacheV.enabled && cacheV.percent % 100 === 0) { 425 | this.scrollEl.scrollTop = cacheV.percent ? (cache.scrollH - cache.clientH - 1) : 1; 426 | } 427 | if(preventScroll && cacheH.enabled && cacheH.percent % 100 === 0) { 428 | this.scrollEl.scrollLeft = cacheH.percent ? (cache.scrollW - cache.clientW - 1) : 1; 429 | } 430 | }, 431 | 432 | 433 | }; 434 | 435 | 436 | var Scrollbar = function (which, instance) { 437 | 438 | var isVertical = (which === 'v'), 439 | parentEl = instance.element, 440 | scrollEl = instance.scrollEl, 441 | settings = instance.settings, 442 | cache = instance.cache, 443 | scrollbarCache = cache[which] = {}, 444 | 445 | sizeProp = isVertical ? 'H' : 'W', 446 | clientSize = 'client' + sizeProp, 447 | scrollSize = 'scroll' + sizeProp, 448 | scrollProp = isVertical ? 'scrollTop' : 'scrollLeft', 449 | evSuffixes = isVertical ? ['top','bottom'] : ['left','right'], 450 | evTypesMatcher = /^(mouse|touch|pointer)/, 451 | 452 | rtlMode = G.scrollbarSpec.rtl, 453 | enabled = false, 454 | scrollbarEl = null, 455 | trackEl = null; 456 | 457 | var events = { 458 | dragData: null, 459 | 460 | dragStart: function (ev) { 461 | ev.preventDefault(); 462 | var evData = ev.touches ? ev.touches[0] : ev; 463 | events.dragData = { x: evData.pageX, y: evData.pageY, scroll: scrollEl[scrollProp] }; 464 | events.bind(true, ev.type.match(evTypesMatcher)[1]); 465 | }, 466 | 467 | dragMove: function (ev) { 468 | var evData = ev.touches ? ev.touches[0] : ev, 469 | dragMode = settings.rtl && rtlMode === 1 && !isVertical ? -1 : 1, 470 | delta, deltaRatio; 471 | 472 | ev.preventDefault(); 473 | delta = isVertical ? evData.pageY - events.dragData.y : evData.pageX - events.dragData.x; 474 | deltaRatio = delta / cache[clientSize]; 475 | 476 | scrollEl[scrollProp] = events.dragData.scroll + deltaRatio * cache[scrollSize] * dragMode; 477 | }, 478 | 479 | dragEnd: function (ev) { 480 | events.dragData = null; 481 | events.bind(false, ev.type.match(evTypesMatcher)[1]); 482 | }, 483 | 484 | bind: function (on, type) { 485 | var method = (on ? 'add' : 'remove') + 'EventListener', 486 | moveEv = type + 'move', 487 | upEv = type + (type === 'touch' ? 'end' : 'up'); 488 | 489 | document[method](moveEv, events.dragMove); 490 | document[method](upEv, events.dragEnd); 491 | document[method](type + 'cancel', events.dragEnd); 492 | }, 493 | 494 | }; 495 | 496 | return { 497 | 498 | 499 | toggle: function (bool) { 500 | enabled = bool; 501 | 502 | if(trackEl) { 503 | toggleClass(parentEl, 'has-' + which + 'track', enabled); 504 | } 505 | 506 | // expose enabled 507 | scrollbarCache.enabled = enabled; 508 | }, 509 | 510 | 511 | create: function () { 512 | scrollbarEl = document.createElement('div'); 513 | trackEl = document.createElement('b'); 514 | 515 | scrollbarEl.className = settings.classPrefix + which; 516 | trackEl.className = settings.classPrefix + which + 'track'; 517 | scrollbarEl.appendChild(trackEl); 518 | parentEl.appendChild(scrollbarEl); 519 | 520 | if(settings.draggableTracks) { 521 | var evTypes = window.PointerEvent ? ['pointerdown'] : ['touchstart', 'mousedown']; 522 | evTypes.forEach(function (evType) { 523 | trackEl.addEventListener(evType, events.dragStart); 524 | }); 525 | } 526 | }, 527 | 528 | 529 | update: function () { 530 | var newSize, oldSize, 531 | newDim, newRelPos, deltaPos; 532 | 533 | // if scrollbar is disabled and no scroll 534 | if(!enabled && cache[clientSize] === cache[scrollSize]) { 535 | return; 536 | } 537 | 538 | newDim = this.calc(); 539 | newSize = newDim.size; 540 | oldSize = scrollbarCache.size; 541 | newRelPos = (1 / newSize) * newDim.position * 100; 542 | deltaPos = Math.abs(newDim.position - (scrollbarCache.position || 0)) * cache[clientSize]; 543 | 544 | if(newSize === 1 && enabled) { 545 | this.toggle(false); 546 | } 547 | 548 | if(newSize < 1 && !enabled) { 549 | this.toggle(true); 550 | } 551 | 552 | if(trackEl && enabled) { 553 | this.style(newRelPos, deltaPos, newSize, oldSize); 554 | } 555 | 556 | // update cache values 557 | scrollbarCache = _extend(scrollbarCache, newDim); 558 | 559 | if(enabled) { 560 | this.fireEdgeEv(); 561 | } 562 | 563 | }, 564 | 565 | 566 | style: function (newRelPos, deltaPos, newSize, oldSize) { 567 | if(newSize !== oldSize) { 568 | trackEl.style[ isVertical ? 'height' : 'width' ] = newSize * 100 + '%'; 569 | if (settings.rtl && !isVertical) { 570 | trackEl.style.marginRight = (1 - newSize) * 100 + '%'; 571 | } 572 | } 573 | trackEl.style[G.cssTransform] = 'translate(' + 574 | (isVertical ? '0%,' + newRelPos + '%' : newRelPos + '%' + ',0%') 575 | + ')'; 576 | }, 577 | 578 | 579 | calc: function () { 580 | var position = scrollEl[scrollProp], 581 | viewS = cache[clientSize], 582 | scrollS = cache[scrollSize], 583 | sizeRatio = viewS / scrollS, 584 | sizeDiff = scrollS - viewS, 585 | positionRatio, percent; 586 | 587 | if(sizeRatio >= 1 || !scrollS) { // no scrollbars needed 588 | return { position: 0, size: 1, percent: 0 }; 589 | } 590 | if (!isVertical && settings.rtl && rtlMode) { 591 | position = sizeDiff - position * rtlMode; 592 | } 593 | 594 | percent = 100 * position / sizeDiff; 595 | 596 | // prevent overscroll effetcs (negative percent) 597 | // and keep 1px tolerance near the edges 598 | if(position <= 1) { percent = 0; } 599 | if(position >= sizeDiff - 1) { percent = 100; } 600 | 601 | // Capped size based on min/max track percentage 602 | sizeRatio = Math.max(sizeRatio, settings.minTrackSize / 100); 603 | sizeRatio = Math.min(sizeRatio, settings.maxTrackSize / 100); 604 | 605 | positionRatio = (1 - sizeRatio) * (percent / 100); 606 | 607 | return { position: positionRatio, size: sizeRatio, percent: percent }; 608 | }, 609 | 610 | 611 | fireEdgeEv: function () { 612 | var percent = scrollbarCache.percent; 613 | 614 | if(scrollbarCache.was !== percent && percent % 100 === 0) { 615 | instance.fireCustomEvent('scrollreachedge'); 616 | instance.fireCustomEvent('scrollreach' + evSuffixes[percent / 100]); 617 | } 618 | 619 | scrollbarCache.was = percent; 620 | }, 621 | 622 | 623 | remove: function () { 624 | // remove parent custom classes 625 | this.toggle(false); 626 | // remove elements 627 | if(scrollbarEl) { 628 | scrollbarEl.parentNode.removeChild(scrollbarEl); 629 | scrollbarEl = null; 630 | } 631 | }, 632 | 633 | }; 634 | 635 | }; 636 | 637 | 638 | var Utils = { 639 | 640 | hideNativeScrollbars: function (scrollEl, isRtl) { 641 | var size = G.scrollbarSpec.width, 642 | scrollElStyle = scrollEl.style; 643 | if(size === 0) { 644 | // hide Webkit/touch scrollbars 645 | var time = Date.now(); 646 | scrollEl.setAttribute('data-scroll', time); 647 | return Utils.addCssRule('[data-scroll="' + time + '"]::-webkit-scrollbar', 'display:none;width:0;height:0;'); 648 | } else { 649 | scrollElStyle[isRtl ? 'left' : 'right'] = -size + 'px'; 650 | scrollElStyle.bottom = -size + 'px'; 651 | return true; 652 | } 653 | }, 654 | 655 | 656 | addCssRule: function (selector, rules) { 657 | var styleSheet = document.getElementById('scroll-sheet'); 658 | if(!styleSheet) { 659 | styleSheet = document.createElement('style'); 660 | styleSheet.id = 'scroll-sheet'; 661 | styleSheet.appendChild(document.createTextNode('')); // WebKit hack 662 | document.head.appendChild(styleSheet); 663 | } 664 | try { 665 | styleSheet.sheet.insertRule(selector + ' {' + rules + '}', 0); 666 | return true; 667 | } catch (e) { return; } 668 | }, 669 | 670 | 671 | createWrapper: function (element, className) { 672 | var wrapper = document.createElement('div'), 673 | child; 674 | while(child = element.childNodes[0]) { 675 | wrapper.appendChild(child); 676 | } 677 | return element.appendChild(wrapper); 678 | }, 679 | 680 | 681 | // Global height checker 682 | // looped to listen element changes 683 | checkLoop: function () { 684 | 685 | if(!G.instances.length) { 686 | G.checkTimer = null; 687 | return; 688 | } 689 | 690 | if(!G.pauseCheck) { // check size only if not scrolling 691 | _invoke(G.instances, 'update'); 692 | } 693 | 694 | if(GS.checkFrequency) { 695 | G.checkTimer = setTimeout(function () { 696 | Utils.checkLoop(); 697 | }, GS.checkFrequency); 698 | } 699 | }, 700 | 701 | 702 | // easeOutCubic function 703 | easingFunction: function (t) { 704 | return (--t) * t * t + 1; 705 | }, 706 | 707 | 708 | }; 709 | 710 | 711 | 712 | // Global variables 713 | var G = Optiscroll.G = { 714 | isTouch: 'ontouchstart' in window, 715 | cssTransition: cssTest('transition'), 716 | cssTransform: cssTest('transform'), 717 | scrollbarSpec: getScrollbarSpec(), 718 | passiveEvent: getPassiveSupport(), 719 | 720 | instances: [], 721 | checkTimer: null, 722 | pauseCheck: false, 723 | }; 724 | 725 | 726 | // Get scrollbars width, thanks Google Closure Library 727 | function getScrollbarSpec () { 728 | var htmlEl = document.documentElement, 729 | outerEl, innerEl, width = 0, rtl = 1; // IE is reverse 730 | 731 | outerEl = document.createElement('div'); 732 | outerEl.style.cssText = 'overflow:scroll;width:50px;height:50px;position:absolute;left:-100px;direction:rtl'; 733 | 734 | innerEl = document.createElement('div'); 735 | innerEl.style.cssText = 'width:100px;height:100px'; 736 | 737 | outerEl.appendChild(innerEl); 738 | htmlEl.appendChild(outerEl); 739 | width = outerEl.offsetWidth - outerEl.clientWidth; 740 | if (outerEl.scrollLeft > 0) { 741 | rtl = 0; // webkit is default 742 | } else { 743 | outerEl.scrollLeft = 1; 744 | if (outerEl.scrollLeft === 0) { 745 | rtl = -1; // firefox is negative 746 | } 747 | } 748 | htmlEl.removeChild(outerEl); 749 | 750 | return { width: width, rtl: rtl }; 751 | } 752 | 753 | 754 | function getPassiveSupport () { 755 | var passive = false; 756 | var options = Object.defineProperty({}, 'passive', { 757 | get: function () { passive = true; }, 758 | }); 759 | window.addEventListener('test', null, options); 760 | return passive ? { capture: false, passive: true } : false; 761 | } 762 | 763 | 764 | // Detect css3 support, thanks Modernizr 765 | function cssTest (prop) { 766 | var ucProp = prop.charAt(0).toUpperCase() + prop.slice(1), 767 | el = document.createElement('test'), 768 | props = [prop, 'Webkit' + ucProp]; 769 | 770 | for (var i in props) { 771 | if(el.style[props[i]] !== undefined) { return props[i]; } 772 | } 773 | return ''; 774 | } 775 | 776 | 777 | 778 | function toggleClass (el, value, bool) { 779 | var classes = el.className.split(/\s+/), 780 | index = classes.indexOf(value); 781 | 782 | if(bool) { 783 | ~index || classes.push(value); 784 | } else { 785 | ~index && classes.splice(index, 1); 786 | } 787 | 788 | el.className = classes.join(' '); 789 | } 790 | 791 | 792 | 793 | 794 | function _extend (dest, src, merge) { 795 | for(var key in src) { 796 | if(!src.hasOwnProperty(key) || dest[key] !== undefined && merge) { 797 | continue; 798 | } 799 | dest[key] = src[key]; 800 | } 801 | return dest; 802 | } 803 | 804 | 805 | function _invoke (collection, fn, args) { 806 | var i, j; 807 | if(collection.length) { 808 | for(i = 0, j = collection.length; i < j; i++) { 809 | collection[i][fn].apply(collection[i], args); 810 | } 811 | } else { 812 | for (i in collection) { 813 | collection[i][fn].apply(collection[i], args); 814 | } 815 | } 816 | } 817 | 818 | function _throttle(fn, threshhold) { 819 | var last, deferTimer; 820 | return function () { 821 | var context = this, 822 | now = Date.now(), 823 | args = arguments; 824 | if (last && now < last + threshhold) { 825 | // hold on to it 826 | clearTimeout(deferTimer); 827 | deferTimer = setTimeout(function () { 828 | last = now; 829 | fn.apply(context, args); 830 | }, threshhold); 831 | } else { 832 | last = now; 833 | fn.apply(context, args); 834 | } 835 | }; 836 | } 837 | 838 | 839 | 840 | // AMD export 841 | if(typeof define == 'function' && define.amd) { 842 | define(function(){ 843 | return Optiscroll; 844 | }); 845 | } 846 | 847 | // commonjs export 848 | if(typeof module !== 'undefined' && module.exports) { 849 | module.exports = Optiscroll; 850 | } 851 | 852 | window.Optiscroll = Optiscroll; 853 | 854 | })(window, document, Math); 855 | 856 | 857 | /** 858 | * jQuery plugin 859 | * create instance of Optiscroll 860 | * and when called again you can call functions 861 | * or change instance settings 862 | * 863 | * ``` 864 | * $(el).optiscroll({ options }) 865 | * $(el).optiscroll('method', arg) 866 | * ``` 867 | */ 868 | 869 | (function ($) { 870 | 871 | var pluginName = 'optiscroll'; 872 | 873 | $.fn[pluginName] = function(options) { 874 | var method, args; 875 | 876 | if(typeof options === 'string') { 877 | args = Array.prototype.slice.call(arguments); 878 | method = args.shift(); 879 | } 880 | 881 | return this.each(function() { 882 | var $el = $(this); 883 | var inst = $el.data(pluginName); 884 | 885 | // start new optiscroll instance 886 | if(!inst) { 887 | inst = new window.Optiscroll(this, options || {}); 888 | $el.data(pluginName, inst); 889 | } 890 | // allow exec method on instance 891 | else if(inst && typeof method === 'string') { 892 | inst[method].apply(inst, args); 893 | if(method === 'destroy') { 894 | $el.removeData(pluginName); 895 | } 896 | } 897 | }); 898 | }; 899 | 900 | }(jQuery || Zepto)); 901 | -------------------------------------------------------------------------------- /test/resources/qunit.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * QUnit 1.14.0 3 | * http://qunitjs.com/ 4 | * 5 | * Copyright 2013 jQuery Foundation and other contributors 6 | * Released under the MIT license 7 | * http://jquery.org/license 8 | * 9 | * Date: 2014-01-31T16:40Z 10 | */ 11 | 12 | (function( window ) { 13 | 14 | var QUnit, 15 | assert, 16 | config, 17 | onErrorFnPrev, 18 | testId = 0, 19 | fileName = (sourceFromStacktrace( 0 ) || "" ).replace(/(:\d+)+\)?/, "").replace(/.+\//, ""), 20 | toString = Object.prototype.toString, 21 | hasOwn = Object.prototype.hasOwnProperty, 22 | // Keep a local reference to Date (GH-283) 23 | Date = window.Date, 24 | setTimeout = window.setTimeout, 25 | clearTimeout = window.clearTimeout, 26 | defined = { 27 | document: typeof window.document !== "undefined", 28 | setTimeout: typeof window.setTimeout !== "undefined", 29 | sessionStorage: (function() { 30 | var x = "qunit-test-string"; 31 | try { 32 | sessionStorage.setItem( x, x ); 33 | sessionStorage.removeItem( x ); 34 | return true; 35 | } catch( e ) { 36 | return false; 37 | } 38 | }()) 39 | }, 40 | /** 41 | * Provides a normalized error string, correcting an issue 42 | * with IE 7 (and prior) where Error.prototype.toString is 43 | * not properly implemented 44 | * 45 | * Based on http://es5.github.com/#x15.11.4.4 46 | * 47 | * @param {String|Error} error 48 | * @return {String} error message 49 | */ 50 | errorString = function( error ) { 51 | var name, message, 52 | errorString = error.toString(); 53 | if ( errorString.substring( 0, 7 ) === "[object" ) { 54 | name = error.name ? error.name.toString() : "Error"; 55 | message = error.message ? error.message.toString() : ""; 56 | if ( name && message ) { 57 | return name + ": " + message; 58 | } else if ( name ) { 59 | return name; 60 | } else if ( message ) { 61 | return message; 62 | } else { 63 | return "Error"; 64 | } 65 | } else { 66 | return errorString; 67 | } 68 | }, 69 | /** 70 | * Makes a clone of an object using only Array or Object as base, 71 | * and copies over the own enumerable properties. 72 | * 73 | * @param {Object} obj 74 | * @return {Object} New object with only the own properties (recursively). 75 | */ 76 | objectValues = function( obj ) { 77 | // Grunt 0.3.x uses an older version of jshint that still has jshint/jshint#392. 78 | /*jshint newcap: false */ 79 | var key, val, 80 | vals = QUnit.is( "array", obj ) ? [] : {}; 81 | for ( key in obj ) { 82 | if ( hasOwn.call( obj, key ) ) { 83 | val = obj[key]; 84 | vals[key] = val === Object(val) ? objectValues(val) : val; 85 | } 86 | } 87 | return vals; 88 | }; 89 | 90 | 91 | // Root QUnit object. 92 | // `QUnit` initialized at top of scope 93 | QUnit = { 94 | 95 | // call on start of module test to prepend name to all tests 96 | module: function( name, testEnvironment ) { 97 | config.currentModule = name; 98 | config.currentModuleTestEnvironment = testEnvironment; 99 | config.modules[name] = true; 100 | }, 101 | 102 | asyncTest: function( testName, expected, callback ) { 103 | if ( arguments.length === 2 ) { 104 | callback = expected; 105 | expected = null; 106 | } 107 | 108 | QUnit.test( testName, expected, callback, true ); 109 | }, 110 | 111 | test: function( testName, expected, callback, async ) { 112 | var test, 113 | nameHtml = "" + escapeText( testName ) + ""; 114 | 115 | if ( arguments.length === 2 ) { 116 | callback = expected; 117 | expected = null; 118 | } 119 | 120 | if ( config.currentModule ) { 121 | nameHtml = "" + escapeText( config.currentModule ) + ": " + nameHtml; 122 | } 123 | 124 | test = new Test({ 125 | nameHtml: nameHtml, 126 | testName: testName, 127 | expected: expected, 128 | async: async, 129 | callback: callback, 130 | module: config.currentModule, 131 | moduleTestEnvironment: config.currentModuleTestEnvironment, 132 | stack: sourceFromStacktrace( 2 ) 133 | }); 134 | 135 | if ( !validTest( test ) ) { 136 | return; 137 | } 138 | 139 | test.queue(); 140 | }, 141 | 142 | // Specify the number of expected assertions to guarantee that failed test (no assertions are run at all) don't slip through. 143 | expect: function( asserts ) { 144 | if (arguments.length === 1) { 145 | config.current.expected = asserts; 146 | } else { 147 | return config.current.expected; 148 | } 149 | }, 150 | 151 | start: function( count ) { 152 | // QUnit hasn't been initialized yet. 153 | // Note: RequireJS (et al) may delay onLoad 154 | if ( config.semaphore === undefined ) { 155 | QUnit.begin(function() { 156 | // This is triggered at the top of QUnit.load, push start() to the event loop, to allow QUnit.load to finish first 157 | setTimeout(function() { 158 | QUnit.start( count ); 159 | }); 160 | }); 161 | return; 162 | } 163 | 164 | config.semaphore -= count || 1; 165 | // don't start until equal number of stop-calls 166 | if ( config.semaphore > 0 ) { 167 | return; 168 | } 169 | // ignore if start is called more often then stop 170 | if ( config.semaphore < 0 ) { 171 | config.semaphore = 0; 172 | QUnit.pushFailure( "Called start() while already started (QUnit.config.semaphore was 0 already)", null, sourceFromStacktrace(2) ); 173 | return; 174 | } 175 | // A slight delay, to avoid any current callbacks 176 | if ( defined.setTimeout ) { 177 | setTimeout(function() { 178 | if ( config.semaphore > 0 ) { 179 | return; 180 | } 181 | if ( config.timeout ) { 182 | clearTimeout( config.timeout ); 183 | } 184 | 185 | config.blocking = false; 186 | process( true ); 187 | }, 13); 188 | } else { 189 | config.blocking = false; 190 | process( true ); 191 | } 192 | }, 193 | 194 | stop: function( count ) { 195 | config.semaphore += count || 1; 196 | config.blocking = true; 197 | 198 | if ( config.testTimeout && defined.setTimeout ) { 199 | clearTimeout( config.timeout ); 200 | config.timeout = setTimeout(function() { 201 | QUnit.ok( false, "Test timed out" ); 202 | config.semaphore = 1; 203 | QUnit.start(); 204 | }, config.testTimeout ); 205 | } 206 | } 207 | }; 208 | 209 | // We use the prototype to distinguish between properties that should 210 | // be exposed as globals (and in exports) and those that shouldn't 211 | (function() { 212 | function F() {} 213 | F.prototype = QUnit; 214 | QUnit = new F(); 215 | // Make F QUnit's constructor so that we can add to the prototype later 216 | QUnit.constructor = F; 217 | }()); 218 | 219 | /** 220 | * Config object: Maintain internal state 221 | * Later exposed as QUnit.config 222 | * `config` initialized at top of scope 223 | */ 224 | config = { 225 | // The queue of tests to run 226 | queue: [], 227 | 228 | // block until document ready 229 | blocking: true, 230 | 231 | // when enabled, show only failing tests 232 | // gets persisted through sessionStorage and can be changed in UI via checkbox 233 | hidepassed: false, 234 | 235 | // by default, run previously failed tests first 236 | // very useful in combination with "Hide passed tests" checked 237 | reorder: true, 238 | 239 | // by default, modify document.title when suite is done 240 | altertitle: true, 241 | 242 | // by default, scroll to top of the page when suite is done 243 | scrolltop: true, 244 | 245 | // when enabled, all tests must call expect() 246 | requireExpects: false, 247 | 248 | // add checkboxes that are persisted in the query-string 249 | // when enabled, the id is set to `true` as a `QUnit.config` property 250 | urlConfig: [ 251 | { 252 | id: "noglobals", 253 | label: "Check for Globals", 254 | tooltip: "Enabling this will test if any test introduces new properties on the `window` object. Stored as query-strings." 255 | }, 256 | { 257 | id: "notrycatch", 258 | label: "No try-catch", 259 | tooltip: "Enabling this will run tests outside of a try-catch block. Makes debugging exceptions in IE reasonable. Stored as query-strings." 260 | } 261 | ], 262 | 263 | // Set of all modules. 264 | modules: {}, 265 | 266 | // logging callback queues 267 | begin: [], 268 | done: [], 269 | log: [], 270 | testStart: [], 271 | testDone: [], 272 | moduleStart: [], 273 | moduleDone: [] 274 | }; 275 | 276 | // Initialize more QUnit.config and QUnit.urlParams 277 | (function() { 278 | var i, current, 279 | location = window.location || { search: "", protocol: "file:" }, 280 | params = location.search.slice( 1 ).split( "&" ), 281 | length = params.length, 282 | urlParams = {}; 283 | 284 | if ( params[ 0 ] ) { 285 | for ( i = 0; i < length; i++ ) { 286 | current = params[ i ].split( "=" ); 287 | current[ 0 ] = decodeURIComponent( current[ 0 ] ); 288 | 289 | // allow just a key to turn on a flag, e.g., test.html?noglobals 290 | current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true; 291 | if ( urlParams[ current[ 0 ] ] ) { 292 | urlParams[ current[ 0 ] ] = [].concat( urlParams[ current[ 0 ] ], current[ 1 ] ); 293 | } else { 294 | urlParams[ current[ 0 ] ] = current[ 1 ]; 295 | } 296 | } 297 | } 298 | 299 | QUnit.urlParams = urlParams; 300 | 301 | // String search anywhere in moduleName+testName 302 | config.filter = urlParams.filter; 303 | 304 | // Exact match of the module name 305 | config.module = urlParams.module; 306 | 307 | config.testNumber = []; 308 | if ( urlParams.testNumber ) { 309 | 310 | // Ensure that urlParams.testNumber is an array 311 | urlParams.testNumber = [].concat( urlParams.testNumber ); 312 | for ( i = 0; i < urlParams.testNumber.length; i++ ) { 313 | current = urlParams.testNumber[ i ]; 314 | config.testNumber.push( parseInt( current, 10 ) ); 315 | } 316 | } 317 | 318 | // Figure out if we're running the tests from a server or not 319 | QUnit.isLocal = location.protocol === "file:"; 320 | }()); 321 | 322 | extend( QUnit, { 323 | 324 | config: config, 325 | 326 | // Initialize the configuration options 327 | init: function() { 328 | extend( config, { 329 | stats: { all: 0, bad: 0 }, 330 | moduleStats: { all: 0, bad: 0 }, 331 | started: +new Date(), 332 | updateRate: 1000, 333 | blocking: false, 334 | autostart: true, 335 | autorun: false, 336 | filter: "", 337 | queue: [], 338 | semaphore: 1 339 | }); 340 | 341 | var tests, banner, result, 342 | qunit = id( "qunit" ); 343 | 344 | if ( qunit ) { 345 | qunit.innerHTML = 346 | "

" + escapeText( document.title ) + "

" + 347 | "

" + 348 | "
" + 349 | "

" + 350 | "
    "; 351 | } 352 | 353 | tests = id( "qunit-tests" ); 354 | banner = id( "qunit-banner" ); 355 | result = id( "qunit-testresult" ); 356 | 357 | if ( tests ) { 358 | tests.innerHTML = ""; 359 | } 360 | 361 | if ( banner ) { 362 | banner.className = ""; 363 | } 364 | 365 | if ( result ) { 366 | result.parentNode.removeChild( result ); 367 | } 368 | 369 | if ( tests ) { 370 | result = document.createElement( "p" ); 371 | result.id = "qunit-testresult"; 372 | result.className = "result"; 373 | tests.parentNode.insertBefore( result, tests ); 374 | result.innerHTML = "Running...
     "; 375 | } 376 | }, 377 | 378 | // Resets the test setup. Useful for tests that modify the DOM. 379 | /* 380 | DEPRECATED: Use multiple tests instead of resetting inside a test. 381 | Use testStart or testDone for custom cleanup. 382 | This method will throw an error in 2.0, and will be removed in 2.1 383 | */ 384 | reset: function() { 385 | var fixture = id( "qunit-fixture" ); 386 | if ( fixture ) { 387 | fixture.innerHTML = config.fixture; 388 | } 389 | }, 390 | 391 | // Safe object type checking 392 | is: function( type, obj ) { 393 | return QUnit.objectType( obj ) === type; 394 | }, 395 | 396 | objectType: function( obj ) { 397 | if ( typeof obj === "undefined" ) { 398 | return "undefined"; 399 | } 400 | 401 | // Consider: typeof null === object 402 | if ( obj === null ) { 403 | return "null"; 404 | } 405 | 406 | var match = toString.call( obj ).match(/^\[object\s(.*)\]$/), 407 | type = match && match[1] || ""; 408 | 409 | switch ( type ) { 410 | case "Number": 411 | if ( isNaN(obj) ) { 412 | return "nan"; 413 | } 414 | return "number"; 415 | case "String": 416 | case "Boolean": 417 | case "Array": 418 | case "Date": 419 | case "RegExp": 420 | case "Function": 421 | return type.toLowerCase(); 422 | } 423 | if ( typeof obj === "object" ) { 424 | return "object"; 425 | } 426 | return undefined; 427 | }, 428 | 429 | push: function( result, actual, expected, message ) { 430 | if ( !config.current ) { 431 | throw new Error( "assertion outside test context, was " + sourceFromStacktrace() ); 432 | } 433 | 434 | var output, source, 435 | details = { 436 | module: config.current.module, 437 | name: config.current.testName, 438 | result: result, 439 | message: message, 440 | actual: actual, 441 | expected: expected 442 | }; 443 | 444 | message = escapeText( message ) || ( result ? "okay" : "failed" ); 445 | message = "" + message + ""; 446 | output = message; 447 | 448 | if ( !result ) { 449 | expected = escapeText( QUnit.jsDump.parse(expected) ); 450 | actual = escapeText( QUnit.jsDump.parse(actual) ); 451 | output += ""; 452 | 453 | if ( actual !== expected ) { 454 | output += ""; 455 | output += ""; 456 | } 457 | 458 | source = sourceFromStacktrace(); 459 | 460 | if ( source ) { 461 | details.source = source; 462 | output += ""; 463 | } 464 | 465 | output += "
    Expected:
    " + expected + "
    Result:
    " + actual + "
    Diff:
    " + QUnit.diff( expected, actual ) + "
    Source:
    " + escapeText( source ) + "
    "; 466 | } 467 | 468 | runLoggingCallbacks( "log", QUnit, details ); 469 | 470 | config.current.assertions.push({ 471 | result: !!result, 472 | message: output 473 | }); 474 | }, 475 | 476 | pushFailure: function( message, source, actual ) { 477 | if ( !config.current ) { 478 | throw new Error( "pushFailure() assertion outside test context, was " + sourceFromStacktrace(2) ); 479 | } 480 | 481 | var output, 482 | details = { 483 | module: config.current.module, 484 | name: config.current.testName, 485 | result: false, 486 | message: message 487 | }; 488 | 489 | message = escapeText( message ) || "error"; 490 | message = "" + message + ""; 491 | output = message; 492 | 493 | output += ""; 494 | 495 | if ( actual ) { 496 | output += ""; 497 | } 498 | 499 | if ( source ) { 500 | details.source = source; 501 | output += ""; 502 | } 503 | 504 | output += "
    Result:
    " + escapeText( actual ) + "
    Source:
    " + escapeText( source ) + "
    "; 505 | 506 | runLoggingCallbacks( "log", QUnit, details ); 507 | 508 | config.current.assertions.push({ 509 | result: false, 510 | message: output 511 | }); 512 | }, 513 | 514 | url: function( params ) { 515 | params = extend( extend( {}, QUnit.urlParams ), params ); 516 | var key, 517 | querystring = "?"; 518 | 519 | for ( key in params ) { 520 | if ( hasOwn.call( params, key ) ) { 521 | querystring += encodeURIComponent( key ) + "=" + 522 | encodeURIComponent( params[ key ] ) + "&"; 523 | } 524 | } 525 | return window.location.protocol + "//" + window.location.host + 526 | window.location.pathname + querystring.slice( 0, -1 ); 527 | }, 528 | 529 | extend: extend, 530 | id: id, 531 | addEvent: addEvent, 532 | addClass: addClass, 533 | hasClass: hasClass, 534 | removeClass: removeClass 535 | // load, equiv, jsDump, diff: Attached later 536 | }); 537 | 538 | /** 539 | * @deprecated: Created for backwards compatibility with test runner that set the hook function 540 | * into QUnit.{hook}, instead of invoking it and passing the hook function. 541 | * QUnit.constructor is set to the empty F() above so that we can add to it's prototype here. 542 | * Doing this allows us to tell if the following methods have been overwritten on the actual 543 | * QUnit object. 544 | */ 545 | extend( QUnit.constructor.prototype, { 546 | 547 | // Logging callbacks; all receive a single argument with the listed properties 548 | // run test/logs.html for any related changes 549 | begin: registerLoggingCallback( "begin" ), 550 | 551 | // done: { failed, passed, total, runtime } 552 | done: registerLoggingCallback( "done" ), 553 | 554 | // log: { result, actual, expected, message } 555 | log: registerLoggingCallback( "log" ), 556 | 557 | // testStart: { name } 558 | testStart: registerLoggingCallback( "testStart" ), 559 | 560 | // testDone: { name, failed, passed, total, runtime } 561 | testDone: registerLoggingCallback( "testDone" ), 562 | 563 | // moduleStart: { name } 564 | moduleStart: registerLoggingCallback( "moduleStart" ), 565 | 566 | // moduleDone: { name, failed, passed, total } 567 | moduleDone: registerLoggingCallback( "moduleDone" ) 568 | }); 569 | 570 | if ( !defined.document || document.readyState === "complete" ) { 571 | config.autorun = true; 572 | } 573 | 574 | QUnit.load = function() { 575 | runLoggingCallbacks( "begin", QUnit, {} ); 576 | 577 | // Initialize the config, saving the execution queue 578 | var banner, filter, i, j, label, len, main, ol, toolbar, val, selection, 579 | urlConfigContainer, moduleFilter, userAgent, 580 | numModules = 0, 581 | moduleNames = [], 582 | moduleFilterHtml = "", 583 | urlConfigHtml = "", 584 | oldconfig = extend( {}, config ); 585 | 586 | QUnit.init(); 587 | extend(config, oldconfig); 588 | 589 | config.blocking = false; 590 | 591 | len = config.urlConfig.length; 592 | 593 | for ( i = 0; i < len; i++ ) { 594 | val = config.urlConfig[i]; 595 | if ( typeof val === "string" ) { 596 | val = { 597 | id: val, 598 | label: val 599 | }; 600 | } 601 | config[ val.id ] = QUnit.urlParams[ val.id ]; 602 | if ( !val.value || typeof val.value === "string" ) { 603 | urlConfigHtml += ""; 611 | } else { 612 | urlConfigHtml += ""; 646 | } 647 | } 648 | for ( i in config.modules ) { 649 | if ( config.modules.hasOwnProperty( i ) ) { 650 | moduleNames.push(i); 651 | } 652 | } 653 | numModules = moduleNames.length; 654 | moduleNames.sort( function( a, b ) { 655 | return a.localeCompare( b ); 656 | }); 657 | moduleFilterHtml += ""; 668 | 669 | // `userAgent` initialized at top of scope 670 | userAgent = id( "qunit-userAgent" ); 671 | if ( userAgent ) { 672 | userAgent.innerHTML = navigator.userAgent; 673 | } 674 | 675 | // `banner` initialized at top of scope 676 | banner = id( "qunit-header" ); 677 | if ( banner ) { 678 | banner.innerHTML = "" + banner.innerHTML + " "; 679 | } 680 | 681 | // `toolbar` initialized at top of scope 682 | toolbar = id( "qunit-testrunner-toolbar" ); 683 | if ( toolbar ) { 684 | // `filter` initialized at top of scope 685 | filter = document.createElement( "input" ); 686 | filter.type = "checkbox"; 687 | filter.id = "qunit-filter-pass"; 688 | 689 | addEvent( filter, "click", function() { 690 | var tmp, 691 | ol = id( "qunit-tests" ); 692 | 693 | if ( filter.checked ) { 694 | ol.className = ol.className + " hidepass"; 695 | } else { 696 | tmp = " " + ol.className.replace( /[\n\t\r]/g, " " ) + " "; 697 | ol.className = tmp.replace( / hidepass /, " " ); 698 | } 699 | if ( defined.sessionStorage ) { 700 | if (filter.checked) { 701 | sessionStorage.setItem( "qunit-filter-passed-tests", "true" ); 702 | } else { 703 | sessionStorage.removeItem( "qunit-filter-passed-tests" ); 704 | } 705 | } 706 | }); 707 | 708 | if ( config.hidepassed || defined.sessionStorage && sessionStorage.getItem( "qunit-filter-passed-tests" ) ) { 709 | filter.checked = true; 710 | // `ol` initialized at top of scope 711 | ol = id( "qunit-tests" ); 712 | ol.className = ol.className + " hidepass"; 713 | } 714 | toolbar.appendChild( filter ); 715 | 716 | // `label` initialized at top of scope 717 | label = document.createElement( "label" ); 718 | label.setAttribute( "for", "qunit-filter-pass" ); 719 | label.setAttribute( "title", "Only show tests and assertions that fail. Stored in sessionStorage." ); 720 | label.innerHTML = "Hide passed tests"; 721 | toolbar.appendChild( label ); 722 | 723 | urlConfigContainer = document.createElement("span"); 724 | urlConfigContainer.innerHTML = urlConfigHtml; 725 | // For oldIE support: 726 | // * Add handlers to the individual elements instead of the container 727 | // * Use "click" instead of "change" for checkboxes 728 | // * Fallback from event.target to event.srcElement 729 | addEvents( urlConfigContainer.getElementsByTagName("input"), "click", function( event ) { 730 | var params = {}, 731 | target = event.target || event.srcElement; 732 | params[ target.name ] = target.checked ? 733 | target.defaultValue || true : 734 | undefined; 735 | window.location = QUnit.url( params ); 736 | }); 737 | addEvents( urlConfigContainer.getElementsByTagName("select"), "change", function( event ) { 738 | var params = {}, 739 | target = event.target || event.srcElement; 740 | params[ target.name ] = target.options[ target.selectedIndex ].value || undefined; 741 | window.location = QUnit.url( params ); 742 | }); 743 | toolbar.appendChild( urlConfigContainer ); 744 | 745 | if (numModules > 1) { 746 | moduleFilter = document.createElement( "span" ); 747 | moduleFilter.setAttribute( "id", "qunit-modulefilter-container" ); 748 | moduleFilter.innerHTML = moduleFilterHtml; 749 | addEvent( moduleFilter.lastChild, "change", function() { 750 | var selectBox = moduleFilter.getElementsByTagName("select")[0], 751 | selectedModule = decodeURIComponent(selectBox.options[selectBox.selectedIndex].value); 752 | 753 | window.location = QUnit.url({ 754 | module: ( selectedModule === "" ) ? undefined : selectedModule, 755 | // Remove any existing filters 756 | filter: undefined, 757 | testNumber: undefined 758 | }); 759 | }); 760 | toolbar.appendChild(moduleFilter); 761 | } 762 | } 763 | 764 | // `main` initialized at top of scope 765 | main = id( "qunit-fixture" ); 766 | if ( main ) { 767 | config.fixture = main.innerHTML; 768 | } 769 | 770 | if ( config.autostart ) { 771 | QUnit.start(); 772 | } 773 | }; 774 | 775 | if ( defined.document ) { 776 | addEvent( window, "load", QUnit.load ); 777 | } 778 | 779 | // `onErrorFnPrev` initialized at top of scope 780 | // Preserve other handlers 781 | onErrorFnPrev = window.onerror; 782 | 783 | // Cover uncaught exceptions 784 | // Returning true will suppress the default browser handler, 785 | // returning false will let it run. 786 | window.onerror = function ( error, filePath, linerNr ) { 787 | var ret = false; 788 | if ( onErrorFnPrev ) { 789 | ret = onErrorFnPrev( error, filePath, linerNr ); 790 | } 791 | 792 | // Treat return value as window.onerror itself does, 793 | // Only do our handling if not suppressed. 794 | if ( ret !== true ) { 795 | if ( QUnit.config.current ) { 796 | if ( QUnit.config.current.ignoreGlobalErrors ) { 797 | return true; 798 | } 799 | QUnit.pushFailure( error, filePath + ":" + linerNr ); 800 | } else { 801 | QUnit.test( "global failure", extend( function() { 802 | QUnit.pushFailure( error, filePath + ":" + linerNr ); 803 | }, { validTest: validTest } ) ); 804 | } 805 | return false; 806 | } 807 | 808 | return ret; 809 | }; 810 | 811 | function done() { 812 | config.autorun = true; 813 | 814 | // Log the last module results 815 | if ( config.previousModule ) { 816 | runLoggingCallbacks( "moduleDone", QUnit, { 817 | name: config.previousModule, 818 | failed: config.moduleStats.bad, 819 | passed: config.moduleStats.all - config.moduleStats.bad, 820 | total: config.moduleStats.all 821 | }); 822 | } 823 | delete config.previousModule; 824 | 825 | var i, key, 826 | banner = id( "qunit-banner" ), 827 | tests = id( "qunit-tests" ), 828 | runtime = +new Date() - config.started, 829 | passed = config.stats.all - config.stats.bad, 830 | html = [ 831 | "Tests completed in ", 832 | runtime, 833 | " milliseconds.
    ", 834 | "", 835 | passed, 836 | " assertions of ", 837 | config.stats.all, 838 | " passed, ", 839 | config.stats.bad, 840 | " failed." 841 | ].join( "" ); 842 | 843 | if ( banner ) { 844 | banner.className = ( config.stats.bad ? "qunit-fail" : "qunit-pass" ); 845 | } 846 | 847 | if ( tests ) { 848 | id( "qunit-testresult" ).innerHTML = html; 849 | } 850 | 851 | if ( config.altertitle && defined.document && document.title ) { 852 | // show ✖ for good, ✔ for bad suite result in title 853 | // use escape sequences in case file gets loaded with non-utf-8-charset 854 | document.title = [ 855 | ( config.stats.bad ? "\u2716" : "\u2714" ), 856 | document.title.replace( /^[\u2714\u2716] /i, "" ) 857 | ].join( " " ); 858 | } 859 | 860 | // clear own sessionStorage items if all tests passed 861 | if ( config.reorder && defined.sessionStorage && config.stats.bad === 0 ) { 862 | // `key` & `i` initialized at top of scope 863 | for ( i = 0; i < sessionStorage.length; i++ ) { 864 | key = sessionStorage.key( i++ ); 865 | if ( key.indexOf( "qunit-test-" ) === 0 ) { 866 | sessionStorage.removeItem( key ); 867 | } 868 | } 869 | } 870 | 871 | // scroll back to top to show results 872 | if ( config.scrolltop && window.scrollTo ) { 873 | window.scrollTo(0, 0); 874 | } 875 | 876 | runLoggingCallbacks( "done", QUnit, { 877 | failed: config.stats.bad, 878 | passed: passed, 879 | total: config.stats.all, 880 | runtime: runtime 881 | }); 882 | } 883 | 884 | /** @return Boolean: true if this test should be ran */ 885 | function validTest( test ) { 886 | var include, 887 | filter = config.filter && config.filter.toLowerCase(), 888 | module = config.module && config.module.toLowerCase(), 889 | fullName = ( test.module + ": " + test.testName ).toLowerCase(); 890 | 891 | // Internally-generated tests are always valid 892 | if ( test.callback && test.callback.validTest === validTest ) { 893 | delete test.callback.validTest; 894 | return true; 895 | } 896 | 897 | if ( config.testNumber.length > 0 ) { 898 | if ( inArray( test.testNumber, config.testNumber ) < 0 ) { 899 | return false; 900 | } 901 | } 902 | 903 | if ( module && ( !test.module || test.module.toLowerCase() !== module ) ) { 904 | return false; 905 | } 906 | 907 | if ( !filter ) { 908 | return true; 909 | } 910 | 911 | include = filter.charAt( 0 ) !== "!"; 912 | if ( !include ) { 913 | filter = filter.slice( 1 ); 914 | } 915 | 916 | // If the filter matches, we need to honour include 917 | if ( fullName.indexOf( filter ) !== -1 ) { 918 | return include; 919 | } 920 | 921 | // Otherwise, do the opposite 922 | return !include; 923 | } 924 | 925 | // so far supports only Firefox, Chrome and Opera (buggy), Safari (for real exceptions) 926 | // Later Safari and IE10 are supposed to support error.stack as well 927 | // See also https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Error/Stack 928 | function extractStacktrace( e, offset ) { 929 | offset = offset === undefined ? 3 : offset; 930 | 931 | var stack, include, i; 932 | 933 | if ( e.stacktrace ) { 934 | // Opera 935 | return e.stacktrace.split( "\n" )[ offset + 3 ]; 936 | } else if ( e.stack ) { 937 | // Firefox, Chrome 938 | stack = e.stack.split( "\n" ); 939 | if (/^error$/i.test( stack[0] ) ) { 940 | stack.shift(); 941 | } 942 | if ( fileName ) { 943 | include = []; 944 | for ( i = offset; i < stack.length; i++ ) { 945 | if ( stack[ i ].indexOf( fileName ) !== -1 ) { 946 | break; 947 | } 948 | include.push( stack[ i ] ); 949 | } 950 | if ( include.length ) { 951 | return include.join( "\n" ); 952 | } 953 | } 954 | return stack[ offset ]; 955 | } else if ( e.sourceURL ) { 956 | // Safari, PhantomJS 957 | // hopefully one day Safari provides actual stacktraces 958 | // exclude useless self-reference for generated Error objects 959 | if ( /qunit.js$/.test( e.sourceURL ) ) { 960 | return; 961 | } 962 | // for actual exceptions, this is useful 963 | return e.sourceURL + ":" + e.line; 964 | } 965 | } 966 | function sourceFromStacktrace( offset ) { 967 | try { 968 | throw new Error(); 969 | } catch ( e ) { 970 | return extractStacktrace( e, offset ); 971 | } 972 | } 973 | 974 | /** 975 | * Escape text for attribute or text content. 976 | */ 977 | function escapeText( s ) { 978 | if ( !s ) { 979 | return ""; 980 | } 981 | s = s + ""; 982 | // Both single quotes and double quotes (for attributes) 983 | return s.replace( /['"<>&]/g, function( s ) { 984 | switch( s ) { 985 | case "'": 986 | return "'"; 987 | case "\"": 988 | return """; 989 | case "<": 990 | return "<"; 991 | case ">": 992 | return ">"; 993 | case "&": 994 | return "&"; 995 | } 996 | }); 997 | } 998 | 999 | function synchronize( callback, last ) { 1000 | config.queue.push( callback ); 1001 | 1002 | if ( config.autorun && !config.blocking ) { 1003 | process( last ); 1004 | } 1005 | } 1006 | 1007 | function process( last ) { 1008 | function next() { 1009 | process( last ); 1010 | } 1011 | var start = new Date().getTime(); 1012 | config.depth = config.depth ? config.depth + 1 : 1; 1013 | 1014 | while ( config.queue.length && !config.blocking ) { 1015 | if ( !defined.setTimeout || config.updateRate <= 0 || ( ( new Date().getTime() - start ) < config.updateRate ) ) { 1016 | config.queue.shift()(); 1017 | } else { 1018 | setTimeout( next, 13 ); 1019 | break; 1020 | } 1021 | } 1022 | config.depth--; 1023 | if ( last && !config.blocking && !config.queue.length && config.depth === 0 ) { 1024 | done(); 1025 | } 1026 | } 1027 | 1028 | function saveGlobal() { 1029 | config.pollution = []; 1030 | 1031 | if ( config.noglobals ) { 1032 | for ( var key in window ) { 1033 | if ( hasOwn.call( window, key ) ) { 1034 | // in Opera sometimes DOM element ids show up here, ignore them 1035 | if ( /^qunit-test-output/.test( key ) ) { 1036 | continue; 1037 | } 1038 | config.pollution.push( key ); 1039 | } 1040 | } 1041 | } 1042 | } 1043 | 1044 | function checkPollution() { 1045 | var newGlobals, 1046 | deletedGlobals, 1047 | old = config.pollution; 1048 | 1049 | saveGlobal(); 1050 | 1051 | newGlobals = diff( config.pollution, old ); 1052 | if ( newGlobals.length > 0 ) { 1053 | QUnit.pushFailure( "Introduced global variable(s): " + newGlobals.join(", ") ); 1054 | } 1055 | 1056 | deletedGlobals = diff( old, config.pollution ); 1057 | if ( deletedGlobals.length > 0 ) { 1058 | QUnit.pushFailure( "Deleted global variable(s): " + deletedGlobals.join(", ") ); 1059 | } 1060 | } 1061 | 1062 | // returns a new Array with the elements that are in a but not in b 1063 | function diff( a, b ) { 1064 | var i, j, 1065 | result = a.slice(); 1066 | 1067 | for ( i = 0; i < result.length; i++ ) { 1068 | for ( j = 0; j < b.length; j++ ) { 1069 | if ( result[i] === b[j] ) { 1070 | result.splice( i, 1 ); 1071 | i--; 1072 | break; 1073 | } 1074 | } 1075 | } 1076 | return result; 1077 | } 1078 | 1079 | function extend( a, b ) { 1080 | for ( var prop in b ) { 1081 | if ( hasOwn.call( b, prop ) ) { 1082 | // Avoid "Member not found" error in IE8 caused by messing with window.constructor 1083 | if ( !( prop === "constructor" && a === window ) ) { 1084 | if ( b[ prop ] === undefined ) { 1085 | delete a[ prop ]; 1086 | } else { 1087 | a[ prop ] = b[ prop ]; 1088 | } 1089 | } 1090 | } 1091 | } 1092 | 1093 | return a; 1094 | } 1095 | 1096 | /** 1097 | * @param {HTMLElement} elem 1098 | * @param {string} type 1099 | * @param {Function} fn 1100 | */ 1101 | function addEvent( elem, type, fn ) { 1102 | if ( elem.addEventListener ) { 1103 | 1104 | // Standards-based browsers 1105 | elem.addEventListener( type, fn, false ); 1106 | } else if ( elem.attachEvent ) { 1107 | 1108 | // support: IE <9 1109 | elem.attachEvent( "on" + type, fn ); 1110 | } else { 1111 | 1112 | // Caller must ensure support for event listeners is present 1113 | throw new Error( "addEvent() was called in a context without event listener support" ); 1114 | } 1115 | } 1116 | 1117 | /** 1118 | * @param {Array|NodeList} elems 1119 | * @param {string} type 1120 | * @param {Function} fn 1121 | */ 1122 | function addEvents( elems, type, fn ) { 1123 | var i = elems.length; 1124 | while ( i-- ) { 1125 | addEvent( elems[i], type, fn ); 1126 | } 1127 | } 1128 | 1129 | function hasClass( elem, name ) { 1130 | return (" " + elem.className + " ").indexOf(" " + name + " ") > -1; 1131 | } 1132 | 1133 | function addClass( elem, name ) { 1134 | if ( !hasClass( elem, name ) ) { 1135 | elem.className += (elem.className ? " " : "") + name; 1136 | } 1137 | } 1138 | 1139 | function removeClass( elem, name ) { 1140 | var set = " " + elem.className + " "; 1141 | // Class name may appear multiple times 1142 | while ( set.indexOf(" " + name + " ") > -1 ) { 1143 | set = set.replace(" " + name + " " , " "); 1144 | } 1145 | // If possible, trim it for prettiness, but not necessarily 1146 | elem.className = typeof set.trim === "function" ? set.trim() : set.replace(/^\s+|\s+$/g, ""); 1147 | } 1148 | 1149 | function id( name ) { 1150 | return defined.document && document.getElementById && document.getElementById( name ); 1151 | } 1152 | 1153 | function registerLoggingCallback( key ) { 1154 | return function( callback ) { 1155 | config[key].push( callback ); 1156 | }; 1157 | } 1158 | 1159 | // Supports deprecated method of completely overwriting logging callbacks 1160 | function runLoggingCallbacks( key, scope, args ) { 1161 | var i, callbacks; 1162 | if ( QUnit.hasOwnProperty( key ) ) { 1163 | QUnit[ key ].call(scope, args ); 1164 | } else { 1165 | callbacks = config[ key ]; 1166 | for ( i = 0; i < callbacks.length; i++ ) { 1167 | callbacks[ i ].call( scope, args ); 1168 | } 1169 | } 1170 | } 1171 | 1172 | // from jquery.js 1173 | function inArray( elem, array ) { 1174 | if ( array.indexOf ) { 1175 | return array.indexOf( elem ); 1176 | } 1177 | 1178 | for ( var i = 0, length = array.length; i < length; i++ ) { 1179 | if ( array[ i ] === elem ) { 1180 | return i; 1181 | } 1182 | } 1183 | 1184 | return -1; 1185 | } 1186 | 1187 | function Test( settings ) { 1188 | extend( this, settings ); 1189 | this.assertions = []; 1190 | this.testNumber = ++Test.count; 1191 | } 1192 | 1193 | Test.count = 0; 1194 | 1195 | Test.prototype = { 1196 | init: function() { 1197 | var a, b, li, 1198 | tests = id( "qunit-tests" ); 1199 | 1200 | if ( tests ) { 1201 | b = document.createElement( "strong" ); 1202 | b.innerHTML = this.nameHtml; 1203 | 1204 | // `a` initialized at top of scope 1205 | a = document.createElement( "a" ); 1206 | a.innerHTML = "Rerun"; 1207 | a.href = QUnit.url({ testNumber: this.testNumber }); 1208 | 1209 | li = document.createElement( "li" ); 1210 | li.appendChild( b ); 1211 | li.appendChild( a ); 1212 | li.className = "running"; 1213 | li.id = this.id = "qunit-test-output" + testId++; 1214 | 1215 | tests.appendChild( li ); 1216 | } 1217 | }, 1218 | setup: function() { 1219 | if ( 1220 | // Emit moduleStart when we're switching from one module to another 1221 | this.module !== config.previousModule || 1222 | // They could be equal (both undefined) but if the previousModule property doesn't 1223 | // yet exist it means this is the first test in a suite that isn't wrapped in a 1224 | // module, in which case we'll just emit a moduleStart event for 'undefined'. 1225 | // Without this, reporters can get testStart before moduleStart which is a problem. 1226 | !hasOwn.call( config, "previousModule" ) 1227 | ) { 1228 | if ( hasOwn.call( config, "previousModule" ) ) { 1229 | runLoggingCallbacks( "moduleDone", QUnit, { 1230 | name: config.previousModule, 1231 | failed: config.moduleStats.bad, 1232 | passed: config.moduleStats.all - config.moduleStats.bad, 1233 | total: config.moduleStats.all 1234 | }); 1235 | } 1236 | config.previousModule = this.module; 1237 | config.moduleStats = { all: 0, bad: 0 }; 1238 | runLoggingCallbacks( "moduleStart", QUnit, { 1239 | name: this.module 1240 | }); 1241 | } 1242 | 1243 | config.current = this; 1244 | 1245 | this.testEnvironment = extend({ 1246 | setup: function() {}, 1247 | teardown: function() {} 1248 | }, this.moduleTestEnvironment ); 1249 | 1250 | this.started = +new Date(); 1251 | runLoggingCallbacks( "testStart", QUnit, { 1252 | name: this.testName, 1253 | module: this.module 1254 | }); 1255 | 1256 | /*jshint camelcase:false */ 1257 | 1258 | 1259 | /** 1260 | * Expose the current test environment. 1261 | * 1262 | * @deprecated since 1.12.0: Use QUnit.config.current.testEnvironment instead. 1263 | */ 1264 | QUnit.current_testEnvironment = this.testEnvironment; 1265 | 1266 | /*jshint camelcase:true */ 1267 | 1268 | if ( !config.pollution ) { 1269 | saveGlobal(); 1270 | } 1271 | if ( config.notrycatch ) { 1272 | this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert ); 1273 | return; 1274 | } 1275 | try { 1276 | this.testEnvironment.setup.call( this.testEnvironment, QUnit.assert ); 1277 | } catch( e ) { 1278 | QUnit.pushFailure( "Setup failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) ); 1279 | } 1280 | }, 1281 | run: function() { 1282 | config.current = this; 1283 | 1284 | var running = id( "qunit-testresult" ); 1285 | 1286 | if ( running ) { 1287 | running.innerHTML = "Running:
    " + this.nameHtml; 1288 | } 1289 | 1290 | if ( this.async ) { 1291 | QUnit.stop(); 1292 | } 1293 | 1294 | this.callbackStarted = +new Date(); 1295 | 1296 | if ( config.notrycatch ) { 1297 | this.callback.call( this.testEnvironment, QUnit.assert ); 1298 | this.callbackRuntime = +new Date() - this.callbackStarted; 1299 | return; 1300 | } 1301 | 1302 | try { 1303 | this.callback.call( this.testEnvironment, QUnit.assert ); 1304 | this.callbackRuntime = +new Date() - this.callbackStarted; 1305 | } catch( e ) { 1306 | this.callbackRuntime = +new Date() - this.callbackStarted; 1307 | 1308 | QUnit.pushFailure( "Died on test #" + (this.assertions.length + 1) + " " + this.stack + ": " + ( e.message || e ), extractStacktrace( e, 0 ) ); 1309 | // else next test will carry the responsibility 1310 | saveGlobal(); 1311 | 1312 | // Restart the tests if they're blocking 1313 | if ( config.blocking ) { 1314 | QUnit.start(); 1315 | } 1316 | } 1317 | }, 1318 | teardown: function() { 1319 | config.current = this; 1320 | if ( config.notrycatch ) { 1321 | if ( typeof this.callbackRuntime === "undefined" ) { 1322 | this.callbackRuntime = +new Date() - this.callbackStarted; 1323 | } 1324 | this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert ); 1325 | return; 1326 | } else { 1327 | try { 1328 | this.testEnvironment.teardown.call( this.testEnvironment, QUnit.assert ); 1329 | } catch( e ) { 1330 | QUnit.pushFailure( "Teardown failed on " + this.testName + ": " + ( e.message || e ), extractStacktrace( e, 1 ) ); 1331 | } 1332 | } 1333 | checkPollution(); 1334 | }, 1335 | finish: function() { 1336 | config.current = this; 1337 | if ( config.requireExpects && this.expected === null ) { 1338 | QUnit.pushFailure( "Expected number of assertions to be defined, but expect() was not called.", this.stack ); 1339 | } else if ( this.expected !== null && this.expected !== this.assertions.length ) { 1340 | QUnit.pushFailure( "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run", this.stack ); 1341 | } else if ( this.expected === null && !this.assertions.length ) { 1342 | QUnit.pushFailure( "Expected at least one assertion, but none were run - call expect(0) to accept zero assertions.", this.stack ); 1343 | } 1344 | 1345 | var i, assertion, a, b, time, li, ol, 1346 | test = this, 1347 | good = 0, 1348 | bad = 0, 1349 | tests = id( "qunit-tests" ); 1350 | 1351 | this.runtime = +new Date() - this.started; 1352 | config.stats.all += this.assertions.length; 1353 | config.moduleStats.all += this.assertions.length; 1354 | 1355 | if ( tests ) { 1356 | ol = document.createElement( "ol" ); 1357 | ol.className = "qunit-assert-list"; 1358 | 1359 | for ( i = 0; i < this.assertions.length; i++ ) { 1360 | assertion = this.assertions[i]; 1361 | 1362 | li = document.createElement( "li" ); 1363 | li.className = assertion.result ? "pass" : "fail"; 1364 | li.innerHTML = assertion.message || ( assertion.result ? "okay" : "failed" ); 1365 | ol.appendChild( li ); 1366 | 1367 | if ( assertion.result ) { 1368 | good++; 1369 | } else { 1370 | bad++; 1371 | config.stats.bad++; 1372 | config.moduleStats.bad++; 1373 | } 1374 | } 1375 | 1376 | // store result when possible 1377 | if ( QUnit.config.reorder && defined.sessionStorage ) { 1378 | if ( bad ) { 1379 | sessionStorage.setItem( "qunit-test-" + this.module + "-" + this.testName, bad ); 1380 | } else { 1381 | sessionStorage.removeItem( "qunit-test-" + this.module + "-" + this.testName ); 1382 | } 1383 | } 1384 | 1385 | if ( bad === 0 ) { 1386 | addClass( ol, "qunit-collapsed" ); 1387 | } 1388 | 1389 | // `b` initialized at top of scope 1390 | b = document.createElement( "strong" ); 1391 | b.innerHTML = this.nameHtml + " (" + bad + ", " + good + ", " + this.assertions.length + ")"; 1392 | 1393 | addEvent(b, "click", function() { 1394 | var next = b.parentNode.lastChild, 1395 | collapsed = hasClass( next, "qunit-collapsed" ); 1396 | ( collapsed ? removeClass : addClass )( next, "qunit-collapsed" ); 1397 | }); 1398 | 1399 | addEvent(b, "dblclick", function( e ) { 1400 | var target = e && e.target ? e.target : window.event.srcElement; 1401 | if ( target.nodeName.toLowerCase() === "span" || target.nodeName.toLowerCase() === "b" ) { 1402 | target = target.parentNode; 1403 | } 1404 | if ( window.location && target.nodeName.toLowerCase() === "strong" ) { 1405 | window.location = QUnit.url({ testNumber: test.testNumber }); 1406 | } 1407 | }); 1408 | 1409 | // `time` initialized at top of scope 1410 | time = document.createElement( "span" ); 1411 | time.className = "runtime"; 1412 | time.innerHTML = this.runtime + " ms"; 1413 | 1414 | // `li` initialized at top of scope 1415 | li = id( this.id ); 1416 | li.className = bad ? "fail" : "pass"; 1417 | li.removeChild( li.firstChild ); 1418 | a = li.firstChild; 1419 | li.appendChild( b ); 1420 | li.appendChild( a ); 1421 | li.appendChild( time ); 1422 | li.appendChild( ol ); 1423 | 1424 | } else { 1425 | for ( i = 0; i < this.assertions.length; i++ ) { 1426 | if ( !this.assertions[i].result ) { 1427 | bad++; 1428 | config.stats.bad++; 1429 | config.moduleStats.bad++; 1430 | } 1431 | } 1432 | } 1433 | 1434 | runLoggingCallbacks( "testDone", QUnit, { 1435 | name: this.testName, 1436 | module: this.module, 1437 | failed: bad, 1438 | passed: this.assertions.length - bad, 1439 | total: this.assertions.length, 1440 | runtime: this.runtime, 1441 | // DEPRECATED: this property will be removed in 2.0.0, use runtime instead 1442 | duration: this.runtime 1443 | }); 1444 | 1445 | QUnit.reset(); 1446 | 1447 | config.current = undefined; 1448 | }, 1449 | 1450 | queue: function() { 1451 | var bad, 1452 | test = this; 1453 | 1454 | synchronize(function() { 1455 | test.init(); 1456 | }); 1457 | function run() { 1458 | // each of these can by async 1459 | synchronize(function() { 1460 | test.setup(); 1461 | }); 1462 | synchronize(function() { 1463 | test.run(); 1464 | }); 1465 | synchronize(function() { 1466 | test.teardown(); 1467 | }); 1468 | synchronize(function() { 1469 | test.finish(); 1470 | }); 1471 | } 1472 | 1473 | // `bad` initialized at top of scope 1474 | // defer when previous test run passed, if storage is available 1475 | bad = QUnit.config.reorder && defined.sessionStorage && 1476 | +sessionStorage.getItem( "qunit-test-" + this.module + "-" + this.testName ); 1477 | 1478 | if ( bad ) { 1479 | run(); 1480 | } else { 1481 | synchronize( run, true ); 1482 | } 1483 | } 1484 | }; 1485 | 1486 | // `assert` initialized at top of scope 1487 | // Assert helpers 1488 | // All of these must either call QUnit.push() or manually do: 1489 | // - runLoggingCallbacks( "log", .. ); 1490 | // - config.current.assertions.push({ .. }); 1491 | assert = QUnit.assert = { 1492 | /** 1493 | * Asserts rough true-ish result. 1494 | * @name ok 1495 | * @function 1496 | * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" ); 1497 | */ 1498 | ok: function( result, msg ) { 1499 | if ( !config.current ) { 1500 | throw new Error( "ok() assertion outside test context, was " + sourceFromStacktrace(2) ); 1501 | } 1502 | result = !!result; 1503 | msg = msg || ( result ? "okay" : "failed" ); 1504 | 1505 | var source, 1506 | details = { 1507 | module: config.current.module, 1508 | name: config.current.testName, 1509 | result: result, 1510 | message: msg 1511 | }; 1512 | 1513 | msg = "" + escapeText( msg ) + ""; 1514 | 1515 | if ( !result ) { 1516 | source = sourceFromStacktrace( 2 ); 1517 | if ( source ) { 1518 | details.source = source; 1519 | msg += "
    Source:
    " +
    1520 | 					escapeText( source ) +
    1521 | 					"
    "; 1522 | } 1523 | } 1524 | runLoggingCallbacks( "log", QUnit, details ); 1525 | config.current.assertions.push({ 1526 | result: result, 1527 | message: msg 1528 | }); 1529 | }, 1530 | 1531 | /** 1532 | * Assert that the first two arguments are equal, with an optional message. 1533 | * Prints out both actual and expected values. 1534 | * @name equal 1535 | * @function 1536 | * @example equal( format( "Received {0} bytes.", 2), "Received 2 bytes.", "format() replaces {0} with next argument" ); 1537 | */ 1538 | equal: function( actual, expected, message ) { 1539 | /*jshint eqeqeq:false */ 1540 | QUnit.push( expected == actual, actual, expected, message ); 1541 | }, 1542 | 1543 | /** 1544 | * @name notEqual 1545 | * @function 1546 | */ 1547 | notEqual: function( actual, expected, message ) { 1548 | /*jshint eqeqeq:false */ 1549 | QUnit.push( expected != actual, actual, expected, message ); 1550 | }, 1551 | 1552 | /** 1553 | * @name propEqual 1554 | * @function 1555 | */ 1556 | propEqual: function( actual, expected, message ) { 1557 | actual = objectValues(actual); 1558 | expected = objectValues(expected); 1559 | QUnit.push( QUnit.equiv(actual, expected), actual, expected, message ); 1560 | }, 1561 | 1562 | /** 1563 | * @name notPropEqual 1564 | * @function 1565 | */ 1566 | notPropEqual: function( actual, expected, message ) { 1567 | actual = objectValues(actual); 1568 | expected = objectValues(expected); 1569 | QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message ); 1570 | }, 1571 | 1572 | /** 1573 | * @name deepEqual 1574 | * @function 1575 | */ 1576 | deepEqual: function( actual, expected, message ) { 1577 | QUnit.push( QUnit.equiv(actual, expected), actual, expected, message ); 1578 | }, 1579 | 1580 | /** 1581 | * @name notDeepEqual 1582 | * @function 1583 | */ 1584 | notDeepEqual: function( actual, expected, message ) { 1585 | QUnit.push( !QUnit.equiv(actual, expected), actual, expected, message ); 1586 | }, 1587 | 1588 | /** 1589 | * @name strictEqual 1590 | * @function 1591 | */ 1592 | strictEqual: function( actual, expected, message ) { 1593 | QUnit.push( expected === actual, actual, expected, message ); 1594 | }, 1595 | 1596 | /** 1597 | * @name notStrictEqual 1598 | * @function 1599 | */ 1600 | notStrictEqual: function( actual, expected, message ) { 1601 | QUnit.push( expected !== actual, actual, expected, message ); 1602 | }, 1603 | 1604 | "throws": function( block, expected, message ) { 1605 | var actual, 1606 | expectedOutput = expected, 1607 | ok = false; 1608 | 1609 | // 'expected' is optional 1610 | if ( !message && typeof expected === "string" ) { 1611 | message = expected; 1612 | expected = null; 1613 | } 1614 | 1615 | config.current.ignoreGlobalErrors = true; 1616 | try { 1617 | block.call( config.current.testEnvironment ); 1618 | } catch (e) { 1619 | actual = e; 1620 | } 1621 | config.current.ignoreGlobalErrors = false; 1622 | 1623 | if ( actual ) { 1624 | 1625 | // we don't want to validate thrown error 1626 | if ( !expected ) { 1627 | ok = true; 1628 | expectedOutput = null; 1629 | 1630 | // expected is an Error object 1631 | } else if ( expected instanceof Error ) { 1632 | ok = actual instanceof Error && 1633 | actual.name === expected.name && 1634 | actual.message === expected.message; 1635 | 1636 | // expected is a regexp 1637 | } else if ( QUnit.objectType( expected ) === "regexp" ) { 1638 | ok = expected.test( errorString( actual ) ); 1639 | 1640 | // expected is a string 1641 | } else if ( QUnit.objectType( expected ) === "string" ) { 1642 | ok = expected === errorString( actual ); 1643 | 1644 | // expected is a constructor 1645 | } else if ( actual instanceof expected ) { 1646 | ok = true; 1647 | 1648 | // expected is a validation function which returns true is validation passed 1649 | } else if ( expected.call( {}, actual ) === true ) { 1650 | expectedOutput = null; 1651 | ok = true; 1652 | } 1653 | 1654 | QUnit.push( ok, actual, expectedOutput, message ); 1655 | } else { 1656 | QUnit.pushFailure( message, null, "No exception was thrown." ); 1657 | } 1658 | } 1659 | }; 1660 | 1661 | /** 1662 | * @deprecated since 1.8.0 1663 | * Kept assertion helpers in root for backwards compatibility. 1664 | */ 1665 | extend( QUnit.constructor.prototype, assert ); 1666 | 1667 | /** 1668 | * @deprecated since 1.9.0 1669 | * Kept to avoid TypeErrors for undefined methods. 1670 | */ 1671 | QUnit.constructor.prototype.raises = function() { 1672 | QUnit.push( false, false, false, "QUnit.raises has been deprecated since 2012 (fad3c1ea), use QUnit.throws instead" ); 1673 | }; 1674 | 1675 | /** 1676 | * @deprecated since 1.0.0, replaced with error pushes since 1.3.0 1677 | * Kept to avoid TypeErrors for undefined methods. 1678 | */ 1679 | QUnit.constructor.prototype.equals = function() { 1680 | QUnit.push( false, false, false, "QUnit.equals has been deprecated since 2009 (e88049a0), use QUnit.equal instead" ); 1681 | }; 1682 | QUnit.constructor.prototype.same = function() { 1683 | QUnit.push( false, false, false, "QUnit.same has been deprecated since 2009 (e88049a0), use QUnit.deepEqual instead" ); 1684 | }; 1685 | 1686 | // Test for equality any JavaScript type. 1687 | // Author: Philippe Rathé 1688 | QUnit.equiv = (function() { 1689 | 1690 | // Call the o related callback with the given arguments. 1691 | function bindCallbacks( o, callbacks, args ) { 1692 | var prop = QUnit.objectType( o ); 1693 | if ( prop ) { 1694 | if ( QUnit.objectType( callbacks[ prop ] ) === "function" ) { 1695 | return callbacks[ prop ].apply( callbacks, args ); 1696 | } else { 1697 | return callbacks[ prop ]; // or undefined 1698 | } 1699 | } 1700 | } 1701 | 1702 | // the real equiv function 1703 | var innerEquiv, 1704 | // stack to decide between skip/abort functions 1705 | callers = [], 1706 | // stack to avoiding loops from circular referencing 1707 | parents = [], 1708 | parentsB = [], 1709 | 1710 | getProto = Object.getPrototypeOf || function ( obj ) { 1711 | /*jshint camelcase:false */ 1712 | return obj.__proto__; 1713 | }, 1714 | callbacks = (function () { 1715 | 1716 | // for string, boolean, number and null 1717 | function useStrictEquality( b, a ) { 1718 | /*jshint eqeqeq:false */ 1719 | if ( b instanceof a.constructor || a instanceof b.constructor ) { 1720 | // to catch short annotation VS 'new' annotation of a 1721 | // declaration 1722 | // e.g. var i = 1; 1723 | // var j = new Number(1); 1724 | return a == b; 1725 | } else { 1726 | return a === b; 1727 | } 1728 | } 1729 | 1730 | return { 1731 | "string": useStrictEquality, 1732 | "boolean": useStrictEquality, 1733 | "number": useStrictEquality, 1734 | "null": useStrictEquality, 1735 | "undefined": useStrictEquality, 1736 | 1737 | "nan": function( b ) { 1738 | return isNaN( b ); 1739 | }, 1740 | 1741 | "date": function( b, a ) { 1742 | return QUnit.objectType( b ) === "date" && a.valueOf() === b.valueOf(); 1743 | }, 1744 | 1745 | "regexp": function( b, a ) { 1746 | return QUnit.objectType( b ) === "regexp" && 1747 | // the regex itself 1748 | a.source === b.source && 1749 | // and its modifiers 1750 | a.global === b.global && 1751 | // (gmi) ... 1752 | a.ignoreCase === b.ignoreCase && 1753 | a.multiline === b.multiline && 1754 | a.sticky === b.sticky; 1755 | }, 1756 | 1757 | // - skip when the property is a method of an instance (OOP) 1758 | // - abort otherwise, 1759 | // initial === would have catch identical references anyway 1760 | "function": function() { 1761 | var caller = callers[callers.length - 1]; 1762 | return caller !== Object && typeof caller !== "undefined"; 1763 | }, 1764 | 1765 | "array": function( b, a ) { 1766 | var i, j, len, loop, aCircular, bCircular; 1767 | 1768 | // b could be an object literal here 1769 | if ( QUnit.objectType( b ) !== "array" ) { 1770 | return false; 1771 | } 1772 | 1773 | len = a.length; 1774 | if ( len !== b.length ) { 1775 | // safe and faster 1776 | return false; 1777 | } 1778 | 1779 | // track reference to avoid circular references 1780 | parents.push( a ); 1781 | parentsB.push( b ); 1782 | for ( i = 0; i < len; i++ ) { 1783 | loop = false; 1784 | for ( j = 0; j < parents.length; j++ ) { 1785 | aCircular = parents[j] === a[i]; 1786 | bCircular = parentsB[j] === b[i]; 1787 | if ( aCircular || bCircular ) { 1788 | if ( a[i] === b[i] || aCircular && bCircular ) { 1789 | loop = true; 1790 | } else { 1791 | parents.pop(); 1792 | parentsB.pop(); 1793 | return false; 1794 | } 1795 | } 1796 | } 1797 | if ( !loop && !innerEquiv(a[i], b[i]) ) { 1798 | parents.pop(); 1799 | parentsB.pop(); 1800 | return false; 1801 | } 1802 | } 1803 | parents.pop(); 1804 | parentsB.pop(); 1805 | return true; 1806 | }, 1807 | 1808 | "object": function( b, a ) { 1809 | /*jshint forin:false */ 1810 | var i, j, loop, aCircular, bCircular, 1811 | // Default to true 1812 | eq = true, 1813 | aProperties = [], 1814 | bProperties = []; 1815 | 1816 | // comparing constructors is more strict than using 1817 | // instanceof 1818 | if ( a.constructor !== b.constructor ) { 1819 | // Allow objects with no prototype to be equivalent to 1820 | // objects with Object as their constructor. 1821 | if ( !(( getProto(a) === null && getProto(b) === Object.prototype ) || 1822 | ( getProto(b) === null && getProto(a) === Object.prototype ) ) ) { 1823 | return false; 1824 | } 1825 | } 1826 | 1827 | // stack constructor before traversing properties 1828 | callers.push( a.constructor ); 1829 | 1830 | // track reference to avoid circular references 1831 | parents.push( a ); 1832 | parentsB.push( b ); 1833 | 1834 | // be strict: don't ensure hasOwnProperty and go deep 1835 | for ( i in a ) { 1836 | loop = false; 1837 | for ( j = 0; j < parents.length; j++ ) { 1838 | aCircular = parents[j] === a[i]; 1839 | bCircular = parentsB[j] === b[i]; 1840 | if ( aCircular || bCircular ) { 1841 | if ( a[i] === b[i] || aCircular && bCircular ) { 1842 | loop = true; 1843 | } else { 1844 | eq = false; 1845 | break; 1846 | } 1847 | } 1848 | } 1849 | aProperties.push(i); 1850 | if ( !loop && !innerEquiv(a[i], b[i]) ) { 1851 | eq = false; 1852 | break; 1853 | } 1854 | } 1855 | 1856 | parents.pop(); 1857 | parentsB.pop(); 1858 | callers.pop(); // unstack, we are done 1859 | 1860 | for ( i in b ) { 1861 | bProperties.push( i ); // collect b's properties 1862 | } 1863 | 1864 | // Ensures identical properties name 1865 | return eq && innerEquiv( aProperties.sort(), bProperties.sort() ); 1866 | } 1867 | }; 1868 | }()); 1869 | 1870 | innerEquiv = function() { // can take multiple arguments 1871 | var args = [].slice.apply( arguments ); 1872 | if ( args.length < 2 ) { 1873 | return true; // end transition 1874 | } 1875 | 1876 | return (function( a, b ) { 1877 | if ( a === b ) { 1878 | return true; // catch the most you can 1879 | } else if ( a === null || b === null || typeof a === "undefined" || 1880 | typeof b === "undefined" || 1881 | QUnit.objectType(a) !== QUnit.objectType(b) ) { 1882 | return false; // don't lose time with error prone cases 1883 | } else { 1884 | return bindCallbacks(a, callbacks, [ b, a ]); 1885 | } 1886 | 1887 | // apply transition with (1..n) arguments 1888 | }( args[0], args[1] ) && innerEquiv.apply( this, args.splice(1, args.length - 1 )) ); 1889 | }; 1890 | 1891 | return innerEquiv; 1892 | }()); 1893 | 1894 | /** 1895 | * jsDump Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com | 1896 | * http://flesler.blogspot.com Licensed under BSD 1897 | * (http://www.opensource.org/licenses/bsd-license.php) Date: 5/15/2008 1898 | * 1899 | * @projectDescription Advanced and extensible data dumping for Javascript. 1900 | * @version 1.0.0 1901 | * @author Ariel Flesler 1902 | * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html} 1903 | */ 1904 | QUnit.jsDump = (function() { 1905 | function quote( str ) { 1906 | return "\"" + str.toString().replace( /"/g, "\\\"" ) + "\""; 1907 | } 1908 | function literal( o ) { 1909 | return o + ""; 1910 | } 1911 | function join( pre, arr, post ) { 1912 | var s = jsDump.separator(), 1913 | base = jsDump.indent(), 1914 | inner = jsDump.indent(1); 1915 | if ( arr.join ) { 1916 | arr = arr.join( "," + s + inner ); 1917 | } 1918 | if ( !arr ) { 1919 | return pre + post; 1920 | } 1921 | return [ pre, inner + arr, base + post ].join(s); 1922 | } 1923 | function array( arr, stack ) { 1924 | var i = arr.length, ret = new Array(i); 1925 | this.up(); 1926 | while ( i-- ) { 1927 | ret[i] = this.parse( arr[i] , undefined , stack); 1928 | } 1929 | this.down(); 1930 | return join( "[", ret, "]" ); 1931 | } 1932 | 1933 | var reName = /^function (\w+)/, 1934 | jsDump = { 1935 | // type is used mostly internally, you can fix a (custom)type in advance 1936 | parse: function( obj, type, stack ) { 1937 | stack = stack || [ ]; 1938 | var inStack, res, 1939 | parser = this.parsers[ type || this.typeOf(obj) ]; 1940 | 1941 | type = typeof parser; 1942 | inStack = inArray( obj, stack ); 1943 | 1944 | if ( inStack !== -1 ) { 1945 | return "recursion(" + (inStack - stack.length) + ")"; 1946 | } 1947 | if ( type === "function" ) { 1948 | stack.push( obj ); 1949 | res = parser.call( this, obj, stack ); 1950 | stack.pop(); 1951 | return res; 1952 | } 1953 | return ( type === "string" ) ? parser : this.parsers.error; 1954 | }, 1955 | typeOf: function( obj ) { 1956 | var type; 1957 | if ( obj === null ) { 1958 | type = "null"; 1959 | } else if ( typeof obj === "undefined" ) { 1960 | type = "undefined"; 1961 | } else if ( QUnit.is( "regexp", obj) ) { 1962 | type = "regexp"; 1963 | } else if ( QUnit.is( "date", obj) ) { 1964 | type = "date"; 1965 | } else if ( QUnit.is( "function", obj) ) { 1966 | type = "function"; 1967 | } else if ( typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined" ) { 1968 | type = "window"; 1969 | } else if ( obj.nodeType === 9 ) { 1970 | type = "document"; 1971 | } else if ( obj.nodeType ) { 1972 | type = "node"; 1973 | } else if ( 1974 | // native arrays 1975 | toString.call( obj ) === "[object Array]" || 1976 | // NodeList objects 1977 | ( typeof obj.length === "number" && typeof obj.item !== "undefined" && ( obj.length ? obj.item(0) === obj[0] : ( obj.item( 0 ) === null && typeof obj[0] === "undefined" ) ) ) 1978 | ) { 1979 | type = "array"; 1980 | } else if ( obj.constructor === Error.prototype.constructor ) { 1981 | type = "error"; 1982 | } else { 1983 | type = typeof obj; 1984 | } 1985 | return type; 1986 | }, 1987 | separator: function() { 1988 | return this.multiline ? this.HTML ? "
    " : "\n" : this.HTML ? " " : " "; 1989 | }, 1990 | // extra can be a number, shortcut for increasing-calling-decreasing 1991 | indent: function( extra ) { 1992 | if ( !this.multiline ) { 1993 | return ""; 1994 | } 1995 | var chr = this.indentChar; 1996 | if ( this.HTML ) { 1997 | chr = chr.replace( /\t/g, " " ).replace( / /g, " " ); 1998 | } 1999 | return new Array( this.depth + ( extra || 0 ) ).join(chr); 2000 | }, 2001 | up: function( a ) { 2002 | this.depth += a || 1; 2003 | }, 2004 | down: function( a ) { 2005 | this.depth -= a || 1; 2006 | }, 2007 | setParser: function( name, parser ) { 2008 | this.parsers[name] = parser; 2009 | }, 2010 | // The next 3 are exposed so you can use them 2011 | quote: quote, 2012 | literal: literal, 2013 | join: join, 2014 | // 2015 | depth: 1, 2016 | // This is the list of parsers, to modify them, use jsDump.setParser 2017 | parsers: { 2018 | window: "[Window]", 2019 | document: "[Document]", 2020 | error: function(error) { 2021 | return "Error(\"" + error.message + "\")"; 2022 | }, 2023 | unknown: "[Unknown]", 2024 | "null": "null", 2025 | "undefined": "undefined", 2026 | "function": function( fn ) { 2027 | var ret = "function", 2028 | // functions never have name in IE 2029 | name = "name" in fn ? fn.name : (reName.exec(fn) || [])[1]; 2030 | 2031 | if ( name ) { 2032 | ret += " " + name; 2033 | } 2034 | ret += "( "; 2035 | 2036 | ret = [ ret, QUnit.jsDump.parse( fn, "functionArgs" ), "){" ].join( "" ); 2037 | return join( ret, QUnit.jsDump.parse(fn,"functionCode" ), "}" ); 2038 | }, 2039 | array: array, 2040 | nodelist: array, 2041 | "arguments": array, 2042 | object: function( map, stack ) { 2043 | /*jshint forin:false */ 2044 | var ret = [ ], keys, key, val, i; 2045 | QUnit.jsDump.up(); 2046 | keys = []; 2047 | for ( key in map ) { 2048 | keys.push( key ); 2049 | } 2050 | keys.sort(); 2051 | for ( i = 0; i < keys.length; i++ ) { 2052 | key = keys[ i ]; 2053 | val = map[ key ]; 2054 | ret.push( QUnit.jsDump.parse( key, "key" ) + ": " + QUnit.jsDump.parse( val, undefined, stack ) ); 2055 | } 2056 | QUnit.jsDump.down(); 2057 | return join( "{", ret, "}" ); 2058 | }, 2059 | node: function( node ) { 2060 | var len, i, val, 2061 | open = QUnit.jsDump.HTML ? "<" : "<", 2062 | close = QUnit.jsDump.HTML ? ">" : ">", 2063 | tag = node.nodeName.toLowerCase(), 2064 | ret = open + tag, 2065 | attrs = node.attributes; 2066 | 2067 | if ( attrs ) { 2068 | for ( i = 0, len = attrs.length; i < len; i++ ) { 2069 | val = attrs[i].nodeValue; 2070 | // IE6 includes all attributes in .attributes, even ones not explicitly set. 2071 | // Those have values like undefined, null, 0, false, "" or "inherit". 2072 | if ( val && val !== "inherit" ) { 2073 | ret += " " + attrs[i].nodeName + "=" + QUnit.jsDump.parse( val, "attribute" ); 2074 | } 2075 | } 2076 | } 2077 | ret += close; 2078 | 2079 | // Show content of TextNode or CDATASection 2080 | if ( node.nodeType === 3 || node.nodeType === 4 ) { 2081 | ret += node.nodeValue; 2082 | } 2083 | 2084 | return ret + open + "/" + tag + close; 2085 | }, 2086 | // function calls it internally, it's the arguments part of the function 2087 | functionArgs: function( fn ) { 2088 | var args, 2089 | l = fn.length; 2090 | 2091 | if ( !l ) { 2092 | return ""; 2093 | } 2094 | 2095 | args = new Array(l); 2096 | while ( l-- ) { 2097 | // 97 is 'a' 2098 | args[l] = String.fromCharCode(97+l); 2099 | } 2100 | return " " + args.join( ", " ) + " "; 2101 | }, 2102 | // object calls it internally, the key part of an item in a map 2103 | key: quote, 2104 | // function calls it internally, it's the content of the function 2105 | functionCode: "[code]", 2106 | // node calls it internally, it's an html attribute value 2107 | attribute: quote, 2108 | string: quote, 2109 | date: quote, 2110 | regexp: literal, 2111 | number: literal, 2112 | "boolean": literal 2113 | }, 2114 | // if true, entities are escaped ( <, >, \t, space and \n ) 2115 | HTML: false, 2116 | // indentation unit 2117 | indentChar: " ", 2118 | // if true, items in a collection, are separated by a \n, else just a space. 2119 | multiline: true 2120 | }; 2121 | 2122 | return jsDump; 2123 | }()); 2124 | 2125 | /* 2126 | * Javascript Diff Algorithm 2127 | * By John Resig (http://ejohn.org/) 2128 | * Modified by Chu Alan "sprite" 2129 | * 2130 | * Released under the MIT license. 2131 | * 2132 | * More Info: 2133 | * http://ejohn.org/projects/javascript-diff-algorithm/ 2134 | * 2135 | * Usage: QUnit.diff(expected, actual) 2136 | * 2137 | * QUnit.diff( "the quick brown fox jumped over", "the quick fox jumps over" ) == "the quick brown fox jumped jumps over" 2138 | */ 2139 | QUnit.diff = (function() { 2140 | /*jshint eqeqeq:false, eqnull:true */ 2141 | function diff( o, n ) { 2142 | var i, 2143 | ns = {}, 2144 | os = {}; 2145 | 2146 | for ( i = 0; i < n.length; i++ ) { 2147 | if ( !hasOwn.call( ns, n[i] ) ) { 2148 | ns[ n[i] ] = { 2149 | rows: [], 2150 | o: null 2151 | }; 2152 | } 2153 | ns[ n[i] ].rows.push( i ); 2154 | } 2155 | 2156 | for ( i = 0; i < o.length; i++ ) { 2157 | if ( !hasOwn.call( os, o[i] ) ) { 2158 | os[ o[i] ] = { 2159 | rows: [], 2160 | n: null 2161 | }; 2162 | } 2163 | os[ o[i] ].rows.push( i ); 2164 | } 2165 | 2166 | for ( i in ns ) { 2167 | if ( hasOwn.call( ns, i ) ) { 2168 | if ( ns[i].rows.length === 1 && hasOwn.call( os, i ) && os[i].rows.length === 1 ) { 2169 | n[ ns[i].rows[0] ] = { 2170 | text: n[ ns[i].rows[0] ], 2171 | row: os[i].rows[0] 2172 | }; 2173 | o[ os[i].rows[0] ] = { 2174 | text: o[ os[i].rows[0] ], 2175 | row: ns[i].rows[0] 2176 | }; 2177 | } 2178 | } 2179 | } 2180 | 2181 | for ( i = 0; i < n.length - 1; i++ ) { 2182 | if ( n[i].text != null && n[ i + 1 ].text == null && n[i].row + 1 < o.length && o[ n[i].row + 1 ].text == null && 2183 | n[ i + 1 ] == o[ n[i].row + 1 ] ) { 2184 | 2185 | n[ i + 1 ] = { 2186 | text: n[ i + 1 ], 2187 | row: n[i].row + 1 2188 | }; 2189 | o[ n[i].row + 1 ] = { 2190 | text: o[ n[i].row + 1 ], 2191 | row: i + 1 2192 | }; 2193 | } 2194 | } 2195 | 2196 | for ( i = n.length - 1; i > 0; i-- ) { 2197 | if ( n[i].text != null && n[ i - 1 ].text == null && n[i].row > 0 && o[ n[i].row - 1 ].text == null && 2198 | n[ i - 1 ] == o[ n[i].row - 1 ]) { 2199 | 2200 | n[ i - 1 ] = { 2201 | text: n[ i - 1 ], 2202 | row: n[i].row - 1 2203 | }; 2204 | o[ n[i].row - 1 ] = { 2205 | text: o[ n[i].row - 1 ], 2206 | row: i - 1 2207 | }; 2208 | } 2209 | } 2210 | 2211 | return { 2212 | o: o, 2213 | n: n 2214 | }; 2215 | } 2216 | 2217 | return function( o, n ) { 2218 | o = o.replace( /\s+$/, "" ); 2219 | n = n.replace( /\s+$/, "" ); 2220 | 2221 | var i, pre, 2222 | str = "", 2223 | out = diff( o === "" ? [] : o.split(/\s+/), n === "" ? [] : n.split(/\s+/) ), 2224 | oSpace = o.match(/\s+/g), 2225 | nSpace = n.match(/\s+/g); 2226 | 2227 | if ( oSpace == null ) { 2228 | oSpace = [ " " ]; 2229 | } 2230 | else { 2231 | oSpace.push( " " ); 2232 | } 2233 | 2234 | if ( nSpace == null ) { 2235 | nSpace = [ " " ]; 2236 | } 2237 | else { 2238 | nSpace.push( " " ); 2239 | } 2240 | 2241 | if ( out.n.length === 0 ) { 2242 | for ( i = 0; i < out.o.length; i++ ) { 2243 | str += "" + out.o[i] + oSpace[i] + ""; 2244 | } 2245 | } 2246 | else { 2247 | if ( out.n[0].text == null ) { 2248 | for ( n = 0; n < out.o.length && out.o[n].text == null; n++ ) { 2249 | str += "" + out.o[n] + oSpace[n] + ""; 2250 | } 2251 | } 2252 | 2253 | for ( i = 0; i < out.n.length; i++ ) { 2254 | if (out.n[i].text == null) { 2255 | str += "" + out.n[i] + nSpace[i] + ""; 2256 | } 2257 | else { 2258 | // `pre` initialized at top of scope 2259 | pre = ""; 2260 | 2261 | for ( n = out.n[i].row + 1; n < out.o.length && out.o[n].text == null; n++ ) { 2262 | pre += "" + out.o[n] + oSpace[n] + ""; 2263 | } 2264 | str += " " + out.n[i].text + nSpace[i] + pre; 2265 | } 2266 | } 2267 | } 2268 | 2269 | return str; 2270 | }; 2271 | }()); 2272 | 2273 | // For browser, export only select globals 2274 | if ( typeof window !== "undefined" ) { 2275 | extend( window, QUnit.constructor.prototype ); 2276 | window.QUnit = QUnit; 2277 | } 2278 | 2279 | // For CommonJS environments, export everything 2280 | if ( typeof module !== "undefined" && module.exports ) { 2281 | module.exports = QUnit; 2282 | } 2283 | 2284 | 2285 | // Get a reference to the global object, like window in browsers 2286 | }( (function() { 2287 | return this; 2288 | })() )); 2289 | --------------------------------------------------------------------------------