govlab-styleguide
Version:
A styleguide / lightweight frontend framework for GovLab websites
437 lines (396 loc) • 13.7 kB
JavaScript
/**
* Sticky module.
* @module foundation.sticky
* @requires foundation.util.triggers
* @requires foundation.util.mediaQuery
*/
!function($, Foundation){
'use strict';
/**
* Creates a new instance of a sticky thing.
* @class
* @param {jQuery} element - jQuery object to make sticky.
* @param {Object} options - options object passed when creating the element programmatically.
*/
function Sticky(element, options){
this.$element = element;
this.options = $.extend({}, Sticky.defaults, this.$element.data(), options);
this._init();
Foundation.registerPlugin(this, 'Sticky');
}
Sticky.defaults = {
/**
* Customizable container template. Add your own classes for styling and sizing.
* @option
* @example '<div data-sticky-container class="small-6 columns"></div>'
*/
container: '<div data-sticky-container></div>',
/**
* Location in the view the element sticks to.
* @option
* @example 'top'
*/
stickTo: 'top',
/**
* If anchored to a single element, the id of that element.
* @option
* @example 'exampleId'
*/
anchor: '',
/**
* If using more than one element as anchor points, the id of the top anchor.
* @option
* @example 'exampleId:top'
*/
topAnchor: '',
/**
* If using more than one element as anchor points, the id of the bottom anchor.
* @option
* @example 'exampleId:bottom'
*/
btmAnchor: '',
/**
* Margin, in `em`'s to apply to the top of the element when it becomes sticky.
* @option
* @example 1
*/
marginTop: 1,
/**
* Margin, in `em`'s to apply to the bottom of the element when it becomes sticky.
* @option
* @example 1
*/
marginBottom: 1,
/**
* Breakpoint string that is the minimum screen size an element should become sticky.
* @option
* @example 'medium'
*/
stickyOn: 'medium',
/**
* Class applied to sticky element, and removed on destruction. Foundation defaults to `sticky`.
* @option
* @example 'sticky'
*/
stickyClass: 'sticky',
/**
* Class applied to sticky container. Foundation defaults to `sticky-container`.
* @option
* @example 'sticky-container'
*/
containerClass: 'sticky-container',
/**
* Number of scroll events between the plugin's recalculating sticky points. Setting it to `0` will cause it to recalc every scroll event, setting it to `-1` will prevent recalc on scroll.
* @option
* @example 50
*/
checkEvery: -1
};
/**
* Initializes the sticky element by adding classes, getting/setting dimensions, breakpoints and attributes
* @function
* @private
*/
Sticky.prototype._init = function(){
var $parent = this.$element.parent('[data-sticky-container]'),
id = this.$element[0].id || Foundation.GetYoDigits(6, 'sticky'),
_this = this;
if(!$parent.length){
this.wasWrapped = true;
}
this.$container = $parent.length ? $parent : $(this.options.container).wrapInner(this.$element);
this.$container.addClass(this.options.containerClass);
this.$element.addClass(this.options.stickyClass)
.attr({'data-resize': id});
this.scrollCount = this.options.checkEvery;
this.isStuck = false;
if(this.options.anchor !== ''){
this.$anchor = $('#' + this.options.anchor);
}else{
this._parsePoints();
}
this._setSizes(function(){
_this._calc(false);
});
this._events(id.split('-').reverse().join('-'));
};
/**
* If using multiple elements as anchors, calculates the top and bottom pixel values the sticky thing should stick and unstick on.
* @function
* @private
*/
Sticky.prototype._parsePoints = function(){
var top = this.options.topAnchor,
btm = this.options.btmAnchor,
pts = [top, btm],
breaks = {};
if(top && btm){
for(var i = 0, len = pts.length; i < len && pts[i]; i++){
var pt;
if(typeof pts[i] === 'number'){
pt = pts[i];
}else{
var place = pts[i].split(':'),
anchor = $('#' + place[0]);
pt = anchor.offset().top;
if(place[1] && place[1].toLowerCase() === 'bottom'){
pt += anchor[0].getBoundingClientRect().height;
}
}
breaks[i] = pt;
}
}else{
breaks = {0: 1, 1: document.documentElement.scrollHeight};
}
this.points = breaks;
return;
};
/**
* Adds event handlers for the scrolling element.
* @private
* @param {String} id - psuedo-random id for unique scroll event listener.
*/
Sticky.prototype._events = function(id){
var _this = this,
scrollListener = this.scrollListener = 'scroll.zf.' + id;
if(this.isOn){ return; }
if(this.canStick){
this.isOn = true;
$(window).off(scrollListener)
.on(scrollListener, function(e){
if(_this.scrollCount === 0){
_this.scrollCount = _this.options.checkEvery;
_this._setSizes(function(){
_this._calc(false, window.pageYOffset);
});
}else{
_this.scrollCount--;
_this._calc(false, window.pageYOffset);
}
});
}
this.$element.off('resizeme.zf.trigger')
.on('resizeme.zf.trigger', function(e, el){
_this._setSizes(function(){
_this._calc(false);
if(_this.canStick){
if(!_this.isOn){
_this._events(id);
}
}else if(_this.isOn){
_this._pauseListeners(scrollListener);
}
});
});
};
/**
* Removes event handlers for scroll and change events on anchor.
* @fires Sticky#pause
* @param {String} scrollListener - unique, namespaced scroll listener attached to `window`
*/
Sticky.prototype._pauseListeners = function(scrollListener){
this.isOn = false;
$(window).off(scrollListener);
/**
* Fires when the plugin is paused due to resize event shrinking the view.
* @event Sticky#pause
* @private
*/
this.$element.trigger('pause.zf.sticky');
};
/**
* Called on every `scroll` event and on `_init`
* fires functions based on booleans and cached values
* @param {Boolean} checkSizes - true if plugin should recalculate sizes and breakpoints.
* @param {Number} scroll - current scroll position passed from scroll event cb function. If not passed, defaults to `window.pageYOffset`.
*/
Sticky.prototype._calc = function(checkSizes, scroll){
if(checkSizes){ this._setSizes(); }
if(!this.canStick){
if(this.isStuck){
this._removeSticky(true);
}
return false;
}
if(!scroll){ scroll = window.pageYOffset; }
if(scroll >= this.topPoint){
if(scroll <= this.bottomPoint){
if(!this.isStuck){
this._setSticky();
}
}else{
if(this.isStuck){
this._removeSticky(false);
}
}
}else{
if(this.isStuck){
this._removeSticky(true);
}
}
};
/**
* Causes the $element to become stuck.
* Adds `position: fixed;`, and helper classes.
* @fires Sticky#stuckto
* @function
* @private
*/
Sticky.prototype._setSticky = function(){
var stickTo = this.options.stickTo,
mrgn = stickTo === 'top' ? 'marginTop' : 'marginBottom',
notStuckTo = stickTo === 'top' ? 'bottom' : 'top',
css = {};
css[mrgn] = this.options[mrgn] + 'em';
css[stickTo] = 0;
css[notStuckTo] = 'auto';
css['left'] = this.$container.offset().left + parseInt(window.getComputedStyle(this.$container[0])["padding-left"], 10);
this.isStuck = true;
this.$element.removeClass('is-anchored is-at-' + notStuckTo)
.addClass('is-stuck is-at-' + stickTo)
.css(css)
/**
* Fires when the $element has become `position: fixed;`
* Namespaced to `top` or `bottom`.
* @event Sticky#stuckto
*/
.trigger('sticky.zf.stuckto:' + stickTo);
};
/**
* Causes the $element to become unstuck.
* Removes `position: fixed;`, and helper classes.
* Adds other helper classes.
* @param {Boolean} isTop - tells the function if the $element should anchor to the top or bottom of its $anchor element.
* @fires Sticky#unstuckfrom
* @private
*/
Sticky.prototype._removeSticky = function(isTop){
var stickTo = this.options.stickTo,
stickToTop = stickTo === 'top',
css = {},
anchorPt = (this.points ? this.points[1] - this.points[0] : this.anchorHeight) - this.elemHeight,
mrgn = stickToTop ? 'marginTop' : 'marginBottom',
notStuckTo = stickToTop ? 'bottom' : 'top',
topOrBottom = isTop ? 'top' : 'bottom';
css[mrgn] = 0;
if((isTop && !stickToTop) || (stickToTop && !isTop)){
css[stickTo] = anchorPt;
css[notStuckTo] = 0;
}else{
css[stickTo] = 0;
css[notStuckTo] = anchorPt;
}
css['left'] = '';
this.isStuck = false;
this.$element.removeClass('is-stuck is-at-' + stickTo)
.addClass('is-anchored is-at-' + topOrBottom)
.css(css)
/**
* Fires when the $element has become anchored.
* Namespaced to `top` or `bottom`.
* @event Sticky#unstuckfrom
*/
.trigger('sticky.zf.unstuckfrom:' + topOrBottom);
};
/**
* Sets the $element and $container sizes for plugin.
* Calls `_setBreakPoints`.
* @param {Function} cb - optional callback function to fire on completion of `_setBreakPoints`.
* @private
*/
Sticky.prototype._setSizes = function(cb){
this.canStick = Foundation.MediaQuery.atLeast(this.options.stickyOn);
if(!this.canStick){ cb(); }
var _this = this,
newElemWidth = this.$container[0].getBoundingClientRect().width,
comp = window.getComputedStyle(this.$container[0]),
pdng = parseInt(comp['padding-right'], 10);
if(this.$anchor && this.$anchor.length){
this.anchorHeight = this.$anchor[0].getBoundingClientRect().height;
}else{
this._parsePoints();
}
this.$element.css({
'max-width': newElemWidth - pdng + 'px'
});
var newContainerHeight = this.$element[0].getBoundingClientRect().height || this.containerHeight;
this.containerHeight = newContainerHeight;
this.$container.css({
height: newContainerHeight
});
this.elemHeight = newContainerHeight;
if (this.isStuck) {
this.$element.css({"left":this.$container.offset().left + parseInt(comp['padding-left'], 10)});
}
this._setBreakPoints(newContainerHeight, function(){
if(cb){ cb(); }
});
};
/**
* Sets the upper and lower breakpoints for the element to become sticky/unsticky.
* @param {Number} elemHeight - px value for sticky.$element height, calculated by `_setSizes`.
* @param {Function} cb - optional callback function to be called on completion.
* @private
*/
Sticky.prototype._setBreakPoints = function(elemHeight, cb){
if(!this.canStick){
if(cb){ cb(); }
else{ return false; }
}
var mTop = emCalc(this.options.marginTop),
mBtm = emCalc(this.options.marginBottom),
topPoint = this.points ? this.points[0] : this.$anchor.offset().top,
bottomPoint = this.points ? this.points[1] : topPoint + this.anchorHeight,
// topPoint = this.$anchor.offset().top || this.points[0],
// bottomPoint = topPoint + this.anchorHeight || this.points[1],
winHeight = window.innerHeight;
if(this.options.stickTo === 'top'){
topPoint -= mTop;
bottomPoint -= (elemHeight + mTop);
}else if(this.options.stickTo === 'bottom'){
topPoint -= (winHeight - (elemHeight + mBtm));
bottomPoint -= (winHeight - mBtm);
}else{
//this would be the stickTo: both option... tricky
}
this.topPoint = topPoint;
this.bottomPoint = bottomPoint;
if(cb){ cb(); }
};
/**
* Destroys the current sticky element.
* Resets the element to the top position first.
* Removes event listeners, JS-added css properties and classes, and unwraps the $element if the JS added the $container.
* @function
*/
Sticky.prototype.destroy = function(){
this._removeSticky(true);
this.$element.removeClass(this.options.stickyClass + ' is-anchored is-at-top')
.css({
height: '',
top: '',
bottom: '',
'max-width': ''
})
.off('resizeme.zf.trigger');
this.$anchor.off('change.zf.sticky');
$(window).off(this.scrollListener);
if(this.wasWrapped){
this.$element.unwrap();
}else{
this.$container.removeClass(this.options.containerClass)
.css({
height: ''
});
}
Foundation.unregisterPlugin(this);
};
/**
* Helper function to calculate em values
* @param Number {em} - number of em's to calculate into pixels
*/
function emCalc(em){
return parseInt(window.getComputedStyle(document.body, null).fontSize, 10) * em;
}
Foundation.plugin(Sticky, 'Sticky');
}(jQuery, window.Foundation);