Blog-Archiv

Donnerstag, 3. Dezember 2015

JS Sticky Table-of-Contents

A nice web page effect is what I call "sticky table-of-contents" here. Other than most sticky headers and footers, a sticky TOC really makes sense. The article smoothly scrolls away while the overview stands by without getting out of sight. Look at the demo below. Try also to resize the panel by its bottom-right grip.

Sticky Table-of-Contents Demo

This page demonstrates how a left-side table-of-contents could behave beside an article on right side. It stays in view as long as the article is scrolled down, but scrolls upwards when the footer appears. The contained script works for top-level elements as well as for elements in an internal scroll-pane.

  • Intro 1
  • One
  • Two
  • Three
  • Four
  • Five
  • Six
  • Seven
  • Intro 2
  • One
  • Two
  • Three
  • Four
  • Five
  • Six
  • Seven
  • Intro 3
  • Summary

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent luctus urna sit amet sollicitudin venenatis. Aenean odio tortor, varius vitae molestie eu, ultricies vel lacus. Nam viverra fermentum dapibus. Nulla at semper diam. Phasellus sit amet hendrerit sapien, non semper felis. Morbi augue leo, mattis nec leo sed, malesuada porta dui. Maecenas pretium eros quis lorem luctus gravida. Sed gravida quam odio, euismod accumsan mauris ornare quis. Duis sed condimentum justo. Phasellus ac dui eget velit bibendum viverra. Aenean porttitor commodo diam, quis interdum mi sagittis lobortis. Donec id ipsum dignissim, pharetra nunc sit amet, porttitor elit. Donec iaculis elit et enim interdum, ac laoreet lorem consequat. Sed eu elit ut quam pellentesque cursus. Integer sed condimentum est. Integer tempor placerat bibendum. Sed ut semper arcu, at porttitor nibh. Fusce vulputate pharetra tellus a laoreet. Cras imperdiet enim sed turpis adipiscing placerat. Vestibulum ut rhoncus mauris. In egestas ullamcorper dolor vitae suscipit. Curabitur non orci rutrum, iaculis ligula quis, sollicitudin neque. Aliquam dapibus dignissim tincidunt. Suspendisse at urna mauris. Vivamus eu lectus et quam viverra accumsan quis nec ligula. Aliquam sed mi sit amet arcu convallis bibendum id eu lorem. Sed pretium eget nibh egestas consectetur. Sed adipiscing, libero sed molestie laoreet, arcu tortor elementum ligula, nec commodo ipsum augue ut tellus. Morbi nibh mauris, facilisis vel bibendum vehicula, dignissim in tortor. Suspendisse augue urna, vestibulum at orci nec, scelerisque cursus mi. Proin congue eget justo et mattis.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent luctus urna sit amet sollicitudin venenatis. Aenean odio tortor, varius vitae molestie eu, ultricies vel lacus. Nam viverra fermentum dapibus. Nulla at semper diam. Phasellus sit amet hendrerit sapien, non semper felis. Morbi augue leo, mattis nec leo sed, malesuada porta dui. Maecenas pretium eros quis lorem luctus gravida. Sed gravida quam odio, euismod accumsan mauris ornare quis. Duis sed condimentum justo. Phasellus ac dui eget velit bibendum viverra. Aenean porttitor commodo diam, quis interdum mi sagittis lobortis. Donec id ipsum dignissim, pharetra nunc sit amet, porttitor elit. Donec iaculis elit et enim interdum, ac laoreet lorem consequat. Sed eu elit ut quam pellentesque cursus. Integer sed condimentum est. Integer tempor placerat bibendum. Sed ut semper arcu, at porttitor nibh. Fusce vulputate pharetra tellus a laoreet. Cras imperdiet enim sed turpis adipiscing placerat. Vestibulum ut rhoncus mauris. In egestas ullamcorper dolor vitae suscipit. Curabitur non orci rutrum, iaculis ligula quis, sollicitudin neque. Aliquam dapibus dignissim tincidunt. Suspendisse at urna mauris. Vivamus eu lectus et quam viverra accumsan quis nec ligula. Aliquam sed mi sit amet arcu convallis bibendum id eu lorem. Sed pretium eget nibh egestas consectetur. Sed adipiscing, libero sed molestie laoreet, arcu tortor elementum ligula, nec commodo ipsum augue ut tellus. Morbi nibh mauris, facilisis vel bibendum vehicula, dignissim in tortor. Suspendisse augue urna, vestibulum at orci nec, scelerisque cursus mi. Proin congue eget justo et mattis.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent luctus urna sit amet sollicitudin venenatis. Aenean odio tortor, varius vitae molestie eu, ultricies vel lacus. Nam viverra fermentum dapibus. Nulla at semper diam. Phasellus sit amet hendrerit sapien, non semper felis. Morbi augue leo, mattis nec leo sed, malesuada porta dui. Maecenas pretium eros quis lorem luctus gravida. Sed gravida quam odio, euismod accumsan mauris ornare quis. Duis sed condimentum justo. Phasellus ac dui eget velit bibendum viverra. Aenean porttitor commodo diam, quis interdum mi sagittis lobortis. Donec id ipsum dignissim, pharetra nunc sit amet, porttitor elit. Donec iaculis elit et enim interdum, ac laoreet lorem consequat. Sed eu elit ut quam pellentesque cursus. Integer sed condimentum est. Integer tempor placerat bibendum. Sed ut semper arcu, at porttitor nibh. Fusce vulputate pharetra tellus a laoreet. Cras imperdiet enim sed turpis adipiscing placerat. Vestibulum ut rhoncus mauris. In egestas ullamcorper dolor vitae suscipit. Curabitur non orci rutrum, iaculis ligula quis, sollicitudin neque. Aliquam dapibus dignissim tincidunt. Suspendisse at urna mauris. Vivamus eu lectus et quam viverra accumsan quis nec ligula. Aliquam sed mi sit amet arcu convallis bibendum id eu lorem. Sed pretium eget nibh egestas consectetur. Sed adipiscing, libero sed molestie laoreet, arcu tortor elementum ligula, nec commodo ipsum augue ut tellus. Morbi nibh mauris, facilisis vel bibendum vehicula, dignissim in tortor. Suspendisse augue urna, vestibulum at orci nec, scelerisque cursus mi. Proin congue eget justo et mattis.

