Use JavaScript to Add a Dynamic Table of Contents to Your Pages



Introduction

Creating a table of contents or menu based on content is time-consuming for editors and prone to errors. You may need such a feature on your data fed pages and not even have the ability to create and link to content on the page.

Here, I create an automated, on-the-fly table of contents without the need for hard-coded anchor links, regardless of the source of your content. It's easily adaptable to turn into a nav bar or similar menu, though for nested menus, it would require a fair bit more logic to determine level and parent etc.. Similarly, this technique could be used to produce a summary with links on an API data feed page for rapid data analysis and drill-down capability.

In this example, I create a structured table of information based on the presence of header tags, much like MS Word. The ultimate result is the table displayed above, where you can inspect the resultant HTML using the Inspect tool.

The presumption is that header tags are being used in a structured manner with <h1> reserved for the page title (and therefore excluded from the table). Although the use of header tags is largely ignored in SEO these days, Mozilla's developer guide has a useful reference on why using properly structured headers is a good idea.

CSS classes are added to each level allowing you to customise the look fully in accordance with your own style guide.

 


Creating the Table of Contents

The listContents function

The function takes four parameters, only one mandatory. Select the target <div> element to write the table to and, optionally, a scope to limit the table to (the page body is the default), the number of header levels to include (from 1 for <h2> tags only up to 5 to include from <h2> to <h6> with 3 as default) and a title (if any) to display.

Inline comments below describe the process:

function listContents( 
  tocElement,
  scopeElement = "body",
  levels = 3,
  tocTitle = false
) {
  // Create Table of Contents (ToC) based on header tags (H2 to H6)
  // Required: tocElement (str) - element ID to create ToC in (<DIV> only)
  // Optional: scopeElement (str) - element to limit the search to, defaults to <body>. 
  //                                             Can be element tag or element ID.
  // Optional: levels (int) - number of levels to include in ToC (1 to 5 starting with H2). Default=3 (H2-H4)
  // Optional: tocTitle (str) - string to display as ToC title, defaults to no title (false)

  let toc, scope
  
  // find target DIV element to write ToC to, only accept DIV as valid element type
  toc = document.getElementById(tocElement);
  if (toc){
    if (toc.tagName !== "DIV"){
      console.error(`ToC: Target element is type <${toc.tagName}>, only <DIV> is valid element type.`);
      toc = null;  
    }
  }

  // find tag name matching scopeElement, if scope tag not found, try finding element by ID
  scope = document.getElementsByTagName(scopeElement)[0];
  if (!scope){
    scope = document.getElementById(scopeElement);
  }
 
  if (scope && toc) {
    // determine which header tags to search by slicing list 'levels' deep
    const tags = ["h2","h3","h4","h5","h6"].slice(0,levels).join();

    // find the relevant header tags contained within the scope element
    const headers = scope.querySelectorAll(tags);

    // create ToC only if headers found
    if (headers.length > 0) {

      // add ToC title if supplied, add css classes
      if (tocTitle) {
        let title = toc.appendChild(document.createElement("P"));
        title.innerText = tocTitle;
        title.classList.add("toc", "toc-title");
      }

      // add ToC list DIV, add css classes
      const list = toc.appendChild(document.createElement("DIV"));
      list.classList.add("toc", "toc-list");

      // loop through headers in order
      for (let i = 0; i < headers.length; i++) {
        // determine nesting level (h2->1, h3->2 etc)
        const level = Number(headers[i].nodeName[1]) - 1;
        
        // if header has no id, create one from slugified title and assign to header
        // pre-fix id with index to avoid duplicate id's
        if (!headers[i].id) {
          headers[i].id = `${i + 1}-${slugify(headers[i].innerText)}`;
        }

        // create element to hold link, add css including level specific css class
        const linkLine = list.appendChild(document.createElement("P"));
        linkLine.classList.add(`toc`, `toc-item-l${level}`);

        // create link to point to ID of header
        const link = linkLine.appendChild(document.createElement("A"));
        link.appendChild(document.createTextNode(headers[i].innerText));
        link.href = `#${headers[i].id}`;
      }
    }
  } else {
    if (!scope) {
      console.error(`ToC: Missing either <${scopeElement}> or element with id=${scopeElement}`);
    }
    if (!toc) {
      console.error(`ToC: Missing ToC target <DIV> element with id=${tocElement}`);
    }
  }
}
 

Slugifying the headers

An ID is added to each header to link to only if there isn't one already, otherwise that ID is used to create the link. Each link created is prefixed with its index to ensure no duplicate ID's are being added.

To create the on-the-fly ID's for the headers, the function relies on a 'slugify' function to convert the text into a useable ID:

