UNPKG

ngsticky

Version:

A simple, pure javascript (No jQuery required!) AngularJS directive to make elements stick when scrolling down.

387 lines (320 loc) 13.2 kB
(function () { 'use strict'; var module = angular.module('sticky', []); // Directive: sticky // module.directive('sticky', ['$window', function ($window) { return { restrict: 'A', // this directive can only be used as an attribute. scope: { disabled: '=disabledSticky' }, link: function linkFn($scope, $elem, $attrs) { var mediaQuery, stickyClass, unstickyClass, bodyClass, elem, $body, doc, initialCSS, initialStyle, isSticking, stickyLine, stickyBottomLine, offset, anchor, confine, prevOffset, matchMedia, usePlaceholder, placeholder, container, ngcontainer; $scope.initSticky = function () { isSticking = false; matchMedia = $window.matchMedia; // elements $body = angular.element(document.body); elem = $elem[0]; doc = document.documentElement; container = document.querySelector($attrs.container) || window; ngcontainer = angular.element(container); // attributes mediaQuery = $attrs.mediaQuery || false; stickyClass = $attrs.stickyClass || ''; unstickyClass = $attrs.unstickyClass || ''; bodyClass = $attrs.bodyClass || ''; usePlaceholder = $attrs.useplaceholder !== undefined; initialStyle = $elem.attr('style') || ''; offset = typeof $attrs.offset === 'string' ? parseInt($attrs.offset.replace(/px;?/, '')) : 0; anchor = typeof $attrs.anchor === 'string' ? $attrs.anchor.toLowerCase().trim() : 'top'; // Define the confine attribute - will confine sticky to it's parent confine = typeof $attrs.confine === 'string' ? $attrs.confine.toLowerCase().trim() : 'false'; confine = (confine === 'true'); // initial style initialCSS = { zIndex: $elem.css('z-index'), top: $elem.css('top'), width: $elem.css('width'), position: $elem.css('position'), marginTop: $elem.css('margin-top'), cssLeft: $elem.css('left') }; switch (anchor) { case 'top': case 'bottom': break; default: anchor = 'top'; break; } // Watcher // prevOffset = $scope.getTopOffset(elem); // Listeners // angular.element(ngcontainer).on('scroll', $scope.checkIfShouldStick); angular.element($window).on('resize', $scope.$apply.bind($scope, $scope.onResize)); $scope.$on('$destroy', $scope.onDestroy); }; $scope.getScrollTop = function () { if (typeof $window.pageYOffset !== 'undefined') { //most browsers except IE before #9 return $window.pageYOffset; } else { var B = document.body; //IE 'quirks' var D = document.documentElement; //IE with doctype D = (D.clientHeight) ? D : B; return D.scrollTop; } }; $scope.getTopOffset = function (element) { if (element.getBoundingClientRect) { // Using getBoundingClientRect is vastly faster, if it's available return element.getBoundingClientRect().top + document.documentElement.scrollTop; } else { var pixels = 0; if (element.offsetParent) { do { pixels += element.offsetTop; element = element.offsetParent; } while (element); } return pixels; } }; $scope.getBottomOffset = function (element) { return element.offsetTop + element.clientHeight; }; $scope.shouldStickWithLimit = function (shouldApplyWithLimit) { if (shouldApplyWithLimit === 'true') { var elementHeight = elem.offsetHeight; var windowHeight = $window.innerHeight; return (windowHeight - (elementHeight + parseInt(offset)) < 0); } else { return false; } }; $scope.onResize = function () { initialCSS.offsetWidth = elem.offsetWidth; $scope.unStickElement(); $scope.checkIfShouldStick(); if (isSticking) { var parent = $window.getComputedStyle(elem.parentElement, null), initialOffsetWidth = elem.parentElement.offsetWidth - parent.getPropertyValue('padding-right').replace('px', '') - parent.getPropertyValue('padding-left').replace('px', ''); $elem.css('width', initialOffsetWidth + 'px'); } }; $scope.onDestroy = function () { angular.element(ngcontainer).off('scroll', $scope.checkIfShouldStick); angular.element($window).off('resize', $scope.onResize); if (bodyClass) { $body.removeClass(bodyClass); } if (placeholder) { placeholder.remove(); } }; // Methods // // Simple helper function to find closest value // from a set of numbers in an array $scope.getClosest = function (array, num) { var minDiff = 1000; var ans; for (var i in array) { var m = Math.abs(num - array[i]); if (m < minDiff) { minDiff = m; ans = array[i]; } } return ans; }; $scope.stickElement = function () { var rect, absoluteLeft; rect = $elem[0].getBoundingClientRect(); absoluteLeft = rect.left; initialCSS.offsetWidth = elem.offsetWidth; isSticking = true; if (bodyClass) { $body.addClass(bodyClass); } if (unstickyClass) { if ($elem.hasClass(unstickyClass)) { $elem.removeClass(unstickyClass); } } if (stickyClass) { $elem.addClass(stickyClass); } //create placeholder to avoid jump if (usePlaceholder) { placeholder = angular.element('<div>'); var elementsHeight = $elem[0].offsetHeight; placeholder.css('height', elementsHeight + 'px'); $elem.after(placeholder); } $elem .css('z-index', '10') .css('width', elem.offsetWidth + 'px') .css('position', 'fixed') .css(anchor, offset + 'px') .css('left', absoluteLeft + 'px') .css('margin-top', 0); if (anchor === 'bottom') { $elem.css('margin-bottom', 0); } }; $scope.checkIfShouldStick = function () { if ($scope.disabled === true){ $scope.unStickElement(); return false; } var scrollTop, shouldStick, scrollBottom, scrolledDistance; if (mediaQuery && !(matchMedia('(' + mediaQuery + ')').matches || matchMedia(mediaQuery).matches)) { // Make sure to unstick element if media query no longer matches if (isSticking) { $scope.unStickElement(); } return; } if (anchor === 'top') { scrolledDistance = $window.pageYOffset || doc.scrollTop || container.scrollTop; scrollTop = scrolledDistance - (doc.clientTop || ngcontainer.clientTop || 0); if (confine === true) { shouldStick = scrollTop >= stickyLine && scrollTop <= stickyBottomLine; } else { shouldStick = scrollTop >= stickyLine; } } else { scrollBottom = $window.pageYOffset + $window.innerHeight; shouldStick = scrollBottom <= 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 if (shouldStick && !$scope.shouldStickWithLimit($attrs.stickLimit) && !isSticking) { $scope.stickElement(); } else if (!shouldStick && isSticking) { // could probably do this better var from, compare, closest; compare = [stickyLine, stickyBottomLine]; closest = $scope.getClosest(compare, scrollTop); // Check to see if we are closer to the top or bottom confines // and set from to let the unstick element know the origin if (closest === stickyLine) { from = 'top'; } else if (closest === stickyBottomLine) { from = 'bottom'; } $scope.unStickElement(from, scrollTop); } }; // Passing in scrolltop and directional origin to help // with some math later $scope.unStickElement = function (fromDirection) { $elem.attr('style', initialStyle); isSticking = false; if (bodyClass) { $body.removeClass(bodyClass); } if (stickyClass) { $elem.removeClass(stickyClass); } if (unstickyClass) { $elem.addClass(unstickyClass); } if (fromDirection === 'top') { $elem .css('z-index', initialCSS.zIndex) .css('width', '') .css('top', initialCSS.top) .css('position', initialCSS.position) .css('left', initialCSS.cssLeft) .css('margin-top', initialCSS.marginTop); } else if (fromDirection === 'bottom' && confine === true) { // make sure we are checking to see if the element is confined to the parent $elem .css('z-index', initialCSS.zIndex) .css('width', '') .css('top', '') .css('bottom', 0) .css('position', 'absolute') .css('left', initialCSS.cssLeft) .css('margin-top', initialCSS.marginTop) .css('margin-bottom', initialCSS.marginBottom); } if (placeholder) { placeholder.remove(); } }; $scope.$watch(function () { // triggered on load and on digest cycle if ($scope.disabled === true){ $scope.unStickElement(); return; } if (isSticking) { return prevOffset + $scope.getScrollTop(); } prevOffset = (anchor === 'top') ? $scope.getTopOffset(elem) : $scope.getBottomOffset(elem); return prevOffset + $scope.getScrollTop(); }, function (newVal, oldVal) { if (( typeof stickyLine === 'undefined' ) && newVal !== 0 && !isSticking) { stickyLine = newVal - offset; // 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]; var parentHeight = parseInt(parent.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 stickyBottomLine = parentHeight - (elem.offsetTop + elem.offsetHeight) + offset + marginBottom; $scope.checkIfShouldStick(); } }); // Init the directive $scope.initSticky(); } }; }] ); // Shiv: matchMedia // window.matchMedia = window.matchMedia || (function () { var warning = 'angular-sticky: This browser does not support ' + 'matchMedia, therefore the minWidth option will not work on ' + 'this browser. Polyfill matchMedia to fix this issue.'; if (window.console && console.warn) { console.warn(warning); } return function () { return { matches: true }; }; }()); }());