Footer: Lots of description, description, description, description, description, description , description, description, description, description, description, description , description, description, description, description, description, description , description, description, description, description, description, description , description, description, description, description, description, description , description, description, description, description, description, description ...



Mind that this effect is useful only for very short TOC lists. A soon as the TOC is longer than the browser's view-port, you can not reach the items on bottom, and you would have to scroll down the content completely just to reach them. Even when you use media-queries, you would have to define a height that actually depends on content, and such always is a maintenance problem.

If you would like to have this effect on your web page, the complete JS code can be found on bottom of this page, in a fold. You also can visit my homepage for the newest state of this project. There you find a bigger example to try out what the script can afford. In between I will try to explain the key logic that works here.

Such a behavior is not possible without JavaScript programming. I used pure JS, no jQuery helpers.

Two Children and their Parent

The story starts with two input parameters of type HTML-element:

  1. the table-of-contents (slim list to the left)
  2. the article element (big text to the right)

These two are bound together by the script in the following way:

When the height of the TOC is bigger than the height of the article, the script does nothing. Else the TOC stops scrolling as soon as the article scrolls away on top. It starts scrolling away only when the bottom is reached and footer begins to slide in. The bottom of the TOC will then be exactly aligned to the bottom of the article, meaning they scroll away together.

Here is the way how the TOC and the article are aligned horizontally to each other by CSS:

    <div>

      <div id="table-of-contents-1"
          style="
            display: inline-block;
            vertical-align: top;
            width: 30%;
            color: green;
          ">
        .....
        .....
      </div><div id="article-1"
          style="
            display: inline-block;
            vertical-align: top;
            width: 70%;
          ">
        .....
        .....
      </div>

    </div>

The display: inline-block; statement works only when the widths of the elements are restricted to fit into their container side by side, which can be done by percentage amounts. Else the article would slip below the TOC.

You might wonder why no newline or space is between the two HTML div elements. This is not missing intention to write readable code, it is a layout necessity! The space distribution 30% - 70% would not work when there was a space between the inline-block participants! See this stackoverflow article.

Mind that the script will not set this CSS to the elements. You must do this. The script just uses position: relative; to fix the TOC at a certain coordinate on scrolling. The article is not manipulated in any way.

