├── .gitignore ├── .travis.yml ├── src ├── include │ └── wrapper.js ├── features.js ├── jQuery.headroom.js ├── angular.headroom.js ├── debouncer.js └── Headroom.js ├── spec ├── .jshintrc ├── helpers │ └── polyfill.js ├── Debouncer.spec.js └── Headroom.spec.js ├── component.json ├── dist ├── jQuery.headroom.min.js ├── angular.headroom.min.js ├── jQuery.headroom.js ├── angular.headroom.js ├── headroom.min.js └── headroom.js ├── bower.json ├── .jshintrc ├── LICENSE ├── package.json ├── Gruntfile.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.8" 4 | - "0.10" 5 | before_script: 6 | - npm install -g grunt-cli -------------------------------------------------------------------------------- /src/include/wrapper.js: -------------------------------------------------------------------------------- 1 | //= ../features.js 2 | //= ../Debouncer.js 3 | //= ../Headroom.js 4 | 5 | module.exports = Headroom; -------------------------------------------------------------------------------- /spec/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals" : { 3 | "describe" : false, 4 | "beforeEach" : false, 5 | "afterEach" : false, 6 | "it" : false, 7 | "expect" : false, 8 | "jasmine" : false, 9 | "spyOn" : false, 10 | "Headroom" :false 11 | } 12 | } -------------------------------------------------------------------------------- /src/features.js: -------------------------------------------------------------------------------- 1 | /* exported features */ 2 | 3 | var features = { 4 | bind : !!(function(){}.bind), 5 | classList : 'classList' in document.documentElement, 6 | rAF : !!(window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame) 7 | }; 8 | 9 | module.exports = features; -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headroom", 3 | "repo": "WickyNilliams/headroom.js", 4 | "description": "Give your pages some headroom. Hide your header until you need.", 5 | "keywords": [ 6 | "headroom", 7 | "header", 8 | "ui" 9 | ], 10 | "version": "0.7.1", 11 | "scripts": [ 12 | "dist/headroom.js" 13 | ], 14 | "main": "dist/headroom.js", 15 | "license": "MIT" 16 | } -------------------------------------------------------------------------------- /dist/jQuery.headroom.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * headroom.js v0.7.0 - Give your page some headroom. Hide your header until you need it 3 | * Copyright (c) 2014 Nick Williams - http://wicky.nillia.ms/headroom.js 4 | * License: MIT 5 | */ 6 | 7 | !function(a){a&&(a.fn.headroom=function(b){return this.each(function(){var c=a(this),d=c.data("headroom"),e="object"==typeof b&&b;e=a.extend(!0,{},Headroom.options,e),d||(d=new Headroom(this,e),d.init(),c.data("headroom",d)),"string"==typeof b&&d[b]()})},a("[data-headroom]").each(function(){var b=a(this);b.headroom(b.data())}))}(window.Zepto||window.jQuery); -------------------------------------------------------------------------------- /dist/angular.headroom.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * headroom.js v0.7.0 - Give your page some headroom. Hide your header until you need it 3 | * Copyright (c) 2014 Nick Williams - http://wicky.nillia.ms/headroom.js 4 | * License: MIT 5 | */ 6 | 7 | !function(a){a&&a.module("headroom",[]).directive("headroom",function(){return{restrict:"EA",scope:{tolerance:"=",offset:"=",classes:"=",scroller:"@"},link:function(b,c){var d={};a.forEach(Headroom.options,function(a,c){d[c]=b[c]||Headroom.options[c]}),d.scroller&&(d.scroller=a.element(d.scroller)[0]);var e=new Headroom(c[0],d);e.init(),b.$on("destroy",function(){e.destroy()})}}})}(window.angular); -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headroom.js", 3 | "version": "0.7.0", 4 | "main": ["dist/headroom.js", "dist/jQuery.headroom.js", "dist/angular.headroom.js"], 5 | "ignore": [ 6 | "**/.*", 7 | "node_modules", 8 | "components", 9 | "spec", 10 | "Gruntfile.js", 11 | "src", 12 | "bower_components", 13 | "test", 14 | "tests" 15 | ], 16 | "homepage": "http://wicky.nillia.ms/headroom.js/", 17 | "authors": [ 18 | "WickyNilliams" 19 | ], 20 | "description": "Hide your header until you need it", 21 | "keywords": [ 22 | "header", 23 | "js", 24 | "scroll" 25 | ], 26 | "license": "MIT" 27 | } 28 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "browser": true, 4 | "esnext": true, 5 | "expr" : true, 6 | "bitwise": true, 7 | "camelcase": true, 8 | "curly": true, 9 | "eqeqeq": true, 10 | "immed": true, 11 | "latedef": true, 12 | "laxbreak": true, 13 | "newcap": true, 14 | "noarg": true, 15 | "quotmark": "single", 16 | "regexp": true, 17 | "undef": true, 18 | "unused": true, 19 | "trailing": true, 20 | "smarttabs": true, 21 | "undef": true, 22 | "indent" : 2, 23 | "globals": { 24 | "requestAnimationFrame": false, 25 | "mozRequestAnimationFrame" : false, 26 | "webkitRequestAnimationFrame" : false, 27 | "features": true, 28 | "Headroom": false, 29 | "Debouncer": false 30 | } 31 | } -------------------------------------------------------------------------------- /spec/helpers/polyfill.js: -------------------------------------------------------------------------------- 1 | if (!Function.prototype.bind) { 2 | Function.prototype.bind = function (oThis) { 3 | if (typeof this !== "function") { 4 | // closest thing possible to the ECMAScript 5 internal IsCallable function 5 | throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); 6 | } 7 | 8 | var aArgs = Array.prototype.slice.call(arguments, 1), 9 | fToBind = this, 10 | fNOP = function () {}, 11 | fBound = function () { 12 | return fToBind.apply(this instanceof fNOP && oThis 13 | ? this 14 | : oThis, 15 | aArgs.concat(Array.prototype.slice.call(arguments))); 16 | }; 17 | 18 | fNOP.prototype = this.prototype; 19 | fBound.prototype = new fNOP(); 20 | 21 | return fBound; 22 | }; 23 | } -------------------------------------------------------------------------------- /src/jQuery.headroom.js: -------------------------------------------------------------------------------- 1 | (function($) { 2 | 3 | if(!$) { 4 | return; 5 | } 6 | 7 | //////////// 8 | // Plugin // 9 | //////////// 10 | 11 | $.fn.headroom = function(option) { 12 | return this.each(function() { 13 | var $this = $(this), 14 | data = $this.data('headroom'), 15 | options = typeof option === 'object' && option; 16 | 17 | options = $.extend(true, {}, Headroom.options, options); 18 | 19 | if (!data) { 20 | data = new Headroom(this, options); 21 | data.init(); 22 | $this.data('headroom', data); 23 | } 24 | if (typeof option === 'string') { 25 | data[option](); 26 | } 27 | }); 28 | }; 29 | 30 | ////////////// 31 | // Data API // 32 | ////////////// 33 | 34 | $('[data-headroom]').each(function() { 35 | var $this = $(this); 36 | $this.headroom($this.data()); 37 | }); 38 | 39 | }(window.Zepto || window.jQuery)); -------------------------------------------------------------------------------- /src/angular.headroom.js: -------------------------------------------------------------------------------- 1 | (function(angular) { 2 | 3 | if(!angular) { 4 | return; 5 | } 6 | 7 | /////////////// 8 | // Directive // 9 | /////////////// 10 | 11 | angular.module('headroom', []).directive('headroom', function() { 12 | return { 13 | restrict: 'EA', 14 | scope: { 15 | tolerance: '=', 16 | offset: '=', 17 | classes: '=', 18 | scroller: '@' 19 | }, 20 | link: function(scope, element) { 21 | var options = {}; 22 | angular.forEach(Headroom.options, function(value, key) { 23 | options[key] = scope[key] || Headroom.options[key]; 24 | }); 25 | if (options.scroller) { 26 | options.scroller = angular.element(options.scroller)[0]; 27 | } 28 | var headroom = new Headroom(element[0], options); 29 | headroom.init(); 30 | scope.$on('destroy', function() { 31 | headroom.destroy(); 32 | }); 33 | } 34 | }; 35 | }); 36 | 37 | }(window.angular)); 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Nick Williams 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /dist/jQuery.headroom.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * headroom.js v0.7.0 - Give your page some headroom. Hide your header until you need it 3 | * Copyright (c) 2014 Nick Williams - http://wicky.nillia.ms/headroom.js 4 | * License: MIT 5 | */ 6 | 7 | (function($) { 8 | 9 | if(!$) { 10 | return; 11 | } 12 | 13 | //////////// 14 | // Plugin // 15 | //////////// 16 | 17 | $.fn.headroom = function(option) { 18 | return this.each(function() { 19 | var $this = $(this), 20 | data = $this.data('headroom'), 21 | options = typeof option === 'object' && option; 22 | 23 | options = $.extend(true, {}, Headroom.options, options); 24 | 25 | if (!data) { 26 | data = new Headroom(this, options); 27 | data.init(); 28 | $this.data('headroom', data); 29 | } 30 | if (typeof option === 'string') { 31 | data[option](); 32 | } 33 | }); 34 | }; 35 | 36 | ////////////// 37 | // Data API // 38 | ////////////// 39 | 40 | $('[data-headroom]').each(function() { 41 | var $this = $(this); 42 | $this.headroom($this.data()); 43 | }); 44 | 45 | }(window.Zepto || window.jQuery)); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "democracyos-headroom.js", 3 | "version": "0.7.0", 4 | "description": "Give your page some headroom. Hide your header until you need it", 5 | "main": "dist/headroom.js", 6 | "scripts": { 7 | "test": "grunt test --verbose" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/DemocracyOS/headroom.js" 12 | }, 13 | "keywords": [ 14 | "header", 15 | "fixed", 16 | "scroll", 17 | "menu" 18 | ], 19 | "author": "Nick Williams", 20 | "homepage": "http://wicky.nillia.ms/headroom.js", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/DemocracyOS/headroom.js/issues" 24 | }, 25 | "scripts": { 26 | "test": "grunt dist --verbose" 27 | }, 28 | "devDependencies": { 29 | "grunt": "~0.4.1", 30 | "grunt-contrib-uglify": "~0.2.2", 31 | "grunt-rigger": "~0.5.0", 32 | "grunt-contrib-jshint": "~0.6.2", 33 | "grunt-contrib-watch": "~0.5.1", 34 | "karma": "~0.10.8", 35 | "grunt-karma": "~0.6.2", 36 | "karma-firefox-launcher": "~0.1.2", 37 | "karma-safari-launcher": "~0.1.1", 38 | "karma-opera-launcher": "~0.1.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/debouncer.js: -------------------------------------------------------------------------------- 1 | window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame; 2 | 3 | /** 4 | * Handles debouncing of events via requestAnimationFrame 5 | * @see http://www.html5rocks.com/en/tutorials/speed/animations/ 6 | * @param {Function} callback The callback to handle whichever event 7 | */ 8 | function Debouncer (callback) { 9 | this.callback = callback; 10 | this.ticking = false; 11 | } 12 | Debouncer.prototype = { 13 | constructor : Debouncer, 14 | 15 | /** 16 | * dispatches the event to the supplied callback 17 | * @private 18 | */ 19 | update : function() { 20 | this.callback && this.callback(); 21 | this.ticking = false; 22 | }, 23 | 24 | /** 25 | * ensures events don't get stacked 26 | * @private 27 | */ 28 | requestTick : function() { 29 | if(!this.ticking) { 30 | requestAnimationFrame(this.rafCallback || (this.rafCallback = this.update.bind(this))); 31 | this.ticking = true; 32 | } 33 | }, 34 | 35 | /** 36 | * Attach this as the event listeners 37 | */ 38 | handleEvent : function() { 39 | this.requestTick(); 40 | } 41 | }; 42 | 43 | module.exports = Debouncer; -------------------------------------------------------------------------------- /dist/angular.headroom.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * headroom.js v0.7.0 - Give your page some headroom. Hide your header until you need it 3 | * Copyright (c) 2014 Nick Williams - http://wicky.nillia.ms/headroom.js 4 | * License: MIT 5 | */ 6 | 7 | (function(angular) { 8 | 9 | if(!angular) { 10 | return; 11 | } 12 | 13 | /////////////// 14 | // Directive // 15 | /////////////// 16 | 17 | angular.module('headroom', []).directive('headroom', function() { 18 | return { 19 | restrict: 'EA', 20 | scope: { 21 | tolerance: '=', 22 | offset: '=', 23 | classes: '=', 24 | scroller: '@' 25 | }, 26 | link: function(scope, element) { 27 | var options = {}; 28 | angular.forEach(Headroom.options, function(value, key) { 29 | options[key] = scope[key] || Headroom.options[key]; 30 | }); 31 | if (options.scroller) { 32 | options.scroller = angular.element(options.scroller)[0]; 33 | } 34 | var headroom = new Headroom(element[0], options); 35 | headroom.init(); 36 | scope.$on('destroy', function() { 37 | headroom.destroy(); 38 | }); 39 | } 40 | }; 41 | }); 42 | 43 | }(window.angular)); -------------------------------------------------------------------------------- /spec/Debouncer.spec.js: -------------------------------------------------------------------------------- 1 | (function(global){ 2 | 3 | describe('Debouncer', function() { 4 | 5 | var callback, debouncer; 6 | 7 | beforeEach(function() { 8 | callback = jasmine.createSpy('callback'); 9 | debouncer = new Debouncer(callback); 10 | }); 11 | 12 | describe('constructor', function() { 13 | 14 | it('stores the supplied callback', function() { 15 | expect(debouncer.callback).toBe(callback); 16 | }); 17 | 18 | it('initialises ticking to false', function() { 19 | expect(debouncer.ticking).toBe(false); 20 | }); 21 | 22 | }); 23 | 24 | describe('update', function() { 25 | 26 | it('executes callback and sets ticking to false', function(){ 27 | debouncer.ticking = true; 28 | 29 | debouncer.update(); 30 | 31 | expect(callback).toHaveBeenCalled(); 32 | expect(debouncer.ticking).toBe(false); 33 | }); 34 | 35 | }); 36 | 37 | 38 | describe('handleEvent', function() { 39 | 40 | it('calls update and requests tick', function() { 41 | var rt = spyOn(Debouncer.prototype, 'requestTick'); 42 | 43 | debouncer.handleEvent(); 44 | 45 | expect(rt).toHaveBeenCalled(); 46 | }); 47 | 48 | }); 49 | 50 | 51 | describe('requestTick', function() { 52 | 53 | var originalRAF, rAF, bind; 54 | 55 | beforeEach(function() { 56 | originalRAF = global.requestAnimationFrame; 57 | global.requestAnimationFrame = rAF = jasmine.createSpy('requestAnimationFrame'); 58 | bind = spyOn(Debouncer.prototype.update, 'bind'); 59 | }); 60 | 61 | afterEach(function() { 62 | global.requestAnimationFrame = originalRAF; 63 | }); 64 | 65 | it('will not queue rAF if already ticking', function() { 66 | debouncer.ticking = true; 67 | debouncer.requestTick(); 68 | 69 | expect(rAF).not.toHaveBeenCalled(); 70 | expect(bind).not.toHaveBeenCalled(); 71 | expect(debouncer.ticking).toBe(true); 72 | }); 73 | 74 | it('queues rAF if not currently ticking', function() { 75 | debouncer.ticking = false; 76 | debouncer.requestTick(); 77 | 78 | expect(rAF).toHaveBeenCalled(); 79 | expect(bind).toHaveBeenCalled(); 80 | expect(debouncer.ticking).toBe(true); 81 | }); 82 | 83 | it('caches the rAF callback', function() { 84 | debouncer.ticking = false; 85 | 86 | debouncer.requestTick(); 87 | debouncer.requestTick(); 88 | 89 | expect(bind.calls.length).toBe(1); 90 | }); 91 | 92 | }); 93 | 94 | }); 95 | }(this)); -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*global module:false*/ 2 | module.exports = function(grunt) { 3 | 4 | 'use strict'; 5 | 6 | grunt.initConfig({ 7 | 8 | pkg: grunt.file.readJSON('package.json'), 9 | meta: { 10 | banner : '/*!\n' + 11 | ' * <%= pkg.name %> v<%= pkg.version %> - <%= pkg.description %>\n' + 12 | ' * Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author %> - <%= pkg.homepage %>\n' + 13 | ' * License: <%= pkg.license %>\n' + 14 | ' */\n\n', 15 | outputDir: 'dist', 16 | output : '<%= meta.outputDir %>/<%= pkg.name %>', 17 | outputMin : '<%= meta.outputDir %>/<%= pkg.name.replace("js", "min.js") %>' 18 | }, 19 | 20 | rig: { 21 | options : { 22 | banner : '<%= meta.banner %>' 23 | }, 24 | dist: { 25 | files: { 26 | '<%= meta.output %>' : ['src/include/wrapper.js'], 27 | 'dist/jQuery.headroom.js' : ['src/jQuery.headroom.js'], 28 | 'dist/angular.headroom.js' : ['src/angular.headroom.js'] 29 | } 30 | } 31 | }, 32 | 33 | uglify: { 34 | options : { 35 | banner : '<%= meta.banner %>', 36 | report: 'gzip' 37 | }, 38 | dist: { 39 | files : { 40 | '<%= meta.outputMin %>' : '<%= meta.output %>', 41 | 'dist/jQuery.headroom.min.js': 'dist/jQuery.headroom.js', 42 | 'dist/angular.headroom.min.js': 'dist/angular.headroom.js' 43 | } 44 | } 45 | }, 46 | 47 | jshint: { 48 | prebuild : { 49 | options : { 50 | jshintrc : '.jshintrc' 51 | }, 52 | files : { 53 | src : [ 54 | 'Gruntfile.js', 55 | 'src/*.js' 56 | ] 57 | } 58 | }, 59 | tests : { 60 | options : grunt.util._.merge( 61 | grunt.file.readJSON('.jshintrc'), 62 | grunt.file.readJSON('spec/.jshintrc')), 63 | files : { 64 | src : ['spec/*.js'] 65 | } 66 | }, 67 | postbuild : { 68 | options : { 69 | jshintrc : '.jshintrc' 70 | }, 71 | files :{ 72 | src : ['<%= meta.output %>'] 73 | } 74 | } 75 | }, 76 | 77 | karma : { 78 | options : { 79 | frameworks : ['jasmine'], 80 | browsers : ['PhantomJS', 'Chrome', 'Opera', 'Safari', 'Firefox'], 81 | files : [ 82 | 'spec/helpers/polyfill.js', // PhantomJS needs Function.prototype.bind polyfill 83 | 'src/*.js', 84 | 'spec/*.js' 85 | ] 86 | }, 87 | unit : { 88 | options : { 89 | background: true, 90 | reporters : 'dots' 91 | }, 92 | }, 93 | continuous : { 94 | options : { 95 | singleRun : true, 96 | browsers : ['PhantomJS'] 97 | } 98 | } 99 | }, 100 | 101 | watch: { 102 | options : { 103 | atBegin : true 104 | }, 105 | files: [ 106 | 'src/*.js', 107 | 'spec/*.js' 108 | ], 109 | tasks: ['prehint', 'karma:unit:run'] 110 | } 111 | }); 112 | 113 | grunt.loadNpmTasks('grunt-contrib-uglify'); 114 | grunt.loadNpmTasks('grunt-rigger'); 115 | grunt.loadNpmTasks('grunt-contrib-jshint'); 116 | grunt.loadNpmTasks('grunt-karma'); 117 | grunt.loadNpmTasks('grunt-contrib-watch'); 118 | 119 | grunt.registerTask('prehint', ['jshint:prebuild', 'jshint:tests']); 120 | grunt.registerTask('ci', ['prehint', 'karma:continuous']); 121 | grunt.registerTask('dist', ['ci', 'rig', 'jshint:postbuild', 'uglify']); 122 | grunt.registerTask('default', ['karma:unit:start', 'watch']); 123 | }; 124 | -------------------------------------------------------------------------------- /dist/headroom.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * headroom.js v0.7.0 - Give your page some headroom. Hide your header until you need it 3 | * Copyright (c) 2014 Nick Williams - http://wicky.nillia.ms/headroom.js 4 | * License: MIT 5 | */ 6 | 7 | function Debouncer(a){this.callback=a,this.ticking=!1}function isDOMElement(a){return a&&"undefined"!=typeof window&&(a===window||a.nodeType)}function extend(a){if(arguments.length<=0)throw new Error("Missing arguments in extend function");var b,c,d=a||{};for(c=1;ca,c=a+this.getViewportHeight()>this.getScrollerHeight();return b||c},toleranceExceeded:function(a,b){return Math.abs(a-this.lastKnownScrollY)>=this.tolerance[b]},shouldUnpin:function(a,b){var c=a>this.lastKnownScrollY,d=a>=this.offset;return c&&d&&b},shouldPin:function(a,b){var c=athis.lastKnownScrollY?"down":"up",c=this.toleranceExceeded(a,b);this.isOutOfBounds(a)||(a<=this.offset?this.top():this.notTop(),this.shouldUnpin(a,c)?this.unpin():this.shouldPin(a,c)&&this.pin(),this.lastKnownScrollY=a)}},Headroom.options={tolerance:{up:0,down:0},offset:0,scroller:window,classes:{pinned:"headroom--pinned",unpinned:"headroom--unpinned",top:"headroom--top",notTop:"headroom--not-top",initial:"headroom"}},Headroom.cutsTheMustard="undefined"!=typeof features&&features.rAF&&features.bind&&features.classList,module.exports=Headroom; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Headroom.js](http://wicky.nillia.ms/headroom.js) 2 | 3 | **Give your pages some headroom. Hide your header until you need it.** 4 | 5 | ## What's it all about? 6 | 7 | Headroom.js is a lightweight, high-performance JS widget (with no dependencies!) that allows you to react to the user's scroll. The header on [this site](http://wicky.nillia.ms/headroom.js) is a living example, it slides out of view when scrolling down and slides back in when scrolling up. 8 | 9 | ### Why use it? 10 | 11 | Fixed headers are a popular approach for keeping the primary navigation in close proximity to the user. This can reduce the effort required for a user to quickly navigate a site, but they are not without problems… 12 | 13 | Large screens are usually landscape-oriented, meaning less vertical than horizontal space. A fixed header can therefore occupy a significant portion of the content area. Small screens are typically used in a portrait orientation. Whilst this results in more vertical space, because of the overall height of the screen a meaningfully-sized header can still be quite imposing. 14 | 15 | Headroom.js allows you to bring elements into view when appropriate, and give focus to your content the rest of the time. 16 | 17 | ### How does it work? 18 | 19 | At it's most basic headroom.js simply adds and removes CSS classes from an element in response to a scroll event. This means **you must supply your own CSS styles separately**. The classes that are used in headroom.js that are added and removed are: 20 | 21 | ```html 22 | 23 |
24 | 25 | 26 |
27 | 28 | 29 |
30 | ``` 31 | 32 | Relying on CSS classes affords headroom.js incredible flexibility. The choice of what to do when scrolling up or down is now entirely yours - anything you can do with CSS you can do in response to the user's scroll. 33 | 34 | ## Usage 35 | 36 | Using headroom.js is really simple. It has a pure JS API, plus an optional jQuery/Zepto plugin and AngularJS directive. 37 | 38 | ### Using Headroom.js with a CDN 39 | 40 | CDN provided by [jsDelivr CDN](http://www.jsdelivr.com/#!headroomjs) 41 | ``` 42 | 43 | 44 | 45 | ``` 46 | 47 | ### With pure JS 48 | 49 | Include the `headroom.js` script in your page, and then: 50 | 51 | ```js 52 | // grab an element 53 | var myElement = document.querySelector("header"); 54 | // construct an instance of Headroom, passing the element 55 | var headroom = new Headroom(myElement); 56 | // initialise 57 | headroom.init(); 58 | ``` 59 | 60 | ### With jQuery/Zepto 61 | 62 | Include the `headroom.js` and `jQuery.headroom.js` scripts in your page, and then: 63 | 64 | ```js 65 | // simple as this! 66 | // NOTE: init() is implicitly called with the plugin 67 | $("header").headroom(); 68 | ``` 69 | 70 | The plugin also offers a data-* API if you prefer a declarative approach. 71 | 72 | ```html 73 | 74 |
75 | ``` 76 | 77 | Note: Zepto's additional [data module](https://github.com/madrobby/zepto#zepto-modules) is required for compatibility. 78 | 79 | ### With AngularJS 80 | 81 | Include the `headroom.js` and `angular.headroom.js` scripts in your page, and then: 82 | 83 | ```html 84 |
85 | 86 | 87 | 88 | 89 | ``` 90 | 91 | Note: in AngularJS, you connot pass a DOM element as a directive attribute. Instead, you have to provide a selector that can be passed to [angular.element](http://docs.angularjs.org/api/ng/function/angular.element). If you use default AngularJS jQLite selector engine, [here are the compliant selectors](https://code.google.com/p/jqlite/wiki/UsingJQLite). 92 | 93 | ## Options 94 | 95 | Headroom.js can also accept an options object to alter the way it behaves. You can see the default options by inspecting `Headroom.options`. The structure of an options object is as follows: 96 | 97 | ```js 98 | { 99 | // vertical offset in px before element is first unpinned 100 | offset : 0, 101 | // scroll tolerance in px before state changes 102 | tolerance : 0, 103 | // or scroll tolerance per direction 104 | tolerance : { 105 | down : 0, 106 | up : 0 107 | }, 108 | // css classes to apply 109 | classes : { 110 | // when element is initialised 111 | initial : "headroom", 112 | // when scrolling up 113 | pinned : "headroom--pinned", 114 | // when scrolling down 115 | unpinned : "headroom--unpinned", 116 | // when above offset 117 | top : "headroom--top", 118 | // when below offset 119 | notTop : "headroom--not-top" 120 | }, 121 | // callback when pinned, `this` is headroom object 122 | onPin : function() {}, 123 | // callback when unpinned, `this` is headroom object 124 | onUnpin : function() {}, 125 | // callback when above offset, `this` is headroom object 126 | onTop : function() {}, 127 | // callback when below offset, `this` is headroom object 128 | onNotTop : function() {} 129 | } 130 | ``` 131 | 132 | ## Examples 133 | 134 | Head over to the [headroom.js playroom](http://wicky.nillia.ms/headroom.js/playroom/) if you want see some example usages. There you can tweak all of headroom's options and apply different CSS effects in an interactive demo. 135 | 136 | ## Browser support 137 | 138 | Headroom.js is dependent on the following browser APIs: 139 | 140 | * [requestAnimationFrame](http://caniuse.com/#feat=requestanimationframe) 141 | * [classList](http://caniuse.com/#feat=classlist) 142 | * [Function.prototype.bind](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind#Browser_compatibility) 143 | 144 | All of these APIs are capable of being polyfilled, so headroom.js can work with less-capable browsers if desired. Check the linked resources above to determine if you must polyfill to achieve your desired level of browser support. 145 | 146 | ## Contributions & Issues 147 | 148 | Contributions are welcome. Please clearly explain the purpose of the PR and follow the current style. 149 | 150 | Issues can be resolved quickest if they are descriptive and include both a reduced test case and a set of steps to reproduce. 151 | 152 | ## License 153 | 154 | Licensed under the [MIT License](http://www.opensource.org/licenses/mit-license.php). 155 | -------------------------------------------------------------------------------- /src/Headroom.js: -------------------------------------------------------------------------------- 1 | var features = require('headroom/src/features.js'); 2 | var Debouncer = require('headroom/src/Debouncer.js'); 3 | 4 | /** 5 | * Check if object is part of the DOM 6 | * @constructor 7 | * @param {Object} obj element to check 8 | */ 9 | function isDOMElement(obj) { 10 | return obj && typeof window !== 'undefined' && (obj === window || obj.nodeType); 11 | } 12 | 13 | /** 14 | * Helper function for extending objects 15 | */ 16 | function extend (object /*, objectN ... */) { 17 | if(arguments.length <= 0) { 18 | throw new Error('Missing arguments in extend function'); 19 | } 20 | 21 | var result = object || {}, 22 | key, 23 | i; 24 | 25 | for (i = 1; i < arguments.length; i++) { 26 | var replacement = arguments[i] || {}; 27 | 28 | for (key in replacement) { 29 | // Recurse into object except if the object is a DOM element 30 | if(typeof result[key] === 'object' && ! isDOMElement(result[key])) { 31 | result[key] = extend(result[key], replacement[key]); 32 | } 33 | else { 34 | result[key] = result[key] || replacement[key]; 35 | } 36 | } 37 | } 38 | 39 | return result; 40 | } 41 | 42 | /** 43 | * Helper function for normalizing tolerance option to object format 44 | */ 45 | function normalizeTolerance (t) { 46 | return t === Object(t) ? t : { down : t, up : t }; 47 | } 48 | 49 | /** 50 | * UI enhancement for fixed headers. 51 | * Hides header when scrolling down 52 | * Shows header when scrolling up 53 | * @constructor 54 | * @param {DOMElement} elem the header element 55 | * @param {Object} options options for the widget 56 | */ 57 | function Headroom (elem, options) { 58 | options = extend(options, Headroom.options); 59 | 60 | this.lastKnownScrollY = 0; 61 | this.elem = elem; 62 | this.debouncer = new Debouncer(this.update.bind(this)); 63 | this.tolerance = normalizeTolerance(options.tolerance); 64 | this.classes = options.classes; 65 | this.offset = options.offset; 66 | this.scroller = options.scroller; 67 | this.initialised = false; 68 | this.onPin = options.onPin; 69 | this.onUnpin = options.onUnpin; 70 | this.onTop = options.onTop; 71 | this.onNotTop = options.onNotTop; 72 | } 73 | Headroom.prototype = { 74 | constructor : Headroom, 75 | 76 | /** 77 | * Initialises the widget 78 | */ 79 | init : function() { 80 | if(!Headroom.cutsTheMustard) { 81 | return; 82 | } 83 | 84 | this.elem.classList.add(this.classes.initial); 85 | 86 | // defer event registration to handle browser 87 | // potentially restoring previous scroll position 88 | setTimeout(this.attachEvent.bind(this), 100); 89 | 90 | return this; 91 | }, 92 | 93 | /** 94 | * Unattaches events and removes any classes that were added 95 | */ 96 | destroy : function() { 97 | var classes = this.classes; 98 | 99 | this.initialised = false; 100 | this.elem.classList.remove(classes.unpinned, classes.pinned, classes.top, classes.initial); 101 | this.scroller.removeEventListener('scroll', this.debouncer, false); 102 | }, 103 | 104 | /** 105 | * Attaches the scroll event 106 | * @private 107 | */ 108 | attachEvent : function() { 109 | if(!this.initialised){ 110 | this.lastKnownScrollY = this.getScrollY(); 111 | this.initialised = true; 112 | this.scroller.addEventListener('scroll', this.debouncer, false); 113 | 114 | this.debouncer.handleEvent(); 115 | } 116 | }, 117 | 118 | /** 119 | * Unpins the header if it's currently pinned 120 | */ 121 | unpin : function() { 122 | var classList = this.elem.classList, 123 | classes = this.classes; 124 | 125 | if(classList.contains(classes.pinned) || !classList.contains(classes.unpinned)) { 126 | classList.add(classes.unpinned); 127 | classList.remove(classes.pinned); 128 | this.onUnpin && this.onUnpin.call(this); 129 | } 130 | }, 131 | 132 | /** 133 | * Pins the header if it's currently unpinned 134 | */ 135 | pin : function() { 136 | var classList = this.elem.classList, 137 | classes = this.classes; 138 | 139 | if(classList.contains(classes.unpinned)) { 140 | classList.remove(classes.unpinned); 141 | classList.add(classes.pinned); 142 | this.onPin && this.onPin.call(this); 143 | } 144 | }, 145 | 146 | /** 147 | * Handles the top states 148 | */ 149 | top : function() { 150 | var classList = this.elem.classList, 151 | classes = this.classes; 152 | 153 | if(!classList.contains(classes.top)) { 154 | classList.add(classes.top); 155 | classList.remove(classes.notTop); 156 | this.onTop && this.onTop.call(this); 157 | } 158 | }, 159 | 160 | /** 161 | * Handles the not top state 162 | */ 163 | notTop : function() { 164 | var classList = this.elem.classList, 165 | classes = this.classes; 166 | 167 | if(!classList.contains(classes.notTop)) { 168 | classList.add(classes.notTop); 169 | classList.remove(classes.top); 170 | this.onNotTop && this.onNotTop.call(this); 171 | } 172 | }, 173 | 174 | /** 175 | * Gets the Y scroll position 176 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Window.scrollY 177 | * @return {Number} pixels the page has scrolled along the Y-axis 178 | */ 179 | getScrollY : function() { 180 | return (this.scroller.pageYOffset !== undefined) 181 | ? this.scroller.pageYOffset 182 | : (this.scroller.scrollTop !== undefined) 183 | ? this.scroller.scrollTop 184 | : (document.documentElement || document.body.parentNode || document.body).scrollTop; 185 | }, 186 | 187 | /** 188 | * Gets the height of the viewport 189 | * @see http://andylangton.co.uk/blog/development/get-viewport-size-width-and-height-javascript 190 | * @return {int} the height of the viewport in pixels 191 | */ 192 | getViewportHeight : function () { 193 | return window.innerHeight 194 | || document.documentElement.clientHeight 195 | || document.body.clientHeight; 196 | }, 197 | 198 | /** 199 | * Gets the height of the document 200 | * @see http://james.padolsey.com/javascript/get-document-height-cross-browser/ 201 | * @return {int} the height of the document in pixels 202 | */ 203 | getDocumentHeight : function () { 204 | var body = document.body, 205 | documentElement = document.documentElement; 206 | 207 | return Math.max( 208 | body.scrollHeight, documentElement.scrollHeight, 209 | body.offsetHeight, documentElement.offsetHeight, 210 | body.clientHeight, documentElement.clientHeight 211 | ); 212 | }, 213 | 214 | /** 215 | * Gets the height of the DOM element 216 | * @param {Object} elm the element to calculate the height of which 217 | * @return {int} the height of the element in pixels 218 | */ 219 | getElementHeight : function (elm) { 220 | return Math.max( 221 | elm.scrollHeight, 222 | elm.offsetHeight, 223 | elm.clientHeight 224 | ); 225 | }, 226 | 227 | /** 228 | * Gets the height of the scroller element 229 | * @return {int} the height of the scroller element in pixels 230 | */ 231 | getScrollerHeight : function () { 232 | return (this.scroller === window || this.scroller === document.body) 233 | ? this.getDocumentHeight() 234 | : this.getElementHeight(this.scroller); 235 | }, 236 | 237 | /** 238 | * determines if the scroll position is outside of document boundaries 239 | * @param {int} currentScrollY the current y scroll position 240 | * @return {bool} true if out of bounds, false otherwise 241 | */ 242 | isOutOfBounds : function (currentScrollY) { 243 | var pastTop = currentScrollY < 0, 244 | pastBottom = currentScrollY + this.getViewportHeight() > this.getScrollerHeight(); 245 | 246 | return pastTop || pastBottom; 247 | }, 248 | 249 | /** 250 | * determines if the tolerance has been exceeded 251 | * @param {int} currentScrollY the current scroll y position 252 | * @return {bool} true if tolerance exceeded, false otherwise 253 | */ 254 | toleranceExceeded : function (currentScrollY, direction) { 255 | return Math.abs(currentScrollY-this.lastKnownScrollY) >= this.tolerance[direction]; 256 | }, 257 | 258 | /** 259 | * determine if it is appropriate to unpin 260 | * @param {int} currentScrollY the current y scroll position 261 | * @param {bool} toleranceExceeded has the tolerance been exceeded? 262 | * @return {bool} true if should unpin, false otherwise 263 | */ 264 | shouldUnpin : function (currentScrollY, toleranceExceeded) { 265 | var scrollingDown = currentScrollY > this.lastKnownScrollY, 266 | pastOffset = currentScrollY >= this.offset; 267 | 268 | return scrollingDown && pastOffset && toleranceExceeded; 269 | }, 270 | 271 | /** 272 | * determine if it is appropriate to pin 273 | * @param {int} currentScrollY the current y scroll position 274 | * @param {bool} toleranceExceeded has the tolerance been exceeded? 275 | * @return {bool} true if should pin, false otherwise 276 | */ 277 | shouldPin : function (currentScrollY, toleranceExceeded) { 278 | var scrollingUp = currentScrollY < this.lastKnownScrollY, 279 | pastOffset = currentScrollY <= this.offset; 280 | 281 | return (scrollingUp && toleranceExceeded) || pastOffset; 282 | }, 283 | 284 | /** 285 | * Handles updating the state of the widget 286 | */ 287 | update : function() { 288 | var currentScrollY = this.getScrollY(), 289 | scrollDirection = currentScrollY > this.lastKnownScrollY ? 'down' : 'up', 290 | toleranceExceeded = this.toleranceExceeded(currentScrollY, scrollDirection); 291 | 292 | if(this.isOutOfBounds(currentScrollY)) { // Ignore bouncy scrolling in OSX 293 | return; 294 | } 295 | 296 | if (currentScrollY <= this.offset ) { 297 | this.top(); 298 | } else { 299 | this.notTop(); 300 | } 301 | 302 | if(this.shouldUnpin(currentScrollY, toleranceExceeded)) { 303 | this.unpin(); 304 | } 305 | else if(this.shouldPin(currentScrollY, toleranceExceeded)) { 306 | this.pin(); 307 | } 308 | 309 | this.lastKnownScrollY = currentScrollY; 310 | } 311 | }; 312 | /** 313 | * Default options 314 | * @type {Object} 315 | */ 316 | Headroom.options = { 317 | tolerance : { 318 | up : 0, 319 | down : 0 320 | }, 321 | offset : 0, 322 | scroller: window, 323 | classes : { 324 | pinned : 'headroom--pinned', 325 | unpinned : 'headroom--unpinned', 326 | top : 'headroom--top', 327 | notTop : 'headroom--not-top', 328 | initial : 'headroom' 329 | } 330 | }; 331 | Headroom.cutsTheMustard = typeof features !== 'undefined' && features.rAF && features.bind && features.classList; 332 | 333 | module.exports = Headroom; -------------------------------------------------------------------------------- /dist/headroom.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * headroom.js v0.7.0 - Give your page some headroom. Hide your header until you need it 3 | * Copyright (c) 2014 Nick Williams - http://wicky.nillia.ms/headroom.js 4 | * License: MIT 5 | */ 6 | 7 | /* exported features */ 8 | 9 | var features = { 10 | bind : !!(function(){}.bind), 11 | classList : 'classList' in document.documentElement, 12 | rAF : !!(window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame) 13 | }; 14 | window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame; 15 | 16 | /** 17 | * Handles debouncing of events via requestAnimationFrame 18 | * @see http://www.html5rocks.com/en/tutorials/speed/animations/ 19 | * @param {Function} callback The callback to handle whichever event 20 | */ 21 | function Debouncer (callback) { 22 | this.callback = callback; 23 | this.ticking = false; 24 | } 25 | Debouncer.prototype = { 26 | constructor : Debouncer, 27 | 28 | /** 29 | * dispatches the event to the supplied callback 30 | * @private 31 | */ 32 | update : function() { 33 | this.callback && this.callback(); 34 | this.ticking = false; 35 | }, 36 | 37 | /** 38 | * ensures events don't get stacked 39 | * @private 40 | */ 41 | requestTick : function() { 42 | if(!this.ticking) { 43 | requestAnimationFrame(this.rafCallback || (this.rafCallback = this.update.bind(this))); 44 | this.ticking = true; 45 | } 46 | }, 47 | 48 | /** 49 | * Attach this as the event listeners 50 | */ 51 | handleEvent : function() { 52 | this.requestTick(); 53 | } 54 | }; 55 | /** 56 | * Check if object is part of the DOM 57 | * @constructor 58 | * @param {Object} obj element to check 59 | */ 60 | function isDOMElement(obj) { 61 | return obj && typeof window !== 'undefined' && (obj === window || obj.nodeType); 62 | } 63 | 64 | /** 65 | * Helper function for extending objects 66 | */ 67 | function extend (object /*, objectN ... */) { 68 | if(arguments.length <= 0) { 69 | throw new Error('Missing arguments in extend function'); 70 | } 71 | 72 | var result = object || {}, 73 | key, 74 | i; 75 | 76 | for (i = 1; i < arguments.length; i++) { 77 | var replacement = arguments[i] || {}; 78 | 79 | for (key in replacement) { 80 | // Recurse into object except if the object is a DOM element 81 | if(typeof result[key] === 'object' && ! isDOMElement(result[key])) { 82 | result[key] = extend(result[key], replacement[key]); 83 | } 84 | else { 85 | result[key] = result[key] || replacement[key]; 86 | } 87 | } 88 | } 89 | 90 | return result; 91 | } 92 | 93 | /** 94 | * Helper function for normalizing tolerance option to object format 95 | */ 96 | function normalizeTolerance (t) { 97 | return t === Object(t) ? t : { down : t, up : t }; 98 | } 99 | 100 | /** 101 | * UI enhancement for fixed headers. 102 | * Hides header when scrolling down 103 | * Shows header when scrolling up 104 | * @constructor 105 | * @param {DOMElement} elem the header element 106 | * @param {Object} options options for the widget 107 | */ 108 | function Headroom (elem, options) { 109 | options = extend(options, Headroom.options); 110 | 111 | this.lastKnownScrollY = 0; 112 | this.elem = elem; 113 | this.debouncer = new Debouncer(this.update.bind(this)); 114 | this.tolerance = normalizeTolerance(options.tolerance); 115 | this.classes = options.classes; 116 | this.offset = options.offset; 117 | this.scroller = options.scroller; 118 | this.initialised = false; 119 | this.onPin = options.onPin; 120 | this.onUnpin = options.onUnpin; 121 | this.onTop = options.onTop; 122 | this.onNotTop = options.onNotTop; 123 | } 124 | Headroom.prototype = { 125 | constructor : Headroom, 126 | 127 | /** 128 | * Initialises the widget 129 | */ 130 | init : function() { 131 | if(!Headroom.cutsTheMustard) { 132 | return; 133 | } 134 | 135 | this.elem.classList.add(this.classes.initial); 136 | 137 | // defer event registration to handle browser 138 | // potentially restoring previous scroll position 139 | setTimeout(this.attachEvent.bind(this), 100); 140 | 141 | return this; 142 | }, 143 | 144 | /** 145 | * Unattaches events and removes any classes that were added 146 | */ 147 | destroy : function() { 148 | var classes = this.classes; 149 | 150 | this.initialised = false; 151 | this.elem.classList.remove(classes.unpinned, classes.pinned, classes.top, classes.initial); 152 | this.scroller.removeEventListener('scroll', this.debouncer, false); 153 | }, 154 | 155 | /** 156 | * Attaches the scroll event 157 | * @private 158 | */ 159 | attachEvent : function() { 160 | if(!this.initialised){ 161 | this.lastKnownScrollY = this.getScrollY(); 162 | this.initialised = true; 163 | this.scroller.addEventListener('scroll', this.debouncer, false); 164 | 165 | this.debouncer.handleEvent(); 166 | } 167 | }, 168 | 169 | /** 170 | * Unpins the header if it's currently pinned 171 | */ 172 | unpin : function() { 173 | var classList = this.elem.classList, 174 | classes = this.classes; 175 | 176 | if(classList.contains(classes.pinned) || !classList.contains(classes.unpinned)) { 177 | classList.add(classes.unpinned); 178 | classList.remove(classes.pinned); 179 | this.onUnpin && this.onUnpin.call(this); 180 | } 181 | }, 182 | 183 | /** 184 | * Pins the header if it's currently unpinned 185 | */ 186 | pin : function() { 187 | var classList = this.elem.classList, 188 | classes = this.classes; 189 | 190 | if(classList.contains(classes.unpinned)) { 191 | classList.remove(classes.unpinned); 192 | classList.add(classes.pinned); 193 | this.onPin && this.onPin.call(this); 194 | } 195 | }, 196 | 197 | /** 198 | * Handles the top states 199 | */ 200 | top : function() { 201 | var classList = this.elem.classList, 202 | classes = this.classes; 203 | 204 | if(!classList.contains(classes.top)) { 205 | classList.add(classes.top); 206 | classList.remove(classes.notTop); 207 | this.onTop && this.onTop.call(this); 208 | } 209 | }, 210 | 211 | /** 212 | * Handles the not top state 213 | */ 214 | notTop : function() { 215 | var classList = this.elem.classList, 216 | classes = this.classes; 217 | 218 | if(!classList.contains(classes.notTop)) { 219 | classList.add(classes.notTop); 220 | classList.remove(classes.top); 221 | this.onNotTop && this.onNotTop.call(this); 222 | } 223 | }, 224 | 225 | /** 226 | * Gets the Y scroll position 227 | * @see https://developer.mozilla.org/en-US/docs/Web/API/Window.scrollY 228 | * @return {Number} pixels the page has scrolled along the Y-axis 229 | */ 230 | getScrollY : function() { 231 | return (this.scroller.pageYOffset !== undefined) 232 | ? this.scroller.pageYOffset 233 | : (this.scroller.scrollTop !== undefined) 234 | ? this.scroller.scrollTop 235 | : (document.documentElement || document.body.parentNode || document.body).scrollTop; 236 | }, 237 | 238 | /** 239 | * Gets the height of the viewport 240 | * @see http://andylangton.co.uk/blog/development/get-viewport-size-width-and-height-javascript 241 | * @return {int} the height of the viewport in pixels 242 | */ 243 | getViewportHeight : function () { 244 | return window.innerHeight 245 | || document.documentElement.clientHeight 246 | || document.body.clientHeight; 247 | }, 248 | 249 | /** 250 | * Gets the height of the document 251 | * @see http://james.padolsey.com/javascript/get-document-height-cross-browser/ 252 | * @return {int} the height of the document in pixels 253 | */ 254 | getDocumentHeight : function () { 255 | var body = document.body, 256 | documentElement = document.documentElement; 257 | 258 | return Math.max( 259 | body.scrollHeight, documentElement.scrollHeight, 260 | body.offsetHeight, documentElement.offsetHeight, 261 | body.clientHeight, documentElement.clientHeight 262 | ); 263 | }, 264 | 265 | /** 266 | * Gets the height of the DOM element 267 | * @param {Object} elm the element to calculate the height of which 268 | * @return {int} the height of the element in pixels 269 | */ 270 | getElementHeight : function (elm) { 271 | return Math.max( 272 | elm.scrollHeight, 273 | elm.offsetHeight, 274 | elm.clientHeight 275 | ); 276 | }, 277 | 278 | /** 279 | * Gets the height of the scroller element 280 | * @return {int} the height of the scroller element in pixels 281 | */ 282 | getScrollerHeight : function () { 283 | return (this.scroller === window || this.scroller === document.body) 284 | ? this.getDocumentHeight() 285 | : this.getElementHeight(this.scroller); 286 | }, 287 | 288 | /** 289 | * determines if the scroll position is outside of document boundaries 290 | * @param {int} currentScrollY the current y scroll position 291 | * @return {bool} true if out of bounds, false otherwise 292 | */ 293 | isOutOfBounds : function (currentScrollY) { 294 | var pastTop = currentScrollY < 0, 295 | pastBottom = currentScrollY + this.getViewportHeight() > this.getScrollerHeight(); 296 | 297 | return pastTop || pastBottom; 298 | }, 299 | 300 | /** 301 | * determines if the tolerance has been exceeded 302 | * @param {int} currentScrollY the current scroll y position 303 | * @return {bool} true if tolerance exceeded, false otherwise 304 | */ 305 | toleranceExceeded : function (currentScrollY, direction) { 306 | return Math.abs(currentScrollY-this.lastKnownScrollY) >= this.tolerance[direction]; 307 | }, 308 | 309 | /** 310 | * determine if it is appropriate to unpin 311 | * @param {int} currentScrollY the current y scroll position 312 | * @param {bool} toleranceExceeded has the tolerance been exceeded? 313 | * @return {bool} true if should unpin, false otherwise 314 | */ 315 | shouldUnpin : function (currentScrollY, toleranceExceeded) { 316 | var scrollingDown = currentScrollY > this.lastKnownScrollY, 317 | pastOffset = currentScrollY >= this.offset; 318 | 319 | return scrollingDown && pastOffset && toleranceExceeded; 320 | }, 321 | 322 | /** 323 | * determine if it is appropriate to pin 324 | * @param {int} currentScrollY the current y scroll position 325 | * @param {bool} toleranceExceeded has the tolerance been exceeded? 326 | * @return {bool} true if should pin, false otherwise 327 | */ 328 | shouldPin : function (currentScrollY, toleranceExceeded) { 329 | var scrollingUp = currentScrollY < this.lastKnownScrollY, 330 | pastOffset = currentScrollY <= this.offset; 331 | 332 | return (scrollingUp && toleranceExceeded) || pastOffset; 333 | }, 334 | 335 | /** 336 | * Handles updating the state of the widget 337 | */ 338 | update : function() { 339 | var currentScrollY = this.getScrollY(), 340 | scrollDirection = currentScrollY > this.lastKnownScrollY ? 'down' : 'up', 341 | toleranceExceeded = this.toleranceExceeded(currentScrollY, scrollDirection); 342 | 343 | if(this.isOutOfBounds(currentScrollY)) { // Ignore bouncy scrolling in OSX 344 | return; 345 | } 346 | 347 | if (currentScrollY <= this.offset ) { 348 | this.top(); 349 | } else { 350 | this.notTop(); 351 | } 352 | 353 | if(this.shouldUnpin(currentScrollY, toleranceExceeded)) { 354 | this.unpin(); 355 | } 356 | else if(this.shouldPin(currentScrollY, toleranceExceeded)) { 357 | this.pin(); 358 | } 359 | 360 | this.lastKnownScrollY = currentScrollY; 361 | } 362 | }; 363 | /** 364 | * Default options 365 | * @type {Object} 366 | */ 367 | Headroom.options = { 368 | tolerance : { 369 | up : 0, 370 | down : 0 371 | }, 372 | offset : 0, 373 | scroller: window, 374 | classes : { 375 | pinned : 'headroom--pinned', 376 | unpinned : 'headroom--unpinned', 377 | top : 'headroom--top', 378 | notTop : 'headroom--not-top', 379 | initial : 'headroom' 380 | } 381 | }; 382 | Headroom.cutsTheMustard = typeof features !== 'undefined' && features.rAF && features.bind && features.classList; 383 | 384 | module.exports = Headroom; -------------------------------------------------------------------------------- /spec/Headroom.spec.js: -------------------------------------------------------------------------------- 1 | (function(global){ 2 | 3 | describe('Headroom', function(){ 4 | 5 | var headroom, elem, classList; 6 | 7 | beforeEach(function() { 8 | classList = jasmine.createSpyObj('classList', ['add', 'remove', 'contains']); 9 | elem = { classList : classList }; 10 | headroom = new Headroom(elem); 11 | Headroom.cutsTheMustard = true; 12 | }); 13 | 14 | describe('constructor', function() { 15 | 16 | var debouncer; 17 | 18 | function onPin(){} 19 | function onUnpin(){} 20 | 21 | beforeEach(function(){ 22 | debouncer = spyOn(global, 'Debouncer').andCallThrough(); 23 | }); 24 | 25 | it('stores the arguments it is passed', function() { 26 | var hr = new Headroom(elem, { 27 | onPin : onPin, 28 | onUnpin : onUnpin 29 | }); 30 | 31 | expect(hr.lastKnownScrollY).toBe(0); 32 | expect(hr.elem).toBe(elem); 33 | expect(hr.debouncer).toBeDefined(); 34 | expect(debouncer).toHaveBeenCalled(); 35 | expect(hr.debouncer instanceof debouncer).toBeTruthy(); 36 | expect(hr.tolerance).toBe(Headroom.options.tolerance); 37 | expect(hr.offset).toBe(Headroom.options.offset); 38 | expect(hr.classes).toBe(Headroom.options.classes); 39 | expect(hr.scroller).toBe(Headroom.options.scroller); 40 | expect(hr.onPin).toBe(onPin); 41 | expect(hr.onUnpin).toBe(onUnpin); 42 | }); 43 | 44 | it('merges the options arguments properly', function() { 45 | var userOpts = { 46 | tolerance : { 47 | down : 5, 48 | up : 30 49 | }, 50 | scroller: document.body, 51 | classes : { 52 | initial : 'hr' 53 | } 54 | }; 55 | 56 | var hr = new Headroom(elem, userOpts); 57 | 58 | expect(hr.tolerance).toBe(userOpts.tolerance); 59 | expect(hr.offset).toBe(Headroom.options.offset); 60 | expect(hr.scroller).toBe(userOpts.scroller); 61 | expect(hr.classes.initial).toBe(userOpts.classes.initial); 62 | expect(hr.classes.pinned).toBe(Headroom.options.classes.pinned); 63 | }); 64 | 65 | }); 66 | 67 | describe('init', function() { 68 | 69 | var st, bind; 70 | 71 | beforeEach(function() { 72 | st = spyOn(global, 'setTimeout'); 73 | bind = spyOn(Headroom.prototype.attachEvent, 'bind').andReturn(function(){}); 74 | }); 75 | 76 | it('adds initial class and binds to scroll event', function() { 77 | headroom.init(); 78 | 79 | expect(classList.add).toHaveBeenCalledWith(headroom.classes.initial); 80 | expect(bind).toHaveBeenCalled(); 81 | expect(st).toHaveBeenCalledWith(jasmine.any(Function), 100); 82 | }); 83 | 84 | it('does nothing if user agent doesn\'t cut the mustatd', function() { 85 | Headroom.cutsTheMustard = false; 86 | 87 | headroom.init(); 88 | 89 | expect(classList.add).not.toHaveBeenCalled(); 90 | expect(bind).not.toHaveBeenCalled(); 91 | expect(st).not.toHaveBeenCalled(); 92 | }); 93 | }); 94 | 95 | describe('destroy', function() { 96 | 97 | var removeEventListener; 98 | 99 | beforeEach(function() { 100 | removeEventListener = spyOn(global, 'removeEventListener'); 101 | }); 102 | 103 | it('cleans up after events and classes', function() { 104 | headroom.initialised = true; 105 | 106 | headroom.destroy(); 107 | 108 | expect(classList.remove).toHaveBeenCalled(); 109 | expect(removeEventListener).toHaveBeenCalledWith('scroll', headroom.debouncer, false); 110 | expect(headroom.initialised).toBe(false); 111 | }); 112 | 113 | }); 114 | 115 | describe('attachEvent', function() { 116 | var addEventListener; 117 | var requestAnimationFrame; 118 | 119 | global.requestAnimationFrame = function() {}; 120 | 121 | beforeEach(function() { 122 | addEventListener = spyOn(global, 'addEventListener'); 123 | requestAnimationFrame = spyOn(global, 'requestAnimationFrame'); 124 | }); 125 | 126 | it('should attach listener for scroll event', function(){ 127 | headroom.attachEvent(); 128 | 129 | expect(headroom.initialised).toBe(true); 130 | expect(addEventListener).toHaveBeenCalledWith('scroll', headroom.debouncer, false); 131 | expect(requestAnimationFrame.calls.length).toBe(1); 132 | }); 133 | 134 | it('will only ever add one listener', function() { 135 | headroom.attachEvent(); 136 | headroom.attachEvent(); 137 | 138 | expect(addEventListener.calls.length).toBe(1); 139 | expect(requestAnimationFrame.calls.length).toBe(1); 140 | }); 141 | 142 | }); 143 | 144 | describe('pin', function() { 145 | 146 | beforeEach(function() { 147 | headroom.onPin = jasmine.createSpy(); 148 | }); 149 | 150 | describe('when unpinned class is present', function() { 151 | 152 | beforeEach(function() { 153 | classList.contains.andReturn(true); 154 | headroom.pin(); 155 | }); 156 | 157 | it('should add pinned class and remove unpinned class', function(){ 158 | expect(classList.remove).toHaveBeenCalledWith(headroom.classes.unpinned); 159 | expect(classList.add).toHaveBeenCalledWith(headroom.classes.pinned); 160 | }); 161 | 162 | it('should invoke callback if supplied', function() { 163 | expect(headroom.onPin).toHaveBeenCalled(); 164 | }); 165 | 166 | }); 167 | 168 | describe('when unpinned class not present', function() { 169 | 170 | beforeEach(function() { 171 | headroom.pin(); 172 | }); 173 | 174 | it('should do nothing', function() { 175 | expect(headroom.onPin).not.toHaveBeenCalled(); 176 | }); 177 | 178 | }); 179 | 180 | }); 181 | 182 | describe('unpin', function() { 183 | 184 | var classes; 185 | 186 | 187 | beforeEach(function() { 188 | headroom.onUnpin = jasmine.createSpy(); 189 | classes = {}; 190 | 191 | classList.contains.andCallFake(function(className) { 192 | return classes[className]; 193 | }); 194 | }); 195 | 196 | function setupFixture (pinned, unpinned) { 197 | classes[Headroom.options.classes.unpinned] = unpinned; 198 | classes[Headroom.options.classes.pinned] = pinned; 199 | } 200 | 201 | describe('when currently pinned', function() { 202 | 203 | beforeEach(function() { 204 | setupFixture(true, false); 205 | headroom.unpin(); 206 | }); 207 | 208 | it('will add unpinned class', function() { 209 | expect(classList.add).toHaveBeenCalledWith(headroom.classes.unpinned); 210 | }); 211 | 212 | it('will remove pinned class', function() { 213 | expect(classList.remove).toHaveBeenCalledWith(headroom.classes.pinned); 214 | }); 215 | 216 | it('will invoke callback if supplied', function() { 217 | expect(headroom.onUnpin).toHaveBeenCalled(); 218 | }); 219 | }); 220 | 221 | describe('when currently unpinned', function() { 222 | it('will do nothing', function() { 223 | setupFixture(false, true); 224 | headroom.unpin(); 225 | expect(headroom.onUnpin).not.toHaveBeenCalled(); 226 | }); 227 | }); 228 | 229 | describe('when never been unpinned', function() { 230 | it('will unpin', function() { 231 | setupFixture(false, false); 232 | headroom.unpin(); 233 | expect(headroom.onUnpin).toHaveBeenCalled(); 234 | }); 235 | }); 236 | 237 | }); 238 | 239 | describe('shouldUnpin', function() { 240 | it('returns true if scrolling down and tolerance exceeded and past offset', function() { 241 | var result = headroom.shouldUnpin(1, true); 242 | expect(result).toBe(true); 243 | }); 244 | }); 245 | 246 | describe('shouldPin', function() { 247 | 248 | it('returns true if scrolling up and tolerance exceeded', function() { 249 | var result = headroom.shouldPin(-1, true); 250 | expect(result).toBe(true); 251 | }); 252 | 253 | it('returns true if pastOffset', function() { 254 | var result = headroom.shouldPin(-1, false); 255 | expect(result).toBe(true); 256 | }); 257 | }); 258 | 259 | describe('top', function() { 260 | 261 | beforeEach(function() { 262 | headroom.onTop = jasmine.createSpy(); 263 | }); 264 | 265 | describe('when top class is not present', function() { 266 | 267 | beforeEach(function() { 268 | classList.contains.andReturn(false); 269 | headroom.top(); 270 | }); 271 | 272 | it('should add top class', function(){ 273 | expect(classList.add).toHaveBeenCalledWith(headroom.classes.top); 274 | }); 275 | 276 | it('should remove notTop class', function(){ 277 | expect(classList.remove).toHaveBeenCalledWith(headroom.classes.notTop); 278 | }); 279 | 280 | it('should invoke callback if supplied', function() { 281 | expect(headroom.onTop).toHaveBeenCalled(); 282 | }); 283 | 284 | }); 285 | 286 | describe('when top class is present', function() { 287 | 288 | beforeEach(function() { 289 | classList.contains.andReturn(true); 290 | headroom.top(); 291 | }); 292 | 293 | it('should do nothing', function() { 294 | expect(headroom.onTop).not.toHaveBeenCalled(); 295 | }); 296 | 297 | }); 298 | 299 | }); 300 | 301 | describe('notTop', function() { 302 | 303 | beforeEach(function() { 304 | headroom.onNotTop = jasmine.createSpy(); 305 | }); 306 | 307 | describe('when top class is present', function() { 308 | 309 | beforeEach(function() { 310 | classList.contains.andReturn(false); 311 | headroom.notTop(); 312 | }); 313 | 314 | it('should remove top class', function(){ 315 | expect(classList.remove).toHaveBeenCalledWith(headroom.classes.top); 316 | }); 317 | 318 | it('should add notTop class', function(){ 319 | expect(classList.add).toHaveBeenCalledWith(headroom.classes.notTop); 320 | }); 321 | 322 | it('should invoke callback if supplied', function() { 323 | expect(headroom.onNotTop).toHaveBeenCalled(); 324 | }); 325 | 326 | }); 327 | 328 | describe('when top class is not present', function() { 329 | 330 | beforeEach(function() { 331 | classList.contains.andReturn(true); 332 | headroom.notTop(); 333 | }); 334 | 335 | it('should do nothing', function() { 336 | expect(headroom.onNotTop).not.toHaveBeenCalled(); 337 | }); 338 | 339 | }); 340 | 341 | }); 342 | 343 | describe('isOutOfBounds', function() { 344 | 345 | var getScrollerHeight, getViewportHeight; 346 | 347 | beforeEach(function() { 348 | getViewportHeight = spyOn(headroom, 'getViewportHeight'); 349 | getScrollerHeight = spyOn(headroom, 'getScrollerHeight'); 350 | }); 351 | 352 | it('return true if past top', function() { 353 | var result = headroom.isOutOfBounds(-1); 354 | expect(result).toBe(true); 355 | }); 356 | 357 | it('return true if past bottom', function() { 358 | var documentHeight = 20; 359 | var viewportHeight = 20; 360 | 361 | getScrollerHeight.andReturn(documentHeight); 362 | getViewportHeight.andReturn(viewportHeight); 363 | var result = headroom.isOutOfBounds(viewportHeight + 1); 364 | 365 | expect(result).toBe(true); 366 | }); 367 | 368 | it('return false if in bounds', function() { 369 | var documentHeight = 200; 370 | var viewportHeight = 20; 371 | 372 | getScrollerHeight.andReturn(documentHeight); 373 | getViewportHeight.andReturn(viewportHeight); 374 | var result = headroom.isOutOfBounds(10); 375 | 376 | expect(result).toBe(false); 377 | }); 378 | }); 379 | 380 | describe('getDocumentHeight', function() { 381 | 382 | }); 383 | 384 | describe('getElementHeight', function() { 385 | 386 | }); 387 | 388 | describe('getScrollerHeight', function() { 389 | 390 | }); 391 | 392 | describe('getViewportHeight', function() { 393 | 394 | }); 395 | 396 | describe('update', function() { 397 | 398 | var pin, unpin, shouldPin, shouldUnpin, isOutOfBounds; 399 | 400 | beforeEach(function() { 401 | pin = spyOn(Headroom.prototype, 'pin'); 402 | unpin = spyOn(Headroom.prototype, 'unpin'); 403 | shouldPin = spyOn(Headroom.prototype, 'shouldPin'); 404 | shouldUnpin = spyOn(Headroom.prototype, 'shouldUnpin'); 405 | isOutOfBounds = spyOn(Headroom.prototype, 'isOutOfBounds'); 406 | }); 407 | 408 | it('should pin if conditions are met', function() { 409 | shouldPin.andReturn(true); 410 | headroom.update(); 411 | expect(pin).toHaveBeenCalled(); 412 | }); 413 | 414 | it('should unpin if conditions are met', function(){ 415 | shouldUnpin.andReturn(true); 416 | headroom.update(); 417 | expect(unpin).toHaveBeenCalled(); 418 | }); 419 | 420 | it('should ignore scroll values out of bounds', function() { 421 | shouldUnpin.andReturn(true); 422 | shouldPin.andReturn(true); 423 | isOutOfBounds.andReturn(true); 424 | 425 | headroom.update(); 426 | 427 | expect(pin).not.toHaveBeenCalled(); 428 | expect(unpin).not.toHaveBeenCalled(); 429 | }); 430 | 431 | }); 432 | 433 | }); 434 | 435 | }(this)); --------------------------------------------------------------------------------