Files
cleveragents-core/docs/javascripts/toc-collapse.js
T

275 lines
9.4 KiB
JavaScript

/**
* Collapsible Table of Contents for mkdocs-material with toc.integrate.
*
* Behaviour:
* - ALL nested TOC items start collapsed on page load.
* - Clicking a parent item toggles it and marks it as "user-pinned" so it
* stays in whatever state the user chose.
* - As the reader scrolls, the ancestors of the currently-active TOC link
* are automatically expanded so the highlighted item is always visible.
* - When the reader scrolls past a section, any items that were only
* auto-expanded (not user-pinned) collapse back down.
* - Works with mkdocs-material instant-loading / SPA navigation.
*
* Page navigation is handled by the top navbar tabs + dropdowns.
* This script only manages the TOC (md-nav--secondary) in the left sidebar.
*/
(function () {
"use strict";
/**
* Set of TOC <li> elements the user has manually expanded.
* These survive auto-collapse and are only cleared on page navigation.
*/
var userPinned = new Set();
/**
* Set of TOC <li> elements that were auto-expanded by the scroll tracker.
* Cleared and recalculated each time the active link changes.
*/
var autoExpanded = new Set();
/** Reference to the sidebar element (cached after first lookup). */
var sidebar = null;
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Return all ancestor <li.md-nav__item--nested> elements between `el`
* and the TOC root (the <ul data-md-component="toc"> list).
*/
function ancestorItems(el) {
var ancestors = [];
var node = el ? el.parentElement : null;
while (node && node !== sidebar) {
if (
node.tagName === "LI" &&
node.classList.contains("md-nav__item--nested")
) {
ancestors.push(node);
}
node = node.parentElement;
}
return ancestors;
}
/**
* Collapse a nested item (add the CSS class that hides children).
*/
function collapse(li) {
li.classList.add("toc-collapsed");
}
/**
* Expand a nested item (remove the CSS class so children are visible).
*/
function expand(li) {
li.classList.remove("toc-collapsed");
}
// ── Shared: make nested items collapsible ────────────────────────────
//
// Scans `containerEl` for <li> items with nested <nav> children and
// turns them into collapsible items: adds the --nested class, collapses
// them, and injects a toggle chevron.
function makeItemsCollapsible(containerEl) {
containerEl.querySelectorAll("li.md-nav__item").forEach(function (li) {
var nestedNav = li.querySelector(":scope > nav.md-nav, :scope > .md-nav");
if (!nestedNav) return;
li.classList.add("md-nav__item--nested");
// Collapse everything on load
collapse(li);
// Inject a dedicated toggle button (chevron) into the link row.
// Clicking the chevron only toggles expand/collapse.
// Clicking the link text still navigates to the heading.
var link = li.querySelector(":scope > a.md-nav__link");
if (link && !link.querySelector(".toc-toggle")) {
var toggle = document.createElement("span");
toggle.className = "toc-toggle";
toggle.setAttribute("role", "button");
toggle.setAttribute("aria-label", "Toggle section");
link.appendChild(toggle);
toggle.addEventListener("click", function (e) {
// Stop the click from propagating to the <a> and navigating
e.preventDefault();
e.stopPropagation();
var isCollapsed = li.classList.contains("toc-collapsed");
if (isCollapsed) {
expand(li);
userPinned.add(li);
} else {
collapse(li);
userPinned.delete(li);
}
});
}
});
}
// ── Initialisation ──────────────────────────────────────────────────
function initCollapsibleToc() {
sidebar = document.querySelector(".md-sidebar--primary");
if (!sidebar) return;
// Reset pinned / auto-expanded tracking on (re-)init
userPinned.clear();
autoExpanded.clear();
// Force the TOC visible. Material's base CSS hides the secondary
// nav via a sibling-combinator rule keyed off the #__toc checkbox.
// We force-check it and set inline display so the TOC is guaranteed
// visible regardless of the CSS cascade.
var tocCheckbox = sidebar.querySelector("#__toc");
if (tocCheckbox) {
tocCheckbox.checked = true;
}
// Find the actual TOC (md-nav--secondary). We scope to the secondary
// nav to avoid touching the page-level nav items that wrap sections.
var tocNav = sidebar.querySelector("nav.md-nav--secondary");
if (!tocNav) return;
tocNav.style.display = "block";
tocNav.style.opacity = "1";
tocNav.style.visibility = "visible";
// Make all nested TOC items collapsible, with pinning support for
// the active-link watcher.
makeItemsCollapsible(tocNav);
// Kick off the active-link watcher
watchActiveLink();
}
// ── Active-link watcher ─────────────────────────────────────────────
//
// mkdocs-material dynamically toggles `.md-nav__link--active` on the
// TOC link that corresponds to the heading currently in view. We use
// a MutationObserver on the TOC container to detect when that class
// changes and react by expanding / collapsing as needed.
var observer = null;
function watchActiveLink() {
// Disconnect any previous observer (e.g. after SPA navigation)
if (observer) {
observer.disconnect();
observer = null;
}
var tocRoot = sidebar
? sidebar.querySelector("[data-md-component='toc']")
: null;
if (!tocRoot) return;
// Respond to the current active link immediately
handleActiveChange(tocRoot);
// Observe subtree attribute changes — the theme adds / removes
// the --active class on <a> elements as the user scrolls.
observer = new MutationObserver(function () {
handleActiveChange(tocRoot);
});
observer.observe(tocRoot, {
subtree: true,
attributes: true,
attributeFilter: ["class"],
});
}
/**
* Called whenever the active TOC link may have changed.
*
* 1. Find the currently-active <a>.
* 2. Determine which ancestor <li>s need to be expanded.
* 3. Auto-expand those ancestors (unless already open).
* 4. Auto-collapse any previously auto-expanded items that are no longer
* ancestors of the active link (but leave user-pinned items alone).
*/
function handleActiveChange(tocRoot) {
var activeLink = tocRoot.querySelector("a.md-nav__link--active");
// Determine which nested <li>s must be open for the active link
var requiredOpen = activeLink ? ancestorItems(activeLink) : [];
// Also include the active link's own <li> if it is a nested item
if (activeLink) {
var parentLi = activeLink.closest("li.md-nav__item--nested");
if (parentLi && requiredOpen.indexOf(parentLi) === -1) {
requiredOpen.push(parentLi);
}
}
var requiredSet = new Set(requiredOpen);
// Collapse items that were auto-expanded last round but are no longer
// needed — skip anything the user has pinned open.
autoExpanded.forEach(function (li) {
if (!requiredSet.has(li) && !userPinned.has(li)) {
collapse(li);
}
});
// Build the new auto-expanded set
autoExpanded = new Set();
requiredOpen.forEach(function (li) {
if (li.classList.contains("toc-collapsed")) {
expand(li);
}
// Track as auto-expanded only if not user-pinned
if (!userPinned.has(li)) {
autoExpanded.add(li);
}
});
}
// ── Desktop detection ────────────────────────────────────────────────
//
// This entire script is designed for the desktop persistent sidebar
// (≥76.25em). On mobile, Material uses a slide-out drawer with its
// own drill-down navigation, scroll management, and back-button
// behaviour. Running our DOM restructuring on mobile breaks the
// drawer's scroll container and makes it impossible to scroll past
// the visible items.
//
// We use matchMedia to detect the breakpoint and only bootstrap on
// desktop. On resize/orientation changes that cross the breakpoint,
// the page reloads via Material's SPA system anyway, so we don't need
// a live resize listener.
var desktopQuery = window.matchMedia("(min-width: 76.25em)");
function isDesktop() {
return desktopQuery.matches;
}
// ── Bootstrap ───────────────────────────────────────────────────────
function bootstrap() {
if (!isDesktop()) return; // skip all DOM manipulation on mobile
initCollapsibleToc();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", bootstrap);
} else {
bootstrap();
}
// Re-init after instant navigation (mkdocs-material SPA mode)
if (typeof document$ !== "undefined") {
document$.subscribe(function () {
bootstrap();
});
}
})();