509 lines
15 KiB
JavaScript
509 lines
15 KiB
JavaScript
|
'use strict';
|
||
|
|
||
|
import $ from 'jquery';
|
||
|
import { onLoad, GetYoDigits } from './foundation.core.utils';
|
||
|
import { MediaQuery } from './foundation.util.mediaQuery';
|
||
|
import { Plugin } from './foundation.core.plugin';
|
||
|
import { Triggers } from './foundation.util.triggers';
|
||
|
|
||
|
/**
|
||
|
* Sticky module.
|
||
|
* @module foundation.sticky
|
||
|
* @requires foundation.util.triggers
|
||
|
* @requires foundation.util.mediaQuery
|
||
|
*/
|
||
|
|
||
|
class Sticky extends Plugin {
|
||
|
/**
|
||
|
* Creates a new instance of a sticky thing.
|
||
|
* @class
|
||
|
* @name Sticky
|
||
|
* @param {jQuery} element - jQuery object to make sticky.
|
||
|
* @param {Object} options - options object passed when creating the element programmatically.
|
||
|
*/
|
||
|
_setup(element, options) {
|
||
|
this.$element = element;
|
||
|
this.options = $.extend({}, Sticky.defaults, this.$element.data(), options);
|
||
|
this.className = 'Sticky'; // ie9 back compat
|
||
|
|
||
|
// Triggers init is idempotent, just need to make sure it is initialized
|
||
|
Triggers.init($);
|
||
|
|
||
|
this._init();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Initializes the sticky element by adding classes, getting/setting dimensions, breakpoints and attributes
|
||
|
* @function
|
||
|
* @private
|
||
|
*/
|
||
|
_init() {
|
||
|
MediaQuery._init();
|
||
|
|
||
|
var $parent = this.$element.parent('[data-sticky-container]'),
|
||
|
id = this.$element[0].id || GetYoDigits(6, 'sticky'),
|
||
|
_this = this;
|
||
|
|
||
|
if($parent.length){
|
||
|
this.$container = $parent;
|
||
|
} else {
|
||
|
this.wasWrapped = true;
|
||
|
this.$element.wrap(this.options.container);
|
||
|
this.$container = this.$element.parent();
|
||
|
}
|
||
|
this.$container.addClass(this.options.containerClass);
|
||
|
|
||
|
this.$element.addClass(this.options.stickyClass).attr({ 'data-resize': id, 'data-mutate': id });
|
||
|
if (this.options.anchor !== '') {
|
||
|
$('#' + _this.options.anchor).attr({ 'data-mutate': id });
|
||
|
}
|
||
|
|
||
|
this.scrollCount = this.options.checkEvery;
|
||
|
this.isStuck = false;
|
||
|
this.onLoadListener = onLoad($(window), function () {
|
||
|
//We calculate the container height to have correct values for anchor points offset calculation.
|
||
|
_this.containerHeight = _this.$element.css("display") == "none" ? 0 : _this.$element[0].getBoundingClientRect().height;
|
||
|
_this.$container.css('height', _this.containerHeight);
|
||
|
_this.elemHeight = _this.containerHeight;
|
||
|
if (_this.options.anchor !== '') {
|
||
|
_this.$anchor = $('#' + _this.options.anchor);
|
||
|
} else {
|
||
|
_this._parsePoints();
|
||
|
}
|
||
|
|
||
|
_this._setSizes(function () {
|
||
|
var scroll = window.pageYOffset;
|
||
|
_this._calc(false, scroll);
|
||
|
//Unstick the element will ensure that proper classes are set.
|
||
|
if (!_this.isStuck) {
|
||
|
_this._removeSticky((scroll >= _this.topPoint) ? false : true);
|
||
|
}
|
||
|
});
|
||
|
_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
|
||
|
*/
|
||
|
_parsePoints() {
|
||
|
var top = this.options.topAnchor == "" ? 1 : this.options.topAnchor,
|
||
|
btm = this.options.btmAnchor== "" ? document.documentElement.scrollHeight : this.options.btmAnchor,
|
||
|
pts = [top, btm],
|
||
|
breaks = {};
|
||
|
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;
|
||
|
}
|
||
|
|
||
|
|
||
|
this.points = breaks;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds event handlers for the scrolling element.
|
||
|
* @private
|
||
|
* @param {String} id - pseudo-random id for unique scroll event listener.
|
||
|
*/
|
||
|
_events(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._eventsHandler(id);
|
||
|
});
|
||
|
|
||
|
this.$element.on('mutateme.zf.trigger', function (e, el) {
|
||
|
_this._eventsHandler(id);
|
||
|
});
|
||
|
|
||
|
if(this.$anchor) {
|
||
|
this.$anchor.on('mutateme.zf.trigger', function (e, el) {
|
||
|
_this._eventsHandler(id);
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handler for events.
|
||
|
* @private
|
||
|
* @param {String} id - pseudo-random id for unique scroll event listener.
|
||
|
*/
|
||
|
_eventsHandler(id) {
|
||
|
var _this = this,
|
||
|
scrollListener = this.scrollListener = `scroll.zf.${id}`;
|
||
|
|
||
|
_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`
|
||
|
*/
|
||
|
_pauseListeners(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`.
|
||
|
*/
|
||
|
_calc(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
|
||
|
*/
|
||
|
_setSticky() {
|
||
|
var _this = this,
|
||
|
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';
|
||
|
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`, e.g. `sticky.zf.stuckto:top`
|
||
|
* @event Sticky#stuckto
|
||
|
*/
|
||
|
.trigger(`sticky.zf.stuckto:${stickTo}`);
|
||
|
this.$element.on("transitionend webkitTransitionEnd oTransitionEnd otransitionend MSTransitionEnd", function() {
|
||
|
_this._setSizes();
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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
|
||
|
*/
|
||
|
_removeSticky(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;
|
||
|
|
||
|
css['bottom'] = 'auto';
|
||
|
if(isTop) {
|
||
|
css['top'] = 0;
|
||
|
} else {
|
||
|
css['top'] = anchorPt;
|
||
|
}
|
||
|
|
||
|
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`, e.g. `sticky.zf.unstuckfrom: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
|
||
|
*/
|
||
|
_setSizes(cb) {
|
||
|
this.canStick = MediaQuery.is(this.options.stickyOn);
|
||
|
if (!this.canStick) {
|
||
|
if (cb && typeof cb === 'function') { cb(); }
|
||
|
}
|
||
|
var _this = this,
|
||
|
newElemWidth = this.$container[0].getBoundingClientRect().width,
|
||
|
comp = window.getComputedStyle(this.$container[0]),
|
||
|
pdngl = parseInt(comp['padding-left'], 10),
|
||
|
pdngr = 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 - pdngl - pdngr}px`
|
||
|
});
|
||
|
|
||
|
var newContainerHeight = this.$element[0].getBoundingClientRect().height || this.containerHeight;
|
||
|
if (this.$element.css("display") == "none") {
|
||
|
newContainerHeight = 0;
|
||
|
}
|
||
|
this.containerHeight = newContainerHeight;
|
||
|
this.$container.css({
|
||
|
height: newContainerHeight
|
||
|
});
|
||
|
this.elemHeight = newContainerHeight;
|
||
|
|
||
|
if (!this.isStuck) {
|
||
|
if (this.$element.hasClass('is-at-bottom')) {
|
||
|
var anchorPt = (this.points ? this.points[1] - this.$container.offset().top : this.anchorHeight) - this.elemHeight;
|
||
|
this.$element.css('top', anchorPt);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this._setBreakPoints(newContainerHeight, function() {
|
||
|
if (cb && typeof cb === 'function') { 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
|
||
|
*/
|
||
|
_setBreakPoints(elemHeight, cb) {
|
||
|
if (!this.canStick) {
|
||
|
if (cb && typeof cb === 'function') { 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 && typeof cb === 'function') { 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
|
||
|
*/
|
||
|
_destroy() {
|
||
|
this._removeSticky(true);
|
||
|
|
||
|
this.$element.removeClass(`${this.options.stickyClass} is-anchored is-at-top`)
|
||
|
.css({
|
||
|
height: '',
|
||
|
top: '',
|
||
|
bottom: '',
|
||
|
'max-width': ''
|
||
|
})
|
||
|
.off('resizeme.zf.trigger')
|
||
|
.off('mutateme.zf.trigger');
|
||
|
if (this.$anchor && this.$anchor.length) {
|
||
|
this.$anchor.off('change.zf.sticky');
|
||
|
}
|
||
|
if (this.scrollListener) $(window).off(this.scrollListener)
|
||
|
if (this.onLoadListener) $(window).off(this.onLoadListener)
|
||
|
|
||
|
if (this.wasWrapped) {
|
||
|
this.$element.unwrap();
|
||
|
} else {
|
||
|
this.$container.removeClass(this.options.containerClass)
|
||
|
.css({
|
||
|
height: ''
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Sticky.defaults = {
|
||
|
/**
|
||
|
* Customizable container template. Add your own classes for styling and sizing.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default '<div data-sticky-container></div>'
|
||
|
*/
|
||
|
container: '<div data-sticky-container></div>',
|
||
|
/**
|
||
|
* Location in the view the element sticks to. Can be `'top'` or `'bottom'`.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default 'top'
|
||
|
*/
|
||
|
stickTo: 'top',
|
||
|
/**
|
||
|
* If anchored to a single element, the id of that element.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default ''
|
||
|
*/
|
||
|
anchor: '',
|
||
|
/**
|
||
|
* If using more than one element as anchor points, the id of the top anchor.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default ''
|
||
|
*/
|
||
|
topAnchor: '',
|
||
|
/**
|
||
|
* If using more than one element as anchor points, the id of the bottom anchor.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default ''
|
||
|
*/
|
||
|
btmAnchor: '',
|
||
|
/**
|
||
|
* Margin, in `em`'s to apply to the top of the element when it becomes sticky.
|
||
|
* @option
|
||
|
* @type {number}
|
||
|
* @default 1
|
||
|
*/
|
||
|
marginTop: 1,
|
||
|
/**
|
||
|
* Margin, in `em`'s to apply to the bottom of the element when it becomes sticky.
|
||
|
* @option
|
||
|
* @type {number}
|
||
|
* @default 1
|
||
|
*/
|
||
|
marginBottom: 1,
|
||
|
/**
|
||
|
* Breakpoint string that is the minimum screen size an element should become sticky.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default 'medium'
|
||
|
*/
|
||
|
stickyOn: 'medium',
|
||
|
/**
|
||
|
* Class applied to sticky element, and removed on destruction. Foundation defaults to `sticky`.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default 'sticky'
|
||
|
*/
|
||
|
stickyClass: 'sticky',
|
||
|
/**
|
||
|
* Class applied to sticky container. Foundation defaults to `sticky-container`.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default '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
|
||
|
* @type {number}
|
||
|
* @default -1
|
||
|
*/
|
||
|
checkEvery: -1
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* 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;
|
||
|
}
|
||
|
|
||
|
export {Sticky};
|