Table of Content (using custom element)

rails app:template LOCATION='https://perron.railsdesigner.com/library/table-of-content/template.rb'

This (headless) component gives you a <table-of-content /> custom element. It is used on this site (check out the docs). You pass it an array with items. Perron provides a table_of_content method on the Perron::Resource class that you can use for this.

You can optionally provide a title attribute to set a title (default to Table of content) or, if you want more control, a <template type="title"></template> to customize the content added at the top of the element. Define an active-classes attribute that will added to the ToC items when the related section is in the viewport.

Template source

file "app/javascript/components/table_of_content.js" do
<<~"_"
// Usage:
//
//  <table-of-content title="On this page" items="<%%= @resource.table_of_content.to_json %>" active-classes="toc__item--highlight">
//    <template type="title">
//      <p>On this page (use this to have more control over the title element)</p>
//    </template>
//  </table-of-content>
//
class TableOfContentElement extends HTMLElement {
  constructor() {
    super();

    this.#items = JSON.parse(this.getAttribute("items"));
  }

  connectedCallback() {
    if (this.#items.length < 1) return

    this.innerHTML = this.#template;
    this.#highlightActiveLink();
  }

  // private

  #items;

  get #template() {
    return `
      <nav>
        ${this.#leader}

        ${this.#list( { for: this.#items })}
      </nav>
    `;
  }

  #list({ for: items }) {
    if (!items?.length) return "";

    const listItems = items.map(item => `
      <li>
        <a href="#${item.id}">
          ${item.text}
        </a>

        ${this.#list({ for: item.children })}
      </li>
    `).join("");

    return `<ul>${listItems}</ul>`;
  }

  #highlightActiveLink() {
    if (!this.#activeClasses) return;

    const selector = "a[href^='#']";
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(({ isIntersecting, target }) => {
        if (!isIntersecting) return;

        this.querySelectorAll(selector).forEach(link => link.classList.remove(...this.#activeClasses));
        this.querySelector(`a[href="#${target.id}"]`)?.classList.add(...this.#activeClasses);
      });
    }, { rootMargin: "0px 0px -80% 0px", threshold: 0 });

    this.querySelectorAll(selector).forEach(({ hash }) => {
      const element = document.getElementById(hash.slice(1));

      if (element) observer.observe(element);
    });

    this.querySelector(selector)?.classList.add(...this.#activeClasses);
  }

  get #leader() {
    return this.querySelector("template[type=title]")?.innerHTML || `<p>${this.getAttribute("title") || "Table of Content"}</p>`;
  }

  get #activeClasses() {
    return this.getAttribute("active-classes")?.split(" ");
  }
}

customElements.define("table-of-content", TableOfContentElement);

_
end


create_file "app/javascript/components/index.js", <<~JS, skip: true
import "./table_of_content"
JS

application_js_path = "app/javascript/application.js"

if File.exist?(application_js_path)
  insert_into_file application_js_path, "\nimport \"components\"\n", after: /\A(?:import .+\n)*/
else
  create_file application_js_path, <<~JS
import "components"
JS
end

unless File.exist?("config/importmap.rb")
  say "Warning: importmap.rb not found!", :yellow
  say "Please set up importmap-rails first by running:"
  say "  rails importmap:install"
  say "Or use your preferred JavaScript set up."

  return
end

insert_into_file "config/importmap.rb", after: /\A.*pin .+\n+/m do
  "\npin_all_from \"app/javascript/components\", under: \"components\", to: \"components\"\n"
end