← Go home

Animated Table of Contents

February 08, 2020

gif

Here’s a hopefully quick post about that one time I used recursion in React.

Creating a TOC

4 Months Ago, I tried to recreate those cool dynamic table of contents side bars that you see on some documentation sites, like Docker.

Inflexible html string

I used gatsby-transformer-remark’s built-in “tableOfContents” option, which parses your markdown for h1 to h6 tags, and returns an html-string, like:

"<ul>↵<li>↵<p><a href="/2020/02/02/#reminders-to-myself">Reminders to myself…</a></p>↵<ul>↵<li><a href="/2020/02/02/#whats-the-movtivation-behind-pract..."

And if you console-log it and indent it correctly, it looks like:

<ul>
  <li>
    <p><a href="/2020/02/02/#reminders-to-myself">Reminders to myself…</a></p>
    <ul>
      <li>
        <a
          href="/2020/02/02/#whats-the-movtivation-behind-practing-coding-questions"
          >What’s the movtivation behind practing coding questions?</a
        >
      </li>
      <li><a href="/2020/02/02/#dont-be-lazy">Don’t be lazy</a></li>
      <li>
        <a href="/2020/02/02/#use-any-helpful-tools">Use any helpful tools</a>
      </li>
    </ul>
  </li>
  <li>
    <p><a href="/2020/02/02/#the-question">The question</a></p>
    <ul>
      <li><a href="/2020/02/02/#my-answer">My answer</a></li>
      <li><a href="/2020/02/02/#approach">Approach</a></li>
      <li><a href="/2020/02/02/#time-complexity">Time Complexity</a></li>
      <li><a href="/2020/02/02/#space-complexity">Space Complexity</a></li>
    </ul>
  </li>
  <li><a href="/2020/02/02/#next-steps">Next Steps</a></li>
</ul>

Initially, the only way I could see how to use this was via React’s dangerouslySetInnerHTML

const { tableOfContents } = pageContext

return <div dangerouslySetInnerHTML={{ __html: tableOfContents }} />

But this was extremely unflexible. I couldn’t figure out how to attach event handlers or styles to the html string.

html-parse-stringify

3 months later, one of my co-workers turned me onto an npm package called “html-parse-stringify”, which takes an html string, and returns an AST containing the different dom tags. I didn’t make any connection that this would be useful until after I did some leetcode binary tree coding questions, where recursion was very handy.

I studied the output of parse, and I manually created interfaces for two distinct node-types that I noticed: LeafNode & ASTNode.

import { useCallback } from "react"
import { parse } from "html-parse-stringify"

interface LeafNode {
  type: "text"
  content: "↵"
}
interface ASTNode {
  type: string
  name: string
  voidElement: boolean
  attrs: any
  children: (LeafNode | ASTNode)[]
}

// props.__html contains the passed-down html string
// generated by gatsby-transformer-remark
const ast: [ASTNode] = useCallback(parse(__html), [])

Recursive Helper

I wrote a recursive helper function to traverse the output of of parse, which initially had two cases:

  • Base Case: If the node has no children, return node.content, which was just "↵"
  • Default Case: Return React.createElement with the node’s name, attributes, and recursively call itself to generate the element’s ‘children’
const generateTree = useCallback(
  (nodes: (ASTNode | LeafNode)[]): React.ReactNode => {
    return nodes.map(node => {
      if (!node.children) return node.content

      return React.createElement(
        node.name,
        node.attrs,
        generateTree(node.children)
      )
    })
  },
  []
)

const tree = generateTree(ast)

return <Container>{tree}</Container>

This allowed me to render the same TOC as before (basically some ul, li, and a tags), but without resorting to dangerouslySetInnerHTML.

I think this was the first time I actually successfully used recursion, outside of random coding questions.

Animating the TOC

Here’s where stuff got messy.

In order to animate the TOC, I had to add one more case to my recursive function. I needed to intercept nodes when they were a tags.

This case does a lot:

  • attach a ref to each a tag
  • for each a tag, find the associated h# tag
  • create an IntersectionObserver to observe each h# tag
  • mutate each a tag’s className based on if the associated h# tag ‘intersecting’. CSS will apply styles/animations to the updating classNames
  • mutate each h# tag’s className based on if it iself is ‘intersecting’
  • update the window’s hash, based on the ‘intersecting’ h# tag
  • attach custom onClick handler, to prevent the window from “jumping” when clicking links
if (node.name === "a") {
  const hash = node.attrs.href
    .replace(window.location.pathname, "")
    .replace("#", "")

  // this needs run after useEffect, aka after the browser
  // gets a chance to paint
  const anchorEl = document.getElementById(hash)
  const offset = anchorEl?.offsetTop

  // create a ref to attach to each TOC <a> tag
  const ref = React.createRef()

  // IO options
  const options = {
    root: null,
    rootMargin: "0px 0px -70%",
    threshold: 1.0,
  }

  const updateHashTo = (hash: string) => {
    history.replaceState
      ? // IE10, Firefox, Chrome, etc
        window.history.replaceState?.(null, null, `#${hash}`)
      : // IE9, IE8, etc
        (window.location.hash = hash)
  }

  // create intersectionObservers to watch if the Header tags
  // are intersecting with a specified area
  const observer = new IntersectionObserver(([entry], observer) => {
    if (entry.isIntersecting) {
      ref.current.className = "TOC TOC__FOCUS"
      entry.target.className = "HEADER HEADER__FOCUS"
      updateHashTo(hash)
    } else {
      ref.current.className = "TOC"
      entry.target.className = "HEADER"
    }
  }, options)
  observer.observe(anchorEl)

  return React.createElement(
    node.name,
    {
      ref,
      ...node.attrs,
      onClick: e => {
        // prevent jumping - but also prevents updating window hash
        e.preventDefault()
        updateHashTo(hash)
        window.scrollTo({
          top: offset,
          behavior: "smooth",
        })
      },
    },
    generateTree(node.children)
  )
}

Gotchas

Look at this chunk from the code snippet above:

// this needs run after useEffect, aka after the browser
// gets a chance to paint
const anchorEl = document.getElementById(hash)
const offset = anchorEl?.offsetTop

Like the comment says, for document.getElementById to be able to find the corresponding h# tags, the entire recursive function needs to be run after the browser has a chance to paint.

React.useEffect - docs

Also, don’t forget that React defers running useEffect until after the browser has painted, so doing extra work is less of a problem.

So, let the browser paint.

const [generated, setGenerated] = useState()
useEffect(() => {
  setGenerated(generateTree(ast))
}, [])

That’s about it.

Next Steps

I’m trying to pick up Rust as a second, lower level language, and in hopes to better understand deno (the node remake, built with Rust & TypeScript).

Tags: coderecursiongatsbyreacthtml-parse-stringifygatsby-transformer-remarkintersection-observer
Kevin Wang

👋 I'm Kevin Wang. Jazz GuitaristBaristaReceptionist Front End Engineer
This is my ever growing sandbox of sorts⛱.

Comments

Create Post

You must log in to comment.

Be the first to comment!