if (!headers[i].id) {
  headers[i].id = `${i + 1}-${slugify(headers[i].innerText)}`;
}
function slugify(str) {
  str = str.replace(/^\s+|\s+$/g, "");

  // Make the string lowercase
  str = str.toLowerCase();

  // Remove accents, swap ñ for n, etc
  var from =
    "ÁÄÂÀÃÅČÇĆĎÉĚËÈÊẼĔȆÍÌÎÏŇÑÓÖÒÔÕØŘŔŠŤÚŮÜÙÛÝŸŽáäâàãåčçćďéěëèêẽĕȇíìîïňñóöòôõøðřŕšťúůüùûýÿžþÞĐđßÆa·/_,:;";
  var to =
    "AAAAAACCCDEEEEEEEEIIIINNOOOOOORRSTUUUUUYYZaaaaaacccdeeeeeeeeiiiinnooooooorrstuuuuuyyzbBDdBAa------";
  for (var i = 0, l = from.length; i < l; i++) {
    str = str.replace(new RegExp(from.charAt(i), "g"), to.charAt(i));
  }

  // Remove invalid chars
  str = str
    .replace(/[^a-z0-9 -]/g, "")
    // Collapse whitespace and replace by -
    .replace(/\s+/g, "-")
    // Collapse dashes
    .replace(/-+/g, "-");

  return str;
}
 

Adding CSS Styling

The rendered HTML includes several CSS classes to style the output with:

  • toc: applies to the whole table
  • toc-title: formatting for the title (if any)
  • toc-list: applies to the area below the title containing all the list items
  • toc-item-l1 - toc-item-l5: use these to style individual levels such as indents etc.

Below is a sample CSS definition for the table:

.toc-title {
  font-family: var(--font-family-headings);
  font-size: var(--font-size-2); 
  margin-bottom: 0;
}
.toc-list {
  padding-bottom: 1rem;
  padding-left: 10px;
}
.toc-item-l1{
  font-size: var(--font-size-5); 
  text-indent: 0rem;
  margin-bottom: 0;
}
.toc-item-l2{
  font-size: var(--font-size-5); 
  text-indent: 0.8rem;  
  margin-bottom: 0;
}
.toc-item-l3{
  text-indent: 1.6rem;
  font-size: var(--font-size-body); 
  margin-bottom: 0;
}
.toc-item-l4{
  text-indent: 2.4rem;
  font-size: var(--font-size-6); 
  margin-bottom: 0;
}
.toc-item-l5{
  text-indent: 3.2rem;
  font-size: var(--font-size-6); 
  margin-bottom: 0;
}
 

Implementing the Table of Contents

Calling listContents

How and when you call the listContents function will depend on the nature of the page you're dealing with, how the content is being delivered.

For a data feed from a RESTful API for instance, you would call it off the success of the data call promise.

For Django/Wagtail sites, once the DOM content has loaded, you already have your headers and can get the function working before everything else has finished.

Deploying as a Stream Block in Wagtail

The example I use here is probably too flexible for use on most commercial sites where consistency across the site is required, but it's here for completeness.

Start off defining the block:

class TableOfContentsBlock(StructBlock):
    toc_title = CharBlock(
        max_length=60, 
        null=True, 
        blank=True,
        required=False,
        label=_("Title (optional)"),
        help_text=_("Optional title to display at the top of the table."),
    )
    
    levels = IntegerBlock(
        default = 3,
        min_value=1,
        max_value=5,
        label=_("Number of levels to include"),
        help_text=_("H1 tags are ignored. 1 level includes H2 only, 5 levels will include H2 to H6."),
    )

    class Meta:
        template = 'blocks/table_of_contents.html'
        icon = 'fas fa-stream'
        label = 'Table of Contents'

The editor has the choice of defining a title to display and the number of levels to include (with 3 as default).

To take the choice away and keep consistency, use a StaticBlock rather than a StructBlock.

The template now just needs to supply the target <div>, the parameters and call the listContents function:

{%load static%}
<div id="autoToC"></div>

<script src="{%static 'js/toc.js'%}"></script>
<script>
  document.addEventListener("DOMContentLoaded", (event) => {
    listContents("autoToC", "main", {{value.levels}}, "{{value.toc_title}}");
  });
</script>

In this example, the scopeElement parameter is "main" representing a <main></main> element containing all the dynamic content in the page's body.

As stated earlier, with a Wagtail/Django page, once the DOM has loaded, you're ready to kick off the function before waiting for the rest of the page to load.

Add the block to the relevant page type definitions and you're set to go.

Deploying on multi-language sites

Because the contents list is built off the currently displayed text, there's no further modification needed. The only translation needed will be the title if used.

For the example above, on a wagtail-localize site, the title is available for translation in the CMS just as any text block is.

Using a StaticBlock with fixed title, I would recommend using something along the lines that I discussed in this blog regarding translating static text in templates. There, you would create a title with a tag like toc_title and call the function from the block template as:

listContents("autoToC", "main", {{value.levels}}, "{{trans.toc_title}}");
 

Conclusion

This is a robust and quick way to add a consistent look and feel for creating tables of contents.

A quick refactoring of the header loop would allow a nested nav bar approach, or any other formatting.

Take this approach a bit further with other tag types to implement a simple BI dashboard summarising content produced by an external data feed and even offer links to those summarised areas to allow readers to quickly jump to the relevant sections despite there being no anchors in the originating render.

 


Thoughts, comments, questions welcome below, or please feel free to contact me directly.

 
Comments
Sign In to leave a comment.