zextra
Version:
zikojs extra components
201 lines (161 loc) • 6.84 kB
JavaScript
import { ZextraUI } from '../../../constructor/zextra-ui.js';
import { tags, call_with_optional_props, UIElement } from 'ziko/dom';
const { ul, li, a } = tags;
export class UITableOfContents extends ZextraUI {
constructor({ content = document.body, depth = 6, labelFn } = {}) {
super({ element: 'nav' });
this.setAttr({
class: 'zextra-toc'
});
this.content = content instanceof UIElement ? content.element : content;
this.toc_list = ul();
this.depth = depth;
this.labelFn = labelFn;
this.observer = null;
this.scrollObserver = null;
this.activeLink = null;
this.isClickScrolling = false; // Flag to prevent observer interference on click
this.append(this.toc_list);
this.#init();
}
#getSelector() {
if (Array.isArray(this.depth)) return this.depth.map(d => `h${d}`).join(', ');
const max = Math.min(Math.max(this.depth, 1), 6);
return Array.from({ length: max }, (_, i) => `h${i + 1}`).join(', ');
}
#setActiveLink(newActive) {
if (!newActive) return;
// Resolve raw HTML element if a ziko UIElement wrapper was passed
const targetEl = newActive instanceof UIElement ? newActive.element : newActive;
// Force remove active state and data-active attribute from ALL links in the TOC list
const allLinks = this.toc_list.element.querySelectorAll('a');
allLinks.forEach(link => {
link.classList.remove('active');
link.removeAttribute('data-active');
});
// Set active attribute on the clicked/scrolled-to element
targetEl.classList.add('active');
targetEl.setAttribute('data-active', 'true');
this.activeLink = targetEl;
}
#setupScrollObserver(headings) {
if (this.scrollObserver) this.scrollObserver.disconnect();
const linkMap = new Map();
this.toc_list.element.querySelectorAll('a').forEach(anchor => {
const id = anchor.getAttribute('href').slice(1);
linkMap.set(id, anchor);
});
this.scrollObserver = new IntersectionObserver(
(entries) => {
// Ignore observer events triggered by anchor clicks
if (this.isClickScrolling) return;
entries.forEach((entry) => {
if (entry.isIntersecting) {
const targetId = entry.target.id;
const newActive = linkMap.get(targetId);
if (newActive && newActive !== this.activeLink) {
console.log('Active TOC Section:', {
id: targetId,
text: newActive.textContent,
element: entry.target
});
this.#setActiveLink(newActive);
}
}
});
},
{
rootMargin: '-20% 0px -40% 0px',
threshold: 0
}
);
headings.forEach((heading) => this.scrollObserver.observe(heading));
}
#buildTOC() {
const root = this.content ?? document.body;
if (!root) return false;
const selector = this.#getSelector();
const headings = root.querySelectorAll(selector);
if (headings.length === 0) return false;
this.toc_list.element.replaceChildren();
const levels = Array.isArray(this.depth)
? [...this.depth].sort((a, b) => a - b)
: Array.from({ length: this.depth }, (_, i) => i + 1);
headings.forEach((heading, i) => {
if (!heading.id) heading.id = `heading-${i + 1}`;
const level = parseInt(heading.tagName[1]);
const relativeIndex = levels.indexOf(level);
const label = this.labelFn
? this.labelFn(heading, level)
: heading.textContent;
const link = a({ href: `#${heading.id}` }, label);
// Handle manual click explicitly
link.on('click', (e) => {
// Get the raw DOM element regardless of event wrapper structure
const targetAnchor = e.target || e.currentTarget;
this.#setActiveLink(targetAnchor);
// Pause observer during scroll jump
this.isClickScrolling = true;
setTimeout(() => {
this.isClickScrolling = false;
}, 800);
});
li({}, link)
.style({
marginLeft: `${relativeIndex * 15}px`
})
.mount(this.toc_list);
});
this.#setupScrollObserver(headings);
return true;
}
#init() {
const success = this.#buildTOC();
if (!success) {
this.observer = new MutationObserver(() => {
if (this.#buildTOC()) {
this.observer?.disconnect();
this.observer = null;
}
});
const startObserving = () => {
const root = this.content ?? document.body;
if (root) {
this.observer.observe(root, {
childList: true,
subtree: true
});
} else {
document.addEventListener('DOMContentLoaded', () => {
const target = this.content ?? document.body;
if (target) {
this.observer?.observe(target, {
childList: true,
subtree: true
});
}
}, { once: true });
}
};
startObserving();
}
}
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.scrollObserver) {
this.scrollObserver.disconnect();
this.scrollObserver = null;
}
}
refresh() {
this.#buildTOC();
}
}
export const TableOfContents = call_with_optional_props(UITableOfContents);
/*
data-visible="true" is applied to every heading currently visible in the viewport.
data-active="true" is applied exclusively to the topmost visible section (or the section manually clicked).
*/