Docs: Improved the navigation seperating out ToC from top nac bar

This commit is contained in:
2026-03-09 23:13:06 -04:00
parent a41fc02f11
commit 01a2a03e2e
4 changed files with 319 additions and 424 deletions
+25 -349
View File
@@ -10,6 +10,9 @@
* - 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";
@@ -68,11 +71,9 @@
//
// 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. This is used both for the active
// page's built-in TOC (md-nav--secondary) and for TOC items lazily
// injected into converted single-page nav sections.
// them, and injects a toggle chevron.
function makeItemsCollapsible(containerEl, trackPinning) {
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;
@@ -101,10 +102,10 @@
var isCollapsed = li.classList.contains("toc-collapsed");
if (isCollapsed) {
expand(li);
if (trackPinning) userPinned.add(li);
userPinned.add(li);
} else {
collapse(li);
if (trackPinning) userPinned.delete(li);
userPinned.delete(li);
}
});
}
@@ -121,25 +122,27 @@
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
// those use Material's native checkbox-based toggle and should not be
// overridden.
//
// NOTE: The primary nav already has .md-nav--integrated added by
// mkdocs-material when the toc.integrate feature is enabled. We do
// NOT re-add that class or extend it to descendant navs — doing so
// causes the CSS rule ".md-nav--integrated .md-nav__item--nested >
// .md-nav { display: block }" to override Material's native
// checkbox-based collapse on primary nav sections (Reference,
// Development, Architecture Decisions, etc.), creating whitespace
// below collapsed section headings.
// 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, true);
makeItemsCollapsible(tocNav);
// Kick off the active-link watcher
watchActiveLink();
@@ -229,336 +232,14 @@
});
}
// ── Convert leaf-page nav items into section-like structures ─────────
//
// Material renders leaf-page nav items (any <li> with just an <a> link
// and no nested <nav>) as flat links with no expand/collapse chevron.
// Only native sections (Reference, Development, Architecture Decisions)
// get a checkbox + label + nested <nav>.
//
// This function converts leaf-page items at two levels:
// 1. Top-level items (Specification, Work Remaining, FAQ)
// 2. Children inside native sections (Action CLI, Session Model, etc.)
//
// It explicitly avoids touching items inside md-nav--secondary (the TOC)
// or md-nav--page-toc (our own lazy-loaded TOC containers).
//
// The ACTIVE page (wherever it sits in the tree) is treated specially:
// it becomes an always-expanded, non-collapsible section with a
// <label> heading instead of an <a>, matching how Material handles
// native sections that contain the current page.
/** Counter for unique checkbox IDs across conversions. */
var pageNavCounter = 100;
/** Cache of fetched TOC HTML keyed by page URL. */
var tocCache = {};
/**
* Collect the <ul> lists whose direct <li> children should be
* converted. This includes:
* - The top-level nav list (direct child of md-nav--primary)
* - The child lists inside native sections (level-1 <nav> elements
* that are NOT md-nav--secondary and NOT md-nav--page-toc)
*/
function collectConvertibleLists(primaryNav) {
var lists = [];
// 1. Top-level list
var topList = primaryNav.querySelector(":scope > .md-nav__list");
if (topList) lists.push(topList);
// 2. Lists inside native sections (one level deep)
if (topList) {
topList.querySelectorAll(":scope > li.md-nav__item--nested").forEach(function (sectionLi) {
var sectionNav = sectionLi.querySelector(":scope > nav.md-nav:not(.md-nav--secondary):not(.md-nav--page-toc)");
if (sectionNav) {
var sectionList = sectionNav.querySelector(":scope > .md-nav__list");
if (sectionList) lists.push(sectionList);
}
});
}
return lists;
}
/**
* Convert leaf-page <li> items into section-like expandable structures.
* Only operates on items in the top-level nav and inside native sections.
*/
function convertPageItems() {
var primaryNav = document.querySelector("nav.md-nav--primary");
if (!primaryNav) return;
var lists = collectConvertibleLists(primaryNav);
lists.forEach(function (ul) {
// Only process DIRECT children of each list
var children = ul.querySelectorAll(":scope > li.md-nav__item");
children.forEach(function (li) {
// Skip items already converted or that are native sections
if (li.classList.contains("md-nav__item--nested")) return;
// Active page items need special handling — always-expanded,
// non-collapsible, matching native section behaviour.
if (li.classList.contains("md-nav__item--active")) {
convertActivePageItem(li);
return;
}
convertInactivePageItem(li);
});
});
}
/**
* Convert an INACTIVE leaf-page <li> into a section-like structure
* with a checkbox + label + nested nav that lazy-loads the page's TOC.
*/
function convertInactivePageItem(li) {
var link = li.querySelector(":scope > a.md-nav__link");
if (!link) return;
var href = link.getAttribute("href");
var text = link.textContent.trim();
var navId = "__nav_" + (pageNavCounter++);
// Build the checkbox
var checkbox = document.createElement("input");
checkbox.className = "md-nav__toggle md-toggle";
checkbox.type = "checkbox";
checkbox.id = navId;
// Build the label (with chevron icon) — identical to native sections
var label = document.createElement("label");
label.className = "md-nav__link";
label.setAttribute("for", navId);
label.tabIndex = 0;
var ellipsis = document.createElement("span");
ellipsis.className = "md-ellipsis";
ellipsis.textContent = text;
label.appendChild(ellipsis);
var icon = document.createElement("span");
icon.className = "md-nav__icon md-icon";
label.appendChild(icon);
// Build the nested nav with the original link as first child
var nav = document.createElement("nav");
nav.className = "md-nav md-nav--page-toc";
nav.setAttribute("data-md-level", "1");
nav.setAttribute("aria-expanded", "false");
var ul = document.createElement("ul");
ul.className = "md-nav__list";
// First child: a link to the page itself (like section index pages)
var indexLi = document.createElement("li");
indexLi.className = "md-nav__item";
var indexLink = document.createElement("a");
indexLink.className = "md-nav__link";
indexLink.href = href;
var indexSpan = document.createElement("span");
indexSpan.className = "md-ellipsis";
indexSpan.textContent = text;
indexLink.appendChild(indexSpan);
indexLi.appendChild(indexLink);
ul.appendChild(indexLi);
nav.appendChild(ul);
// Replace the <li> contents
li.innerHTML = "";
li.classList.add("md-nav__item--nested");
li.appendChild(checkbox);
li.appendChild(label);
li.appendChild(nav);
// Lazy-load the TOC when first expanded
checkbox.addEventListener("change", function () {
if (!checkbox.checked) return;
if (tocCache[href]) {
injectToc(ul, tocCache[href]);
return;
}
loadToc(href, ul);
});
}
/**
* Convert an ACTIVE page item to look like a native expanded section.
*
* Material emits a __toc checkbox + label (with a TOC-list icon) plus
* an <a> link and the md-nav--secondary TOC nav. We hide the __toc
* machinery and replace the <a> with a non-navigating <label> that
* has the same section-heading + chevron appearance as native sections.
*
* The TOC stays always-visible and cannot be collapsed — matching how
* Material treats native sections that contain the current page.
*/
function convertActivePageItem(li) {
// 1. Force-check and hide the __toc checkbox so Material keeps the
// TOC visible, and prevent any future toggling.
var tocCheckbox = li.querySelector(":scope > #__toc");
if (tocCheckbox) {
tocCheckbox.checked = true;
tocCheckbox.disabled = true;
tocCheckbox.style.display = "none";
}
// 2. Hide the __toc label (it has the wrong icon and toggles the
// checkbox — we don't want either).
var tocLabel = li.querySelector(':scope > label[for="__toc"]');
if (tocLabel) {
tocLabel.style.display = "none";
}
// 3. Mark the <li> as nested + our custom marker so CSS can style it.
li.classList.add("md-nav__item--nested");
li.classList.add("md-nav__item--section-page");
// 4. Replace the <a> with a non-navigating <label> that matches the
// exact structure of native section headings.
var link = li.querySelector(":scope > a.md-nav__link");
if (link) {
var text = link.textContent.trim();
var heading = document.createElement("label");
heading.className = "md-nav__link";
heading.tabIndex = 0;
var ellipsis = document.createElement("span");
ellipsis.className = "md-ellipsis";
ellipsis.textContent = text;
heading.appendChild(ellipsis);
var icon = document.createElement("span");
icon.className = "md-nav__icon md-icon";
heading.appendChild(icon);
link.parentNode.replaceChild(heading, link);
}
// 5. Force the TOC nav always visible (belt-and-suspenders alongside
// the CSS rule).
var tocNav = li.querySelector(":scope > nav.md-nav--secondary");
if (tocNav) {
tocNav.style.display = "block";
}
}
/**
* Fetch a page and extract its TOC <ul> content, then inject it.
*/
function loadToc(href, targetUl) {
// Add a placeholder
var placeholder = document.createElement("li");
placeholder.className = "md-nav__item md-nav__toc-placeholder";
placeholder.textContent = "Loading…";
targetUl.appendChild(placeholder);
fetch(href)
.then(function (res) { return res.text(); })
.then(function (html) {
// Remove placeholder
if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
// Parse the fetched page and extract TOC items
var parser = new DOMParser();
var doc = parser.parseFromString(html, "text/html");
var tocList = doc.querySelector("[data-md-component='toc']");
if (!tocList) return;
// Cache the TOC items (as an array of <li> outerHTML)
var items = [];
tocList.querySelectorAll(":scope > li").forEach(function (li) {
items.push(li.outerHTML);
});
tocCache[href] = items;
injectToc(targetUl, items);
})
.catch(function () {
if (placeholder.parentNode) placeholder.parentNode.removeChild(placeholder);
});
}
/**
* Inject cached TOC items into the target <ul>.
*/
function injectToc(targetUl, items) {
// Don't inject if already done
if (targetUl.dataset.tocLoaded) return;
targetUl.dataset.tocLoaded = "true";
items.forEach(function (itemHtml) {
var temp = document.createElement("template");
temp.innerHTML = itemHtml;
var li = temp.content.firstElementChild;
if (li) {
// Fix relative anchor links — they need the page href prefix
li.querySelectorAll("a[href^='#']").forEach(function (a) {
var pageLink = targetUl.querySelector("li:first-child a");
if (pageLink) {
a.href = pageLink.href + a.getAttribute("href");
}
});
targetUl.appendChild(li);
}
});
// Initialise collapse/expand behaviour on any nested items that
// were just injected (sub-sections inside the fetched TOC).
// No pinning tracking — these are non-active pages with no
// MutationObserver watching them.
makeItemsCollapsible(targetUl, false);
}
// ── Lock active ancestor sections ────────────────────────────────────
//
// When you are viewing a page inside a section (e.g. CI/CD Pipeline
// inside Development), Material marks every ancestor section <li> with
// md-nav__item--active and checks its toggle. By default the user can
// still uncheck (collapse) those sections. We prevent this so that
// every section on the path to the active page stays permanently open,
// matching the intuitive expectation that you cannot collapse a section
// you are currently inside.
function lockActiveAncestorSections() {
var primaryNav = document.querySelector("nav.md-nav--primary");
if (!primaryNav) return;
// Find native sections that are ancestors of the current page.
// These have both --active (propagated up from the active page)
// and --nested (they are real sections, not converted page items).
primaryNav.querySelectorAll(
"li.md-nav__item--active.md-nav__item--nested:not(.md-nav__item--section-page)"
).forEach(function (li) {
var toggle = li.querySelector(":scope > input.md-nav__toggle");
if (!toggle) return;
// Force checked and prevent user from unchecking
toggle.checked = true;
// Intercept click/change to keep it locked
toggle.addEventListener("click", function (e) {
e.preventDefault();
});
toggle.addEventListener("change", function () {
toggle.checked = true;
});
});
}
// ── 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 (convertPageItems,
// initCollapsibleToc, lockActiveAncestorSections) on mobile breaks
// the drawer's scroll container and makes it impossible to scroll
// past the visible items.
// 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,
@@ -576,8 +257,6 @@
function bootstrap() {
if (!isDesktop()) return; // skip all DOM manipulation on mobile
initCollapsibleToc();
convertPageItems();
lockActiveAncestorSections();
}
if (document.readyState === "loading") {
@@ -589,9 +268,6 @@
// Re-init after instant navigation (mkdocs-material SPA mode)
if (typeof document$ !== "undefined") {
document$.subscribe(function () {
// Reset TOC cache and counter on navigation
tocCache = {};
pageNavCounter = 100;
bootstrap();
});
}