foundation.sticky.js 15 KB

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