2
* ngSticky - https://github.com/d-oliveros/ngSticky
4
* A simple, pure javascript (No jQuery required!) AngularJS directive
5
* to make elements stick when scrolling down.
7
* Credits: https://github.com/d-oliveros/ngSticky/graphs/contributors
12
var module = angular.module('sticky', []);
17
module.directive('sticky', ['$window', '$timeout', function($window, $timeout) {
19
restrict: 'A', // this directive can only be used as an attribute.
21
disabled: '=disabledSticky'
23
link: function linkFn($scope, $elem, $attrs) {
26
var scrollableNodeTagName = 'sticky-scroll';
27
var initialPosition = $elem.css('position');
28
var initialStyle = $elem.attr('style') || '';
29
var stickyBottomLine = 0;
30
var isSticking = false;
31
var onStickyHeighUnbind;
32
var originalInitialCSS;
39
var stickyClass = $attrs.stickyClass || '';
40
var unstickyClass = $attrs.unstickyClass || '';
41
var bodyClass = $attrs.bodyClass || '';
42
var bottomClass = $attrs.bottomClass || '';
45
var scrollbar = deriveScrollingViewport ($elem);
48
var windowElement = angular.element($window);
49
var scrollbarElement = angular.element(scrollbar);
50
var $body = angular.element(document.body);
53
var $onResize = function () {
54
if ($scope.$root && !$scope.$root.$$phase) {
55
$scope.$apply(onResize);
62
var usePlaceholder = ($attrs.usePlaceholder !== 'false');
63
var anchor = $attrs.anchor === 'bottom' ? 'bottom' : 'top';
64
var confine = ($attrs.confine === 'true');
66
// flag: can react to recalculating the initial CSS dimensions later
67
// as link executes prematurely. defaults to immediate checking
68
var isStickyLayoutDeferred = $attrs.isStickyLayoutDeferred !== undefined
69
? ($attrs.isStickyLayoutDeferred === 'true')
72
// flag: is sticky content constantly observed for changes.
73
// Should be true if content uses ngBind to show text
74
// that may vary in size over time
75
var isStickyLayoutWatched = $attrs.isStickyLayoutWatched !== undefined
76
? ($attrs.isStickyLayoutWatched === 'true')
80
var offset = $attrs.offset
81
? parseInt ($attrs.offset.replace(/px;?/, ''))
85
* Trigger to initialize the sticky
86
* Because of the `timeout()` method for the call of
89
var shouldInitialize = true;
94
function initSticky() {
96
if (shouldInitialize) {
99
scrollbarElement.on('scroll', checkIfShouldStick);
100
windowElement.on('resize', $onResize);
102
memorizeDimensions(); // remember sticky's layout dimensions
104
// Setup watcher on digest and change
105
$scope.$watch(onDigest, onChange);
108
$scope.$on('$destroy', onDestroy);
109
shouldInitialize = false;
114
* need to recall sticky's DOM attributes (make sure layout has occured)
116
function memorizeDimensions() {
117
// immediate assignment, but there is the potential for wrong values if content not ready
118
initialCSS = $scope.getInitialDimensions();
120
// option to calculate the dimensions when layout is 'ready'
121
if (isStickyLayoutDeferred) {
123
// logic: when this directive link() runs before the content has had a chance to layout on browser, height could be 0
124
if (!$elem[0].getBoundingClientRect().height) {
126
onStickyHeighUnbind = $scope.$watch(
128
return $elem.height();
131
// state change: sticky content's height set
132
function onStickyContentLayoutInitialHeightSet(newValue, oldValue) {
135
initialCSS = $scope.getInitialDimensions();
137
if (!isStickyLayoutWatched) {
138
// preference was to do just a one-time async watch on the sticky's content; now stop watching
139
onStickyHeighUnbind();
149
* Determine if the element should be sticking or not.
151
var checkIfShouldStick = function() {
152
if ($scope.disabled === true || mediaQueryMatches()) {
153
if (isSticking) unStickElement();
157
// What's the document client top for?
158
var scrollbarPosition = scrollbarYPos();
161
if (anchor === 'top') {
162
if (confine === true) {
163
shouldStick = scrollbarPosition > stickyLine && scrollbarPosition <= stickyBottomLine;
165
shouldStick = scrollbarPosition > stickyLine;
168
shouldStick = scrollbarPosition <= stickyLine;
171
// Switch the sticky mode if the element crosses the sticky line
172
// $attrs.stickLimit - when it's equal to true it enables the user
173
// to turn off the sticky function when the elem height is
174
// bigger then the viewport
175
var closestLine = getClosest (scrollbarPosition, stickyLine, stickyBottomLine);
177
if (shouldStick && !shouldStickWithLimit ($attrs.stickLimit) && !isSticking) {
178
stickElement (closestLine);
179
} else if (!shouldStick && isSticking) {
180
unStickElement(closestLine, scrollbarPosition);
181
} else if (confine && !shouldStick) {
182
// If we are confined to the parent, refresh, and past the stickyBottomLine
183
// We should 'remember' the original offset and unstick the element which places it at the stickyBottomLine
184
originalOffset = elementsOffsetFromTop ($elem[0]);
185
unStickElement (closestLine, scrollbarPosition);
190
* determine the respective node that handles scrolling, defaulting to browser window
192
function deriveScrollingViewport(stickyNode) {
193
// derive relevant scrolling by ascending the DOM tree
194
var match =findAncestorTag (scrollableNodeTagName, stickyNode);
195
return (match.length === 1) ? match[0] : $window;
199
* since jqLite lacks closest(), this is a pseudo emulator (by tag name)
201
function findAncestorTag(tag, context) {
202
var m = []; // nodelist container
203
var n = context.parent(); // starting point
207
var node = n[0]; // break out of jqLite
208
// limit DOM territory
209
if (node.nodeType !== 1) {
214
if (node.tagName.toUpperCase() === tag.toUpperCase()) {
219
n = p; // set to parent
220
} while (p.length !== 0);
222
return m; // empty set
226
* Seems to be undocumented functionality
228
function shouldStickWithLimit(shouldApplyWithLimit) {
229
return shouldApplyWithLimit === 'true'
230
? ($window.innerHeight - ($elem[0].offsetHeight + parseInt(offset)) < 0)
235
* Finds the closest value from a set of numbers in an array.
237
function getClosest(scrollTop, stickyLine, stickyBottomLine) {
239
var topDistance = Math.abs(scrollTop - stickyLine);
240
var bottomDistance = Math.abs(scrollTop - stickyBottomLine);
242
if (topDistance > bottomDistance) {
250
* Unsticks the element
252
function unStickElement(fromDirection) {
254
$elem.attr('style', initialStyle);
258
initialCSS.width = $scope.getInitialDimensions().width;
260
$body.removeClass(bodyClass);
261
$elem.removeClass(stickyClass);
262
$elem.addClass(unstickyClass);
264
if (fromDirection === 'top') {
265
$elem.removeClass(bottomClass);
269
.css('width', initialCSS.width)
270
.css('top', initialCSS.top)
271
.css('position', initialCSS.position)
272
.css('left', initialCSS.cssLeft)
273
.css('margin-top', initialCSS.marginTop)
274
.css('height', initialCSS.height);
275
} else if (fromDirection === 'bottom' && confine === true) {
276
$elem.addClass(bottomClass);
278
// It's possible to page down page and skip the 'stickElement'.
279
// In that case we should create a placeholder so the offsets don't get off.
284
.css('width', initialCSS.width)
287
.css('position', 'absolute')
288
.css('left', initialCSS.cssLeft)
289
.css('margin-top', initialCSS.marginTop)
290
.css('margin-bottom', initialCSS.marginBottom)
291
.css('height', initialCSS.height);
294
if (placeholder && fromDirection === anchor) {
295
placeholder.remove();
302
function stickElement(closestLine) {
305
$timeout(function() {
306
initialCSS.offsetWidth = $elem[0].offsetWidth;
308
$body.addClass(bodyClass);
309
$elem.removeClass(unstickyClass);
310
$elem.removeClass(bottomClass);
311
$elem.addClass(stickyClass);
316
.css('z-index', '10')
317
.css('width', $elem[0].offsetWidth + 'px')
318
.css('position', 'fixed')
319
.css('left', $elem.css('left').replace('px', '') + 'px')
320
.css(anchor, (offset + elementsOffsetFromTop (scrollbar)) + 'px')
321
.css('margin-top', 0);
323
if (anchor === 'bottom') {
324
$elem.css('margin-bottom', 0);
331
var onDestroy = function() {
332
scrollbarElement.off('scroll', checkIfShouldStick);
333
windowElement.off('resize', $onResize);
337
$body.removeClass(bodyClass);
340
placeholder.remove();
347
function onResize() {
348
unStickElement (anchor);
349
checkIfShouldStick();
353
* Triggered on load / digest cycle
354
* return `0` if the DOM element is hidden
356
var onDigest = function() {
357
if ($scope.disabled === true) {
358
return unStickElement();
360
var offsetFromTop = elementsOffsetFromTop ($elem[0]);
361
if (offsetFromTop === 0) {
362
return offsetFromTop;
364
if (anchor === 'top') {
365
return (originalOffset || offsetFromTop) - elementsOffsetFromTop (scrollbar) + scrollbarYPos();
367
return offsetFromTop - scrollbarHeight() + $elem[0].offsetHeight + scrollbarYPos();
372
* Triggered on change
374
var onChange = function (newVal, oldVal) {
377
* Indicate if the DOM element is showed, or not
380
var elemIsShowed = !!newVal;
383
* Indicate if the DOM element was showed, or not
386
var elemWasHidden = !oldVal;
387
var valChange = (newVal !== oldVal || typeof stickyLine === 'undefined');
388
var notSticking = (!isSticking && !isBottomedOut());
390
if (valChange && notSticking && newVal > 0 && elemIsShowed) {
391
stickyLine = newVal - offset;
392
//Update dimensions of sticky element when is showed
393
if (elemIsShowed && elemWasHidden) {
394
$scope.updateStickyContentUpdateDimensions($elem[0].offsetWidth, $elem[0].offsetHeight);
396
// IF the sticky is confined, we want to make sure the parent is relatively positioned,
397
// otherwise it won't bottom out properly
400
'position': 'relative'
404
// Get Parent height, so we know when to bottom out for confined stickies
405
var parent = $elem.parent()[0];
407
// Offset parent height by the elements height, if we're not using a placeholder
408
var parentHeight = parseInt (parent.offsetHeight) - (usePlaceholder ? 0 : $elem[0].offsetHeight);
410
// and now lets ensure we adhere to the bottom margins
411
// TODO: make this an attribute? Maybe like ignore-margin?
412
var marginBottom = parseInt ($elem.css('margin-bottom').replace(/px;?/, '')) || 0;
414
// specify the bottom out line for the sticky to unstick
415
var elementsDistanceFromTop = elementsOffsetFromTop ($elem[0]);
416
var parentsDistanceFromTop = elementsOffsetFromTop (parent)
417
var scrollbarDistanceFromTop = elementsOffsetFromTop (scrollbar);
419
var elementsDistanceFromScrollbarStart = elementsDistanceFromTop - scrollbarDistanceFromTop;
420
var elementsDistanceFromBottom = parentsDistanceFromTop + parentHeight - elementsDistanceFromTop;
422
stickyBottomLine = elementsDistanceFromScrollbarStart
423
+ elementsDistanceFromBottom
424
- $elem[0].offsetHeight
429
checkIfShouldStick();
438
* Create a placeholder
440
function createPlaceholder() {
441
if (usePlaceholder) {
442
// Remove the previous placeholder
444
placeholder.remove();
447
placeholder = angular.element('<div>');
448
var elementsHeight = $elem[0].offsetHeight;
449
var computedStyle = $elem[0].currentStyle || window.getComputedStyle($elem[0]);
450
elementsHeight += parseInt(computedStyle.marginTop, 10);
451
elementsHeight += parseInt(computedStyle.marginBottom, 10);
452
elementsHeight += parseInt(computedStyle.borderTopWidth, 10);
453
elementsHeight += parseInt(computedStyle.borderBottomWidth, 10);
454
placeholder.css('height', $elem[0].offsetHeight + 'px');
456
$elem.after(placeholder);
461
* Are we bottomed out of the parent element?
463
function isBottomedOut() {
464
if (confine && scrollbarYPos() > stickyBottomLine) {
472
* Fetch top offset of element
474
function elementsOffsetFromTop(element) {
477
if (element.getBoundingClientRect) {
478
offset = element.getBoundingClientRect().top;
485
* Retrieves top scroll distance
487
function scrollbarYPos() {
490
if (typeof scrollbar.scrollTop !== 'undefined') {
491
position = scrollbar.scrollTop;
492
} else if (typeof scrollbar.pageYOffset !== 'undefined') {
493
position = scrollbar.pageYOffset;
495
position = document.documentElement.scrollTop;
502
* Determine scrollbar's height
504
function scrollbarHeight() {
507
if (scrollbarElement[0] instanceof HTMLElement) {
508
// isn't bounding client rect cleaner than insane regex mess?
509
height = $window.getComputedStyle(scrollbarElement[0], null)
510
.getPropertyValue('height')
511
.replace(/px;?/, '');
513
height = $window.innerHeight;
516
return parseInt (height) || 0;
520
* Checks if the media matches
522
function mediaQueryMatches() {
523
var mediaQuery = $attrs.mediaQuery || false;
524
var matchMedia = $window.matchMedia;
526
return mediaQuery && !(matchMedia ('(' + mediaQuery + ')').matches || matchMedia (mediaQuery).matches);
530
* Get more accurate CSS values
532
function getCSS($el, prop){
534
computed = window.getComputedStyle(el),
535
prevDisplay = computed.display,
538
// hide the element so that we can get original css
539
// values instead of computed values
540
el.style.display = "none";
542
// NOTE - computed style declaration object is a reference
543
// to the element's CSSStyleDeclaration, so it will always
544
// reflect the current style of the element
545
val = computed[prop];
547
// restore previous display value
548
el.style.display = prevDisplay;
553
// public accessors for the controller to hitch into. Helps with external API access
554
$scope.getElement = function() { return $elem; };
555
$scope.getScrollbar = function() { return scrollbar; };
556
$scope.getInitialCSS = function() { return initialCSS; };
557
$scope.getAnchor = function() { return anchor; };
558
$scope.isSticking = function() { return isSticking; };
559
$scope.getOriginalInitialCSS = function() { return originalInitialCSS; };
560
// pass through aliases
561
$scope.processUnStickElement = function(anchor) { unStickElement(anchor)};
562
$scope.processCheckIfShouldStick =function() { checkIfShouldStick(); };
565
* set the dimensions for the defaults of the content block occupied by the sticky element
567
$scope.getInitialDimensions = function() {
569
zIndex: $elem.css('z-index'),
570
top: $elem.css('top'),
571
position: initialPosition, // revert to true initial state
572
marginTop: $elem.css('margin-top'),
573
marginBottom: $elem.css('margin-bottom'),
574
cssLeft: getCSS($elem, 'left'),
575
width: $elem[0].offsetWidth,
576
height: $elem.css('height')
581
* only change content box dimensions
583
$scope.updateStickyContentUpdateDimensions = function(width, height) {
584
if (width && height) {
586
initialCSS.width = width + 'px';
587
initialCSS.height = height + 'px';
591
// ----------- configuration -----------
593
$timeout(function() {
594
originalInitialCSS = $scope.getInitialDimensions(); // preserve a copy
595
// Init the directive
601
* +++++++++ public APIs+++++++++++++
603
controller: ['$scope', '$window', function($scope, $window) {
606
* integration method allows for an outside client to reset the pinned state back to unpinned.
607
* Useful for when refreshing the scrollable DIV content completely
608
* if newWidth and newHeight integer values are not supplied then function will make a best guess
610
this.resetLayout = function(newWidth, newHeight) {
612
var scrollbar = $scope.getScrollbar(),
613
initialCSS = $scope.getInitialCSS(),
614
anchor = $scope.getAnchor();
616
function _resetScrollPosition() {
618
// reset means content is scrolled to anchor position
619
if (anchor === 'top') {
620
// window based scroller
621
if (scrollbar === $window) {
622
$window.scrollTo(0, 0);
623
// DIV based sticky scroller
625
if (scrollbar.scrollTop > 0) {
626
scrollbar.scrollTop = 0;
630
// todo: need bottom use case
633
// only if pinned, force unpinning, otherwise height is inadvertently reset to 0
634
if ($scope.isSticking()) {
635
$scope.processUnStickElement (anchor);
636
$scope.processCheckIfShouldStick();
638
// remove layout-affecting attribures that were modified by this sticky
639
$scope.getElement().css({ 'width': '', 'height': '', 'position': '', 'top': '', zIndex: '' });
641
initialCSS.position = $scope.getOriginalInitialCSS().position; // revert to original state
642
delete initialCSS.offsetWidth; // stickElement affected
644
// use this directive element's as default, if no measurements passed in
645
if (newWidth === undefined && newHeight === undefined) {
646
var e_bcr = $scope.getElement()[0].getBoundingClientRect();
647
newWidth = e_bcr.width;
648
newHeight = e_bcr.height;
651
// update model with new dimensions (if supplied from client's own measurement)
652
$scope.updateStickyContentUpdateDimensions(newWidth, newHeight); // update layout dimensions only
654
_resetScrollPosition();
658
* return a reference to the scrolling element (window or DIV with overflow)
660
this.getScrollbar = function() {
661
return $scope.getScrollbar();
669
window.matchMedia = window.matchMedia || (function() {
670
var warning = 'angular-sticky: This browser does not support ' +
671
'matchMedia, therefore the minWidth option will not work on ' +
672
'this browser. Polyfill matchMedia to fix this issue.';
674
if (window.console && console.warn) {
675
console.warn(warning);