UNPKG

15.5 kBJavaScriptView Raw
1'use strict';
2
3import $ from 'jquery';
4import { GetYoDigits } from './foundation.util.core';
5import { MediaQuery } from './foundation.util.mediaQuery';
6import { Plugin } from './foundation.plugin';
7import { Triggers } from './foundation.util.triggers';
8
9/**
10 * Sticky module.
11 * @module foundation.sticky
12 * @requires foundation.util.triggers
13 * @requires foundation.util.mediaQuery
14 */
15
16class Sticky extends Plugin {
17 /**
18 * Creates a new instance of a sticky thing.
19 * @class
20 * @name Sticky
21 * @param {jQuery} element - jQuery object to make sticky.
22 * @param {Object} options - options object passed when creating the element programmatically.
23 */
24 _setup(element, options) {
25 this.$element = element;
26 this.options = $.extend({}, Sticky.defaults, this.$element.data(), options);
27 this.className = 'Sticky'; // ie9 back compat
28
29 // Triggers init is idempotent, just need to make sure it is initialized
30 Triggers.init($);
31
32 this._init();
33 }
34
35 /**
36 * Initializes the sticky element by adding classes, getting/setting dimensions, breakpoints and attributes
37 * @function
38 * @private
39 */
40 _init() {
41 MediaQuery._init();
42
43 var $parent = this.$element.parent('[data-sticky-container]'),
44 id = this.$element[0].id || GetYoDigits(6, 'sticky'),
45 _this = this;
46
47 if($parent.length){
48 this.$container = $parent;
49 } else {
50 this.wasWrapped = true;
51 this.$element.wrap(this.options.container);
52 this.$container = this.$element.parent();
53 }
54 this.$container.addClass(this.options.containerClass);
55
56 this.$element.addClass(this.options.stickyClass).attr({ 'data-resize': id, 'data-mutate': id });
57 if (this.options.anchor !== '') {
58 $('#' + _this.options.anchor).attr({ 'data-mutate': id });
59 }
60
61 this.scrollCount = this.options.checkEvery;
62 this.isStuck = false;
63 $(window).one('load.zf.sticky', function(){
64 //We calculate the container height to have correct values for anchor points offset calculation.
65 _this.containerHeight = _this.$element.css("display") == "none" ? 0 : _this.$element[0].getBoundingClientRect().height;
66 _this.$container.css('height', _this.containerHeight);
67 _this.elemHeight = _this.containerHeight;
68 if(_this.options.anchor !== ''){
69 _this.$anchor = $('#' + _this.options.anchor);
70 }else{
71 _this._parsePoints();
72 }
73
74 _this._setSizes(function(){
75 var scroll = window.pageYOffset;
76 _this._calc(false, scroll);
77 //Unstick the element will ensure that proper classes are set.
78 if (!_this.isStuck) {
79 _this._removeSticky((scroll >= _this.topPoint) ? false : true);
80 }
81 });
82 _this._events(id.split('-').reverse().join('-'));
83 });
84 }
85
86 /**
87 * If using multiple elements as anchors, calculates the top and bottom pixel values the sticky thing should stick and unstick on.
88 * @function
89 * @private
90 */
91 _parsePoints() {
92 var top = this.options.topAnchor == "" ? 1 : this.options.topAnchor,
93 btm = this.options.btmAnchor== "" ? document.documentElement.scrollHeight : this.options.btmAnchor,
94 pts = [top, btm],
95 breaks = {};
96 for (var i = 0, len = pts.length; i < len && pts[i]; i++) {
97 var pt;
98 if (typeof pts[i] === 'number') {
99 pt = pts[i];
100 } else {
101 var place = pts[i].split(':'),
102 anchor = $(`#${place[0]}`);
103
104 pt = anchor.offset().top;
105 if (place[1] && place[1].toLowerCase() === 'bottom') {
106 pt += anchor[0].getBoundingClientRect().height;
107 }
108 }
109 breaks[i] = pt;
110 }
111
112
113 this.points = breaks;
114 return;
115 }
116
117 /**
118 * Adds event handlers for the scrolling element.
119 * @private
120 * @param {String} id - pseudo-random id for unique scroll event listener.
121 */
122 _events(id) {
123 var _this = this,
124 scrollListener = this.scrollListener = `scroll.zf.${id}`;
125 if (this.isOn) { return; }
126 if (this.canStick) {
127 this.isOn = true;
128 $(window).off(scrollListener)
129 .on(scrollListener, function(e) {
130 if (_this.scrollCount === 0) {
131 _this.scrollCount = _this.options.checkEvery;
132 _this._setSizes(function() {
133 _this._calc(false, window.pageYOffset);
134 });
135 } else {
136 _this.scrollCount--;
137 _this._calc(false, window.pageYOffset);
138 }
139 });
140 }
141
142 this.$element.off('resizeme.zf.trigger')
143 .on('resizeme.zf.trigger', function(e, el) {
144 _this._eventsHandler(id);
145 });
146
147 this.$element.on('mutateme.zf.trigger', function (e, el) {
148 _this._eventsHandler(id);
149 });
150
151 if(this.$anchor) {
152 this.$anchor.on('mutateme.zf.trigger', function (e, el) {
153 _this._eventsHandler(id);
154 });
155 }
156 }
157
158 /**
159 * Handler for events.
160 * @private
161 * @param {String} id - pseudo-random id for unique scroll event listener.
162 */
163 _eventsHandler(id) {
164 var _this = this,
165 scrollListener = this.scrollListener = `scroll.zf.${id}`;
166
167 _this._setSizes(function() {
168 _this._calc(false);
169 if (_this.canStick) {
170 if (!_this.isOn) {
171 _this._events(id);
172 }
173 } else if (_this.isOn) {
174 _this._pauseListeners(scrollListener);
175 }
176 });
177 }
178
179 /**
180 * Removes event handlers for scroll and change events on anchor.
181 * @fires Sticky#pause
182 * @param {String} scrollListener - unique, namespaced scroll listener attached to `window`
183 */
184 _pauseListeners(scrollListener) {
185 this.isOn = false;
186 $(window).off(scrollListener);
187
188 /**
189 * Fires when the plugin is paused due to resize event shrinking the view.
190 * @event Sticky#pause
191 * @private
192 */
193 this.$element.trigger('pause.zf.sticky');
194 }
195
196 /**
197 * Called on every `scroll` event and on `_init`
198 * fires functions based on booleans and cached values
199 * @param {Boolean} checkSizes - true if plugin should recalculate sizes and breakpoints.
200 * @param {Number} scroll - current scroll position passed from scroll event cb function. If not passed, defaults to `window.pageYOffset`.
201 */
202 _calc(checkSizes, scroll) {
203 if (checkSizes) { this._setSizes(); }
204
205 if (!this.canStick) {
206 if (this.isStuck) {
207 this._removeSticky(true);
208 }
209 return false;
210 }
211
212 if (!scroll) { scroll = window.pageYOffset; }
213
214 if (scroll >= this.topPoint) {
215 if (scroll <= this.bottomPoint) {
216 if (!this.isStuck) {
217 this._setSticky();
218 }
219 } else {
220 if (this.isStuck) {
221 this._removeSticky(false);
222 }
223 }
224 } else {
225 if (this.isStuck) {
226 this._removeSticky(true);
227 }
228 }
229 }
230
231 /**
232 * Causes the $element to become stuck.
233 * Adds `position: fixed;`, and helper classes.
234 * @fires Sticky#stuckto
235 * @function
236 * @private
237 */
238 _setSticky() {
239 var _this = this,
240 stickTo = this.options.stickTo,
241 mrgn = stickTo === 'top' ? 'marginTop' : 'marginBottom',
242 notStuckTo = stickTo === 'top' ? 'bottom' : 'top',
243 css = {};
244
245 css[mrgn] = `${this.options[mrgn]}em`;
246 css[stickTo] = 0;
247 css[notStuckTo] = 'auto';
248 this.isStuck = true;
249 this.$element.removeClass(`is-anchored is-at-${notStuckTo}`)
250 .addClass(`is-stuck is-at-${stickTo}`)
251 .css(css)
252 /**
253 * Fires when the $element has become `position: fixed;`
254 * Namespaced to `top` or `bottom`, e.g. `sticky.zf.stuckto:top`
255 * @event Sticky#stuckto
256 */
257 .trigger(`sticky.zf.stuckto:${stickTo}`);
258 this.$element.on("transitionend webkitTransitionEnd oTransitionEnd otransitionend MSTransitionEnd", function() {
259 _this._setSizes();
260 });
261 }
262
263 /**
264 * Causes the $element to become unstuck.
265 * Removes `position: fixed;`, and helper classes.
266 * Adds other helper classes.
267 * @param {Boolean} isTop - tells the function if the $element should anchor to the top or bottom of its $anchor element.
268 * @fires Sticky#unstuckfrom
269 * @private
270 */
271 _removeSticky(isTop) {
272 var stickTo = this.options.stickTo,
273 stickToTop = stickTo === 'top',
274 css = {},
275 anchorPt = (this.points ? this.points[1] - this.points[0] : this.anchorHeight) - this.elemHeight,
276 mrgn = stickToTop ? 'marginTop' : 'marginBottom',
277 notStuckTo = stickToTop ? 'bottom' : 'top',
278 topOrBottom = isTop ? 'top' : 'bottom';
279
280 css[mrgn] = 0;
281
282 css['bottom'] = 'auto';
283 if(isTop) {
284 css['top'] = 0;
285 } else {
286 css['top'] = anchorPt;
287 }
288
289 this.isStuck = false;
290 this.$element.removeClass(`is-stuck is-at-${stickTo}`)
291 .addClass(`is-anchored is-at-${topOrBottom}`)
292 .css(css)
293 /**
294 * Fires when the $element has become anchored.
295 * Namespaced to `top` or `bottom`, e.g. `sticky.zf.unstuckfrom:bottom`
296 * @event Sticky#unstuckfrom
297 */
298 .trigger(`sticky.zf.unstuckfrom:${topOrBottom}`);
299 }
300
301 /**
302 * Sets the $element and $container sizes for plugin.
303 * Calls `_setBreakPoints`.
304 * @param {Function} cb - optional callback function to fire on completion of `_setBreakPoints`.
305 * @private
306 */
307 _setSizes(cb) {
308 this.canStick = MediaQuery.is(this.options.stickyOn);
309 if (!this.canStick) {
310 if (cb && typeof cb === 'function') { cb(); }
311 }
312 var _this = this,
313 newElemWidth = this.$container[0].getBoundingClientRect().width,
314 comp = window.getComputedStyle(this.$container[0]),
315 pdngl = parseInt(comp['padding-left'], 10),
316 pdngr = parseInt(comp['padding-right'], 10);
317
318 if (this.$anchor && this.$anchor.length) {
319 this.anchorHeight = this.$anchor[0].getBoundingClientRect().height;
320 } else {
321 this._parsePoints();
322 }
323
324 this.$element.css({
325 'max-width': `${newElemWidth - pdngl - pdngr}px`
326 });
327
328 var newContainerHeight = this.$element[0].getBoundingClientRect().height || this.containerHeight;
329 if (this.$element.css("display") == "none") {
330 newContainerHeight = 0;
331 }
332 this.containerHeight = newContainerHeight;
333 this.$container.css({
334 height: newContainerHeight
335 });
336 this.elemHeight = newContainerHeight;
337
338 if (!this.isStuck) {
339 if (this.$element.hasClass('is-at-bottom')) {
340 var anchorPt = (this.points ? this.points[1] - this.$container.offset().top : this.anchorHeight) - this.elemHeight;
341 this.$element.css('top', anchorPt);
342 }
343 }
344
345 this._setBreakPoints(newContainerHeight, function() {
346 if (cb && typeof cb === 'function') { cb(); }
347 });
348 }
349
350 /**
351 * Sets the upper and lower breakpoints for the element to become sticky/unsticky.
352 * @param {Number} elemHeight - px value for sticky.$element height, calculated by `_setSizes`.
353 * @param {Function} cb - optional callback function to be called on completion.
354 * @private
355 */
356 _setBreakPoints(elemHeight, cb) {
357 if (!this.canStick) {
358 if (cb && typeof cb === 'function') { cb(); }
359 else { return false; }
360 }
361 var mTop = emCalc(this.options.marginTop),
362 mBtm = emCalc(this.options.marginBottom),
363 topPoint = this.points ? this.points[0] : this.$anchor.offset().top,
364 bottomPoint = this.points ? this.points[1] : topPoint + this.anchorHeight,
365 // topPoint = this.$anchor.offset().top || this.points[0],
366 // bottomPoint = topPoint + this.anchorHeight || this.points[1],
367 winHeight = window.innerHeight;
368
369 if (this.options.stickTo === 'top') {
370 topPoint -= mTop;
371 bottomPoint -= (elemHeight + mTop);
372 } else if (this.options.stickTo === 'bottom') {
373 topPoint -= (winHeight - (elemHeight + mBtm));
374 bottomPoint -= (winHeight - mBtm);
375 } else {
376 //this would be the stickTo: both option... tricky
377 }
378
379 this.topPoint = topPoint;
380 this.bottomPoint = bottomPoint;
381
382 if (cb && typeof cb === 'function') { cb(); }
383 }
384
385 /**
386 * Destroys the current sticky element.
387 * Resets the element to the top position first.
388 * Removes event listeners, JS-added css properties and classes, and unwraps the $element if the JS added the $container.
389 * @function
390 */
391 _destroy() {
392 this._removeSticky(true);
393
394 this.$element.removeClass(`${this.options.stickyClass} is-anchored is-at-top`)
395 .css({
396 height: '',
397 top: '',
398 bottom: '',
399 'max-width': ''
400 })
401 .off('resizeme.zf.trigger')
402 .off('mutateme.zf.trigger');
403 if (this.$anchor && this.$anchor.length) {
404 this.$anchor.off('change.zf.sticky');
405 }
406 $(window).off(this.scrollListener);
407
408 if (this.wasWrapped) {
409 this.$element.unwrap();
410 } else {
411 this.$container.removeClass(this.options.containerClass)
412 .css({
413 height: ''
414 });
415 }
416 }
417}
418
419Sticky.defaults = {
420 /**
421 * Customizable container template. Add your own classes for styling and sizing.
422 * @option
423 * @type {string}
424 * @default '&lt;div data-sticky-container&gt;&lt;/div&gt;'
425 */
426 container: '<div data-sticky-container></div>',
427 /**
428 * Location in the view the element sticks to. Can be `'top'` or `'bottom'`.
429 * @option
430 * @type {string}
431 * @default 'top'
432 */
433 stickTo: 'top',
434 /**
435 * If anchored to a single element, the id of that element.
436 * @option
437 * @type {string}
438 * @default ''
439 */
440 anchor: '',
441 /**
442 * If using more than one element as anchor points, the id of the top anchor.
443 * @option
444 * @type {string}
445 * @default ''
446 */
447 topAnchor: '',
448 /**
449 * If using more than one element as anchor points, the id of the bottom anchor.
450 * @option
451 * @type {string}
452 * @default ''
453 */
454 btmAnchor: '',
455 /**
456 * Margin, in `em`'s to apply to the top of the element when it becomes sticky.
457 * @option
458 * @type {number}
459 * @default 1
460 */
461 marginTop: 1,
462 /**
463 * Margin, in `em`'s to apply to the bottom of the element when it becomes sticky.
464 * @option
465 * @type {number}
466 * @default 1
467 */
468 marginBottom: 1,
469 /**
470 * Breakpoint string that is the minimum screen size an element should become sticky.
471 * @option
472 * @type {string}
473 * @default 'medium'
474 */
475 stickyOn: 'medium',
476 /**
477 * Class applied to sticky element, and removed on destruction. Foundation defaults to `sticky`.
478 * @option
479 * @type {string}
480 * @default 'sticky'
481 */
482 stickyClass: 'sticky',
483 /**
484 * Class applied to sticky container. Foundation defaults to `sticky-container`.
485 * @option
486 * @type {string}
487 * @default 'sticky-container'
488 */
489 containerClass: 'sticky-container',
490 /**
491 * 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.
492 * @option
493 * @type {number}
494 * @default -1
495 */
496 checkEvery: -1
497};
498
499/**
500 * Helper function to calculate em values
501 * @param Number {em} - number of em's to calculate into pixels
502 */
503function emCalc(em) {
504 return parseInt(window.getComputedStyle(document.body, null).fontSize, 10) * em;
505}
506
507export {Sticky};