You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

881 lines
27 KiB

  1. /*!
  2. * # Semantic UI 2.0.0 - Sticky
  3. * http://github.com/semantic-org/semantic-ui/
  4. *
  5. *
  6. * Copyright 2015 Contributors
  7. * Released under the MIT license
  8. * http://opensource.org/licenses/MIT
  9. *
  10. */
  11. ;(function ( $, window, document, undefined ) {
  12. "use strict";
  13. $.fn.sticky = function(parameters) {
  14. var
  15. $allModules = $(this),
  16. moduleSelector = $allModules.selector || '',
  17. time = new Date().getTime(),
  18. performance = [],
  19. query = arguments[0],
  20. methodInvoked = (typeof query == 'string'),
  21. queryArguments = [].slice.call(arguments, 1),
  22. returnedValue
  23. ;
  24. $allModules
  25. .each(function() {
  26. var
  27. settings = ( $.isPlainObject(parameters) )
  28. ? $.extend(true, {}, $.fn.sticky.settings, parameters)
  29. : $.extend({}, $.fn.sticky.settings),
  30. className = settings.className,
  31. namespace = settings.namespace,
  32. error = settings.error,
  33. eventNamespace = '.' + namespace,
  34. moduleNamespace = 'module-' + namespace,
  35. $module = $(this),
  36. $window = $(window),
  37. $scroll = $(settings.scrollContext),
  38. $container,
  39. $context,
  40. selector = $module.selector || '',
  41. instance = $module.data(moduleNamespace),
  42. requestAnimationFrame = window.requestAnimationFrame
  43. || window.mozRequestAnimationFrame
  44. || window.webkitRequestAnimationFrame
  45. || window.msRequestAnimationFrame
  46. || function(callback) { setTimeout(callback, 0); },
  47. element = this,
  48. observer,
  49. module
  50. ;
  51. module = {
  52. initialize: function() {
  53. module.determineContainer();
  54. module.determineContext();
  55. module.verbose('Initializing sticky', settings, $container);
  56. module.save.positions();
  57. module.checkErrors();
  58. module.bind.events();
  59. if(settings.observeChanges) {
  60. module.observeChanges();
  61. }
  62. module.instantiate();
  63. },
  64. instantiate: function() {
  65. module.verbose('Storing instance of module', module);
  66. instance = module;
  67. $module
  68. .data(moduleNamespace, module)
  69. ;
  70. },
  71. destroy: function() {
  72. module.verbose('Destroying previous instance');
  73. module.reset();
  74. if(observer) {
  75. observer.disconnect();
  76. }
  77. $window
  78. .off('load' + eventNamespace, module.event.load)
  79. .off('resize' + eventNamespace, module.event.resize)
  80. ;
  81. $scroll
  82. .off('scrollchange' + eventNamespace, module.event.scrollchange)
  83. ;
  84. $module.removeData(moduleNamespace);
  85. },
  86. observeChanges: function() {
  87. var
  88. context = $context[0]
  89. ;
  90. if('MutationObserver' in window) {
  91. observer = new MutationObserver(function(mutations) {
  92. clearTimeout(module.timer);
  93. module.timer = setTimeout(function() {
  94. module.verbose('DOM tree modified, updating sticky menu', mutations);
  95. module.refresh();
  96. }, 100);
  97. });
  98. observer.observe(element, {
  99. childList : true,
  100. subtree : true
  101. });
  102. observer.observe(context, {
  103. childList : true,
  104. subtree : true
  105. });
  106. module.debug('Setting up mutation observer', observer);
  107. }
  108. },
  109. determineContainer: function() {
  110. $container = $module.offsetParent();
  111. },
  112. determineContext: function() {
  113. if(settings.context) {
  114. $context = $(settings.context);
  115. }
  116. else {
  117. $context = $container;
  118. }
  119. if($context.length === 0) {
  120. module.error(error.invalidContext, settings.context, $module);
  121. return;
  122. }
  123. },
  124. checkErrors: function() {
  125. if( module.is.hidden() ) {
  126. module.error(error.visible, $module);
  127. }
  128. if(module.cache.element.height > module.cache.context.height) {
  129. module.reset();
  130. module.error(error.elementSize, $module);
  131. return;
  132. }
  133. },
  134. bind: {
  135. events: function() {
  136. $window
  137. .on('load' + eventNamespace, module.event.load)
  138. .on('resize' + eventNamespace, module.event.resize)
  139. ;
  140. // pub/sub pattern
  141. $scroll
  142. .off('scroll' + eventNamespace)
  143. .on('scroll' + eventNamespace, module.event.scroll)
  144. .on('scrollchange' + eventNamespace, module.event.scrollchange)
  145. ;
  146. }
  147. },
  148. event: {
  149. load: function() {
  150. module.verbose('Page contents finished loading');
  151. requestAnimationFrame(module.refresh);
  152. },
  153. resize: function() {
  154. module.verbose('Window resized');
  155. requestAnimationFrame(module.refresh);
  156. },
  157. scroll: function() {
  158. requestAnimationFrame(function() {
  159. $scroll.triggerHandler('scrollchange' + eventNamespace, $scroll.scrollTop() );
  160. });
  161. },
  162. scrollchange: function(event, scrollPosition) {
  163. module.stick(scrollPosition);
  164. settings.onScroll.call(element);
  165. }
  166. },
  167. refresh: function(hardRefresh) {
  168. module.reset();
  169. if(!settings.context) {
  170. module.determineContext();
  171. }
  172. if(hardRefresh) {
  173. module.determineContainer();
  174. }
  175. module.save.positions();
  176. module.stick();
  177. settings.onReposition.call(element);
  178. },
  179. supports: {
  180. sticky: function() {
  181. var
  182. $element = $('<div/>'),
  183. element = $element[0]
  184. ;
  185. $element.addClass(className.supported);
  186. return($element.css('position').match('sticky'));
  187. }
  188. },
  189. save: {
  190. lastScroll: function(scroll) {
  191. module.lastScroll = scroll;
  192. },
  193. elementScroll: function(scroll) {
  194. module.elementScroll = scroll;
  195. },
  196. positions: function() {
  197. var
  198. window = {
  199. height: $window.height()
  200. },
  201. element = {
  202. margin: {
  203. top : parseInt($module.css('margin-top'), 10),
  204. bottom : parseInt($module.css('margin-bottom'), 10),
  205. },
  206. offset : $module.offset(),
  207. width : $module.outerWidth(),
  208. height : $module.outerHeight()
  209. },
  210. context = {
  211. offset : $context.offset(),
  212. height : $context.outerHeight(),
  213. bottomPadding : parseInt($context.css('padding-bottom'), 10)
  214. },
  215. container = {
  216. height: $container.outerHeight()
  217. }
  218. ;
  219. module.cache = {
  220. fits : ( element.height < window.height ),
  221. window: {
  222. height: window.height
  223. },
  224. element: {
  225. margin : element.margin,
  226. top : element.offset.top - element.margin.top,
  227. left : element.offset.left,
  228. width : element.width,
  229. height : element.height,
  230. bottom : element.offset.top + element.height
  231. },
  232. context: {
  233. top : context.offset.top,
  234. height : context.height,
  235. bottomPadding : context.bottomPadding,
  236. bottom : context.offset.top + context.height - context.bottomPadding
  237. }
  238. };
  239. module.set.containerSize();
  240. module.set.size();
  241. module.stick();
  242. module.debug('Caching element positions', module.cache);
  243. }
  244. },
  245. get: {
  246. direction: function(scroll) {
  247. var
  248. direction = 'down'
  249. ;
  250. scroll = scroll || $scroll.scrollTop();
  251. if(module.lastScroll !== undefined) {
  252. if(module.lastScroll < scroll) {
  253. direction = 'down';
  254. }
  255. else if(module.lastScroll > scroll) {
  256. direction = 'up';
  257. }
  258. }
  259. return direction;
  260. },
  261. scrollChange: function(scroll) {
  262. scroll = scroll || $scroll.scrollTop();
  263. return (module.lastScroll)
  264. ? (scroll - module.lastScroll)
  265. : 0
  266. ;
  267. },
  268. currentElementScroll: function() {
  269. if(module.elementScroll) {
  270. return module.elementScroll;
  271. }
  272. return ( module.is.top() )
  273. ? Math.abs(parseInt($module.css('top'), 10)) || 0
  274. : Math.abs(parseInt($module.css('bottom'), 10)) || 0
  275. ;
  276. },
  277. elementScroll: function(scroll) {
  278. scroll = scroll || $scroll.scrollTop();
  279. var
  280. element = module.cache.element,
  281. window = module.cache.window,
  282. delta = module.get.scrollChange(scroll),
  283. maxScroll = (element.height - window.height + settings.offset),
  284. elementScroll = module.get.currentElementScroll(),
  285. possibleScroll = (elementScroll + delta)
  286. ;
  287. if(module.cache.fits || possibleScroll < 0) {
  288. elementScroll = 0;
  289. }
  290. else if(possibleScroll > maxScroll ) {
  291. elementScroll = maxScroll;
  292. }
  293. else {
  294. elementScroll = possibleScroll;
  295. }
  296. return elementScroll;
  297. }
  298. },
  299. remove: {
  300. lastScroll: function() {
  301. delete module.lastScroll;
  302. },
  303. elementScroll: function(scroll) {
  304. delete module.elementScroll;
  305. },
  306. offset: function() {
  307. $module.css('margin-top', '');
  308. }
  309. },
  310. set: {
  311. offset: function() {
  312. module.verbose('Setting offset on element', settings.offset);
  313. $module
  314. .css('margin-top', settings.offset)
  315. ;
  316. },
  317. containerSize: function() {
  318. var
  319. tagName = $container.get(0).tagName
  320. ;
  321. if(tagName === 'HTML' || tagName == 'body') {
  322. // this can trigger for too many reasons
  323. //module.error(error.container, tagName, $module);
  324. module.determineContainer();
  325. }
  326. else {
  327. if( Math.abs($container.outerHeight() - module.cache.context.height) > settings.jitter) {
  328. module.debug('Context has padding, specifying exact height for container', module.cache.context.height);
  329. $container.css({
  330. height: module.cache.context.height
  331. });
  332. }
  333. }
  334. },
  335. minimumSize: function() {
  336. var
  337. element = module.cache.element
  338. ;
  339. $container
  340. .css('min-height', element.height)
  341. ;
  342. },
  343. scroll: function(scroll) {
  344. module.debug('Setting scroll on element', scroll);
  345. if(module.elementScroll == scroll) {
  346. return;
  347. }
  348. if( module.is.top() ) {
  349. $module
  350. .css('bottom', '')
  351. .css('top', -scroll)
  352. ;
  353. }
  354. if( module.is.bottom() ) {
  355. $module
  356. .css('top', '')
  357. .css('bottom', scroll)
  358. ;
  359. }
  360. },
  361. size: function() {
  362. if(module.cache.element.height !== 0 && module.cache.element.width !== 0) {
  363. $module
  364. .css({
  365. width : module.cache.element.width,
  366. height : module.cache.element.height
  367. })
  368. ;
  369. }
  370. }
  371. },
  372. is: {
  373. top: function() {
  374. return $module.hasClass(className.top);
  375. },
  376. bottom: function() {
  377. return $module.hasClass(className.bottom);
  378. },
  379. initialPosition: function() {
  380. return (!module.is.fixed() && !module.is.bound());
  381. },
  382. hidden: function() {
  383. return (!$module.is(':visible'));
  384. },
  385. bound: function() {
  386. return $module.hasClass(className.bound);
  387. },
  388. fixed: function() {
  389. return $module.hasClass(className.fixed);
  390. }
  391. },
  392. stick: function(scroll) {
  393. var
  394. cachedPosition = scroll || $scroll.scrollTop(),
  395. cache = module.cache,
  396. fits = cache.fits,
  397. element = cache.element,
  398. window = cache.window,
  399. context = cache.context,
  400. offset = (module.is.bottom() && settings.pushing)
  401. ? settings.bottomOffset
  402. : settings.offset,
  403. scroll = {
  404. top : cachedPosition + offset,
  405. bottom : cachedPosition + offset + window.height
  406. },
  407. direction = module.get.direction(scroll.top),
  408. elementScroll = (fits)
  409. ? 0
  410. : module.get.elementScroll(scroll.top),
  411. // shorthand
  412. doesntFit = !fits,
  413. elementVisible = (element.height !== 0)
  414. ;
  415. if(elementVisible) {
  416. if( module.is.initialPosition() ) {
  417. if(scroll.top > context.bottom) {
  418. module.debug('Element bottom of container');
  419. module.bindBottom();
  420. }
  421. else if(scroll.top > element.top) {
  422. module.debug('Element passed, fixing element to page');
  423. module.fixTop();
  424. }
  425. }
  426. else if( module.is.fixed() ) {
  427. // currently fixed top
  428. if( module.is.top() ) {
  429. if( scroll.top < element.top ) {
  430. module.debug('Fixed element reached top of container');
  431. module.setInitialPosition();
  432. }
  433. else if( (element.height + scroll.top - elementScroll) > context.bottom ) {
  434. module.debug('Fixed element reached bottom of container');
  435. module.bindBottom();
  436. }
  437. // scroll element if larger than screen
  438. else if(doesntFit) {
  439. module.set.scroll(elementScroll);
  440. }
  441. }
  442. // currently fixed bottom
  443. else if(module.is.bottom() ) {
  444. // top edge
  445. if( (scroll.bottom - element.height) < element.top) {
  446. module.debug('Bottom fixed rail has reached top of container');
  447. module.setInitialPosition();
  448. }
  449. // bottom edge
  450. else if(scroll.bottom > context.bottom) {
  451. module.debug('Bottom fixed rail has reached bottom of container');
  452. module.bindBottom();
  453. }
  454. // scroll element if larger than screen
  455. else if(doesntFit) {
  456. module.set.scroll(elementScroll);
  457. }
  458. }
  459. }
  460. else if( module.is.bottom() ) {
  461. if(settings.pushing) {
  462. if(module.is.bound() && scroll.bottom < context.bottom ) {
  463. module.debug('Fixing bottom attached element to bottom of browser.');
  464. module.fixBottom();
  465. }
  466. }
  467. else {
  468. if(module.is.bound() && (scroll.top < context.bottom - element.height) ) {
  469. module.debug('Fixing bottom attached element to top of browser.');
  470. module.fixTop();
  471. }
  472. }
  473. }
  474. }
  475. // save current scroll for next run
  476. module.save.lastScroll(scroll.top);
  477. module.save.elementScroll(elementScroll);
  478. },
  479. bindTop: function() {
  480. module.debug('Binding element to top of parent container');
  481. module.remove.offset();
  482. $module
  483. .css({
  484. left : '',
  485. top : '',
  486. marginBottom : ''
  487. })
  488. .removeClass(className.fixed)
  489. .removeClass(className.bottom)
  490. .addClass(className.bound)
  491. .addClass(className.top)
  492. ;
  493. settings.onTop.call(element);
  494. settings.onUnstick.call(element);
  495. },
  496. bindBottom: function() {
  497. module.debug('Binding element to bottom of parent container');
  498. module.remove.offset();
  499. $module
  500. .css({
  501. left : '',
  502. top : '',
  503. marginBottom : module.cache.context.bottomPadding
  504. })
  505. .removeClass(className.fixed)
  506. .removeClass(className.top)
  507. .addClass(className.bound)
  508. .addClass(className.bottom)
  509. ;
  510. settings.onBottom.call(element);
  511. settings.onUnstick.call(element);
  512. },
  513. setInitialPosition: function() {
  514. module.unfix();
  515. module.unbind();
  516. },
  517. fixTop: function() {
  518. module.debug('Fixing element to top of page');
  519. module.set.minimumSize();
  520. module.set.offset();
  521. $module
  522. .css({
  523. left : module.cache.element.left,
  524. bottom : '',
  525. marginBottom : ''
  526. })
  527. .removeClass(className.bound)
  528. .removeClass(className.bottom)
  529. .addClass(className.fixed)
  530. .addClass(className.top)
  531. ;
  532. settings.onStick.call(element);
  533. },
  534. fixBottom: function() {
  535. module.debug('Sticking element to bottom of page');
  536. module.set.minimumSize();
  537. module.set.offset();
  538. $module
  539. .css({
  540. left : module.cache.element.left,
  541. bottom : '',
  542. marginBottom : ''
  543. })
  544. .removeClass(className.bound)
  545. .removeClass(className.top)
  546. .addClass(className.fixed)
  547. .addClass(className.bottom)
  548. ;
  549. settings.onStick.call(element);
  550. },
  551. unbind: function() {
  552. module.debug('Removing absolute position on element');
  553. module.remove.offset();
  554. $module
  555. .removeClass(className.bound)
  556. .removeClass(className.top)
  557. .removeClass(className.bottom)
  558. ;
  559. },
  560. unfix: function() {
  561. module.debug('Removing fixed position on element');
  562. module.remove.offset();
  563. $module
  564. .removeClass(className.fixed)
  565. .removeClass(className.top)
  566. .removeClass(className.bottom)
  567. ;
  568. settings.onUnstick.call(element);
  569. },
  570. reset: function() {
  571. module.debug('Reseting elements position');
  572. module.unbind();
  573. module.unfix();
  574. module.resetCSS();
  575. module.remove.offset();
  576. module.remove.lastScroll();
  577. },
  578. resetCSS: function() {
  579. $module
  580. .css({
  581. width : '',
  582. height : ''
  583. })
  584. ;
  585. $container
  586. .css({
  587. height: ''
  588. })
  589. ;
  590. },
  591. setting: function(name, value) {
  592. if( $.isPlainObject(name) ) {
  593. $.extend(true, settings, name);
  594. }
  595. else if(value !== undefined) {
  596. settings[name] = value;
  597. }
  598. else {
  599. return settings[name];
  600. }
  601. },
  602. internal: function(name, value) {
  603. if( $.isPlainObject(name) ) {
  604. $.extend(true, module, name);
  605. }
  606. else if(value !== undefined) {
  607. module[name] = value;
  608. }
  609. else {
  610. return module[name];
  611. }
  612. },
  613. debug: function() {
  614. if(settings.debug) {
  615. if(settings.performance) {
  616. module.performance.log(arguments);
  617. }
  618. else {
  619. module.debug = Function.prototype.bind.call(console.info, console, settings.name + ':');
  620. module.debug.apply(console, arguments);
  621. }
  622. }
  623. },
  624. verbose: function() {
  625. if(settings.verbose && settings.debug) {
  626. if(settings.performance) {
  627. module.performance.log(arguments);
  628. }
  629. else {
  630. module.verbose = Function.prototype.bind.call(console.info, console, settings.name + ':');
  631. module.verbose.apply(console, arguments);
  632. }
  633. }
  634. },
  635. error: function() {
  636. module.error = Function.prototype.bind.call(console.error, console, settings.name + ':');
  637. module.error.apply(console, arguments);
  638. },
  639. performance: {
  640. log: function(message) {
  641. var
  642. currentTime,
  643. executionTime,
  644. previousTime
  645. ;
  646. if(settings.performance) {
  647. currentTime = new Date().getTime();
  648. previousTime = time || currentTime;
  649. executionTime = currentTime - previousTime;
  650. time = currentTime;
  651. performance.push({
  652. 'Name' : message[0],
  653. 'Arguments' : [].slice.call(message, 1) || '',
  654. 'Element' : element,
  655. 'Execution Time' : executionTime
  656. });
  657. }
  658. clearTimeout(module.performance.timer);
  659. module.performance.timer = setTimeout(module.performance.display, 0);
  660. },
  661. display: function() {
  662. var
  663. title = settings.name + ':',
  664. totalTime = 0
  665. ;
  666. time = false;
  667. clearTimeout(module.performance.timer);
  668. $.each(performance, function(index, data) {
  669. totalTime += data['Execution Time'];
  670. });
  671. title += ' ' + totalTime + 'ms';
  672. if(moduleSelector) {
  673. title += ' \'' + moduleSelector + '\'';
  674. }
  675. if( (console.group !== undefined || console.table !== undefined) && performance.length > 0) {
  676. console.groupCollapsed(title);
  677. if(console.table) {
  678. console.table(performance);
  679. }
  680. else {
  681. $.each(performance, function(index, data) {
  682. console.log(data['Name'] + ': ' + data['Execution Time']+'ms');
  683. });
  684. }
  685. console.groupEnd();
  686. }
  687. performance = [];
  688. }
  689. },
  690. invoke: function(query, passedArguments, context) {
  691. var
  692. object = instance,
  693. maxDepth,
  694. found,
  695. response
  696. ;
  697. passedArguments = passedArguments || queryArguments;
  698. context = element || context;
  699. if(typeof query == 'string' && object !== undefined) {
  700. query = query.split(/[\. ]/);
  701. maxDepth = query.length - 1;
  702. $.each(query, function(depth, value) {
  703. var camelCaseValue = (depth != maxDepth)
  704. ? value + query[depth + 1].charAt(0).toUpperCase() + query[depth + 1].slice(1)
  705. : query
  706. ;
  707. if( $.isPlainObject( object[camelCaseValue] ) && (depth != maxDepth) ) {
  708. object = object[camelCaseValue];
  709. }
  710. else if( object[camelCaseValue] !== undefined ) {
  711. found = object[camelCaseValue];
  712. return false;
  713. }
  714. else if( $.isPlainObject( object[value] ) && (depth != maxDepth) ) {
  715. object = object[value];
  716. }
  717. else if( object[value] !== undefined ) {
  718. found = object[value];
  719. return false;
  720. }
  721. else {
  722. return false;
  723. }
  724. });
  725. }
  726. if ( $.isFunction( found ) ) {
  727. response = found.apply(context, passedArguments);
  728. }
  729. else if(found !== undefined) {
  730. response = found;
  731. }
  732. if($.isArray(returnedValue)) {
  733. returnedValue.push(response);
  734. }
  735. else if(returnedValue !== undefined) {
  736. returnedValue = [returnedValue, response];
  737. }
  738. else if(response !== undefined) {
  739. returnedValue = response;
  740. }
  741. return found;
  742. }
  743. };
  744. if(methodInvoked) {
  745. if(instance === undefined) {
  746. module.initialize();
  747. }
  748. module.invoke(query);
  749. }
  750. else {
  751. if(instance !== undefined) {
  752. instance.invoke('destroy');
  753. }
  754. module.initialize();
  755. }
  756. })
  757. ;
  758. return (returnedValue !== undefined)
  759. ? returnedValue
  760. : this
  761. ;
  762. };
  763. $.fn.sticky.settings = {
  764. name : 'Sticky',
  765. namespace : 'sticky',
  766. debug : false,
  767. verbose : true,
  768. performance : true,
  769. // whether to stick in the opposite direction on scroll up
  770. pushing : false,
  771. context : false,
  772. // Context to watch scroll events
  773. scrollContext : window,
  774. // Offset to adjust scroll
  775. offset : 0,
  776. // Offset to adjust scroll when attached to bottom of screen
  777. bottomOffset : 0,
  778. jitter : 5, // will only set container height if difference between context and container is larger than this number
  779. // Whether to automatically observe changes with Mutation Observers
  780. observeChanges : false,
  781. // Called when position is recalculated
  782. onReposition : function(){},
  783. // Called on each scroll
  784. onScroll : function(){},
  785. // Called when element is stuck to viewport
  786. onStick : function(){},
  787. // Called when element is unstuck from viewport
  788. onUnstick : function(){},
  789. // Called when element reaches top of context
  790. onTop : function(){},
  791. // Called when element reaches bottom of context
  792. onBottom : function(){},
  793. error : {
  794. container : 'Sticky element must be inside a relative container',
  795. visible : 'Element is hidden, you must call refresh after element becomes visible',
  796. method : 'The method you called is not defined.',
  797. invalidContext : 'Context specified does not exist',
  798. elementSize : 'Sticky element is larger than its container, cannot create sticky.'
  799. },
  800. className : {
  801. bound : 'bound',
  802. fixed : 'fixed',
  803. supported : 'native',
  804. top : 'top',
  805. bottom : 'bottom'
  806. }
  807. };
  808. })( jQuery, window , document );