/** * ngSticky - https://github.com/d-oliveros/ngSticky * * A simple, pure javascript (No jQuery required!) AngularJS directive * to make elements stick when scrolling down. * * Credits: https://github.com/d-oliveros/ngSticky/graphs/contributors */ (function() { 'use strict'; var module = angular.module('sticky', []); /** * Directive: sticky */ module.directive('sticky', ['$window', '$timeout', function($window, $timeout) { return { restrict: 'A', // this directive can only be used as an attribute. scope: { disabled: '=disabledSticky' }, link: function linkFn($scope, $elem, $attrs) { // Initial scope var scrollableNodeTagName = 'sticky-scroll'; var initialPosition = $elem.css('position'); var initialStyle = $elem.attr('style') || ''; var stickyBottomLine = 0; var isSticking = false; var onStickyHeighUnbind; var originalInitialCSS; var originalOffset; var placeholder; var stickyLine; var initialCSS; // Optional Classes var stickyClass = $attrs.stickyClass || ''; var unstickyClass = $attrs.unstickyClass || ''; var bodyClass = $attrs.bodyClass || ''; var bottomClass = $attrs.bottomClass || ''; // Find scrollbar var scrollbar = deriveScrollingViewport ($elem); // Define elements var windowElement = angular.element($window); var scrollbarElement = angular.element(scrollbar); var $body = angular.element(document.body); // Resize callback var $onResize = function () { if ($scope.$root && !$scope.$root.$$phase) { $scope.$apply(onResize); } else { onResize(); } }; // Define options var usePlaceholder = ($attrs.usePlaceholder !== 'false'); var anchor = $attrs.anchor === 'bottom' ? 'bottom' : 'top'; var confine = ($attrs.confine === 'true'); // flag: can react to recalculating the initial CSS dimensions later // as link executes prematurely. defaults to immediate checking var isStickyLayoutDeferred = $attrs.isStickyLayoutDeferred !== undefined ? ($attrs.isStickyLayoutDeferred === 'true') : false; // flag: is sticky content constantly observed for changes. // Should be true if content uses ngBind to show text // that may vary in size over time var isStickyLayoutWatched = $attrs.isStickyLayoutWatched !== undefined ? ($attrs.isStickyLayoutWatched === 'true') : true; var offset = $attrs.offset ? parseInt ($attrs.offset.replace(/px;?/, '')) : 0; /** * Trigger to initialize the sticky * Because of the `timeout()` method for the call of * @type {Boolean} */ var shouldInitialize = true; /** * Initialize Sticky */ function initSticky() { if (shouldInitialize) { // Listeners scrollbarElement.on('scroll', checkIfShouldStick); windowElement.on('resize', $onResize); memorizeDimensions(); // remember sticky's layout dimensions // Setup watcher on digest and change $scope.$watch(onDigest, onChange); // Clean up $scope.$on('$destroy', onDestroy); shouldInitialize = false; } }; /** * need to recall sticky's DOM attributes (make sure layout has occured) */ function memorizeDimensions() { // immediate assignment, but there is the potential for wrong values if content not ready initialCSS = $scope.getInitialDimensions(); // option to calculate the dimensions when layout is 'ready' if (isStickyLayoutDeferred) { // logic: when this directive link() runs before the content has had a chance to layout on browser, height could be 0 if (!$elem[0].getBoundingClientRect().height) { onStickyHeighUnbind = $scope.$watch( function() { return $elem.height(); }, // state change: sticky content's height set function onStickyContentLayoutInitialHeightSet(newValue, oldValue) { if (newValue > 0) { // now can memorize initialCSS = $scope.getInitialDimensions(); if (!isStickyLayoutWatched) { // preference was to do just a one-time async watch on the sticky's content; now stop watching onStickyHeighUnbind(); } } } ); } } } /** * Determine if the element should be sticking or not. */ var checkIfShouldStick = function() { if ($scope.disabled === true || mediaQueryMatches()) { if (isSticking) unStickElement(); return false; } // What's the document client top for? var scrollbarPosition = scrollbarYPos(); var shouldStick; if (anchor === 'top') { if (confine === true) { shouldStick = scrollbarPosition > stickyLine && scrollbarPosition <= stickyBottomLine; } else { shouldStick = scrollbarPosition > stickyLine; } } else { shouldStick = scrollbarPosition <= stickyLine; } // Switch the sticky mode if the element crosses the sticky line // $attrs.stickLimit - when it's equal to true it enables the user // to turn off the sticky function when the elem height is // bigger then the viewport var closestLine = getClosest (scrollbarPosition, stickyLine, stickyBottomLine); if (shouldStick && !shouldStickWithLimit ($attrs.stickLimit) && !isSticking) { stickElement (closestLine); } else if (!shouldStick && isSticking) { unStickElement(closestLine, scrollbarPosition); } else if (confine && !shouldStick) { // If we are confined to the parent, refresh, and past the stickyBottomLine // We should 'remember' the original offset and unstick the element which places it at the stickyBottomLine originalOffset = elementsOffsetFromTop ($elem[0]); unStickElement (closestLine, scrollbarPosition); } }; /** * determine the respective node that handles scrolling, defaulting to browser window */ function deriveScrollingViewport(stickyNode) { // derive relevant scrolling by ascending the DOM tree var match =findAncestorTag (scrollableNodeTagName, stickyNode); return (match.length === 1) ? match[0] : $window; } /** * since jqLite lacks closest(), this is a pseudo emulator (by tag name) */ function findAncestorTag(tag, context) { var m = []; // nodelist container var n = context.parent(); // starting point var p; do { var node = n[0]; // break out of jqLite // limit DOM territory if (node.nodeType !== 1) { break; } // success if (node.tagName.toUpperCase() === tag.toUpperCase()) { return n; } p = n.parent(); n = p; // set to parent } while (p.length !== 0); return m; // empty set } /** * Seems to be undocumented functionality */ function shouldStickWithLimit(shouldApplyWithLimit) { return shouldApplyWithLimit === 'true' ? ($window.innerHeight - ($elem[0].offsetHeight + parseInt(offset)) < 0) : false; } /** * Finds the closest value from a set of numbers in an array. */ function getClosest(scrollTop, stickyLine, stickyBottomLine) { var closest = 'top'; var topDistance = Math.abs(scrollTop - stickyLine); var bottomDistance = Math.abs(scrollTop - stickyBottomLine); if (topDistance > bottomDistance) { closest = 'bottom'; } return closest; } /** * Unsticks the element */ function unStickElement(fromDirection) { if (initialStyle) { $elem.attr('style', initialStyle); } isSticking = false; initialCSS.width = $scope.getInitialDimensions().width; $body.removeClass(bodyClass); $elem.removeClass(stickyClass); $elem.addClass(unstickyClass); if (fromDirection === 'top') { $elem.removeClass(bottomClass); $elem .css('z-index', 10) .css('width', initialCSS.width) .css('top', initialCSS.top) .css('position', initialCSS.position) .css('left', initialCSS.cssLeft) .css('margin-top', initialCSS.marginTop) .css('height', initialCSS.height); } else if (fromDirection === 'bottom' && confine === true) { $elem.addClass(bottomClass); // It's possible to page down page and skip the 'stickElement'. // In that case we should create a placeholder so the offsets don't get off. createPlaceholder(); $elem .css('z-index', 10) .css('width', initialCSS.width) .css('top', '') .css('bottom', 0) .css('position', 'absolute') .css('left', initialCSS.cssLeft) .css('margin-top', initialCSS.marginTop) .css('margin-bottom', initialCSS.marginBottom) .css('height', initialCSS.height); } if (placeholder && fromDirection === anchor) { placeholder.remove(); } } /** * Sticks the element */ function stickElement(closestLine) { // Set sticky state isSticking = true; $timeout(function() { initialCSS.offsetWidth = $elem[0].offsetWidth; }, 0); $body.addClass(bodyClass); $elem.removeClass(unstickyClass); $elem.removeClass(bottomClass); $elem.addClass(stickyClass); createPlaceholder(); $elem .css('z-index', '10') .css('width', $elem[0].offsetWidth + 'px') .css('position', 'fixed') .css('left', $elem.css('left').replace('px', '') + 'px') .css(anchor, (offset + elementsOffsetFromTop (scrollbar)) + 'px') .css('margin-top', 0); if (anchor === 'bottom') { $elem.css('margin-bottom', 0); } } /** * Clean up directive */ var onDestroy = function() { scrollbarElement.off('scroll', checkIfShouldStick); windowElement.off('resize', $onResize); $onResize = null; $body.removeClass(bodyClass); if (placeholder) { placeholder.remove(); } }; /** * Updates on resize. */ function onResize() { unStickElement (anchor); checkIfShouldStick(); } /** * Triggered on load / digest cycle * return `0` if the DOM element is hidden */ var onDigest = function() { if ($scope.disabled === true) { return unStickElement(); } var offsetFromTop = elementsOffsetFromTop ($elem[0]); if (offsetFromTop === 0) { return offsetFromTop; } if (anchor === 'top') { return (originalOffset || offsetFromTop) - elementsOffsetFromTop (scrollbar) + scrollbarYPos(); } else { return offsetFromTop - scrollbarHeight() + $elem[0].offsetHeight + scrollbarYPos(); } }; /** * Triggered on change */ var onChange = function (newVal, oldVal) { /** * Indicate if the DOM element is showed, or not * @type {boolean} */ var elemIsShowed = !!newVal; /** * Indicate if the DOM element was showed, or not * @type {boolean} */ var elemWasHidden = !oldVal; var valChange = (newVal !== oldVal || typeof stickyLine === 'undefined'); var notSticking = (!isSticking && !isBottomedOut()); if (valChange && notSticking && newVal > 0 && elemIsShowed) { stickyLine = newVal - offset; //Update dimensions of sticky element when is showed if (elemIsShowed && elemWasHidden) { $scope.updateStickyContentUpdateDimensions($elem[0].offsetWidth, $elem[0].offsetHeight); } // IF the sticky is confined, we want to make sure the parent is relatively positioned, // otherwise it won't bottom out properly if (confine) { $elem.parent().css({ 'position': 'relative' }); } // Get Parent height, so we know when to bottom out for confined stickies var parent = $elem.parent()[0]; // Offset parent height by the elements height, if we're not using a placeholder var parentHeight = parseInt (parent.offsetHeight) - (usePlaceholder ? 0 : $elem[0].offsetHeight); // and now lets ensure we adhere to the bottom margins // TODO: make this an attribute? Maybe like ignore-margin? var marginBottom = parseInt ($elem.css('margin-bottom').replace(/px;?/, '')) || 0; // specify the bottom out line for the sticky to unstick var elementsDistanceFromTop = elementsOffsetFromTop ($elem[0]); var parentsDistanceFromTop = elementsOffsetFromTop (parent) var scrollbarDistanceFromTop = elementsOffsetFromTop (scrollbar); var elementsDistanceFromScrollbarStart = elementsDistanceFromTop - scrollbarDistanceFromTop; var elementsDistanceFromBottom = parentsDistanceFromTop + parentHeight - elementsDistanceFromTop; stickyBottomLine = elementsDistanceFromScrollbarStart + elementsDistanceFromBottom - $elem[0].offsetHeight - marginBottom - offset + +scrollbarYPos(); checkIfShouldStick(); } }; /** * Helper Functions */ /** * Create a placeholder */ function createPlaceholder() { if (usePlaceholder) { // Remove the previous placeholder if (placeholder) { placeholder.remove(); } placeholder = angular.element('