├── _redirects ├── .gitignore ├── test ├── test.css ├── index.html ├── qunit │ ├── qunit.css │ └── qunit.js └── tests.js ├── demo ├── imgs │ ├── bike.jpg │ ├── cows.jpg │ ├── large.jpg │ ├── monkey.jpg │ ├── monks.jpg │ ├── bike-thmb.jpg │ ├── cows-thmb.jpg │ ├── interior.jpg │ ├── large-thmb.jpg │ ├── monkey-thmb.jpg │ ├── monks-thmb.jpg │ └── interior-thmb.jpg ├── docs.css └── index.html ├── dist ├── snapper-init.js ├── snapper.css └── snapper.js ├── src ├── snapper-init.js ├── snapper.scss ├── snapper.js └── lib │ └── intersection-observer.js ├── package.json ├── LICENSE ├── Gruntfile.js └── README.md /_redirects: -------------------------------------------------------------------------------- 1 | / /demo -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules -------------------------------------------------------------------------------- /test/test.css: -------------------------------------------------------------------------------- 1 | /* 2 | Styles for testing snapper 3 | */ 4 | -------------------------------------------------------------------------------- /demo/imgs/bike.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/bike.jpg -------------------------------------------------------------------------------- /demo/imgs/cows.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/cows.jpg -------------------------------------------------------------------------------- /demo/imgs/large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/large.jpg -------------------------------------------------------------------------------- /demo/imgs/monkey.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/monkey.jpg -------------------------------------------------------------------------------- /demo/imgs/monks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/monks.jpg -------------------------------------------------------------------------------- /demo/imgs/bike-thmb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/bike-thmb.jpg -------------------------------------------------------------------------------- /demo/imgs/cows-thmb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/cows-thmb.jpg -------------------------------------------------------------------------------- /demo/imgs/interior.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/interior.jpg -------------------------------------------------------------------------------- /demo/imgs/large-thmb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/large-thmb.jpg -------------------------------------------------------------------------------- /demo/imgs/monkey-thmb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/monkey-thmb.jpg -------------------------------------------------------------------------------- /demo/imgs/monks-thmb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/monks-thmb.jpg -------------------------------------------------------------------------------- /demo/imgs/interior-thmb.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/filamentgroup/snapper/HEAD/demo/imgs/interior-thmb.jpg -------------------------------------------------------------------------------- /dist/snapper-init.js: -------------------------------------------------------------------------------- 1 | /* snapper css snap points carousel */ 2 | ;(function( w, $ ){ 3 | // auto-init on enhance 4 | $( document ).bind( "enhance", function( e ){ 5 | $( ".snapper", e.target ).add( e.target ).filter( ".snapper" ).snapper(); 6 | }); 7 | }( this, jQuery )); -------------------------------------------------------------------------------- /src/snapper-init.js: -------------------------------------------------------------------------------- 1 | /* snapper css snap points carousel */ 2 | ;(function( w, $ ){ 3 | // auto-init on enhance 4 | $( document ).bind( "enhance", function( e ){ 5 | $( ".snapper", e.target ).add( e.target ).filter( ".snapper" ).snapper(); 6 | }); 7 | }( this, jQuery )); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fg-snapper", 3 | "version": "4.0.0-2", 4 | "description": "A CSS Snap-Points based carousel (and lightweight polyfill)", 5 | "author": { 6 | "name": "Scott Jehl, Filament Group, Inc.", 7 | "url": "http://filamentgroup.com" 8 | }, 9 | "main": "dist/snapper.js", 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/filamentgroup/snapper.git" 13 | }, 14 | "keywords": [ 15 | "CSS", 16 | "snap points" 17 | ], 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/filamentgroup/snapper/issues" 21 | }, 22 | "devDependencies": { 23 | "grunt": "^1.0.4", 24 | "grunt-cli": "^1.3.2", 25 | "grunt-contrib-copy": "^1.0.0", 26 | "grunt-contrib-qunit": "^3.1.0", 27 | "grunt-contrib-watch": "^0.6.1", 28 | "grunt-sass": "^3.1.0", 29 | "matchdep": "^2.0.0", 30 | "node-sass": "^4.13.1" 31 | }, 32 | "dependencies": { 33 | "jquery": "3.4.1", 34 | "intersection-observer": "0.7.0" 35 | }, 36 | "scripts": { 37 | "test": "grunt", 38 | "build": "grunt" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Filament Group 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Snapper Test Suite 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 |

Snapper Test Suite

22 |

23 |
24 |

25 |
    26 | 27 |
    28 |
    29 |
    30 |
    31 |
    32 | 33 |
    34 |
    35 | 36 |
    37 |
    38 | 39 |
    40 |
    41 |
    42 | 43 |
    44 | 45 | 46 | 47 |
    48 | just a test link 49 | 50 |
    51 |
    52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*global module:false*/ 2 | module.exports = function(grunt) { 3 | 4 | var os = require( 'os' ), 5 | path = require( 'path' ), 6 | isWindows = os.platform().toLowerCase().indexOf( 'win' ) === 0; // watch out for 'darwin' 7 | const sass = require('node-sass'); 8 | 9 | require( 'matchdep' ).filterDev( 'grunt-*' ).forEach( grunt.loadNpmTasks ); 10 | 11 | 12 | // Project configuration. 13 | grunt.initConfig({ 14 | pkg: '', 15 | 16 | meta: { 17 | banner: '/*! snapper - v<%= pkg.version %> - ' + 18 | '<%= grunt.template.today("yyyy-mm-dd") %>\n' + 19 | '* Copyright (c) <%= grunt.template.today("yyyy") %> Filament Group */' 20 | }, 21 | 22 | copy: { 23 | js: { 24 | files: [ 25 | { expand: true, cwd: "src", src: ["*.js"], dest: "dist/" }, 26 | { expand: true, cwd: 'node_modules/jquery/dist', src: [ "jquery.js" ], dest: "src/lib"}, 27 | { expand: true, cwd: 'node_modules/intersection-observer', src: [ "intersection-observer.js" ], dest: "src/lib"} 28 | ] 29 | } 30 | }, 31 | 32 | sass: { 33 | options: { 34 | banner: '<%= meta.banner %>', 35 | implementation: sass 36 | }, 37 | src: { 38 | files: [{ 39 | expand: true, 40 | cwd: 'src', 41 | src: [ '**/*.scss' ], 42 | dest: 'dist/', 43 | ext: '.css' 44 | }] 45 | } 46 | }, 47 | qunit: { 48 | files: ['test/**/*.html'] 49 | }, 50 | watch: { 51 | all: { 52 | files: [ 53 | '**/*.js', 54 | '**/*.scss', 55 | '**/*.html' 56 | ], 57 | tasks: 'default' 58 | } 59 | } 60 | 61 | }); 62 | 63 | grunt.registerTask('test', ['qunit']); 64 | 65 | grunt.registerTask('default', [ 66 | 'copy', 67 | 'sass', 68 | 'test' 69 | ]); 70 | 71 | grunt.registerTask('stage', [ 72 | 'default' 73 | ]); 74 | 75 | }; 76 | -------------------------------------------------------------------------------- /dist/snapper.css: -------------------------------------------------------------------------------- 1 | /* snapper css snap points carousel */ 2 | /* snapper css snap points carousel */ 3 | .snapper * { 4 | box-sizing: border-box; } 5 | 6 | .snapper, 7 | .snapper_nextprev_contain { 8 | position: relative; } 9 | 10 | .snapper:focus { 11 | /* snapper div receives a tabindex to allow focus for keyboard arrow control */ 12 | outline: none; } 13 | 14 | @supports (scroll-snap-type: mandatory) { 15 | .snapper_pane { 16 | /* IE and edge */ 17 | -ms-overflow-style: none; 18 | /* Firefox */ 19 | scrollbar-width: none; } } 20 | 21 | .snapper_pane::-webkit-scrollbar { 22 | display: none; } 23 | 24 | .snapper_pane { 25 | overflow: auto; 26 | width: 100%; 27 | /* keep old API for iOS older than 13, then use new API */ 28 | -webkit-overflow-scrolling: touch; 29 | /* snap to points */ 30 | scroll-snap-type: mandatory; 31 | scroll-snap-type: x mandatory; 32 | /* x interval for snapping (100% of container width) */ 33 | scroll-snap-points-x: repeat(100%); 34 | position: relative; 35 | z-index: 0; } 36 | 37 | .snapper-sliding .snapper_pane { 38 | scroll-snap-type: none; } 39 | 40 | .snapper_items { 41 | display: flex; 42 | flex-flow: row nowrap; } 43 | 44 | .snapper_items > *, 45 | .snapper_item { 46 | position: relative; 47 | white-space: normal; 48 | scroll-snap-align: start; 49 | box-sizing: border-box; 50 | padding-right: 1px; 51 | padding-left: 1px; 52 | flex: 1 0 auto; 53 | width: 100%; } 54 | 55 | .snapper_items img { 56 | width: 100%; 57 | display: block; } 58 | 59 | /* next prev arrow selectors */ 60 | .snapper_nextprev-disabled, 61 | .snapper-hide-nav .snapper_nextprev, 62 | .snapper-hide-nav .snapper_nav { 63 | opacity: .3; 64 | cursor: default; } 65 | 66 | .snapper_nav, 67 | .snapper_nav_inner { 68 | position: relative; 69 | margin: 1em 0; 70 | overflow: auto; 71 | -webkit-overflow-scrolling: touch; 72 | display: flex; 73 | justify-content: flex-start; 74 | width: 100%; } 75 | 76 | .snapper_nav a { 77 | overflow: hidden; 78 | border: 1px solid #ddd; 79 | white-space: normal; 80 | flex: 0 0 auto; 81 | vertical-align: middle; 82 | height: 50px; 83 | width: auto; 84 | margin: 0 5px 0 0; } 85 | 86 | .snapper_nav a.snapper_nav_item-selected { 87 | /* selected styles here */ 88 | outline: 1px solid black; } 89 | 90 | .snapper_nav img { 91 | display: block; 92 | height: 100%; 93 | width: auto; 94 | max-width: 100%; } 95 | -------------------------------------------------------------------------------- /src/snapper.scss: -------------------------------------------------------------------------------- 1 | /* snapper css snap points carousel */ 2 | /* snapper css snap points carousel */ 3 | .snapper * { 4 | box-sizing: border-box; 5 | } 6 | .snapper, 7 | .snapper_nextprev_contain { 8 | position: relative; 9 | } 10 | .snapper:focus { /* snapper div receives a tabindex to allow focus for keyboard arrow control */ 11 | outline: none; 12 | } 13 | @supports (scroll-snap-type: mandatory ){ 14 | .snapper_pane { 15 | /* IE and edge */ 16 | -ms-overflow-style: none; 17 | /* Firefox */ 18 | scrollbar-width: none; 19 | } 20 | } 21 | .snapper_pane::-webkit-scrollbar { 22 | display: none; 23 | } 24 | .snapper_pane { 25 | overflow: auto; 26 | width: 100%; 27 | /* keep old API for iOS older than 13, then use new API */ 28 | -webkit-overflow-scrolling: touch; 29 | /* snap to points */ 30 | scroll-snap-type: mandatory; 31 | scroll-snap-type: x mandatory; 32 | /* x interval for snapping (100% of container width) */ 33 | scroll-snap-points-x: repeat(100%); 34 | position: relative; 35 | z-index: 0; 36 | } 37 | 38 | .snapper-sliding .snapper_pane { 39 | scroll-snap-type: none; 40 | } 41 | .snapper_items { 42 | display: flex; 43 | flex-flow: row nowrap; 44 | } 45 | .snapper_items > *, 46 | .snapper_item { 47 | position: relative; 48 | white-space: normal; 49 | scroll-snap-align: start; 50 | box-sizing: border-box; 51 | padding-right: 1px; 52 | padding-left: 1px; 53 | flex: 1 0 auto; 54 | width: 100%; 55 | } 56 | .snapper_items img { 57 | width: 100%; 58 | display: block; 59 | } 60 | 61 | 62 | /* next prev arrow selectors */ 63 | .snapper_nextprev, 64 | .snapper_nextprev_item { 65 | } 66 | .snapper_nextprev_next, 67 | .snapper_nextprev_prev { 68 | } 69 | .snapper_nextprev_next:hover, 70 | .snapper_nextprev_prev:hover, 71 | .snapper_nextprev_next:focus, 72 | .snapper_nextprev_prev:focus { 73 | } 74 | .snapper_nextprev_next { 75 | } 76 | .snapper_nextprev_prev { 77 | } 78 | 79 | .snapper_nextprev-disabled, 80 | .snapper-hide-nav .snapper_nextprev, 81 | .snapper-hide-nav .snapper_nav { 82 | opacity: .3; 83 | cursor: default; 84 | } 85 | 86 | 87 | .snapper_nav, 88 | .snapper_nav_inner { 89 | position: relative; 90 | margin: 1em 0; 91 | overflow: auto; 92 | -webkit-overflow-scrolling: touch; 93 | display: flex; 94 | justify-content: flex-start; 95 | width: 100%; 96 | } 97 | .snapper_nav a { 98 | overflow: hidden; 99 | border: 1px solid #ddd; 100 | white-space: normal; 101 | flex: 0 0 auto; 102 | vertical-align: middle; 103 | height: 50px; 104 | width: auto; 105 | margin: 0 5px 0 0; 106 | } 107 | .snapper_nav a.snapper_nav_item-selected { 108 | /* selected styles here */ 109 | outline: 1px solid black; 110 | } 111 | .snapper_nav img { 112 | display: block; 113 | height: 100%; 114 | width: auto; 115 | max-width: 100%; 116 | } 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | :warning: This project is archived and the repository is no longer maintained. 2 | 3 | # snapper 4 | 5 | A CSS Snap-Points based carousel (and lightweight polyfill) 6 | 7 | MIT License 8 | [c] 2020 Filament Group, Inc 9 | 10 | ## Dependencies 11 | - jQuery or similar DOM library 12 | - Intersection Observer Polyfill. Run `$ npm install` to download a copy to `./node_modules/intersection-observer/intersection-observer.js` 13 | 14 | ## Demo 15 | 16 | [https://filamentgroup.github.io/snapper/demo](https://filamentgroup.github.io/snapper/demo/) 17 | 18 | 19 | ## Docs 20 | 21 | 1. Include dependencies, plus the css and js files in the src dir. 22 | 2. Use the markup pattern below. 23 | 24 | ``` html 25 |
    26 |
    27 |
    28 |
    29 | 30 |
    31 |
    32 | 33 |
    34 |
    35 | 36 |
    37 |
    38 | 39 |
    40 |
    41 |
    42 |
    43 | ``` 44 | 45 | 3. Trigger an "enhance" event on a parent of the markup to initialize. You might do this on domready, as shown below: 46 | 47 | ``` js 48 | $( function(){ 49 | $( document ).trigger( "enhance" ); 50 | }); 51 | ``` 52 | 53 | ### Adding thumbnails 54 | 55 | To add thumbnail or graphic navigation to the carousel, you can append the following markup to the end of the snapper div (substituting your own styles, images, and hrefs to correspond to the IDs of their associated slides): 56 | 57 | ``` html 58 |
    59 | 60 | 61 | 62 | 63 |
    64 | ``` 65 | 66 | ### Adding next/prev navigation 67 | 68 | To add next and previous links that persist state, you can add a `data-snapper-nextprev` attribute to the snapper div. 69 | 70 | ``` html 71 |
    72 | ... 73 |
    74 | ``` 75 | 76 | 77 | ### Showing multiple images at a time 78 | 79 | If you want to show more than one snapper item at a time, you can set the widths on `.snapper_item` elements. You can aslo adjust widths as viewport width changes. For backwards compatibility, we recommend adding a `scroll-snap-points-x` rule on the `.snapper_pane` that matches the widths. As shown below. 80 | 81 | ``` css 82 | @media (min-width: 30em){ 83 | .snapper_pane { 84 | scroll-snap-points-x: repeat(50%); 85 | } 86 | .snapper_item { 87 | width: 50%; 88 | } 89 | } 90 | ``` 91 | 92 | ### Showing partial image reveals 93 | 94 | Just as the above specifies, you can use widths to reveal part of the next image to show there's more to scroll. 95 | 96 | 97 | ``` css 98 | @media (min-width: 30em){ 99 | .snapper_pane { 100 | scroll-snap-points-x: repeat(45%); 101 | } 102 | .snapper_item { 103 | width: 45%; 104 | } 105 | } 106 | 107 | 108 | ### Looping (*experimental) 109 | 110 | To make a snapper loop endlessly in either direction, you can add the data-snapper-loop attribute. This feature is experimental in this release. 111 | 112 | 113 | ``` html 114 |
    115 | ... 116 |
    117 | ``` 118 | 119 | 120 | ## Changes in 4.0x 121 | 122 | Version 4.0 breaks a few features and changes the way snapper works. Some notes on that: 123 | 124 | - The HTML is largely the same 125 | - Fake snapping is no longer supported. If a browser doesn't support CSS scroll snap, it won't happen, but the scrolling will still work. 126 | - Snap and scroll related events no longer fire. This is because we no longer support polyfilled snapping. The goto, next, prev events remain as they were. 127 | - Active state is tracked via intersection observer for performance reasons. 128 | - A "snapper.active" and "snapper.inactive" event is fired whenever snapper items become one or the other. 129 | - Endless looping is optionally available as an experimental feature. Accessibility impact is TBD on this feature. 130 | - CSS now uses flexbox, not floats. This means the JS can do less work calculating widths. You can set the widths on snapper item elements directly now instead of worrying about calculated total widths on the parent. If you set widths on the parent, it'll likely conflict with this. Instead, just set desired widths on the items. 131 | 132 | 133 | ### Support 134 | 135 | CSS Scroll Snap support can be found here: [CSS Snap Points on Caniuse.com](http://caniuse.com/#feat=css-snappoints) 136 | This plugin is tested to work broadly across modern browsers, and as long as you use thumbnail navigation. Various features may not work as well across older browsers, such as those that do not support snapping, but scroller content will still be accessible. 137 | -------------------------------------------------------------------------------- /test/qunit/qunit.css: -------------------------------------------------------------------------------- 1 | /** 2 | * QUnit v1.3.0pre - A JavaScript Unit Testing Framework 3 | * 4 | * http://docs.jquery.com/QUnit 5 | * 6 | * Copyright (c) 2011 John Resig, Jörn Zaefferer 7 | * Dual licensed under the MIT (MIT-LICENSE.txt) 8 | * or GPL (GPL-LICENSE.txt) licenses. 9 | */ 10 | 11 | /** Font Family and Sizes */ 12 | 13 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult { 14 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; 15 | } 16 | 17 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 18 | #qunit-tests { font-size: smaller; } 19 | 20 | 21 | /** Resets */ 22 | 23 | #qunit-tests, #qunit-tests ol, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult { 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | 29 | /** Header */ 30 | 31 | #qunit-header { 32 | padding: 0.5em 0 0.5em 1em; 33 | 34 | color: #8699a4; 35 | background-color: #0d3349; 36 | 37 | font-size: 1.5em; 38 | line-height: 1em; 39 | font-weight: normal; 40 | 41 | border-radius: 15px 15px 0 0; 42 | -moz-border-radius: 15px 15px 0 0; 43 | -webkit-border-top-right-radius: 15px; 44 | -webkit-border-top-left-radius: 15px; 45 | } 46 | 47 | #qunit-header a { 48 | text-decoration: none; 49 | color: #c2ccd1; 50 | } 51 | 52 | #qunit-header a:hover, 53 | #qunit-header a:focus { 54 | color: #fff; 55 | } 56 | 57 | #qunit-banner { 58 | height: 5px; 59 | } 60 | 61 | #qunit-testrunner-toolbar { 62 | padding: 0.5em 0 0.5em 2em; 63 | color: #5E740B; 64 | background-color: #eee; 65 | } 66 | 67 | #qunit-userAgent { 68 | padding: 0.5em 0 0.5em 2.5em; 69 | background-color: #2b81af; 70 | color: #fff; 71 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 72 | } 73 | 74 | 75 | /** Tests: Pass/Fail */ 76 | 77 | #qunit-tests { 78 | list-style-position: inside; 79 | } 80 | 81 | #qunit-tests li { 82 | padding: 0.4em 0.5em 0.4em 2.5em; 83 | border-bottom: 1px solid #fff; 84 | list-style-position: inside; 85 | } 86 | 87 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running { 88 | display: none; 89 | } 90 | 91 | #qunit-tests li strong { 92 | cursor: pointer; 93 | } 94 | 95 | #qunit-tests li a { 96 | padding: 0.5em; 97 | color: #c2ccd1; 98 | text-decoration: none; 99 | } 100 | #qunit-tests li a:hover, 101 | #qunit-tests li a:focus { 102 | color: #000; 103 | } 104 | 105 | #qunit-tests ol { 106 | margin-top: 0.5em; 107 | padding: 0.5em; 108 | 109 | background-color: #fff; 110 | 111 | border-radius: 15px; 112 | -moz-border-radius: 15px; 113 | -webkit-border-radius: 15px; 114 | 115 | box-shadow: inset 0px 2px 13px #999; 116 | -moz-box-shadow: inset 0px 2px 13px #999; 117 | -webkit-box-shadow: inset 0px 2px 13px #999; 118 | } 119 | 120 | #qunit-tests table { 121 | border-collapse: collapse; 122 | margin-top: .2em; 123 | } 124 | 125 | #qunit-tests th { 126 | text-align: right; 127 | vertical-align: top; 128 | padding: 0 .5em 0 0; 129 | } 130 | 131 | #qunit-tests td { 132 | vertical-align: top; 133 | } 134 | 135 | #qunit-tests pre { 136 | margin: 0; 137 | white-space: pre-wrap; 138 | word-wrap: break-word; 139 | } 140 | 141 | #qunit-tests del { 142 | background-color: #e0f2be; 143 | color: #374e0c; 144 | text-decoration: none; 145 | } 146 | 147 | #qunit-tests ins { 148 | background-color: #ffcaca; 149 | color: #500; 150 | text-decoration: none; 151 | } 152 | 153 | /*** Test Counts */ 154 | 155 | #qunit-tests b.counts { color: black; } 156 | #qunit-tests b.passed { color: #5E740B; } 157 | #qunit-tests b.failed { color: #710909; } 158 | 159 | #qunit-tests li li { 160 | margin: 0.5em; 161 | padding: 0.4em 0.5em 0.4em 0.5em; 162 | background-color: #fff; 163 | border-bottom: none; 164 | list-style-position: inside; 165 | } 166 | 167 | /*** Passing Styles */ 168 | 169 | #qunit-tests li li.pass { 170 | color: #5E740B; 171 | background-color: #fff; 172 | border-left: 26px solid #C6E746; 173 | } 174 | 175 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 176 | #qunit-tests .pass .test-name { color: #366097; } 177 | 178 | #qunit-tests .pass .test-actual, 179 | #qunit-tests .pass .test-expected { color: #999999; } 180 | 181 | #qunit-banner.qunit-pass { background-color: #C6E746; } 182 | 183 | /*** Failing Styles */ 184 | 185 | #qunit-tests li li.fail { 186 | color: #710909; 187 | background-color: #fff; 188 | border-left: 26px solid #EE5757; 189 | white-space: pre; 190 | } 191 | 192 | #qunit-tests > li:last-child { 193 | border-radius: 0 0 15px 15px; 194 | -moz-border-radius: 0 0 15px 15px; 195 | -webkit-border-bottom-right-radius: 15px; 196 | -webkit-border-bottom-left-radius: 15px; 197 | } 198 | 199 | #qunit-tests .fail { color: #000000; background-color: #EE5757; } 200 | #qunit-tests .fail .test-name, 201 | #qunit-tests .fail .module-name { color: #000000; } 202 | 203 | #qunit-tests .fail .test-actual { color: #EE5757; } 204 | #qunit-tests .fail .test-expected { color: green; } 205 | 206 | #qunit-banner.qunit-fail { background-color: #EE5757; } 207 | 208 | 209 | /** Result */ 210 | 211 | #qunit-testresult { 212 | padding: 0.5em 0.5em 0.5em 2.5em; 213 | 214 | color: #2b81af; 215 | background-color: #D2E0E6; 216 | 217 | border-bottom: 1px solid white; 218 | } 219 | 220 | /** Fixture */ 221 | -------------------------------------------------------------------------------- /demo/docs.css: -------------------------------------------------------------------------------- 1 | /* Demo styles */ 2 | body { 3 | font-family: sans-serif; 4 | font-size: 100%; 5 | } 6 | .docs-main { 7 | margin: 1em; 8 | max-width: 48em; 9 | } 10 | @media (min-width: 50em){ 11 | .docs-main { 12 | margin: 1em auto; 13 | } 14 | } 15 | label { 16 | display: block; 17 | margin: 1em 0; 18 | } 19 | input, 20 | textarea { 21 | display: block; 22 | width: 100%; 23 | -webkit-box-sizing: border-box; 24 | -moz-box-sizing: border-box; 25 | box-sizing: border-box; 26 | 27 | margin-top: .4em; 28 | padding: .6em; 29 | font-size: 100%; 30 | } 31 | 32 | .menu { 33 | background-color: white; 34 | box-sizing: border-box; 35 | border: 1px solid black; 36 | width: 10em; 37 | } 38 | 39 | .menu ul, .menu ol { 40 | list-style: none; 41 | padding: 5px; 42 | margin: 0; 43 | } 44 | 45 | .menu-selected { 46 | color: white; 47 | background-color: #aaa; 48 | } 49 | 50 | input { 51 | box-sizing: border-box; 52 | width: 10em; 53 | } 54 | 55 | h1.docs, 56 | h2.docs, 57 | h3.docs, 58 | h4.docs, 59 | h5.docs { 60 | font-weight: 500; 61 | margin: 1em 0; 62 | text-transform: none; 63 | color: #000; 64 | clear: both; 65 | } 66 | 67 | h1.docs { font-size: 2.8em; margin-top: .1em; text-transform: uppercase; } 68 | h2.docs { font-size: 2.2em; margin-top: 1.5em; border-top:1px solid #ddd; padding-top: .6em; float:none; } 69 | h3.docs { font-size: 1.6em; margin-top: 1.5em; margin-bottom: .5em; } 70 | h4.docs { font-size: 1.4em; margin-top: 1.5em; } 71 | 72 | p.docs, 73 | p.docs-intro, 74 | ol.docs, 75 | ul.docs, 76 | p.docs-note, 77 | dl.docs { 78 | margin: 1em 0; 79 | font-size: 1em; 80 | } 81 | 82 | ul.docs, 83 | ol.docs { 84 | padding-bottom: .5em; 85 | } 86 | ol.docs li, 87 | ul.docs li { 88 | margin-bottom: 8px; 89 | } 90 | ul.docs ul, 91 | ol.docs ul { 92 | padding-top: 8px; 93 | } 94 | .docs code { 95 | font-size: 1.1em; 96 | } 97 | 98 | p.docs strong { 99 | font-weight: bold; 100 | } 101 | 102 | .docs-note { 103 | background-color: #FFFAA4; 104 | } 105 | .docs-note p, 106 | .docs-note pre, 107 | p.docs-note { 108 | padding: .5em; 109 | margin: 0; 110 | } 111 | 112 | 113 | /** 114 | * prism.js default theme for JavaScript, CSS and HTML 115 | * Based on dabblet (http://dabblet.com) 116 | * @author Lea Verou 117 | */ 118 | 119 | code[class*="language-"], 120 | pre[class*="language-"] { 121 | color: black; 122 | text-shadow: 0 1px white; 123 | font-weight: normal; 124 | font-family: Consolas, Monaco, 'Andale Mono', monospace; 125 | direction: ltr; 126 | text-align: left; 127 | white-space: pre; 128 | word-spacing: normal; 129 | font-size: .9em; 130 | 131 | -moz-tab-size: 4; 132 | -o-tab-size: 4; 133 | tab-size: 4; 134 | 135 | -webkit-hyphens: none; 136 | -moz-hyphens: none; 137 | -ms-hyphens: none; 138 | hyphens: none; 139 | } 140 | 141 | @media print { 142 | code[class*="language-"], 143 | pre[class*="language-"] { 144 | text-shadow: none; 145 | } 146 | } 147 | 148 | /* Code blocks */ 149 | pre[class*="language-"] { 150 | padding: 1em; 151 | margin: .5em 0; 152 | overflow: auto; 153 | } 154 | 155 | :not(pre) > code[class*="language-"], 156 | pre[class*="language-"] { 157 | background: #f5f2f0; 158 | } 159 | 160 | /* Inline code */ 161 | :not(pre) > code[class*="language-"] { 162 | padding: .1em; 163 | border-radius: .3em; 164 | } 165 | 166 | pre[class*="language-"] { 167 | padding: 1em; 168 | margin: 0; 169 | margin-bottom: 1em; 170 | } 171 | 172 | 173 | 174 | /* next prev arrow selectors */ 175 | .snapper_nextprev, 176 | .snapper_nextprev_item { 177 | list-style: none; 178 | margin: 0; 179 | padding: 0; 180 | } 181 | .snapper_nextprev_next, 182 | .snapper_nextprev_prev { 183 | position: absolute; 184 | top: 50%; 185 | width: 46px; 186 | height: 46px; 187 | line-height: 46px; 188 | margin-top: -23px; 189 | background-color: #fff; 190 | border-radius: 100%; 191 | overflow: hidden; 192 | text-align: center; 193 | font-size: .7em; 194 | text-transform: uppercase; 195 | text-decoration: none; 196 | border: 1px solid #eee; 197 | box-shadow: 0 0 5px rgba(0,0,0,.5); 198 | } 199 | .snapper_nextprev_next:not(.snapper_nextprev-disabled), 200 | .snapper_nextprev_prev:not(.snapper_nextprev-disabled) { 201 | opacity: .8; 202 | cursor: pointer; 203 | } 204 | .snapper_nextprev_next:not(.snapper_nextprev-disabled):hover, 205 | .snapper_nextprev_next:not(.snapper_nextprev-disabled):focus, 206 | .snapper_nextprev_prev:not(.snapper_nextprev-disabled):hover, 207 | .snapper_nextprev_prev:not(.snapper_nextprev-disabled):focus { 208 | opacity: 1; 209 | } 210 | .snapper_nextprev_next { 211 | right: -23px; 212 | } 213 | .snapper_nextprev_prev { 214 | left: -23px; 215 | } 216 | 217 | 218 | @media (min-width: 40em) { 219 | .snapper_nextprev_next, 220 | .snapper_nextprev_prev { 221 | width: 50px; 222 | height: 50px; 223 | line-height: 50px; 224 | margin-top: -25px; 225 | } 226 | .snapper_nextprev_next { 227 | right: -25px; 228 | } 229 | .snapper_nextprev_prev { 230 | left: -25px; 231 | } 232 | } 233 | 234 | 235 | 236 | /* dots nav */ 237 | .snapper_nav-dots { 238 | display: block; 239 | margin: 0; 240 | text-align: center; 241 | } 242 | .snapper_nav-dots a { 243 | display: inline-block; 244 | width: 10px; 245 | height: 10px; 246 | margin: 0 2px; 247 | background: #ccc; 248 | border-radius: 100%; 249 | overflow: hidden; 250 | text-indent: -9999px; 251 | cursor: pointer; 252 | } 253 | .snapper_nav.snapper_nav-dots a { 254 | float: none; 255 | } 256 | .snapper_nav-dots a.snapper_nav_item-selected { 257 | background: #111; 258 | box-shadow: none; 259 | border: none; 260 | outline: none; 261 | } 262 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | /* 2 | Snapper unit tests - using qUnit 3 | */ 4 | 5 | window.onload = function(){ 6 | /* TESTS HERE */ 7 | 8 | 9 | 10 | //snapper tests 11 | test( 'API Properties: $.fn.snapper is a function', function() { 12 | ok( typeof( $.fn.snapper ) === "function" ); 13 | }); 14 | 15 | asyncTest( 'Enhancement steps', function() { 16 | $(function(){ 17 | $( ".snapper" ).snapper(); 18 | start(); 19 | ok($(".snapper_nextprev").length, "next prev generated"); 20 | ok($(".snapper_nextprev a").length === 2, "2 next prev links"); 21 | }); 22 | 23 | 24 | }); 25 | 26 | 27 | asyncTest( 'Snapping occurs after scrolling to a spot that is not a snap point', function() { 28 | expect(1); 29 | $(".snapper").snapper(); 30 | $(".snapper_pane")[0].scrollLeft = 0; 31 | $(".snapper_pane")[0].scrollLeft = 35; 32 | setTimeout(function(){ 33 | ok( $(".snapper_pane")[0].scrollLeft ===0 ); 34 | start(); 35 | },1000); 36 | }); 37 | 38 | 39 | 40 | asyncTest( 'thumbnail clicks cause pane to scroll', function() { 41 | $(".snapper").snapper(); 42 | expect(1); 43 | $(".snapper_pane")[0].scrollLeft = 0; 44 | $(".snapper_nav a").last().trigger( "click" ); 45 | setTimeout(function(){ 46 | ok( $(".snapper_pane")[0].scrollLeft !== 0, "scroll changed" ); 47 | start(); 48 | },1000); 49 | }); 50 | 51 | 52 | asyncTest( 'disabled arrow classes are present at extremes', function() { 53 | $(".snapper").snapper(); 54 | expect(4); 55 | 56 | 57 | setTimeout(function(){ 58 | ok( $(".snapper_nextprev_prev.snapper_nextprev-disabled").length === 0, "prev link is not disabled "); 59 | ok( $(".snapper_nextprev_next.snapper_nextprev-disabled").length === 1, "next link is disabled "); 60 | start(); 61 | },2000); 62 | 63 | ok( $(".snapper_nextprev_prev.snapper_nextprev-disabled").length === 1, "prev link is disabled "); 64 | ok( $(".snapper_nextprev_next.snapper_nextprev-disabled").length === 0, "next link is not disabled "); 65 | 66 | $(".snapper_pane")[0].scrollTo(5000,0); 67 | }); 68 | 69 | 70 | asyncTest( 'Arrows navigate', function() { 71 | $(".snapper").snapper(); 72 | expect(1); 73 | $(".snapper_pane")[0].scrollLeft = 0; 74 | 75 | setTimeout(function(){ 76 | ok( $(".snapper_pane")[0].scrollLeft !== 0, "scroll changed" ); 77 | start(); 78 | },2000); 79 | setTimeout(() => { 80 | $(".snapper_nextprev_next").click(); 81 | }, 1000); 82 | }); 83 | 84 | 85 | asyncTest( 'Arrows navigate back', function() { 86 | $(".snapper").snapper(); 87 | expect(2); 88 | $(".snapper_pane")[0].scrollLeft = 0; 89 | 90 | setTimeout(function(){ 91 | ok( $(".snapper_pane")[0].scrollLeft === 0, "scroll changed" ); 92 | start(); 93 | },4000); 94 | 95 | setTimeout(function(){ 96 | ok( $(".snapper_pane")[0].scrollLeft !== 0, "scroll changed" ); 97 | $(".snapper_nextprev_prev").click(); 98 | },2000); 99 | 100 | setTimeout(function(){ 101 | $(".snapper_nextprev_next").click(); 102 | }, 1000); 103 | }); 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | asyncTest( 'random # link clicks are ignored', function() { 113 | $(".snapper").snapper(); 114 | expect(1); 115 | $("#testlink").trigger( "click" ); 116 | ok( true ); 117 | start(); 118 | }); 119 | 120 | 121 | 122 | asyncTest( 'get index returns correct index after goto', function(){ 123 | var $snapper = $(".snapper").snapper(); 124 | expect(1); 125 | 126 | 127 | setTimeout(function(){ 128 | equal($snapper.snapper("getIndex"), 1); 129 | start(); 130 | }, 2000); 131 | 132 | $snapper.snapper("goto", 1); 133 | }); 134 | 135 | asyncTest( 'autoplay advances a few times once started', function(){ 136 | expect(2); 137 | var eventCounter = 0; 138 | var checkBinding; 139 | var $snapperElem = $(".snapper"); 140 | var $snapper; 141 | 142 | $snapperElem.attr( "data-snapper-autoplay", "500" ); 143 | 144 | $snapperElem.bind("snapper.after-goto", checkBinding = function(){ 145 | ok(true, "after-goto called"); 146 | 147 | if(++eventCounter === 2){ 148 | $snapperElem.removeAttr( "data-snapper-autoplay" ); 149 | $(document).unbind("snapper.after-goto", checkBinding); 150 | start(); 151 | } 152 | }); 153 | 154 | $snapperElem.snapper(); 155 | }); 156 | 157 | asyncTest( 'looping goes endlessly forward', function(){ 158 | expect(5); 159 | var eventCounter = 0; 160 | var checkBinding; 161 | var $snapperElem = $(".snapper"); 162 | var $snapper; 163 | 164 | $snapperElem.attr( "data-snapper-loop", "500" ); 165 | 166 | $snapperElem.bind("snapper.after-next", checkBinding = function(){ 167 | ok(true, "after-next called"); 168 | 169 | if(++eventCounter === 5){ 170 | $snapperElem.removeAttr( "data-snapper-loop" ); 171 | $(document).unbind("snapper.after-goto", checkBinding); 172 | start(); 173 | } 174 | else{ 175 | $(".snapper_nextprev_next").click(); 176 | } 177 | }); 178 | 179 | $snapperElem.snapper(); 180 | setTimeout(() => { 181 | $(".snapper_nextprev_next").click(); 182 | }, 500); 183 | }); 184 | 185 | 186 | asyncTest( 'looping goes endlessly in reverse', function(){ 187 | expect(5); 188 | var eventCounter = 0; 189 | var checkBinding; 190 | var $snapperElem = $(".snapper"); 191 | var $snapper; 192 | 193 | $snapperElem.attr( "data-snapper-loop", "500" ); 194 | 195 | $snapperElem.bind("snapper.after-prev", checkBinding = function(){ 196 | ok(true, "after-prev called"); 197 | 198 | if(++eventCounter === 5){ 199 | $snapperElem.removeAttr( "data-snapper-loop" ); 200 | $(document).unbind("snapper.after-goto", checkBinding); 201 | start(); 202 | } 203 | else{ 204 | $(".snapper_nextprev_prev").click(); 205 | } 206 | }); 207 | 208 | $snapperElem.snapper(); 209 | setTimeout(() => { 210 | $(".snapper_nextprev_prev").click(); 211 | }, 500); 212 | }); 213 | 214 | }; 215 | -------------------------------------------------------------------------------- /dist/snapper.js: -------------------------------------------------------------------------------- 1 | /* snapper css snap points carousel */ 2 | ;(function( w, $ ){ 3 | var pluginName = "snapper"; 4 | var navActiveClass = pluginName + "_nav_item-selected"; 5 | $.fn[ pluginName ] = function(optionsOrMethod){ 6 | var pluginArgs = arguments; 7 | 8 | function observerCallback( entries ){ 9 | var parentElem = $( entries[0].target ).closest( "." + pluginName ); 10 | var navElem = parentElem.find( "." + pluginName + "_nav" ); 11 | for(i in entries){ 12 | var entry = entries[i]; 13 | var entryNavLink = parentElem.find( "a[href='#" + entry.target.id + "']" ); 14 | if (entry.isIntersecting && entry.intersectionRatio >= .75 ) { 15 | entry.target.classList.add( pluginName + "_item-active" ); 16 | $( entry.target ).trigger( pluginName + ".active" ); 17 | if( navElem.length ){ 18 | entryNavLink[0].classList.add( navActiveClass ); 19 | if( navElem[0].scrollTo ){ 20 | navElem[0].scrollTo({ left: entryNavLink[0].offsetLeft, behavior: "smooth" }); 21 | } 22 | else { 23 | navElem[0].scrollLeft = entryNavLink[0].offsetLeft; 24 | } 25 | } 26 | } 27 | else { 28 | entry.target.classList.remove( pluginName + "_item-active" ); 29 | $( entry.target ).trigger( pluginName + ".inactive" ); 30 | if( entryNavLink.length ){ 31 | entryNavLink[0].classList.remove( navActiveClass ); 32 | } 33 | } 34 | } 35 | } 36 | 37 | function idItems( $elem ){ 38 | $elem.children().each(function(){ 39 | if( $( this ).attr("id") === undefined ){ 40 | $( this ).attr("id", new Date().getTime() ); 41 | } 42 | }); 43 | } 44 | 45 | function observeItems( elem ){ 46 | var observer = new IntersectionObserver(observerCallback, {root: elem, threshold: .75 }); 47 | $( elem ).find( "." + pluginName + "_item" ).each(function(){ 48 | observer.observe(this); 49 | }); 50 | observer.takeRecords(); 51 | } 52 | 53 | // get the snapper_item elements whose left offsets fall within the scroll pane. 54 | function activeItems( elem ){ 55 | return $( elem ).find( "." + pluginName + "_item-active" ); 56 | } 57 | 58 | // sort an item to either end to ensure there's always something to advance to 59 | function updateSort(el) { 60 | if( !$(el).closest( "[data-snapper-loop], [data-loop]" ).length ){ 61 | return; 62 | } 63 | var scrollWidth = el.scrollWidth; 64 | var scrollLeft = el.scrollLeft; 65 | var contain = $(el).find( "." + pluginName + "_items" ); 66 | var items = contain.children(); 67 | var width = el.offsetWidth; 68 | 69 | if (scrollLeft < width ) { 70 | var sortItem = items.last(); 71 | var sortItemWidth = sortItem.width(); 72 | contain.prepend(sortItem); 73 | el.scrollLeft = scrollLeft + sortItemWidth; 74 | } 75 | else if (scrollWidth - scrollLeft - width <= 0 ) { 76 | var sortItem = items.first(); 77 | var sortItemWidth = sortItem.width(); 78 | contain.append(sortItem); 79 | el.scrollLeft = scrollLeft - sortItemWidth; 80 | } 81 | } 82 | 83 | // disable or enable snapper arrows depending on whether they can advance 84 | function setArrowState($el) { 85 | // old api helper here. 86 | if( $el.closest( "[data-snapper-loop], [data-loop]" ).length ){ 87 | return; 88 | } 89 | var pane = $el.find(".snapper_pane"); 90 | var nextLink = $el.find(".snapper_nextprev_next"); 91 | var prevLink = $el.find(".snapper_nextprev_prev"); 92 | var currScroll = pane[0].scrollLeft; 93 | var scrollWidth = pane[0].scrollWidth; 94 | var width = pane.width(); 95 | 96 | var noScrollAvailable = (width === scrollWidth); 97 | 98 | var maxScroll = scrollWidth - width; 99 | if (currScroll >= maxScroll - 3 || noScrollAvailable ) { // 3 here is arbitrary tolerance 100 | nextLink 101 | .addClass("snapper_nextprev-disabled") 102 | .attr("tabindex", -1); 103 | } else { 104 | nextLink 105 | .removeClass("snapper_nextprev-disabled") 106 | .attr("tabindex", 0); 107 | } 108 | 109 | if (currScroll > 3 && !noScrollAvailable ) { // 3 is arbitrary tolerance 110 | prevLink 111 | .removeClass("snapper_nextprev-disabled") 112 | .attr("tabindex", 0); 113 | } else { 114 | prevLink 115 | .addClass("snapper_nextprev-disabled") 116 | .attr("tabindex", -1); 117 | } 118 | 119 | if( noScrollAvailable ){ 120 | $el.addClass( "snapper-hide-nav" ); 121 | } 122 | else { 123 | $el.removeClass( "snapper-hide-nav" ); 124 | } 125 | } 126 | 127 | function goto( elem, x, callback ){ 128 | if( elem.scrollTo ){ 129 | elem.scrollTo({ left: x, behavior: "smooth" }); 130 | } 131 | else { 132 | elem.scrollLeft = x; 133 | } 134 | var activeSlides = activeItems( elem ); 135 | 136 | $( elem ).trigger( pluginName + ".after-goto", { 137 | activeSlides: activeSlides[ 0 ] 138 | }); 139 | if( callback ){ 140 | callback(); 141 | }; 142 | } 143 | 144 | var result, innerResult; 145 | 146 | // Loop through snapper elements and enhance/bind events 147 | result = this.each(function(){ 148 | if( innerResult !== undefined ){ 149 | return; 150 | } 151 | 152 | var self = this; 153 | var $self = $( self ); 154 | var addNextPrev = $self.is( "[data-" + pluginName + "-nextprev]" ); 155 | var autoTimeout; 156 | var $slider = $( "." + pluginName + "_pane", self ); 157 | // give the pane a tabindex for arrow key handling 158 | $slider.attr("tabindex", "0"); 159 | var $itemsContain = $slider.find( "." + pluginName + "_items" ); 160 | // make sure items are ID'd. This is critical for arrow nav and sorting. 161 | idItems( $itemsContain ); 162 | var $items = $itemsContain.children(); 163 | $items.addClass( pluginName + "_item" ); 164 | var numItems = $items.length; 165 | 166 | if( typeof optionsOrMethod === "string" ){ 167 | var args = Array.prototype.slice.call(pluginArgs, 1); 168 | var index; 169 | 170 | switch(optionsOrMethod) { 171 | case "goto": 172 | index = args[0] % numItems; 173 | 174 | var offset = $itemsContain.children().eq(index)[0].offsetLeft; 175 | goto( $slider[ 0 ], offset, function(){ 176 | // invoke the callback if it was supplied 177 | if( typeof args[1] === "function" ){ 178 | args[1](); 179 | } 180 | }); 181 | break; 182 | case "getIndex": 183 | innerResult = activeItems($slider).index(); 184 | break; 185 | } 186 | return; 187 | } 188 | 189 | // avoid double enhance activities 190 | if( $self.attr("data-" + pluginName + "-enhanced") ) { 191 | return; 192 | } 193 | 194 | observeItems($slider[ 0 ]); 195 | 196 | // if the nextprev option is set, add the nextprev nav 197 | if( addNextPrev ){ 198 | var $nextprev = $( '' ); 199 | var $nextprevContain = $( ".snapper_nextprev_contain", self ); 200 | if( !$nextprevContain.length ){ 201 | $nextprevContain = $( self ); 202 | } 203 | $nextprev.appendTo( $nextprevContain ); 204 | } 205 | 206 | // This click binding will allow linking to slides from thumbnails without causing the page to scroll to the carousel container 207 | // this also supports click handling for generated next/prev links 208 | $( "a", this ).bind( "click", function( e ){ 209 | clearTimeout(autoTimeout); 210 | var slideID = $( this ).attr( "href" ); 211 | 212 | if( $( this ).is( ".snapper_nextprev_next" ) ){ 213 | e.preventDefault(); 214 | return arrowNavigate( true ); 215 | } 216 | else if( $( this ).is( ".snapper_nextprev_prev" ) ){ 217 | e.preventDefault(); 218 | return arrowNavigate( false ); 219 | } 220 | // internal links to slides 221 | else if( slideID.indexOf( "#" ) === 0 && slideID.length > 1 ){ 222 | e.preventDefault(); 223 | gotoSlide( slideID ); 224 | } 225 | }); 226 | 227 | // arrow key bindings for next/prev 228 | $( this ) 229 | .bind( "keydown", function( e ){ 230 | if( e.keyCode === 37 || e.keyCode === 38 ){ 231 | clearTimeout(autoTimeout); 232 | e.preventDefault(); 233 | e.stopImmediatePropagation(); 234 | arrowNavigate( false ); 235 | } 236 | if( e.keyCode === 39 || e.keyCode === 40 ){ 237 | clearTimeout(autoTimeout); 238 | e.preventDefault(); 239 | e.stopImmediatePropagation(); 240 | arrowNavigate( true ); 241 | } 242 | } ); 243 | 244 | function gotoSlide( href, callback ){ 245 | var $slide = $( href, self ); 246 | if( $slide.length ){ 247 | goto( $slider[ 0 ], $slide[ 0 ].offsetLeft, function(){ 248 | if( callback ){ 249 | callback(); 250 | } 251 | } ); 252 | } 253 | } 254 | 255 | 256 | 257 | var afterResize; 258 | var currSlide; 259 | function resizeUpdates(){ 260 | clearTimeout( afterResize ); 261 | if( !currSlide ){ 262 | currSlide = activeItems($slider).first(); 263 | } 264 | afterResize = setTimeout( function(){ 265 | // retain snapping on resize 266 | gotoSlide( currSlide.attr("id") ); 267 | currSlide = null; 268 | // resize can reveal or hide slides, so update arrows 269 | setArrowState( $self ); 270 | }, 300 ); 271 | } 272 | $( w ).bind( "resize", resizeUpdates ); 273 | 274 | // next/prev links or arrows should loop back to the other end when an extreme is reached 275 | function arrowNavigate( forward ){ 276 | if( forward ){ 277 | next(); 278 | } 279 | else { 280 | prev(); 281 | } 282 | } 283 | 284 | // advance slide one full scrollpane's width forward 285 | function next(){ 286 | var currentActive = activeItems($slider).first(); 287 | var next = currentActive.next(); 288 | if( next.length ){ 289 | gotoSlide( "#" + next.attr( "id" ), function(){ 290 | $slider.trigger( pluginName + ".after-next" ); 291 | } ); 292 | } 293 | } 294 | 295 | // advance slide one full scrollpane's width backwards 296 | function prev(){ 297 | var currentActive = activeItems($slider).first(); 298 | var prev = currentActive.prev(); 299 | if( prev.length ){ 300 | gotoSlide( "#" + prev.attr( "id" ), function(){ 301 | $slider.trigger( pluginName + ".after-prev" ); 302 | } ); 303 | } 304 | } 305 | 306 | function getAutoplayInterval() { 307 | var activeSlide = activeItems($slider).last(); 308 | var autoTiming = activeSlide.attr( "data-snapper-autoplay" ) || $self.attr( "data-snapper-autoplay" ); 309 | if( autoTiming ) { 310 | autoTiming = parseInt(autoTiming, 10) || 5000; 311 | } 312 | return autoTiming; 313 | } 314 | 315 | // if the `data-autoplay` attribute is assigned a natural number value 316 | // use it to make the slides cycle until there is a user interaction 317 | function autoplay( autoTiming ) { 318 | if( autoTiming ){ 319 | // autoTimeout is cleared in each user interaction binding 320 | autoTimeout = setTimeout(function(){ 321 | var timeout = getAutoplayInterval(); 322 | if( timeout ) { 323 | arrowNavigate(true); 324 | autoplay( timeout ); 325 | } 326 | }, autoTiming); 327 | } 328 | } 329 | 330 | // if a touch event is fired on the snapper we know the user is trying to 331 | // interact with it and we should disable the auto play 332 | $slider.bind("pointerdown click mouseenter focus", function(){ 333 | clearTimeout(autoTimeout); 334 | }); 335 | 336 | var scrolling; 337 | $slider.bind("scroll", function(){ 338 | window.clearTimeout(scrolling); 339 | scrolling = setTimeout(function(){ 340 | updateSort( $slider[0] ); 341 | setArrowState( $self ); 342 | },66); 343 | }); 344 | 345 | updateSort( $slider[0] ); 346 | 347 | setArrowState( $self ); 348 | 349 | autoplay( getAutoplayInterval() ); 350 | $self.attr("data-" + pluginName + "-enhanced", true); 351 | }); 352 | 353 | return (innerResult !== undefined ? innerResult : result); 354 | }; 355 | }( this, jQuery )); 356 | -------------------------------------------------------------------------------- /src/snapper.js: -------------------------------------------------------------------------------- 1 | /* snapper css snap points carousel */ 2 | ;(function( w, $ ){ 3 | var pluginName = "snapper"; 4 | var navActiveClass = pluginName + "_nav_item-selected"; 5 | $.fn[ pluginName ] = function(optionsOrMethod){ 6 | var pluginArgs = arguments; 7 | 8 | function observerCallback( entries ){ 9 | var parentElem = $( entries[0].target ).closest( "." + pluginName ); 10 | var navElem = parentElem.find( "." + pluginName + "_nav" ); 11 | for(i in entries){ 12 | var entry = entries[i]; 13 | var entryNavLink = parentElem.find( "a[href='#" + entry.target.id + "']" ); 14 | if (entry.isIntersecting && entry.intersectionRatio >= .75 ) { 15 | entry.target.classList.add( pluginName + "_item-active" ); 16 | $( entry.target ).trigger( pluginName + ".active" ); 17 | if( navElem.length ){ 18 | entryNavLink[0].classList.add( navActiveClass ); 19 | if( navElem[0].scrollTo ){ 20 | navElem[0].scrollTo({ left: entryNavLink[0].offsetLeft, behavior: "smooth" }); 21 | } 22 | else { 23 | navElem[0].scrollLeft = entryNavLink[0].offsetLeft; 24 | } 25 | } 26 | } 27 | else { 28 | entry.target.classList.remove( pluginName + "_item-active" ); 29 | $( entry.target ).trigger( pluginName + ".inactive" ); 30 | if( entryNavLink.length ){ 31 | entryNavLink[0].classList.remove( navActiveClass ); 32 | } 33 | } 34 | } 35 | } 36 | 37 | function idItems( $elem ){ 38 | $elem.children().each(function(){ 39 | if( $( this ).attr("id") === undefined ){ 40 | $( this ).attr("id", new Date().getTime() ); 41 | } 42 | }); 43 | } 44 | 45 | function observeItems( elem ){ 46 | var observer = new IntersectionObserver(observerCallback, {root: elem, threshold: .75 }); 47 | $( elem ).find( "." + pluginName + "_item" ).each(function(){ 48 | observer.observe(this); 49 | }); 50 | observer.takeRecords(); 51 | } 52 | 53 | // get the snapper_item elements whose left offsets fall within the scroll pane. 54 | function activeItems( elem ){ 55 | return $( elem ).find( "." + pluginName + "_item-active" ); 56 | } 57 | 58 | // sort an item to either end to ensure there's always something to advance to 59 | function updateSort(el) { 60 | if( !$(el).closest( "[data-snapper-loop], [data-loop]" ).length ){ 61 | return; 62 | } 63 | var scrollWidth = el.scrollWidth; 64 | var scrollLeft = el.scrollLeft; 65 | var contain = $(el).find( "." + pluginName + "_items" ); 66 | var items = contain.children(); 67 | var width = el.offsetWidth; 68 | 69 | if (scrollLeft < width ) { 70 | var sortItem = items.last(); 71 | var sortItemWidth = sortItem.width(); 72 | contain.prepend(sortItem); 73 | el.scrollLeft = scrollLeft + sortItemWidth; 74 | } 75 | else if (scrollWidth - scrollLeft - width <= 0 ) { 76 | var sortItem = items.first(); 77 | var sortItemWidth = sortItem.width(); 78 | contain.append(sortItem); 79 | el.scrollLeft = scrollLeft - sortItemWidth; 80 | } 81 | } 82 | 83 | // disable or enable snapper arrows depending on whether they can advance 84 | function setArrowState($el) { 85 | // old api helper here. 86 | if( $el.closest( "[data-snapper-loop], [data-loop]" ).length ){ 87 | return; 88 | } 89 | var pane = $el.find(".snapper_pane"); 90 | var nextLink = $el.find(".snapper_nextprev_next"); 91 | var prevLink = $el.find(".snapper_nextprev_prev"); 92 | var currScroll = pane[0].scrollLeft; 93 | var scrollWidth = pane[0].scrollWidth; 94 | var width = pane.width(); 95 | 96 | var noScrollAvailable = (width === scrollWidth); 97 | 98 | var maxScroll = scrollWidth - width; 99 | if (currScroll >= maxScroll - 3 || noScrollAvailable ) { // 3 here is arbitrary tolerance 100 | nextLink 101 | .addClass("snapper_nextprev-disabled") 102 | .attr("tabindex", -1); 103 | } else { 104 | nextLink 105 | .removeClass("snapper_nextprev-disabled") 106 | .attr("tabindex", 0); 107 | } 108 | 109 | if (currScroll > 3 && !noScrollAvailable ) { // 3 is arbitrary tolerance 110 | prevLink 111 | .removeClass("snapper_nextprev-disabled") 112 | .attr("tabindex", 0); 113 | } else { 114 | prevLink 115 | .addClass("snapper_nextprev-disabled") 116 | .attr("tabindex", -1); 117 | } 118 | 119 | if( noScrollAvailable ){ 120 | $el.addClass( "snapper-hide-nav" ); 121 | } 122 | else { 123 | $el.removeClass( "snapper-hide-nav" ); 124 | } 125 | } 126 | 127 | function goto( elem, x, callback ){ 128 | if( elem.scrollTo ){ 129 | elem.scrollTo({ left: x, behavior: "smooth" }); 130 | } 131 | else { 132 | elem.scrollLeft = x; 133 | } 134 | var activeSlides = activeItems( elem ); 135 | 136 | $( elem ).trigger( pluginName + ".after-goto", { 137 | activeSlides: activeSlides[ 0 ] 138 | }); 139 | if( callback ){ 140 | callback(); 141 | }; 142 | } 143 | 144 | var result, innerResult; 145 | 146 | // Loop through snapper elements and enhance/bind events 147 | result = this.each(function(){ 148 | if( innerResult !== undefined ){ 149 | return; 150 | } 151 | 152 | var self = this; 153 | var $self = $( self ); 154 | var addNextPrev = $self.is( "[data-" + pluginName + "-nextprev]" ); 155 | var autoTimeout; 156 | var $slider = $( "." + pluginName + "_pane", self ); 157 | // give the pane a tabindex for arrow key handling 158 | $slider.attr("tabindex", "0"); 159 | var $itemsContain = $slider.find( "." + pluginName + "_items" ); 160 | // make sure items are ID'd. This is critical for arrow nav and sorting. 161 | idItems( $itemsContain ); 162 | var $items = $itemsContain.children(); 163 | $items.addClass( pluginName + "_item" ); 164 | var numItems = $items.length; 165 | 166 | if( typeof optionsOrMethod === "string" ){ 167 | var args = Array.prototype.slice.call(pluginArgs, 1); 168 | var index; 169 | 170 | switch(optionsOrMethod) { 171 | case "goto": 172 | index = args[0] % numItems; 173 | 174 | var offset = $itemsContain.children().eq(index)[0].offsetLeft; 175 | goto( $slider[ 0 ], offset, function(){ 176 | // invoke the callback if it was supplied 177 | if( typeof args[1] === "function" ){ 178 | args[1](); 179 | } 180 | }); 181 | break; 182 | case "getIndex": 183 | innerResult = activeItems($slider).index(); 184 | break; 185 | } 186 | return; 187 | } 188 | 189 | // avoid double enhance activities 190 | if( $self.attr("data-" + pluginName + "-enhanced") ) { 191 | return; 192 | } 193 | 194 | observeItems($slider[ 0 ]); 195 | 196 | // if the nextprev option is set, add the nextprev nav 197 | if( addNextPrev ){ 198 | var $nextprev = $( '' ); 199 | var $nextprevContain = $( ".snapper_nextprev_contain", self ); 200 | if( !$nextprevContain.length ){ 201 | $nextprevContain = $( self ); 202 | } 203 | $nextprev.appendTo( $nextprevContain ); 204 | } 205 | 206 | // This click binding will allow linking to slides from thumbnails without causing the page to scroll to the carousel container 207 | // this also supports click handling for generated next/prev links 208 | $( "a", this ).bind( "click", function( e ){ 209 | clearTimeout(autoTimeout); 210 | var slideID = $( this ).attr( "href" ); 211 | 212 | if( $( this ).is( ".snapper_nextprev_next" ) ){ 213 | e.preventDefault(); 214 | return arrowNavigate( true ); 215 | } 216 | else if( $( this ).is( ".snapper_nextprev_prev" ) ){ 217 | e.preventDefault(); 218 | return arrowNavigate( false ); 219 | } 220 | // internal links to slides 221 | else if( slideID.indexOf( "#" ) === 0 && slideID.length > 1 ){ 222 | e.preventDefault(); 223 | gotoSlide( slideID ); 224 | } 225 | }); 226 | 227 | // arrow key bindings for next/prev 228 | $( this ) 229 | .bind( "keydown", function( e ){ 230 | if( e.keyCode === 37 || e.keyCode === 38 ){ 231 | clearTimeout(autoTimeout); 232 | e.preventDefault(); 233 | e.stopImmediatePropagation(); 234 | arrowNavigate( false ); 235 | } 236 | if( e.keyCode === 39 || e.keyCode === 40 ){ 237 | clearTimeout(autoTimeout); 238 | e.preventDefault(); 239 | e.stopImmediatePropagation(); 240 | arrowNavigate( true ); 241 | } 242 | } ); 243 | 244 | function gotoSlide( href, callback ){ 245 | var $slide = $( href, self ); 246 | if( $slide.length ){ 247 | goto( $slider[ 0 ], $slide[ 0 ].offsetLeft, function(){ 248 | if( callback ){ 249 | callback(); 250 | } 251 | } ); 252 | } 253 | } 254 | 255 | 256 | 257 | var afterResize; 258 | var currSlide; 259 | function resizeUpdates(){ 260 | clearTimeout( afterResize ); 261 | if( !currSlide ){ 262 | currSlide = activeItems($slider).first(); 263 | } 264 | afterResize = setTimeout( function(){ 265 | // retain snapping on resize 266 | gotoSlide( currSlide.attr("id") ); 267 | currSlide = null; 268 | // resize can reveal or hide slides, so update arrows 269 | setArrowState( $self ); 270 | }, 300 ); 271 | } 272 | $( w ).bind( "resize", resizeUpdates ); 273 | 274 | // next/prev links or arrows should loop back to the other end when an extreme is reached 275 | function arrowNavigate( forward ){ 276 | if( forward ){ 277 | next(); 278 | } 279 | else { 280 | prev(); 281 | } 282 | } 283 | 284 | // advance slide one full scrollpane's width forward 285 | function next(){ 286 | var currentActive = activeItems($slider).first(); 287 | var next = currentActive.next(); 288 | if( next.length ){ 289 | gotoSlide( "#" + next.attr( "id" ), function(){ 290 | $slider.trigger( pluginName + ".after-next" ); 291 | } ); 292 | } 293 | } 294 | 295 | // advance slide one full scrollpane's width backwards 296 | function prev(){ 297 | var currentActive = activeItems($slider).first(); 298 | var prev = currentActive.prev(); 299 | if( prev.length ){ 300 | gotoSlide( "#" + prev.attr( "id" ), function(){ 301 | $slider.trigger( pluginName + ".after-prev" ); 302 | } ); 303 | } 304 | } 305 | 306 | function getAutoplayInterval() { 307 | var activeSlide = activeItems($slider).last(); 308 | var autoTiming = activeSlide.attr( "data-snapper-autoplay" ) || $self.attr( "data-snapper-autoplay" ); 309 | if( autoTiming ) { 310 | autoTiming = parseInt(autoTiming, 10) || 5000; 311 | } 312 | return autoTiming; 313 | } 314 | 315 | // if the `data-autoplay` attribute is assigned a natural number value 316 | // use it to make the slides cycle until there is a user interaction 317 | function autoplay( autoTiming ) { 318 | if( autoTiming ){ 319 | // autoTimeout is cleared in each user interaction binding 320 | autoTimeout = setTimeout(function(){ 321 | var timeout = getAutoplayInterval(); 322 | if( timeout ) { 323 | arrowNavigate(true); 324 | autoplay( timeout ); 325 | } 326 | }, autoTiming); 327 | } 328 | } 329 | 330 | // if a touch event is fired on the snapper we know the user is trying to 331 | // interact with it and we should disable the auto play 332 | $slider.bind("pointerdown click mouseenter focus", function(){ 333 | clearTimeout(autoTimeout); 334 | }); 335 | 336 | var scrolling; 337 | $slider.bind("scroll", function(){ 338 | window.clearTimeout(scrolling); 339 | scrolling = setTimeout(function(){ 340 | updateSort( $slider[0] ); 341 | setArrowState( $self ); 342 | },66); 343 | }); 344 | 345 | updateSort( $slider[0] ); 346 | 347 | setArrowState( $self ); 348 | 349 | autoplay( getAutoplayInterval() ); 350 | $self.attr("data-" + pluginName + "-enhanced", true); 351 | }); 352 | 353 | return (innerResult !== undefined ? innerResult : result); 354 | }; 355 | }( this, jQuery )); 356 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Snapper Demo 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 24 | 25 | 26 |
    27 |
    28 | 29 |
    30 |
    31 |

    Demo of Snapper.js 32 | A CSS Snap Points Helper and Polyfill 33 |

    34 | 38 |
    39 |
    40 | 52 |
    53 | 54 | 55 |

    Basic Snapper example

    56 |

    A snapper carousel with some thumbnail links.

    57 |

    Thumbnails are just regular links to a slide's ID attribute. The scrollbar is cropped from sight using the optional snapper_pane_crop div (only recommended when thumbnails or next/prev navigation is in play).

    58 |
    59 |
    60 |
    61 |
    62 |
    63 | 64 |
    65 |
    66 | 67 |
    68 |
    69 | 70 |
    71 |
    72 | 73 |
    74 |
    75 | 76 |
    77 |
    78 | 79 |
    80 |
    81 | 82 |
    83 |
    84 | 85 |
    86 |
    87 | 88 |
    89 |
    90 | 91 |
    92 |
    93 | 94 |
    95 |
    96 | 97 |
    98 |
    99 | 100 |
    101 |
    102 | 103 |
    104 |
    105 | 106 |
    107 |
    108 |
    109 |
    110 |
    111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 |
    127 |
    128 | 129 | 130 | 131 | 132 |

    Example with next prev nav

    133 |

    A snapper carousel with next/prev links automatically added through the addition of a data-snapper-nextprev attribute.

    134 |

    Also, this demo includes an optional ".snapper_nextprev_contain" div, which the next/prev nav will append to if present. This element wraps the scroll pane and allows you to precisely position arrows based on the height of the pane, without the thumbnails or dot nav.

    135 |
    136 |
    137 |
    138 |
    139 |
    140 |
    141 | 142 |
    143 |
    144 | 145 |
    146 |
    147 | 148 |
    149 |
    150 | 151 |
    152 |
    153 | 154 |
    155 |
    156 | 157 |
    158 |
    159 |
    160 |
    161 |
    162 | 163 |
    164 | 165 | 185 | 186 |

    Example w/ multiple slides showing

    187 |

    This example plays nicely with CSS breakpoints to show a different number of slides depending on the viewport size. To use breakpoints in this way, for back compat, be sure to include Snap Points that correspond to the item widths. See CSS for this example

    188 | 189 |
    190 |
    191 |
    192 |
    193 |
    194 | 195 |
    196 |
    197 | 198 |
    199 |
    200 | 201 |
    202 |
    203 | 204 |
    205 |
    206 | 207 |
    208 |
    209 | 210 |
    211 |
    212 |
    213 |
    214 |
    215 | 216 | 217 |

    CSS for this example

    218 |
    
    219 | /* breakpoints example */
    220 | @media (min-width: 30em){
    221 | .snapper_item {
    222 | width: 50%;
    223 | }
    224 | .snapper_pane {
    225 | scroll-snap-points-x: repeat(50%);
    226 | }
    227 | }
    228 | @media (min-width: 50em){
    229 | .snapper_item {
    230 | width: 33.333%;
    231 | }
    232 | .snapper_pane {
    233 | scroll-snap-points-x: repeat(33.33333%);
    234 | }
    235 | }
    236 | 
    237 | 238 | 239 | 240 | 241 |

    Auto-play Snapper example

    242 |

    By setting the data-snapper-autoplay attribute on the class="snapper" element to a natural number value snapper will automatically rotate through the images. The value represents a the millisecond delay between item transitions. In the example below we have data-snapper-autoplay="2000"

    243 |

    You can also set the attribute on snapper_item elements to get individual timing.

    244 |
    245 |
    246 |
    247 |
    248 |
    249 | 250 |
    251 |
    252 | 253 |
    254 |
    255 | 256 |
    257 |
    258 | 259 |
    260 |
    261 | 262 |
    263 |
    264 | 265 |
    266 |
    267 |
    268 |
    269 |
    270 | 271 | 272 | 273 | 274 | 275 | 276 |
    277 |
    278 | 279 | 280 | 281 | 291 |

    Example w/ multiple slides and revealing on partially

    292 | 293 |
    294 |
    295 |
    296 |
    297 |
    298 | 299 |
    300 |
    301 | 302 |
    303 |
    304 | 305 |
    306 |
    307 | 308 |
    309 |
    310 | 311 |
    312 |
    313 | 314 |
    315 |
    316 |
    317 |
    318 |
    319 | 320 | 321 |

    CSS for this example

    322 |
    
    323 | .revealexample .snapper_item {
    324 | width: 45%;
    325 | }
    326 | .revealexample .snapper_pane {
    327 | scroll-snap-points-x: repeat(45%);
    328 | }
    329 | 
    330 | 331 | 332 |

    Example with endless looping (experimental feature)

    333 |

    A snapper carousel with data-snapper-loop will append items to either end as needed so the scroll is infinite. This is recommended for 1-slide-at-a-time carousels.

    334 | 335 |
    336 |
    337 |
    338 |
    339 |
    340 |
    341 | 342 |
    343 |
    344 | 345 |
    346 |
    347 | 348 |
    349 |
    350 | 351 |
    352 |
    353 | 354 |
    355 |
    356 | 357 |
    358 |
    359 |
    360 |
    361 |
    362 | 363 |
    364 | 365 | 366 |
    367 | 368 | 369 | 370 | -------------------------------------------------------------------------------- /src/lib/intersection-observer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Google Inc. All Rights Reserved. 3 | * 4 | * Licensed under the W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE. 5 | * 6 | * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document 7 | * 8 | */ 9 | (function() { 10 | 'use strict'; 11 | 12 | // Exit early if we're not running in a browser. 13 | if (typeof window !== 'object') { 14 | return; 15 | } 16 | 17 | // Exit early if all IntersectionObserver and IntersectionObserverEntry 18 | // features are natively supported. 19 | if ('IntersectionObserver' in window && 20 | 'IntersectionObserverEntry' in window && 21 | 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { 22 | 23 | // Minimal polyfill for Edge 15's lack of `isIntersecting` 24 | // See: https://github.com/w3c/IntersectionObserver/issues/211 25 | if (!('isIntersecting' in window.IntersectionObserverEntry.prototype)) { 26 | Object.defineProperty(window.IntersectionObserverEntry.prototype, 27 | 'isIntersecting', { 28 | get: function () { 29 | return this.intersectionRatio > 0; 30 | } 31 | }); 32 | } 33 | return; 34 | } 35 | 36 | 37 | /** 38 | * A local reference to the document. 39 | */ 40 | var document = window.document; 41 | 42 | 43 | /** 44 | * An IntersectionObserver registry. This registry exists to hold a strong 45 | * reference to IntersectionObserver instances currently observing a target 46 | * element. Without this registry, instances without another reference may be 47 | * garbage collected. 48 | */ 49 | var registry = []; 50 | 51 | 52 | /** 53 | * Creates the global IntersectionObserverEntry constructor. 54 | * https://w3c.github.io/IntersectionObserver/#intersection-observer-entry 55 | * @param {Object} entry A dictionary of instance properties. 56 | * @constructor 57 | */ 58 | function IntersectionObserverEntry(entry) { 59 | this.time = entry.time; 60 | this.target = entry.target; 61 | this.rootBounds = entry.rootBounds; 62 | this.boundingClientRect = entry.boundingClientRect; 63 | this.intersectionRect = entry.intersectionRect || getEmptyRect(); 64 | this.isIntersecting = !!entry.intersectionRect; 65 | 66 | // Calculates the intersection ratio. 67 | var targetRect = this.boundingClientRect; 68 | var targetArea = targetRect.width * targetRect.height; 69 | var intersectionRect = this.intersectionRect; 70 | var intersectionArea = intersectionRect.width * intersectionRect.height; 71 | 72 | // Sets intersection ratio. 73 | if (targetArea) { 74 | // Round the intersection ratio to avoid floating point math issues: 75 | // https://github.com/w3c/IntersectionObserver/issues/324 76 | this.intersectionRatio = Number((intersectionArea / targetArea).toFixed(4)); 77 | } else { 78 | // If area is zero and is intersecting, sets to 1, otherwise to 0 79 | this.intersectionRatio = this.isIntersecting ? 1 : 0; 80 | } 81 | } 82 | 83 | 84 | /** 85 | * Creates the global IntersectionObserver constructor. 86 | * https://w3c.github.io/IntersectionObserver/#intersection-observer-interface 87 | * @param {Function} callback The function to be invoked after intersection 88 | * changes have queued. The function is not invoked if the queue has 89 | * been emptied by calling the `takeRecords` method. 90 | * @param {Object=} opt_options Optional configuration options. 91 | * @constructor 92 | */ 93 | function IntersectionObserver(callback, opt_options) { 94 | 95 | var options = opt_options || {}; 96 | 97 | if (typeof callback != 'function') { 98 | throw new Error('callback must be a function'); 99 | } 100 | 101 | if (options.root && options.root.nodeType != 1) { 102 | throw new Error('root must be an Element'); 103 | } 104 | 105 | // Binds and throttles `this._checkForIntersections`. 106 | this._checkForIntersections = throttle( 107 | this._checkForIntersections.bind(this), this.THROTTLE_TIMEOUT); 108 | 109 | // Private properties. 110 | this._callback = callback; 111 | this._observationTargets = []; 112 | this._queuedEntries = []; 113 | this._rootMarginValues = this._parseRootMargin(options.rootMargin); 114 | 115 | // Public properties. 116 | this.thresholds = this._initThresholds(options.threshold); 117 | this.root = options.root || null; 118 | this.rootMargin = this._rootMarginValues.map(function(margin) { 119 | return margin.value + margin.unit; 120 | }).join(' '); 121 | } 122 | 123 | 124 | /** 125 | * The minimum interval within which the document will be checked for 126 | * intersection changes. 127 | */ 128 | IntersectionObserver.prototype.THROTTLE_TIMEOUT = 100; 129 | 130 | 131 | /** 132 | * The frequency in which the polyfill polls for intersection changes. 133 | * this can be updated on a per instance basis and must be set prior to 134 | * calling `observe` on the first target. 135 | */ 136 | IntersectionObserver.prototype.POLL_INTERVAL = null; 137 | 138 | /** 139 | * Use a mutation observer on the root element 140 | * to detect intersection changes. 141 | */ 142 | IntersectionObserver.prototype.USE_MUTATION_OBSERVER = true; 143 | 144 | 145 | /** 146 | * Starts observing a target element for intersection changes based on 147 | * the thresholds values. 148 | * @param {Element} target The DOM element to observe. 149 | */ 150 | IntersectionObserver.prototype.observe = function(target) { 151 | var isTargetAlreadyObserved = this._observationTargets.some(function(item) { 152 | return item.element == target; 153 | }); 154 | 155 | if (isTargetAlreadyObserved) { 156 | return; 157 | } 158 | 159 | if (!(target && target.nodeType == 1)) { 160 | throw new Error('target must be an Element'); 161 | } 162 | 163 | this._registerInstance(); 164 | this._observationTargets.push({element: target, entry: null}); 165 | this._monitorIntersections(); 166 | this._checkForIntersections(); 167 | }; 168 | 169 | 170 | /** 171 | * Stops observing a target element for intersection changes. 172 | * @param {Element} target The DOM element to observe. 173 | */ 174 | IntersectionObserver.prototype.unobserve = function(target) { 175 | this._observationTargets = 176 | this._observationTargets.filter(function(item) { 177 | 178 | return item.element != target; 179 | }); 180 | if (!this._observationTargets.length) { 181 | this._unmonitorIntersections(); 182 | this._unregisterInstance(); 183 | } 184 | }; 185 | 186 | 187 | /** 188 | * Stops observing all target elements for intersection changes. 189 | */ 190 | IntersectionObserver.prototype.disconnect = function() { 191 | this._observationTargets = []; 192 | this._unmonitorIntersections(); 193 | this._unregisterInstance(); 194 | }; 195 | 196 | 197 | /** 198 | * Returns any queue entries that have not yet been reported to the 199 | * callback and clears the queue. This can be used in conjunction with the 200 | * callback to obtain the absolute most up-to-date intersection information. 201 | * @return {Array} The currently queued entries. 202 | */ 203 | IntersectionObserver.prototype.takeRecords = function() { 204 | var records = this._queuedEntries.slice(); 205 | this._queuedEntries = []; 206 | return records; 207 | }; 208 | 209 | 210 | /** 211 | * Accepts the threshold value from the user configuration object and 212 | * returns a sorted array of unique threshold values. If a value is not 213 | * between 0 and 1 and error is thrown. 214 | * @private 215 | * @param {Array|number=} opt_threshold An optional threshold value or 216 | * a list of threshold values, defaulting to [0]. 217 | * @return {Array} A sorted list of unique and valid threshold values. 218 | */ 219 | IntersectionObserver.prototype._initThresholds = function(opt_threshold) { 220 | var threshold = opt_threshold || [0]; 221 | if (!Array.isArray(threshold)) threshold = [threshold]; 222 | 223 | return threshold.sort().filter(function(t, i, a) { 224 | if (typeof t != 'number' || isNaN(t) || t < 0 || t > 1) { 225 | throw new Error('threshold must be a number between 0 and 1 inclusively'); 226 | } 227 | return t !== a[i - 1]; 228 | }); 229 | }; 230 | 231 | 232 | /** 233 | * Accepts the rootMargin value from the user configuration object 234 | * and returns an array of the four margin values as an object containing 235 | * the value and unit properties. If any of the values are not properly 236 | * formatted or use a unit other than px or %, and error is thrown. 237 | * @private 238 | * @param {string=} opt_rootMargin An optional rootMargin value, 239 | * defaulting to '0px'. 240 | * @return {Array} An array of margin objects with the keys 241 | * value and unit. 242 | */ 243 | IntersectionObserver.prototype._parseRootMargin = function(opt_rootMargin) { 244 | var marginString = opt_rootMargin || '0px'; 245 | var margins = marginString.split(/\s+/).map(function(margin) { 246 | var parts = /^(-?\d*\.?\d+)(px|%)$/.exec(margin); 247 | if (!parts) { 248 | throw new Error('rootMargin must be specified in pixels or percent'); 249 | } 250 | return {value: parseFloat(parts[1]), unit: parts[2]}; 251 | }); 252 | 253 | // Handles shorthand. 254 | margins[1] = margins[1] || margins[0]; 255 | margins[2] = margins[2] || margins[0]; 256 | margins[3] = margins[3] || margins[1]; 257 | 258 | return margins; 259 | }; 260 | 261 | 262 | /** 263 | * Starts polling for intersection changes if the polling is not already 264 | * happening, and if the page's visibility state is visible. 265 | * @private 266 | */ 267 | IntersectionObserver.prototype._monitorIntersections = function() { 268 | if (!this._monitoringIntersections) { 269 | this._monitoringIntersections = true; 270 | 271 | // If a poll interval is set, use polling instead of listening to 272 | // resize and scroll events or DOM mutations. 273 | if (this.POLL_INTERVAL) { 274 | this._monitoringInterval = setInterval( 275 | this._checkForIntersections, this.POLL_INTERVAL); 276 | } 277 | else { 278 | addEvent(window, 'resize', this._checkForIntersections, true); 279 | addEvent(document, 'scroll', this._checkForIntersections, true); 280 | 281 | if (this.USE_MUTATION_OBSERVER && 'MutationObserver' in window) { 282 | this._domObserver = new MutationObserver(this._checkForIntersections); 283 | this._domObserver.observe(document, { 284 | attributes: true, 285 | childList: true, 286 | characterData: true, 287 | subtree: true 288 | }); 289 | } 290 | } 291 | } 292 | }; 293 | 294 | 295 | /** 296 | * Stops polling for intersection changes. 297 | * @private 298 | */ 299 | IntersectionObserver.prototype._unmonitorIntersections = function() { 300 | if (this._monitoringIntersections) { 301 | this._monitoringIntersections = false; 302 | 303 | clearInterval(this._monitoringInterval); 304 | this._monitoringInterval = null; 305 | 306 | removeEvent(window, 'resize', this._checkForIntersections, true); 307 | removeEvent(document, 'scroll', this._checkForIntersections, true); 308 | 309 | if (this._domObserver) { 310 | this._domObserver.disconnect(); 311 | this._domObserver = null; 312 | } 313 | } 314 | }; 315 | 316 | 317 | /** 318 | * Scans each observation target for intersection changes and adds them 319 | * to the internal entries queue. If new entries are found, it 320 | * schedules the callback to be invoked. 321 | * @private 322 | */ 323 | IntersectionObserver.prototype._checkForIntersections = function() { 324 | var rootIsInDom = this._rootIsInDom(); 325 | var rootRect = rootIsInDom ? this._getRootRect() : getEmptyRect(); 326 | 327 | this._observationTargets.forEach(function(item) { 328 | var target = item.element; 329 | var targetRect = getBoundingClientRect(target); 330 | var rootContainsTarget = this._rootContainsTarget(target); 331 | var oldEntry = item.entry; 332 | var intersectionRect = rootIsInDom && rootContainsTarget && 333 | this._computeTargetAndRootIntersection(target, rootRect); 334 | 335 | var newEntry = item.entry = new IntersectionObserverEntry({ 336 | time: now(), 337 | target: target, 338 | boundingClientRect: targetRect, 339 | rootBounds: rootRect, 340 | intersectionRect: intersectionRect 341 | }); 342 | 343 | if (!oldEntry) { 344 | this._queuedEntries.push(newEntry); 345 | } else if (rootIsInDom && rootContainsTarget) { 346 | // If the new entry intersection ratio has crossed any of the 347 | // thresholds, add a new entry. 348 | if (this._hasCrossedThreshold(oldEntry, newEntry)) { 349 | this._queuedEntries.push(newEntry); 350 | } 351 | } else { 352 | // If the root is not in the DOM or target is not contained within 353 | // root but the previous entry for this target had an intersection, 354 | // add a new record indicating removal. 355 | if (oldEntry && oldEntry.isIntersecting) { 356 | this._queuedEntries.push(newEntry); 357 | } 358 | } 359 | }, this); 360 | 361 | if (this._queuedEntries.length) { 362 | this._callback(this.takeRecords(), this); 363 | } 364 | }; 365 | 366 | 367 | /** 368 | * Accepts a target and root rect computes the intersection between then 369 | * following the algorithm in the spec. 370 | * TODO(philipwalton): at this time clip-path is not considered. 371 | * https://w3c.github.io/IntersectionObserver/#calculate-intersection-rect-algo 372 | * @param {Element} target The target DOM element 373 | * @param {Object} rootRect The bounding rect of the root after being 374 | * expanded by the rootMargin value. 375 | * @return {?Object} The final intersection rect object or undefined if no 376 | * intersection is found. 377 | * @private 378 | */ 379 | IntersectionObserver.prototype._computeTargetAndRootIntersection = 380 | function(target, rootRect) { 381 | 382 | // If the element isn't displayed, an intersection can't happen. 383 | if (window.getComputedStyle(target).display == 'none') return; 384 | 385 | var targetRect = getBoundingClientRect(target); 386 | var intersectionRect = targetRect; 387 | var parent = getParentNode(target); 388 | var atRoot = false; 389 | 390 | while (!atRoot) { 391 | var parentRect = null; 392 | var parentComputedStyle = parent.nodeType == 1 ? 393 | window.getComputedStyle(parent) : {}; 394 | 395 | // If the parent isn't displayed, an intersection can't happen. 396 | if (parentComputedStyle.display == 'none') return; 397 | 398 | if (parent == this.root || parent == document) { 399 | atRoot = true; 400 | parentRect = rootRect; 401 | } else { 402 | // If the element has a non-visible overflow, and it's not the 403 | // or element, update the intersection rect. 404 | // Note: and cannot be clipped to a rect that's not also 405 | // the document rect, so no need to compute a new intersection. 406 | if (parent != document.body && 407 | parent != document.documentElement && 408 | parentComputedStyle.overflow != 'visible') { 409 | parentRect = getBoundingClientRect(parent); 410 | } 411 | } 412 | 413 | // If either of the above conditionals set a new parentRect, 414 | // calculate new intersection data. 415 | if (parentRect) { 416 | intersectionRect = computeRectIntersection(parentRect, intersectionRect); 417 | 418 | if (!intersectionRect) break; 419 | } 420 | parent = getParentNode(parent); 421 | } 422 | return intersectionRect; 423 | }; 424 | 425 | 426 | /** 427 | * Returns the root rect after being expanded by the rootMargin value. 428 | * @return {Object} The expanded root rect. 429 | * @private 430 | */ 431 | IntersectionObserver.prototype._getRootRect = function() { 432 | var rootRect; 433 | if (this.root) { 434 | rootRect = getBoundingClientRect(this.root); 435 | } else { 436 | // Use / instead of window since scroll bars affect size. 437 | var html = document.documentElement; 438 | var body = document.body; 439 | rootRect = { 440 | top: 0, 441 | left: 0, 442 | right: html.clientWidth || body.clientWidth, 443 | width: html.clientWidth || body.clientWidth, 444 | bottom: html.clientHeight || body.clientHeight, 445 | height: html.clientHeight || body.clientHeight 446 | }; 447 | } 448 | return this._expandRectByRootMargin(rootRect); 449 | }; 450 | 451 | 452 | /** 453 | * Accepts a rect and expands it by the rootMargin value. 454 | * @param {Object} rect The rect object to expand. 455 | * @return {Object} The expanded rect. 456 | * @private 457 | */ 458 | IntersectionObserver.prototype._expandRectByRootMargin = function(rect) { 459 | var margins = this._rootMarginValues.map(function(margin, i) { 460 | return margin.unit == 'px' ? margin.value : 461 | margin.value * (i % 2 ? rect.width : rect.height) / 100; 462 | }); 463 | var newRect = { 464 | top: rect.top - margins[0], 465 | right: rect.right + margins[1], 466 | bottom: rect.bottom + margins[2], 467 | left: rect.left - margins[3] 468 | }; 469 | newRect.width = newRect.right - newRect.left; 470 | newRect.height = newRect.bottom - newRect.top; 471 | 472 | return newRect; 473 | }; 474 | 475 | 476 | /** 477 | * Accepts an old and new entry and returns true if at least one of the 478 | * threshold values has been crossed. 479 | * @param {?IntersectionObserverEntry} oldEntry The previous entry for a 480 | * particular target element or null if no previous entry exists. 481 | * @param {IntersectionObserverEntry} newEntry The current entry for a 482 | * particular target element. 483 | * @return {boolean} Returns true if a any threshold has been crossed. 484 | * @private 485 | */ 486 | IntersectionObserver.prototype._hasCrossedThreshold = 487 | function(oldEntry, newEntry) { 488 | 489 | // To make comparing easier, an entry that has a ratio of 0 490 | // but does not actually intersect is given a value of -1 491 | var oldRatio = oldEntry && oldEntry.isIntersecting ? 492 | oldEntry.intersectionRatio || 0 : -1; 493 | var newRatio = newEntry.isIntersecting ? 494 | newEntry.intersectionRatio || 0 : -1; 495 | 496 | // Ignore unchanged ratios 497 | if (oldRatio === newRatio) return; 498 | 499 | for (var i = 0; i < this.thresholds.length; i++) { 500 | var threshold = this.thresholds[i]; 501 | 502 | // Return true if an entry matches a threshold or if the new ratio 503 | // and the old ratio are on the opposite sides of a threshold. 504 | if (threshold == oldRatio || threshold == newRatio || 505 | threshold < oldRatio !== threshold < newRatio) { 506 | return true; 507 | } 508 | } 509 | }; 510 | 511 | 512 | /** 513 | * Returns whether or not the root element is an element and is in the DOM. 514 | * @return {boolean} True if the root element is an element and is in the DOM. 515 | * @private 516 | */ 517 | IntersectionObserver.prototype._rootIsInDom = function() { 518 | return !this.root || containsDeep(document, this.root); 519 | }; 520 | 521 | 522 | /** 523 | * Returns whether or not the target element is a child of root. 524 | * @param {Element} target The target element to check. 525 | * @return {boolean} True if the target element is a child of root. 526 | * @private 527 | */ 528 | IntersectionObserver.prototype._rootContainsTarget = function(target) { 529 | return containsDeep(this.root || document, target); 530 | }; 531 | 532 | 533 | /** 534 | * Adds the instance to the global IntersectionObserver registry if it isn't 535 | * already present. 536 | * @private 537 | */ 538 | IntersectionObserver.prototype._registerInstance = function() { 539 | if (registry.indexOf(this) < 0) { 540 | registry.push(this); 541 | } 542 | }; 543 | 544 | 545 | /** 546 | * Removes the instance from the global IntersectionObserver registry. 547 | * @private 548 | */ 549 | IntersectionObserver.prototype._unregisterInstance = function() { 550 | var index = registry.indexOf(this); 551 | if (index != -1) registry.splice(index, 1); 552 | }; 553 | 554 | 555 | /** 556 | * Returns the result of the performance.now() method or null in browsers 557 | * that don't support the API. 558 | * @return {number} The elapsed time since the page was requested. 559 | */ 560 | function now() { 561 | return window.performance && performance.now && performance.now(); 562 | } 563 | 564 | 565 | /** 566 | * Throttles a function and delays its execution, so it's only called at most 567 | * once within a given time period. 568 | * @param {Function} fn The function to throttle. 569 | * @param {number} timeout The amount of time that must pass before the 570 | * function can be called again. 571 | * @return {Function} The throttled function. 572 | */ 573 | function throttle(fn, timeout) { 574 | var timer = null; 575 | return function () { 576 | if (!timer) { 577 | timer = setTimeout(function() { 578 | fn(); 579 | timer = null; 580 | }, timeout); 581 | } 582 | }; 583 | } 584 | 585 | 586 | /** 587 | * Adds an event handler to a DOM node ensuring cross-browser compatibility. 588 | * @param {Node} node The DOM node to add the event handler to. 589 | * @param {string} event The event name. 590 | * @param {Function} fn The event handler to add. 591 | * @param {boolean} opt_useCapture Optionally adds the even to the capture 592 | * phase. Note: this only works in modern browsers. 593 | */ 594 | function addEvent(node, event, fn, opt_useCapture) { 595 | if (typeof node.addEventListener == 'function') { 596 | node.addEventListener(event, fn, opt_useCapture || false); 597 | } 598 | else if (typeof node.attachEvent == 'function') { 599 | node.attachEvent('on' + event, fn); 600 | } 601 | } 602 | 603 | 604 | /** 605 | * Removes a previously added event handler from a DOM node. 606 | * @param {Node} node The DOM node to remove the event handler from. 607 | * @param {string} event The event name. 608 | * @param {Function} fn The event handler to remove. 609 | * @param {boolean} opt_useCapture If the event handler was added with this 610 | * flag set to true, it should be set to true here in order to remove it. 611 | */ 612 | function removeEvent(node, event, fn, opt_useCapture) { 613 | if (typeof node.removeEventListener == 'function') { 614 | node.removeEventListener(event, fn, opt_useCapture || false); 615 | } 616 | else if (typeof node.detatchEvent == 'function') { 617 | node.detatchEvent('on' + event, fn); 618 | } 619 | } 620 | 621 | 622 | /** 623 | * Returns the intersection between two rect objects. 624 | * @param {Object} rect1 The first rect. 625 | * @param {Object} rect2 The second rect. 626 | * @return {?Object} The intersection rect or undefined if no intersection 627 | * is found. 628 | */ 629 | function computeRectIntersection(rect1, rect2) { 630 | var top = Math.max(rect1.top, rect2.top); 631 | var bottom = Math.min(rect1.bottom, rect2.bottom); 632 | var left = Math.max(rect1.left, rect2.left); 633 | var right = Math.min(rect1.right, rect2.right); 634 | var width = right - left; 635 | var height = bottom - top; 636 | 637 | return (width >= 0 && height >= 0) && { 638 | top: top, 639 | bottom: bottom, 640 | left: left, 641 | right: right, 642 | width: width, 643 | height: height 644 | }; 645 | } 646 | 647 | 648 | /** 649 | * Shims the native getBoundingClientRect for compatibility with older IE. 650 | * @param {Element} el The element whose bounding rect to get. 651 | * @return {Object} The (possibly shimmed) rect of the element. 652 | */ 653 | function getBoundingClientRect(el) { 654 | var rect; 655 | 656 | try { 657 | rect = el.getBoundingClientRect(); 658 | } catch (err) { 659 | // Ignore Windows 7 IE11 "Unspecified error" 660 | // https://github.com/w3c/IntersectionObserver/pull/205 661 | } 662 | 663 | if (!rect) return getEmptyRect(); 664 | 665 | // Older IE 666 | if (!(rect.width && rect.height)) { 667 | rect = { 668 | top: rect.top, 669 | right: rect.right, 670 | bottom: rect.bottom, 671 | left: rect.left, 672 | width: rect.right - rect.left, 673 | height: rect.bottom - rect.top 674 | }; 675 | } 676 | return rect; 677 | } 678 | 679 | 680 | /** 681 | * Returns an empty rect object. An empty rect is returned when an element 682 | * is not in the DOM. 683 | * @return {Object} The empty rect. 684 | */ 685 | function getEmptyRect() { 686 | return { 687 | top: 0, 688 | bottom: 0, 689 | left: 0, 690 | right: 0, 691 | width: 0, 692 | height: 0 693 | }; 694 | } 695 | 696 | /** 697 | * Checks to see if a parent element contains a child element (including inside 698 | * shadow DOM). 699 | * @param {Node} parent The parent element. 700 | * @param {Node} child The child element. 701 | * @return {boolean} True if the parent node contains the child node. 702 | */ 703 | function containsDeep(parent, child) { 704 | var node = child; 705 | while (node) { 706 | if (node == parent) return true; 707 | 708 | node = getParentNode(node); 709 | } 710 | return false; 711 | } 712 | 713 | 714 | /** 715 | * Gets the parent node of an element or its host element if the parent node 716 | * is a shadow root. 717 | * @param {Node} node The node whose parent to get. 718 | * @return {Node|null} The parent node or null if no parent exists. 719 | */ 720 | function getParentNode(node) { 721 | var parent = node.parentNode; 722 | 723 | if (parent && parent.nodeType == 11 && parent.host) { 724 | // If the parent is a shadow root, return the host element. 725 | return parent.host; 726 | } 727 | 728 | if (parent && parent.assignedSlot) { 729 | // If the parent is distributed in a , return the parent of a slot. 730 | return parent.assignedSlot.parentNode; 731 | } 732 | 733 | return parent; 734 | } 735 | 736 | 737 | // Exposes the constructors globally. 738 | window.IntersectionObserver = IntersectionObserver; 739 | window.IntersectionObserverEntry = IntersectionObserverEntry; 740 | 741 | }()); 742 | -------------------------------------------------------------------------------- /test/qunit/qunit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * QUnit v1.3.0pre - A JavaScript Unit Testing Framework 3 | * 4 | * http://docs.jquery.com/QUnit 5 | * 6 | * Copyright (c) 2011 John Resig, Jörn Zaefferer 7 | * Dual licensed under the MIT (MIT-LICENSE.txt) 8 | * or GPL (GPL-LICENSE.txt) licenses. 9 | */ 10 | 11 | (function(window) { 12 | 13 | var defined = { 14 | setTimeout: typeof window.setTimeout !== "undefined", 15 | sessionStorage: (function() { 16 | try { 17 | return !!sessionStorage.getItem; 18 | } catch(e) { 19 | return false; 20 | } 21 | })() 22 | }; 23 | 24 | var testId = 0, 25 | toString = Object.prototype.toString, 26 | hasOwn = Object.prototype.hasOwnProperty; 27 | 28 | var Test = function(name, testName, expected, testEnvironmentArg, async, callback) { 29 | this.name = name; 30 | this.testName = testName; 31 | this.expected = expected; 32 | this.testEnvironmentArg = testEnvironmentArg; 33 | this.async = async; 34 | this.callback = callback; 35 | this.assertions = []; 36 | }; 37 | Test.prototype = { 38 | init: function() { 39 | var tests = id("qunit-tests"); 40 | if (tests) { 41 | var b = document.createElement("strong"); 42 | b.innerHTML = "Running " + this.name; 43 | var li = document.createElement("li"); 44 | li.appendChild( b ); 45 | li.className = "running"; 46 | li.id = this.id = "test-output" + testId++; 47 | tests.appendChild( li ); 48 | } 49 | }, 50 | setup: function() { 51 | if (this.module != config.previousModule) { 52 | if ( config.previousModule ) { 53 | runLoggingCallbacks('moduleDone', QUnit, { 54 | name: config.previousModule, 55 | failed: config.moduleStats.bad, 56 | passed: config.moduleStats.all - config.moduleStats.bad, 57 | total: config.moduleStats.all 58 | } ); 59 | } 60 | config.previousModule = this.module; 61 | config.moduleStats = { all: 0, bad: 0 }; 62 | runLoggingCallbacks( 'moduleStart', QUnit, { 63 | name: this.module 64 | } ); 65 | } 66 | 67 | config.current = this; 68 | this.testEnvironment = extend({ 69 | setup: function() {}, 70 | teardown: function() {} 71 | }, this.moduleTestEnvironment); 72 | if (this.testEnvironmentArg) { 73 | extend(this.testEnvironment, this.testEnvironmentArg); 74 | } 75 | 76 | runLoggingCallbacks( 'testStart', QUnit, { 77 | name: this.testName, 78 | module: this.module 79 | }); 80 | 81 | // allow utility functions to access the current test environment 82 | // TODO why?? 83 | QUnit.current_testEnvironment = this.testEnvironment; 84 | 85 | try { 86 | if ( !config.pollution ) { 87 | saveGlobal(); 88 | } 89 | 90 | this.testEnvironment.setup.call(this.testEnvironment); 91 | } catch(e) { 92 | QUnit.ok( false, "Setup failed on " + this.testName + ": " + e.message ); 93 | } 94 | }, 95 | run: function() { 96 | config.current = this; 97 | if ( this.async ) { 98 | QUnit.stop(); 99 | } 100 | 101 | if ( config.notrycatch ) { 102 | this.callback.call(this.testEnvironment); 103 | return; 104 | } 105 | try { 106 | this.callback.call(this.testEnvironment); 107 | } catch(e) { 108 | fail("Test " + this.testName + " died, exception and test follows", e, this.callback); 109 | QUnit.ok( false, "Died on test #" + (this.assertions.length + 1) + ": " + e.message + " - " + QUnit.jsDump.parse(e) ); 110 | // else next test will carry the responsibility 111 | saveGlobal(); 112 | 113 | // Restart the tests if they're blocking 114 | if ( config.blocking ) { 115 | QUnit.start(); 116 | } 117 | } 118 | }, 119 | teardown: function() { 120 | config.current = this; 121 | try { 122 | this.testEnvironment.teardown.call(this.testEnvironment); 123 | checkPollution(); 124 | } catch(e) { 125 | QUnit.ok( false, "Teardown failed on " + this.testName + ": " + e.message ); 126 | } 127 | }, 128 | finish: function() { 129 | config.current = this; 130 | if ( this.expected != null && this.expected != this.assertions.length ) { 131 | QUnit.ok( false, "Expected " + this.expected + " assertions, but " + this.assertions.length + " were run" ); 132 | } 133 | 134 | var good = 0, bad = 0, 135 | tests = id("qunit-tests"); 136 | 137 | config.stats.all += this.assertions.length; 138 | config.moduleStats.all += this.assertions.length; 139 | 140 | if ( tests ) { 141 | var ol = document.createElement("ol"); 142 | 143 | for ( var i = 0; i < this.assertions.length; i++ ) { 144 | var assertion = this.assertions[i]; 145 | 146 | var li = document.createElement("li"); 147 | li.className = assertion.result ? "pass" : "fail"; 148 | li.innerHTML = assertion.message || (assertion.result ? "okay" : "failed"); 149 | ol.appendChild( li ); 150 | 151 | if ( assertion.result ) { 152 | good++; 153 | } else { 154 | bad++; 155 | config.stats.bad++; 156 | config.moduleStats.bad++; 157 | } 158 | } 159 | 160 | // store result when possible 161 | if ( QUnit.config.reorder && defined.sessionStorage ) { 162 | if (bad) { 163 | sessionStorage.setItem("qunit-" + this.module + "-" + this.testName, bad); 164 | } else { 165 | sessionStorage.removeItem("qunit-" + this.module + "-" + this.testName); 166 | } 167 | } 168 | 169 | if (bad == 0) { 170 | ol.style.display = "none"; 171 | } 172 | 173 | var b = document.createElement("strong"); 174 | b.innerHTML = this.name + " (" + bad + ", " + good + ", " + this.assertions.length + ")"; 175 | 176 | var a = document.createElement("a"); 177 | a.innerHTML = "Rerun"; 178 | a.href = QUnit.url({ filter: getText([b]).replace(/\([^)]+\)$/, "").replace(/(^\s*|\s*$)/g, "") }); 179 | 180 | addEvent(b, "click", function() { 181 | var next = b.nextSibling.nextSibling, 182 | display = next.style.display; 183 | next.style.display = display === "none" ? "block" : "none"; 184 | }); 185 | 186 | addEvent(b, "dblclick", function(e) { 187 | var target = e && e.target ? e.target : window.event.srcElement; 188 | if ( target.nodeName.toLowerCase() == "span" || target.nodeName.toLowerCase() == "b" ) { 189 | target = target.parentNode; 190 | } 191 | if ( window.location && target.nodeName.toLowerCase() === "strong" ) { 192 | window.location = QUnit.url({ filter: getText([target]).replace(/\([^)]+\)$/, "").replace(/(^\s*|\s*$)/g, "") }); 193 | } 194 | }); 195 | 196 | var li = id(this.id); 197 | li.className = bad ? "fail" : "pass"; 198 | li.removeChild( li.firstChild ); 199 | li.appendChild( b ); 200 | li.appendChild( a ); 201 | li.appendChild( ol ); 202 | 203 | } else { 204 | for ( var i = 0; i < this.assertions.length; i++ ) { 205 | if ( !this.assertions[i].result ) { 206 | bad++; 207 | config.stats.bad++; 208 | config.moduleStats.bad++; 209 | } 210 | } 211 | } 212 | 213 | try { 214 | QUnit.reset(); 215 | } catch(e) { 216 | fail("reset() failed, following Test " + this.testName + ", exception and reset fn follows", e, QUnit.reset); 217 | } 218 | 219 | runLoggingCallbacks( 'testDone', QUnit, { 220 | name: this.testName, 221 | module: this.module, 222 | failed: bad, 223 | passed: this.assertions.length - bad, 224 | total: this.assertions.length 225 | } ); 226 | }, 227 | 228 | queue: function() { 229 | var test = this; 230 | synchronize(function() { 231 | test.init(); 232 | }); 233 | function run() { 234 | // each of these can by async 235 | synchronize(function() { 236 | test.setup(); 237 | }); 238 | synchronize(function() { 239 | test.run(); 240 | }); 241 | synchronize(function() { 242 | test.teardown(); 243 | }); 244 | synchronize(function() { 245 | test.finish(); 246 | }); 247 | } 248 | // defer when previous test run passed, if storage is available 249 | var bad = QUnit.config.reorder && defined.sessionStorage && +sessionStorage.getItem("qunit-" + this.module + "-" + this.testName); 250 | if (bad) { 251 | run(); 252 | } else { 253 | synchronize(run, true); 254 | }; 255 | } 256 | 257 | }; 258 | 259 | var QUnit = { 260 | 261 | // call on start of module test to prepend name to all tests 262 | module: function(name, testEnvironment) { 263 | config.currentModule = name; 264 | config.currentModuleTestEnviroment = testEnvironment; 265 | }, 266 | 267 | asyncTest: function(testName, expected, callback) { 268 | if ( arguments.length === 2 ) { 269 | callback = expected; 270 | expected = null; 271 | } 272 | 273 | QUnit.test(testName, expected, callback, true); 274 | }, 275 | 276 | test: function(testName, expected, callback, async) { 277 | var name = '' + escapeInnerText(testName) + '', testEnvironmentArg; 278 | 279 | if ( arguments.length === 2 ) { 280 | callback = expected; 281 | expected = null; 282 | } 283 | // is 2nd argument a testEnvironment? 284 | if ( expected && typeof expected === 'object') { 285 | testEnvironmentArg = expected; 286 | expected = null; 287 | } 288 | 289 | if ( config.currentModule ) { 290 | name = '' + config.currentModule + ": " + name; 291 | } 292 | 293 | if ( !validTest(config.currentModule + ": " + testName) ) { 294 | return; 295 | } 296 | 297 | var test = new Test(name, testName, expected, testEnvironmentArg, async, callback); 298 | test.module = config.currentModule; 299 | test.moduleTestEnvironment = config.currentModuleTestEnviroment; 300 | test.queue(); 301 | }, 302 | 303 | /** 304 | * Specify the number of expected assertions to gurantee that failed test (no assertions are run at all) don't slip through. 305 | */ 306 | expect: function(asserts) { 307 | config.current.expected = asserts; 308 | }, 309 | 310 | /** 311 | * Asserts true. 312 | * @example ok( "asdfasdf".length > 5, "There must be at least 5 chars" ); 313 | */ 314 | ok: function(a, msg) { 315 | a = !!a; 316 | var details = { 317 | result: a, 318 | message: msg 319 | }; 320 | msg = escapeInnerText(msg); 321 | runLoggingCallbacks( 'log', QUnit, details ); 322 | config.current.assertions.push({ 323 | result: a, 324 | message: msg 325 | }); 326 | }, 327 | 328 | /** 329 | * Checks that the first two arguments are equal, with an optional message. 330 | * Prints out both actual and expected values. 331 | * 332 | * Prefered to ok( actual == expected, message ) 333 | * 334 | * @example equal( format("Received {0} bytes.", 2), "Received 2 bytes." ); 335 | * 336 | * @param Object actual 337 | * @param Object expected 338 | * @param String message (optional) 339 | */ 340 | equal: function(actual, expected, message) { 341 | QUnit.push(expected == actual, actual, expected, message); 342 | }, 343 | 344 | notEqual: function(actual, expected, message) { 345 | QUnit.push(expected != actual, actual, expected, message); 346 | }, 347 | 348 | deepEqual: function(actual, expected, message) { 349 | QUnit.push(QUnit.equiv(actual, expected), actual, expected, message); 350 | }, 351 | 352 | notDeepEqual: function(actual, expected, message) { 353 | QUnit.push(!QUnit.equiv(actual, expected), actual, expected, message); 354 | }, 355 | 356 | strictEqual: function(actual, expected, message) { 357 | QUnit.push(expected === actual, actual, expected, message); 358 | }, 359 | 360 | notStrictEqual: function(actual, expected, message) { 361 | QUnit.push(expected !== actual, actual, expected, message); 362 | }, 363 | 364 | raises: function(block, expected, message) { 365 | var actual, ok = false; 366 | 367 | if (typeof expected === 'string') { 368 | message = expected; 369 | expected = null; 370 | } 371 | 372 | try { 373 | block(); 374 | } catch (e) { 375 | actual = e; 376 | } 377 | 378 | if (actual) { 379 | // we don't want to validate thrown error 380 | if (!expected) { 381 | ok = true; 382 | // expected is a regexp 383 | } else if (QUnit.objectType(expected) === "regexp") { 384 | ok = expected.test(actual); 385 | // expected is a constructor 386 | } else if (actual instanceof expected) { 387 | ok = true; 388 | // expected is a validation function which returns true is validation passed 389 | } else if (expected.call({}, actual) === true) { 390 | ok = true; 391 | } 392 | } 393 | 394 | QUnit.ok(ok, message); 395 | }, 396 | 397 | start: function(count) { 398 | config.semaphore -= count || 1; 399 | if (config.semaphore > 0) { 400 | // don't start until equal number of stop-calls 401 | return; 402 | } 403 | if (config.semaphore < 0) { 404 | // ignore if start is called more often then stop 405 | config.semaphore = 0; 406 | } 407 | // A slight delay, to avoid any current callbacks 408 | if ( defined.setTimeout ) { 409 | window.setTimeout(function() { 410 | if (config.semaphore > 0) { 411 | return; 412 | } 413 | if ( config.timeout ) { 414 | clearTimeout(config.timeout); 415 | } 416 | 417 | config.blocking = false; 418 | process(true); 419 | }, 13); 420 | } else { 421 | config.blocking = false; 422 | process(true); 423 | } 424 | }, 425 | 426 | stop: function(count) { 427 | config.semaphore += count || 1; 428 | config.blocking = true; 429 | 430 | if ( config.testTimeout && defined.setTimeout ) { 431 | clearTimeout(config.timeout); 432 | config.timeout = window.setTimeout(function() { 433 | QUnit.ok( false, "Test timed out" ); 434 | config.semaphore = 1; 435 | QUnit.start(); 436 | }, config.testTimeout); 437 | } 438 | } 439 | }; 440 | 441 | //We want access to the constructor's prototype 442 | (function() { 443 | function F(){}; 444 | F.prototype = QUnit; 445 | QUnit = new F(); 446 | //Make F QUnit's constructor so that we can add to the prototype later 447 | QUnit.constructor = F; 448 | })(); 449 | 450 | // Backwards compatibility, deprecated 451 | QUnit.equals = QUnit.equal; 452 | QUnit.same = QUnit.deepEqual; 453 | 454 | // Maintain internal state 455 | var config = { 456 | // The queue of tests to run 457 | queue: [], 458 | 459 | // block until document ready 460 | blocking: true, 461 | 462 | // when enabled, show only failing tests 463 | // gets persisted through sessionStorage and can be changed in UI via checkbox 464 | hidepassed: false, 465 | 466 | // by default, run previously failed tests first 467 | // very useful in combination with "Hide passed tests" checked 468 | reorder: true, 469 | 470 | // by default, modify document.title when suite is done 471 | altertitle: true, 472 | 473 | urlConfig: ['noglobals', 'notrycatch'], 474 | 475 | //logging callback queues 476 | begin: [], 477 | done: [], 478 | log: [], 479 | testStart: [], 480 | testDone: [], 481 | moduleStart: [], 482 | moduleDone: [] 483 | }; 484 | 485 | // Load paramaters 486 | (function() { 487 | var location = window.location || { search: "", protocol: "file:" }, 488 | params = location.search.slice( 1 ).split( "&" ), 489 | length = params.length, 490 | urlParams = {}, 491 | current; 492 | 493 | if ( params[ 0 ] ) { 494 | for ( var i = 0; i < length; i++ ) { 495 | current = params[ i ].split( "=" ); 496 | current[ 0 ] = decodeURIComponent( current[ 0 ] ); 497 | // allow just a key to turn on a flag, e.g., test.html?noglobals 498 | current[ 1 ] = current[ 1 ] ? decodeURIComponent( current[ 1 ] ) : true; 499 | urlParams[ current[ 0 ] ] = current[ 1 ]; 500 | } 501 | } 502 | 503 | QUnit.urlParams = urlParams; 504 | config.filter = urlParams.filter; 505 | 506 | // Figure out if we're running the tests from a server or not 507 | QUnit.isLocal = !!(location.protocol === 'file:'); 508 | })(); 509 | 510 | // Expose the API as global variables, unless an 'exports' 511 | // object exists, in that case we assume we're in CommonJS 512 | if ( typeof exports === "undefined" || typeof require === "undefined" ) { 513 | extend(window, QUnit); 514 | window.QUnit = QUnit; 515 | } else { 516 | extend(exports, QUnit); 517 | exports.QUnit = QUnit; 518 | } 519 | 520 | // define these after exposing globals to keep them in these QUnit namespace only 521 | extend(QUnit, { 522 | config: config, 523 | 524 | // Initialize the configuration options 525 | init: function() { 526 | extend(config, { 527 | stats: { all: 0, bad: 0 }, 528 | moduleStats: { all: 0, bad: 0 }, 529 | started: +new Date, 530 | updateRate: 1000, 531 | blocking: false, 532 | autostart: true, 533 | autorun: false, 534 | filter: "", 535 | queue: [], 536 | semaphore: 0 537 | }); 538 | 539 | var tests = id( "qunit-tests" ), 540 | banner = id( "qunit-banner" ), 541 | result = id( "qunit-testresult" ); 542 | 543 | if ( tests ) { 544 | tests.innerHTML = ""; 545 | } 546 | 547 | if ( banner ) { 548 | banner.className = ""; 549 | } 550 | 551 | if ( result ) { 552 | result.parentNode.removeChild( result ); 553 | } 554 | 555 | if ( tests ) { 556 | result = document.createElement( "p" ); 557 | result.id = "qunit-testresult"; 558 | result.className = "result"; 559 | tests.parentNode.insertBefore( result, tests ); 560 | result.innerHTML = 'Running...
     '; 561 | } 562 | }, 563 | 564 | /** 565 | * Resets the test setup. Useful for tests that modify the DOM. 566 | * 567 | * If jQuery is available, uses jQuery's html(), otherwise just innerHTML. 568 | */ 569 | reset: function() { 570 | if ( window.jQuery ) { 571 | jQuery( "#qunit-fixture" ).html( config.fixture ); 572 | } else { 573 | var main = id( 'qunit-fixture' ); 574 | if ( main ) { 575 | main.innerHTML = config.fixture; 576 | } 577 | } 578 | }, 579 | 580 | /** 581 | * Trigger an event on an element. 582 | * 583 | * @example triggerEvent( document.body, "click" ); 584 | * 585 | * @param DOMElement elem 586 | * @param String type 587 | */ 588 | triggerEvent: function( elem, type, event ) { 589 | if ( document.createEvent ) { 590 | event = document.createEvent("MouseEvents"); 591 | event.initMouseEvent(type, true, true, elem.ownerDocument.defaultView, 592 | 0, 0, 0, 0, 0, false, false, false, false, 0, null); 593 | elem.dispatchEvent( event ); 594 | 595 | } else if ( elem.fireEvent ) { 596 | elem.fireEvent("on"+type); 597 | } 598 | }, 599 | 600 | // Safe object type checking 601 | is: function( type, obj ) { 602 | return QUnit.objectType( obj ) == type; 603 | }, 604 | 605 | objectType: function( obj ) { 606 | if (typeof obj === "undefined") { 607 | return "undefined"; 608 | 609 | // consider: typeof null === object 610 | } 611 | if (obj === null) { 612 | return "null"; 613 | } 614 | 615 | var type = toString.call( obj ).match(/^\[object\s(.*)\]$/)[1] || ''; 616 | 617 | switch (type) { 618 | case 'Number': 619 | if (isNaN(obj)) { 620 | return "nan"; 621 | } else { 622 | return "number"; 623 | } 624 | case 'String': 625 | case 'Boolean': 626 | case 'Array': 627 | case 'Date': 628 | case 'RegExp': 629 | case 'Function': 630 | return type.toLowerCase(); 631 | } 632 | if (typeof obj === "object") { 633 | return "object"; 634 | } 635 | return undefined; 636 | }, 637 | 638 | push: function(result, actual, expected, message) { 639 | var details = { 640 | result: result, 641 | message: message, 642 | actual: actual, 643 | expected: expected 644 | }; 645 | 646 | message = escapeInnerText(message) || (result ? "okay" : "failed"); 647 | message = '' + message + ""; 648 | expected = escapeInnerText(QUnit.jsDump.parse(expected)); 649 | actual = escapeInnerText(QUnit.jsDump.parse(actual)); 650 | var output = message + ''; 651 | if (actual != expected) { 652 | output += ''; 653 | output += ''; 654 | } 655 | if (!result) { 656 | var source = sourceFromStacktrace(); 657 | if (source) { 658 | details.source = source; 659 | output += ''; 660 | } 661 | } 662 | output += "
    Expected:
    ' + expected + '
    Result:
    ' + actual + '
    Diff:
    ' + QUnit.diff(expected, actual) +'
    Source:
    ' + escapeInnerText(source) + '
    "; 663 | 664 | runLoggingCallbacks( 'log', QUnit, details ); 665 | 666 | config.current.assertions.push({ 667 | result: !!result, 668 | message: output 669 | }); 670 | }, 671 | 672 | url: function( params ) { 673 | params = extend( extend( {}, QUnit.urlParams ), params ); 674 | var querystring = "?", 675 | key; 676 | for ( key in params ) { 677 | if ( !hasOwn.call( params, key ) ) { 678 | continue; 679 | } 680 | querystring += encodeURIComponent( key ) + "=" + 681 | encodeURIComponent( params[ key ] ) + "&"; 682 | } 683 | return window.location.pathname + querystring.slice( 0, -1 ); 684 | }, 685 | 686 | extend: extend, 687 | id: id, 688 | addEvent: addEvent 689 | }); 690 | 691 | //QUnit.constructor is set to the empty F() above so that we can add to it's prototype later 692 | //Doing this allows us to tell if the following methods have been overwritten on the actual 693 | //QUnit object, which is a deprecated way of using the callbacks. 694 | extend(QUnit.constructor.prototype, { 695 | // Logging callbacks; all receive a single argument with the listed properties 696 | // run test/logs.html for any related changes 697 | begin: registerLoggingCallback('begin'), 698 | // done: { failed, passed, total, runtime } 699 | done: registerLoggingCallback('done'), 700 | // log: { result, actual, expected, message } 701 | log: registerLoggingCallback('log'), 702 | // testStart: { name } 703 | testStart: registerLoggingCallback('testStart'), 704 | // testDone: { name, failed, passed, total } 705 | testDone: registerLoggingCallback('testDone'), 706 | // moduleStart: { name } 707 | moduleStart: registerLoggingCallback('moduleStart'), 708 | // moduleDone: { name, failed, passed, total } 709 | moduleDone: registerLoggingCallback('moduleDone') 710 | }); 711 | 712 | if ( typeof document === "undefined" || document.readyState === "complete" ) { 713 | config.autorun = true; 714 | } 715 | 716 | QUnit.load = function() { 717 | runLoggingCallbacks( 'begin', QUnit, {} ); 718 | 719 | // Initialize the config, saving the execution queue 720 | var oldconfig = extend({}, config); 721 | QUnit.init(); 722 | extend(config, oldconfig); 723 | 724 | config.blocking = false; 725 | 726 | var urlConfigHtml = '', len = config.urlConfig.length; 727 | for ( var i = 0, val; i < len, val = config.urlConfig[i]; i++ ) { 728 | config[val] = QUnit.urlParams[val]; 729 | urlConfigHtml += ''; 730 | } 731 | 732 | var userAgent = id("qunit-userAgent"); 733 | if ( userAgent ) { 734 | userAgent.innerHTML = navigator.userAgent; 735 | } 736 | var banner = id("qunit-header"); 737 | if ( banner ) { 738 | banner.innerHTML = ' ' + banner.innerHTML + ' ' + urlConfigHtml; 739 | addEvent( banner, "change", function( event ) { 740 | var params = {}; 741 | params[ event.target.name ] = event.target.checked ? true : undefined; 742 | window.location = QUnit.url( params ); 743 | }); 744 | } 745 | 746 | var toolbar = id("qunit-testrunner-toolbar"); 747 | if ( toolbar ) { 748 | var filter = document.createElement("input"); 749 | filter.type = "checkbox"; 750 | filter.id = "qunit-filter-pass"; 751 | addEvent( filter, "click", function() { 752 | var ol = document.getElementById("qunit-tests"); 753 | if ( filter.checked ) { 754 | ol.className = ol.className + " hidepass"; 755 | } else { 756 | var tmp = " " + ol.className.replace( /[\n\t\r]/g, " " ) + " "; 757 | ol.className = tmp.replace(/ hidepass /, " "); 758 | } 759 | if ( defined.sessionStorage ) { 760 | if (filter.checked) { 761 | sessionStorage.setItem("qunit-filter-passed-tests", "true"); 762 | } else { 763 | sessionStorage.removeItem("qunit-filter-passed-tests"); 764 | } 765 | } 766 | }); 767 | if ( config.hidepassed || defined.sessionStorage && sessionStorage.getItem("qunit-filter-passed-tests") ) { 768 | filter.checked = true; 769 | var ol = document.getElementById("qunit-tests"); 770 | ol.className = ol.className + " hidepass"; 771 | } 772 | toolbar.appendChild( filter ); 773 | 774 | var label = document.createElement("label"); 775 | label.setAttribute("for", "qunit-filter-pass"); 776 | label.innerHTML = "Hide passed tests"; 777 | toolbar.appendChild( label ); 778 | } 779 | 780 | var main = id('qunit-fixture'); 781 | if ( main ) { 782 | config.fixture = main.innerHTML; 783 | } 784 | 785 | if (config.autostart) { 786 | QUnit.start(); 787 | } 788 | }; 789 | 790 | addEvent(window, "load", QUnit.load); 791 | 792 | // addEvent(window, "error") gives us a useless event object 793 | window.onerror = function( message, file, line ) { 794 | if ( QUnit.config.current ) { 795 | ok( false, message + ", " + file + ":" + line ); 796 | } else { 797 | test( "global failure", function() { 798 | ok( false, message + ", " + file + ":" + line ); 799 | }); 800 | } 801 | }; 802 | 803 | function done() { 804 | config.autorun = true; 805 | 806 | // Log the last module results 807 | if ( config.currentModule ) { 808 | runLoggingCallbacks( 'moduleDone', QUnit, { 809 | name: config.currentModule, 810 | failed: config.moduleStats.bad, 811 | passed: config.moduleStats.all - config.moduleStats.bad, 812 | total: config.moduleStats.all 813 | } ); 814 | } 815 | 816 | var banner = id("qunit-banner"), 817 | tests = id("qunit-tests"), 818 | runtime = +new Date - config.started, 819 | passed = config.stats.all - config.stats.bad, 820 | html = [ 821 | 'Tests completed in ', 822 | runtime, 823 | ' milliseconds.
    ', 824 | '', 825 | passed, 826 | ' tests of ', 827 | config.stats.all, 828 | ' passed, ', 829 | config.stats.bad, 830 | ' failed.' 831 | ].join(''); 832 | 833 | if ( banner ) { 834 | banner.className = (config.stats.bad ? "qunit-fail" : "qunit-pass"); 835 | } 836 | 837 | if ( tests ) { 838 | id( "qunit-testresult" ).innerHTML = html; 839 | } 840 | 841 | if ( config.altertitle && typeof document !== "undefined" && document.title ) { 842 | // show ✖ for good, ✔ for bad suite result in title 843 | // use escape sequences in case file gets loaded with non-utf-8-charset 844 | document.title = [ 845 | (config.stats.bad ? "\u2716" : "\u2714"), 846 | document.title.replace(/^[\u2714\u2716] /i, "") 847 | ].join(" "); 848 | } 849 | 850 | runLoggingCallbacks( 'done', QUnit, { 851 | failed: config.stats.bad, 852 | passed: passed, 853 | total: config.stats.all, 854 | runtime: runtime 855 | } ); 856 | } 857 | 858 | function validTest( name ) { 859 | var filter = config.filter, 860 | run = false; 861 | 862 | if ( !filter ) { 863 | return true; 864 | } 865 | 866 | var not = filter.charAt( 0 ) === "!"; 867 | if ( not ) { 868 | filter = filter.slice( 1 ); 869 | } 870 | 871 | if ( name.indexOf( filter ) !== -1 ) { 872 | return !not; 873 | } 874 | 875 | if ( not ) { 876 | run = true; 877 | } 878 | 879 | return run; 880 | } 881 | 882 | // so far supports only Firefox, Chrome and Opera (buggy) 883 | // could be extended in the future to use something like https://github.com/csnover/TraceKit 884 | function sourceFromStacktrace() { 885 | try { 886 | throw new Error(); 887 | } catch ( e ) { 888 | if (e.stacktrace) { 889 | // Opera 890 | return e.stacktrace.split("\n")[6]; 891 | } else if (e.stack) { 892 | // Firefox, Chrome 893 | return e.stack.split("\n")[4]; 894 | } else if (e.sourceURL) { 895 | // Safari, PhantomJS 896 | // TODO sourceURL points at the 'throw new Error' line above, useless 897 | //return e.sourceURL + ":" + e.line; 898 | } 899 | } 900 | } 901 | 902 | function escapeInnerText(s) { 903 | if (!s) { 904 | return ""; 905 | } 906 | s = s + ""; 907 | return s.replace(/[\&<>]/g, function(s) { 908 | switch(s) { 909 | case "&": return "&"; 910 | case "<": return "<"; 911 | case ">": return ">"; 912 | default: return s; 913 | } 914 | }); 915 | } 916 | 917 | function synchronize( callback, last ) { 918 | config.queue.push( callback ); 919 | 920 | if ( config.autorun && !config.blocking ) { 921 | process(last); 922 | } 923 | } 924 | 925 | function process( last ) { 926 | var start = new Date().getTime(); 927 | config.depth = config.depth ? config.depth + 1 : 1; 928 | 929 | while ( config.queue.length && !config.blocking ) { 930 | if ( !defined.setTimeout || config.updateRate <= 0 || ( ( new Date().getTime() - start ) < config.updateRate ) ) { 931 | config.queue.shift()(); 932 | } else { 933 | window.setTimeout( function(){ 934 | process( last ); 935 | }, 13 ); 936 | break; 937 | } 938 | } 939 | config.depth--; 940 | if ( last && !config.blocking && !config.queue.length && config.depth === 0 ) { 941 | done(); 942 | } 943 | } 944 | 945 | function saveGlobal() { 946 | config.pollution = []; 947 | 948 | if ( config.noglobals ) { 949 | for ( var key in window ) { 950 | if ( !hasOwn.call( window, key ) ) { 951 | continue; 952 | } 953 | config.pollution.push( key ); 954 | } 955 | } 956 | } 957 | 958 | function checkPollution( name ) { 959 | var old = config.pollution; 960 | saveGlobal(); 961 | 962 | var newGlobals = diff( config.pollution, old ); 963 | if ( newGlobals.length > 0 ) { 964 | ok( false, "Introduced global variable(s): " + newGlobals.join(", ") ); 965 | } 966 | 967 | var deletedGlobals = diff( old, config.pollution ); 968 | if ( deletedGlobals.length > 0 ) { 969 | ok( false, "Deleted global variable(s): " + deletedGlobals.join(", ") ); 970 | } 971 | } 972 | 973 | // returns a new Array with the elements that are in a but not in b 974 | function diff( a, b ) { 975 | var result = a.slice(); 976 | for ( var i = 0; i < result.length; i++ ) { 977 | for ( var j = 0; j < b.length; j++ ) { 978 | if ( result[i] === b[j] ) { 979 | result.splice(i, 1); 980 | i--; 981 | break; 982 | } 983 | } 984 | } 985 | return result; 986 | } 987 | 988 | function fail(message, exception, callback) { 989 | if ( typeof console !== "undefined" && console.error && console.warn ) { 990 | console.error(message); 991 | console.error(exception); 992 | console.error(exception.stack); 993 | console.warn(callback.toString()); 994 | 995 | } else if ( window.opera && opera.postError ) { 996 | opera.postError(message, exception, callback.toString); 997 | } 998 | } 999 | 1000 | function extend(a, b) { 1001 | for ( var prop in b ) { 1002 | if ( b[prop] === undefined ) { 1003 | delete a[prop]; 1004 | 1005 | // Avoid "Member not found" error in IE8 caused by setting window.constructor 1006 | } else if ( prop !== "constructor" || a !== window ) { 1007 | a[prop] = b[prop]; 1008 | } 1009 | } 1010 | 1011 | return a; 1012 | } 1013 | 1014 | function addEvent(elem, type, fn) { 1015 | if ( elem.addEventListener ) { 1016 | elem.addEventListener( type, fn, false ); 1017 | } else if ( elem.attachEvent ) { 1018 | elem.attachEvent( "on" + type, fn ); 1019 | } else { 1020 | fn(); 1021 | } 1022 | } 1023 | 1024 | function id(name) { 1025 | return !!(typeof document !== "undefined" && document && document.getElementById) && 1026 | document.getElementById( name ); 1027 | } 1028 | 1029 | function registerLoggingCallback(key){ 1030 | return function(callback){ 1031 | config[key].push( callback ); 1032 | }; 1033 | } 1034 | 1035 | // Supports deprecated method of completely overwriting logging callbacks 1036 | function runLoggingCallbacks(key, scope, args) { 1037 | //debugger; 1038 | var callbacks; 1039 | if ( QUnit.hasOwnProperty(key) ) { 1040 | QUnit[key].call(scope, args); 1041 | } else { 1042 | callbacks = config[key]; 1043 | for( var i = 0; i < callbacks.length; i++ ) { 1044 | callbacks[i].call( scope, args ); 1045 | } 1046 | } 1047 | } 1048 | 1049 | // Test for equality any JavaScript type. 1050 | // Author: Philippe Rathé 1051 | QUnit.equiv = function () { 1052 | 1053 | var innerEquiv; // the real equiv function 1054 | var callers = []; // stack to decide between skip/abort functions 1055 | var parents = []; // stack to avoiding loops from circular referencing 1056 | 1057 | // Call the o related callback with the given arguments. 1058 | function bindCallbacks(o, callbacks, args) { 1059 | var prop = QUnit.objectType(o); 1060 | if (prop) { 1061 | if (QUnit.objectType(callbacks[prop]) === "function") { 1062 | return callbacks[prop].apply(callbacks, args); 1063 | } else { 1064 | return callbacks[prop]; // or undefined 1065 | } 1066 | } 1067 | } 1068 | 1069 | var getProto = Object.getPrototypeOf || function (obj) { 1070 | return obj.__proto__; 1071 | }; 1072 | 1073 | var callbacks = function () { 1074 | 1075 | // for string, boolean, number and null 1076 | function useStrictEquality(b, a) { 1077 | if (b instanceof a.constructor || a instanceof b.constructor) { 1078 | // to catch short annotaion VS 'new' annotation of a 1079 | // declaration 1080 | // e.g. var i = 1; 1081 | // var j = new Number(1); 1082 | return a == b; 1083 | } else { 1084 | return a === b; 1085 | } 1086 | } 1087 | 1088 | return { 1089 | "string" : useStrictEquality, 1090 | "boolean" : useStrictEquality, 1091 | "number" : useStrictEquality, 1092 | "null" : useStrictEquality, 1093 | "undefined" : useStrictEquality, 1094 | 1095 | "nan" : function(b) { 1096 | return isNaN(b); 1097 | }, 1098 | 1099 | "date" : function(b, a) { 1100 | return QUnit.objectType(b) === "date" 1101 | && a.valueOf() === b.valueOf(); 1102 | }, 1103 | 1104 | "regexp" : function(b, a) { 1105 | return QUnit.objectType(b) === "regexp" 1106 | && a.source === b.source && // the regex itself 1107 | a.global === b.global && // and its modifers 1108 | // (gmi) ... 1109 | a.ignoreCase === b.ignoreCase 1110 | && a.multiline === b.multiline; 1111 | }, 1112 | 1113 | // - skip when the property is a method of an instance (OOP) 1114 | // - abort otherwise, 1115 | // initial === would have catch identical references anyway 1116 | "function" : function() { 1117 | var caller = callers[callers.length - 1]; 1118 | return caller !== Object && typeof caller !== "undefined"; 1119 | }, 1120 | 1121 | "array" : function(b, a) { 1122 | var i, j, loop; 1123 | var len; 1124 | 1125 | // b could be an object literal here 1126 | if (!(QUnit.objectType(b) === "array")) { 1127 | return false; 1128 | } 1129 | 1130 | len = a.length; 1131 | if (len !== b.length) { // safe and faster 1132 | return false; 1133 | } 1134 | 1135 | // track reference to avoid circular references 1136 | parents.push(a); 1137 | for (i = 0; i < len; i++) { 1138 | loop = false; 1139 | for (j = 0; j < parents.length; j++) { 1140 | if (parents[j] === a[i]) { 1141 | loop = true;// dont rewalk array 1142 | } 1143 | } 1144 | if (!loop && !innerEquiv(a[i], b[i])) { 1145 | parents.pop(); 1146 | return false; 1147 | } 1148 | } 1149 | parents.pop(); 1150 | return true; 1151 | }, 1152 | 1153 | "object" : function(b, a) { 1154 | var i, j, loop; 1155 | var eq = true; // unless we can proove it 1156 | var aProperties = [], bProperties = []; // collection of 1157 | // strings 1158 | 1159 | // comparing constructors is more strict than using 1160 | // instanceof 1161 | if (a.constructor !== b.constructor) { 1162 | // Allow objects with no prototype to be equivalent to 1163 | // objects with Object as their constructor. 1164 | if (!((getProto(a) === null && getProto(b) === Object.prototype) || 1165 | (getProto(b) === null && getProto(a) === Object.prototype))) 1166 | { 1167 | return false; 1168 | } 1169 | } 1170 | 1171 | // stack constructor before traversing properties 1172 | callers.push(a.constructor); 1173 | // track reference to avoid circular references 1174 | parents.push(a); 1175 | 1176 | for (i in a) { // be strict: don't ensures hasOwnProperty 1177 | // and go deep 1178 | loop = false; 1179 | for (j = 0; j < parents.length; j++) { 1180 | if (parents[j] === a[i]) 1181 | loop = true; // don't go down the same path 1182 | // twice 1183 | } 1184 | aProperties.push(i); // collect a's properties 1185 | 1186 | if (!loop && !innerEquiv(a[i], b[i])) { 1187 | eq = false; 1188 | break; 1189 | } 1190 | } 1191 | 1192 | callers.pop(); // unstack, we are done 1193 | parents.pop(); 1194 | 1195 | for (i in b) { 1196 | bProperties.push(i); // collect b's properties 1197 | } 1198 | 1199 | // Ensures identical properties name 1200 | return eq 1201 | && innerEquiv(aProperties.sort(), bProperties 1202 | .sort()); 1203 | } 1204 | }; 1205 | }(); 1206 | 1207 | innerEquiv = function() { // can take multiple arguments 1208 | var args = Array.prototype.slice.apply(arguments); 1209 | if (args.length < 2) { 1210 | return true; // end transition 1211 | } 1212 | 1213 | return (function(a, b) { 1214 | if (a === b) { 1215 | return true; // catch the most you can 1216 | } else if (a === null || b === null || typeof a === "undefined" 1217 | || typeof b === "undefined" 1218 | || QUnit.objectType(a) !== QUnit.objectType(b)) { 1219 | return false; // don't lose time with error prone cases 1220 | } else { 1221 | return bindCallbacks(a, callbacks, [ b, a ]); 1222 | } 1223 | 1224 | // apply transition with (1..n) arguments 1225 | })(args[0], args[1]) 1226 | && arguments.callee.apply(this, args.splice(1, 1227 | args.length - 1)); 1228 | }; 1229 | 1230 | return innerEquiv; 1231 | 1232 | }(); 1233 | 1234 | /** 1235 | * jsDump Copyright (c) 2008 Ariel Flesler - aflesler(at)gmail(dot)com | 1236 | * http://flesler.blogspot.com Licensed under BSD 1237 | * (http://www.opensource.org/licenses/bsd-license.php) Date: 5/15/2008 1238 | * 1239 | * @projectDescription Advanced and extensible data dumping for Javascript. 1240 | * @version 1.0.0 1241 | * @author Ariel Flesler 1242 | * @link {http://flesler.blogspot.com/2008/05/jsdump-pretty-dump-of-any-javascript.html} 1243 | */ 1244 | QUnit.jsDump = (function() { 1245 | function quote( str ) { 1246 | return '"' + str.toString().replace(/"/g, '\\"') + '"'; 1247 | }; 1248 | function literal( o ) { 1249 | return o + ''; 1250 | }; 1251 | function join( pre, arr, post ) { 1252 | var s = jsDump.separator(), 1253 | base = jsDump.indent(), 1254 | inner = jsDump.indent(1); 1255 | if ( arr.join ) 1256 | arr = arr.join( ',' + s + inner ); 1257 | if ( !arr ) 1258 | return pre + post; 1259 | return [ pre, inner + arr, base + post ].join(s); 1260 | }; 1261 | function array( arr, stack ) { 1262 | var i = arr.length, ret = Array(i); 1263 | this.up(); 1264 | while ( i-- ) 1265 | ret[i] = this.parse( arr[i] , undefined , stack); 1266 | this.down(); 1267 | return join( '[', ret, ']' ); 1268 | }; 1269 | 1270 | var reName = /^function (\w+)/; 1271 | 1272 | var jsDump = { 1273 | parse:function( obj, type, stack ) { //type is used mostly internally, you can fix a (custom)type in advance 1274 | stack = stack || [ ]; 1275 | var parser = this.parsers[ type || this.typeOf(obj) ]; 1276 | type = typeof parser; 1277 | var inStack = inArray(obj, stack); 1278 | if (inStack != -1) { 1279 | return 'recursion('+(inStack - stack.length)+')'; 1280 | } 1281 | //else 1282 | if (type == 'function') { 1283 | stack.push(obj); 1284 | var res = parser.call( this, obj, stack ); 1285 | stack.pop(); 1286 | return res; 1287 | } 1288 | // else 1289 | return (type == 'string') ? parser : this.parsers.error; 1290 | }, 1291 | typeOf:function( obj ) { 1292 | var type; 1293 | if ( obj === null ) { 1294 | type = "null"; 1295 | } else if (typeof obj === "undefined") { 1296 | type = "undefined"; 1297 | } else if (QUnit.is("RegExp", obj)) { 1298 | type = "regexp"; 1299 | } else if (QUnit.is("Date", obj)) { 1300 | type = "date"; 1301 | } else if (QUnit.is("Function", obj)) { 1302 | type = "function"; 1303 | } else if (typeof obj.setInterval !== undefined && typeof obj.document !== "undefined" && typeof obj.nodeType === "undefined") { 1304 | type = "window"; 1305 | } else if (obj.nodeType === 9) { 1306 | type = "document"; 1307 | } else if (obj.nodeType) { 1308 | type = "node"; 1309 | } else if ( 1310 | // native arrays 1311 | toString.call( obj ) === "[object Array]" || 1312 | // NodeList objects 1313 | ( typeof obj.length === "number" && typeof obj.item !== "undefined" && ( obj.length ? obj.item(0) === obj[0] : ( obj.item( 0 ) === null && typeof obj[0] === "undefined" ) ) ) 1314 | ) { 1315 | type = "array"; 1316 | } else { 1317 | type = typeof obj; 1318 | } 1319 | return type; 1320 | }, 1321 | separator:function() { 1322 | return this.multiline ? this.HTML ? '
    ' : '\n' : this.HTML ? ' ' : ' '; 1323 | }, 1324 | indent:function( extra ) {// extra can be a number, shortcut for increasing-calling-decreasing 1325 | if ( !this.multiline ) 1326 | return ''; 1327 | var chr = this.indentChar; 1328 | if ( this.HTML ) 1329 | chr = chr.replace(/\t/g,' ').replace(/ /g,' '); 1330 | return Array( this._depth_ + (extra||0) ).join(chr); 1331 | }, 1332 | up:function( a ) { 1333 | this._depth_ += a || 1; 1334 | }, 1335 | down:function( a ) { 1336 | this._depth_ -= a || 1; 1337 | }, 1338 | setParser:function( name, parser ) { 1339 | this.parsers[name] = parser; 1340 | }, 1341 | // The next 3 are exposed so you can use them 1342 | quote:quote, 1343 | literal:literal, 1344 | join:join, 1345 | // 1346 | _depth_: 1, 1347 | // This is the list of parsers, to modify them, use jsDump.setParser 1348 | parsers:{ 1349 | window: '[Window]', 1350 | document: '[Document]', 1351 | error:'[ERROR]', //when no parser is found, shouldn't happen 1352 | unknown: '[Unknown]', 1353 | 'null':'null', 1354 | 'undefined':'undefined', 1355 | 'function':function( fn ) { 1356 | var ret = 'function', 1357 | name = 'name' in fn ? fn.name : (reName.exec(fn)||[])[1];//functions never have name in IE 1358 | if ( name ) 1359 | ret += ' ' + name; 1360 | ret += '('; 1361 | 1362 | ret = [ ret, QUnit.jsDump.parse( fn, 'functionArgs' ), '){'].join(''); 1363 | return join( ret, QUnit.jsDump.parse(fn,'functionCode'), '}' ); 1364 | }, 1365 | array: array, 1366 | nodelist: array, 1367 | arguments: array, 1368 | object:function( map, stack ) { 1369 | var ret = [ ]; 1370 | QUnit.jsDump.up(); 1371 | for ( var key in map ) { 1372 | var val = map[key]; 1373 | ret.push( QUnit.jsDump.parse(key,'key') + ': ' + QUnit.jsDump.parse(val, undefined, stack)); 1374 | } 1375 | QUnit.jsDump.down(); 1376 | return join( '{', ret, '}' ); 1377 | }, 1378 | node:function( node ) { 1379 | var open = QUnit.jsDump.HTML ? '<' : '<', 1380 | close = QUnit.jsDump.HTML ? '>' : '>'; 1381 | 1382 | var tag = node.nodeName.toLowerCase(), 1383 | ret = open + tag; 1384 | 1385 | for ( var a in QUnit.jsDump.DOMAttrs ) { 1386 | var val = node[QUnit.jsDump.DOMAttrs[a]]; 1387 | if ( val ) 1388 | ret += ' ' + a + '=' + QUnit.jsDump.parse( val, 'attribute' ); 1389 | } 1390 | return ret + close + open + '/' + tag + close; 1391 | }, 1392 | functionArgs:function( fn ) {//function calls it internally, it's the arguments part of the function 1393 | var l = fn.length; 1394 | if ( !l ) return ''; 1395 | 1396 | var args = Array(l); 1397 | while ( l-- ) 1398 | args[l] = String.fromCharCode(97+l);//97 is 'a' 1399 | return ' ' + args.join(', ') + ' '; 1400 | }, 1401 | key:quote, //object calls it internally, the key part of an item in a map 1402 | functionCode:'[code]', //function calls it internally, it's the content of the function 1403 | attribute:quote, //node calls it internally, it's an html attribute value 1404 | string:quote, 1405 | date:quote, 1406 | regexp:literal, //regex 1407 | number:literal, 1408 | 'boolean':literal 1409 | }, 1410 | DOMAttrs:{//attributes to dump from nodes, name=>realName 1411 | id:'id', 1412 | name:'name', 1413 | 'class':'className' 1414 | }, 1415 | HTML:false,//if true, entities are escaped ( <, >, \t, space and \n ) 1416 | indentChar:' ',//indentation unit 1417 | multiline:true //if true, items in a collection, are separated by a \n, else just a space. 1418 | }; 1419 | 1420 | return jsDump; 1421 | })(); 1422 | 1423 | // from Sizzle.js 1424 | function getText( elems ) { 1425 | var ret = "", elem; 1426 | 1427 | for ( var i = 0; elems[i]; i++ ) { 1428 | elem = elems[i]; 1429 | 1430 | // Get the text from text nodes and CDATA nodes 1431 | if ( elem.nodeType === 3 || elem.nodeType === 4 ) { 1432 | ret += elem.nodeValue; 1433 | 1434 | // Traverse everything else, except comment nodes 1435 | } else if ( elem.nodeType !== 8 ) { 1436 | ret += getText( elem.childNodes ); 1437 | } 1438 | } 1439 | 1440 | return ret; 1441 | }; 1442 | 1443 | //from jquery.js 1444 | function inArray( elem, array ) { 1445 | if ( array.indexOf ) { 1446 | return array.indexOf( elem ); 1447 | } 1448 | 1449 | for ( var i = 0, length = array.length; i < length; i++ ) { 1450 | if ( array[ i ] === elem ) { 1451 | return i; 1452 | } 1453 | } 1454 | 1455 | return -1; 1456 | } 1457 | 1458 | /* 1459 | * Javascript Diff Algorithm 1460 | * By John Resig (http://ejohn.org/) 1461 | * Modified by Chu Alan "sprite" 1462 | * 1463 | * Released under the MIT license. 1464 | * 1465 | * More Info: 1466 | * http://ejohn.org/projects/javascript-diff-algorithm/ 1467 | * 1468 | * Usage: QUnit.diff(expected, actual) 1469 | * 1470 | * QUnit.diff("the quick brown fox jumped over", "the quick fox jumps over") == "the quick brown fox jumped jumps over" 1471 | */ 1472 | QUnit.diff = (function() { 1473 | function diff(o, n) { 1474 | var ns = {}; 1475 | var os = {}; 1476 | 1477 | for (var i = 0; i < n.length; i++) { 1478 | if (ns[n[i]] == null) 1479 | ns[n[i]] = { 1480 | rows: [], 1481 | o: null 1482 | }; 1483 | ns[n[i]].rows.push(i); 1484 | } 1485 | 1486 | for (var i = 0; i < o.length; i++) { 1487 | if (os[o[i]] == null) 1488 | os[o[i]] = { 1489 | rows: [], 1490 | n: null 1491 | }; 1492 | os[o[i]].rows.push(i); 1493 | } 1494 | 1495 | for (var i in ns) { 1496 | if ( !hasOwn.call( ns, i ) ) { 1497 | continue; 1498 | } 1499 | if (ns[i].rows.length == 1 && typeof(os[i]) != "undefined" && os[i].rows.length == 1) { 1500 | n[ns[i].rows[0]] = { 1501 | text: n[ns[i].rows[0]], 1502 | row: os[i].rows[0] 1503 | }; 1504 | o[os[i].rows[0]] = { 1505 | text: o[os[i].rows[0]], 1506 | row: ns[i].rows[0] 1507 | }; 1508 | } 1509 | } 1510 | 1511 | for (var i = 0; i < n.length - 1; i++) { 1512 | if (n[i].text != null && n[i + 1].text == null && n[i].row + 1 < o.length && o[n[i].row + 1].text == null && 1513 | n[i + 1] == o[n[i].row + 1]) { 1514 | n[i + 1] = { 1515 | text: n[i + 1], 1516 | row: n[i].row + 1 1517 | }; 1518 | o[n[i].row + 1] = { 1519 | text: o[n[i].row + 1], 1520 | row: i + 1 1521 | }; 1522 | } 1523 | } 1524 | 1525 | for (var i = n.length - 1; i > 0; i--) { 1526 | if (n[i].text != null && n[i - 1].text == null && n[i].row > 0 && o[n[i].row - 1].text == null && 1527 | n[i - 1] == o[n[i].row - 1]) { 1528 | n[i - 1] = { 1529 | text: n[i - 1], 1530 | row: n[i].row - 1 1531 | }; 1532 | o[n[i].row - 1] = { 1533 | text: o[n[i].row - 1], 1534 | row: i - 1 1535 | }; 1536 | } 1537 | } 1538 | 1539 | return { 1540 | o: o, 1541 | n: n 1542 | }; 1543 | } 1544 | 1545 | return function(o, n) { 1546 | o = o.replace(/\s+$/, ''); 1547 | n = n.replace(/\s+$/, ''); 1548 | var out = diff(o == "" ? [] : o.split(/\s+/), n == "" ? [] : n.split(/\s+/)); 1549 | 1550 | var str = ""; 1551 | 1552 | var oSpace = o.match(/\s+/g); 1553 | if (oSpace == null) { 1554 | oSpace = [" "]; 1555 | } 1556 | else { 1557 | oSpace.push(" "); 1558 | } 1559 | var nSpace = n.match(/\s+/g); 1560 | if (nSpace == null) { 1561 | nSpace = [" "]; 1562 | } 1563 | else { 1564 | nSpace.push(" "); 1565 | } 1566 | 1567 | if (out.n.length == 0) { 1568 | for (var i = 0; i < out.o.length; i++) { 1569 | str += '' + out.o[i] + oSpace[i] + ""; 1570 | } 1571 | } 1572 | else { 1573 | if (out.n[0].text == null) { 1574 | for (n = 0; n < out.o.length && out.o[n].text == null; n++) { 1575 | str += '' + out.o[n] + oSpace[n] + ""; 1576 | } 1577 | } 1578 | 1579 | for (var i = 0; i < out.n.length; i++) { 1580 | if (out.n[i].text == null) { 1581 | str += '' + out.n[i] + nSpace[i] + ""; 1582 | } 1583 | else { 1584 | var pre = ""; 1585 | 1586 | for (n = out.n[i].row + 1; n < out.o.length && out.o[n].text == null; n++) { 1587 | pre += '' + out.o[n] + oSpace[n] + ""; 1588 | } 1589 | str += " " + out.n[i].text + nSpace[i] + pre; 1590 | } 1591 | } 1592 | } 1593 | 1594 | return str; 1595 | }; 1596 | })(); 1597 | 1598 | })(this); --------------------------------------------------------------------------------