Table of Content (using custom element)
to run in your site's root directory
View 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
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 will be used to the ToC items when the related section is in the viewport.