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