The "third man" in this game is the parent div. When this parent would be styled to a restricted height, and to show a scrollbar, all offset calculations would have to be relative to that parent, not to the page. Also scroll events would have to be received from that parent. When there wouldn't be a way to find a scroll-parent via JavaScript, we'd need to pass the parent as third parameter (because it might not be the direct parent but some levels above the TOC).

The way the script finds a possible scroll-parent is this:

      var isVerticalScrollPane = function(element) {
        return element.clientHeight < element.scrollHeight;
      };

      var findVerticalScrollPane = function(element) {
        while (element.parentElement && element.parentElement !== document.body) {
          if (isVerticalScrollPane(element.parentElement))
            return element.parentElement;
            
          element = element.parentElement;
        }
        return undefined;
      };

Calculating Minimal and Maximal Y-Offset

To attach some behavior to the TOC, we need to find out under which condition this should happen. There are two situations where the scroll-behavior of the TOC must change:

  1. when the article begins to scroll away on top
  2. when the TOC's bottom is on same line with the article's bottom

The first point is the mimimal y-offset, and the second the maximal y-offset. We can calculate the first from the top-offset of the article. The second is the top-offset of the article, plus the height of the article, minus the height of the TOC. When the TOC's top passes through one of these points, its behavior must change.

      var tocMinY = -1;
      var tocMaxY = -1;

      var doSettings = function() {
        var articleRect = boundingClientRect(article);
        var tocRect = boundingClientRect(tableOfContents);
        if (articleRect.height <= tocRect.height)
          return;
        
        tocMinY = absoluteYOffset(articleRect);
        
        tocMaxY = tocMinY + articleRect.height - tocRect.height;
        
        ....
      };

As you might recognize, the script relies on the existence of the element.getBoundingClientRect() function. This is not available in old or exotic browsers.

Here are the remaining coordinate calculation functions. Don't worry if you don't understand them, they are not important for realizing the basic concepts, they seem to work, took some time to write them.

      var scrollOffsetY = function() {
        if (scrollPane)
          return scrollPane.scrollTop;
          
        if (window.pageYOffset !== undefined)
          return window.pageYOffset;
          
        return document.documentElement.scrollTop;
      };
      
      var absoluteYOffset = function(boundingClientRect) {
        return scrollOffsetY() + boundingClientRect.top;
      };
      
      var boundingClientRect = function(element) {
        var rectangle = element.getBoundingClientRect();
        if ( ! scrollPane )
          return rectangle;
        
        return {
          top: rectangle.top - scrollPane.getBoundingClientRect().top,
          height: rectangle.height
        };
      };

Scroll Behavior

Now that we have implemented all we need to put together the mentioned behavior, let's do it.

      var onScroll = function() {
        if (tocMinY < 0 || tocMaxY < 0)
          return;
          
        var scrollY = scrollOffsetY();
        if (scrollY > tocMinY) {
          tableOfContents.style.position = "relative";
          if (scrollY < tocMaxY)
            tableOfContents.style.top = (scrollY - tocMinY)+"px";
          else
            tableOfContents.style.top = (tocMaxY - tocMinY)+"px";
        }
        else {
          tableOfContents.style.position = "static";
        }
      };

This is executed through listening to scroll events sent by the browser.

When article is shorter than TOC, do nothing.
Else get the current scroll offset. When it is bigger than the minimal y-offset, fix the TOC to a static position on top. To achieve this, a CSS position: relative; is set to the TOC. Then it can be positioned by setting its top CSS property. And this property is set to the current scroll offset, minus the minimum y-offset, in case the maximum y-offset is not yet reached. If it is already on bottom, top is set in a way that the TOC scrolls away, parallel with the article, stuck to its maximum y-offset.
When the scroll position did not pass the minimal y-offset, the position is set to static. That means the element is where it was initially.

Event Listening

All that is nothing without the events that make the thing live. We need to listen to 'load' (page is ready), 'resize' (browser window has changed dimensions), and 'scroll' events (user scrolled the view). Further it is possible that the user resizes an embedded scrollpane (CSS resize: both), this currently can be listened to only by 'DOMAttrModified'.

      var scrollPane;

      var start = function() {
        scrollPane = findVerticalScrollPane(tableOfContents);
       
        doSettings();
        
        window.addEventListener('load', doSettings);
        window.addEventListener('resize', doSettings);
      
        if (scrollPane)
          scrollPane.addEventListener("DOMAttrModified", doSettings);
      
        var eventSource = (scrollPane ? scrollPane : document);
        eventSource.addEventListener('scroll', onScroll);
      };

