import { attribute, customElement } from "simple-custom-elements"; import { contentAttribute__boolean } from "@hesxenon/prelude/Codec.js"; import { create, text } from "@hesxenon/prelude/Dom.js"; /** * the `` element can be used to generate a table of contents for headings within the surrounding element. * * @example * * ```html *
* * * *
*

Foo

*
*
*

Bar

*
*
* ``` */ @customElement({ tagname: "uwc-toc" }) export class UwcTocElement extends HTMLElement { private static defaults = { headingSelector: "h1,h2,h3,h4,h5,h6", }; static config = { /** * the headings to consider * * @default "h1,h2,h3,h4,h5,h6" */ headingSelector: UwcTocElement.defaults.headingSelector, } satisfies (typeof UwcTocElement)["defaults"]; @attribute({ set() { this.#renderList(); }, }) headingselector = UwcTocElement.config.headingSelector; @attribute({ codec: contentAttribute__boolean }) nolinks = false; get #container() { const parent = this.parentElement; if (parent == null) { throw new Error(" must be child of a valid HTMLElement"); } return parent; } get #headings() { return this.#container.querySelectorAll(this.headingselector); } #disconnect = new AbortController(); #rerender = new AbortController(); connectedCallback() { const mutationObserver = new MutationObserver((records) => { if ( records.some( (record) => record.target instanceof HTMLElement && record.target.matches(this.headingselector), ) ) { this.#renderList(); } }); mutationObserver.observe(this, { childList: true, subtree: true }); this.#disconnect.signal.addEventListener("abort", () => mutationObserver.disconnect(), ); this.#renderList(); } disconnectedCallback() { this.#disconnect.abort(); } #renderList = () => { this.#rerender.abort(); this.#rerender = new AbortController(); this.innerHTML = ""; /** * associates a heading with its toc entry */ const entryHeadingMap = new Map(); const intersectionObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { const tocEntry = entryHeadingMap.get(entry.target as HTMLElement); if (tocEntry == null) { continue; } tocEntry.ariaCurrent = String(entry.isIntersecting); } }, { root: this.#container }, ); this.#rerender.signal.addEventListener("abort", () => { intersectionObserver.disconnect(); }); const createEntry = (heading: HTMLElement): HTMLElement => { const entry = create("li", {}, [ create( "a", { href: this.nolinks ? undefined : (heading.getAttribute("id") ?? undefined), onclick: () => { heading.scrollIntoView({ block: "start" }); }, }, [text(heading.getAttribute("title") ?? heading.innerText)], ), ]); intersectionObserver.observe(heading); entryHeadingMap.set(heading, entry); return entry; }; const entries = create("ul"); for (const heading of this.#headings) { if (!(heading instanceof HTMLElement)) { continue; } entries.append(createEntry(heading)); } this.append(entries); }; } declare global { const UwcTocElement: typeof import("./uwc-toc.ts").UwcTocElement; type UwcTocElement = import("./uwc-toc.ts").UwcTocElement; interface HTMLElementTagNameMap { "uwc-toc": UwcTocElement; } } Object.assign(globalThis, { UwcTocElement });