That's it, the rest is "boiler-plate". We need to install event listeners at start, and we need to find the TOC and the article elements in the HTML document. Wrap a function around the whole thing that receives TOC and article as parameters, finds a scroll-pane by itself, and hopefully works in any situation. Mind that scrollbars may be at some offset already when a 'load' event arrives. When you scroll down the view and then press browser-reload, this happens.

You find an AMD version of the script on its test page on bottom.
Hope this was helpful!


Here is the full JS source code for take away. The HTML you must do yourself :-)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
    var stickyTableOfContents = function(tableOfContents, article)
    {
      /** Upper and lower bounds of tableOfContents. */
      var tocMinY = -1;
      var tocMaxY = -1;
      
      /** Optionally tableOfContents and article are in an embedded div. */
      var scrollPane;
      
      
      /** @return current vertical scroll offset of the page, in pixels. */
      var scrollOffsetY = function() {
        if (scrollPane)
          return scrollPane.scrollTop;
          
        if (window.pageYOffset !== undefined)
          return window.pageYOffset;
          
        return document.documentElement.scrollTop;
      };
      
      /** @return absolute Y-coordinate of given rectangle, in pixels. */
      var absoluteYOffset = function(boundingClientRect) {
        return scrollOffsetY() + boundingClientRect.top;
      };
      
      /** @return absolute Y-coordinate of given rectangle, in pixels. */
      var boundingClientRect = function(element) {
        var rectangle = element.getBoundingClientRect();
        if ( ! scrollPane )
          return rectangle;
        
        return {
          top: rectangle.top - scrollPane.getBoundingClientRect().top,
          height: rectangle.height
        };
      };
      
      /** Sets upper and lower scroll-bounds for tableOfContents. */
      var doSettings = function() {
        var articleRect = boundingClientRect(article);
        var tocRect = boundingClientRect(tableOfContents);
        if (articleRect.height <= tocRect.height)
          return;
        
        tocMinY = absoluteYOffset(articleRect);
        
        tocMaxY = tocMinY + articleRect.height - tocRect.height;
        
        onScroll(); /* adjust view at any scroll-position */
      };
      
      /** Scroll listener callback, executed any time a scroll event appears. */
      var onScroll = function() {
        if (tocMinY < 0 || tocMaxY < 0)
          return;
          
        var scrollY = scrollOffsetY();
        if (scrollY > tocMinY) {
          tableOfContents.style.position = "relative";
          if (scrollY < tocMaxY)
            tableOfContents.style.top = (scrollY - tocMinY)+"px";
          else
            tableOfContents.style.top = (tocMaxY - tocMinY)+"px";
        }
        else {
          tableOfContents.style.position = "static";
        }
      };
      
      var isVerticalScrollPane = function(element) {
        return element.clientHeight < element.scrollHeight;
      };

      var findVerticalScrollPane = function(element) {
        while (element.parentElement && element.parentElement !== document.body) {
          if (isVerticalScrollPane(element.parentElement))
            return element.parentElement;
            
          element = element.parentElement;
        }
        return undefined;
      };
      
      var start = function() {
        scrollPane = findVerticalScrollPane(tableOfContents);
       
        doSettings();
        
        /** Install listeners: calculate settings on load and on resize. */
        window.addEventListener('load', doSettings);
        window.addEventListener('resize', doSettings);
      
        if (scrollPane) /* listen also to resize events of the scrollpane */
          scrollPane.addEventListener("DOMAttrModified", doSettings);
      
        /** Listen to scroll events. */
        var eventSource = (scrollPane ? scrollPane : document);
        eventSource.addEventListener('scroll', onScroll);
      };
      
      
      start();
      
      return { /* Return an object that can reset all states and restart event-listening. */
        start: start,
        
        stop: function() {
          window.removeEventListener('load', doSettings);
          window.removeEventListener('resize', doSettings);
      
          if (scrollPane)
            scrollPane.removeEventListener("DOMAttrModified", doSettings);
      
          var eventSource = (scrollPane ? scrollPane : document);
          eventSource.removeEventListener('scroll', onScroll);
        }
      };
      
    };

    /* Example usage:
    
    var tableOfContents1 = document.getElementById("table-of-contents-1");
    var article1 = document.getElementById("article-1");
    stickyTableOfContents(tableOfContents1, article1);

     */



Keine Kommentare: