Help: mobile slide-over TOC with overlay, search content highlighting
CI / lint (push) Failing after 4s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / security (push) Successful in 7s

This commit is contained in:
2026-06-03 09:27:06 -04:00
parent 99b00eea0c
commit 03586256f1
3 changed files with 163 additions and 83 deletions
+79 -37
View File
@@ -225,7 +225,9 @@ function initHelpNavigation() {
const navList = document.getElementById("help-nav-list");
const nav = document.getElementById("help-nav");
const searchInput = document.getElementById("help-nav-search");
const toggleBtn = document.getElementById("help-nav-toggle");
const hamburger = document.getElementById("help-hamburger");
const navClose = document.getElementById("help-nav-close");
const overlay = document.getElementById("help-nav-overlay");
const helpContent = document.querySelector(".help-content");
if (!helpBody || !navList || !nav) return;
@@ -235,27 +237,41 @@ function initHelpNavigation() {
const sectionMap = {};
allSections.forEach(s => { sectionMap[s.id] = s; });
// Build text index for content search (section title + text content)
// Build text index for content search
const sectionTextCache = {};
allSections.forEach(s => {
sectionTextCache[s.id] = (s.textContent || '').toLowerCase();
});
// ── Mobile: open/close TOC slide-over ──
function openTOC() {
nav.classList.add("open");
if (overlay) overlay.classList.add("visible");
if (searchInput) setTimeout(() => searchInput.focus(), 150);
document.body.style.overflow = "hidden";
}
function closeTOC() {
nav.classList.remove("open");
if (overlay) overlay.classList.remove("visible");
document.body.style.overflow = "";
}
function isMobile() { return window.innerWidth <= 768; }
if (hamburger) hamburger.addEventListener("click", openTOC);
if (navClose) navClose.addEventListener("click", closeTOC);
if (overlay) overlay.addEventListener("click", closeTOC);
// ── Scroll to section ──
function scrollToSection(targetId) {
const section = sectionMap[targetId];
if (!section) return;
section.scrollIntoView({ behavior: "smooth", block: "start" });
section.setAttribute("tabindex", "-1");
// On mobile, collapse nav after selection
if (window.innerWidth <= 768 && nav) {
nav.classList.add("collapsed");
}
if (isMobile()) closeTOC();
}
// ── Focus the content pane (first heading in active section) ──
// ── Focus the content pane ──
function focusContent() {
// Find the active section and focus its first heading
const activeLink = navList.querySelector(".help-nav-link.active:not(.hidden)");
if (activeLink) {
const targetId = activeLink.getAttribute("href").substring(1);
@@ -265,12 +281,12 @@ function initHelpNavigation() {
if (h2) { h2.setAttribute("tabindex", "-1"); h2.focus({ preventScroll: true }); return; }
}
}
// Fallback: focus the content area itself
if (helpContent) { helpContent.setAttribute("tabindex", "-1"); helpContent.focus({ preventScroll: true }); }
}
// ── Focus TOC active link ──
function focusTOC() {
if (isMobile() && !nav.classList.contains("open")) openTOC();
const activeLink = navList.querySelector(".help-nav-link.active:not(.hidden)");
if (activeLink) { activeLink.focus(); return; }
const firstLink = navList.querySelector(".help-nav-link:not(.hidden)");
@@ -301,11 +317,9 @@ function initHelpNavigation() {
const prev = (currentIdx - 1 + items.length) % items.length;
items[prev].focus();
} else if (e.key === "ArrowRight") {
// Right arrow: go to content pane
e.preventDefault();
focusContent();
} else if (e.key === "ArrowLeft") {
// Left arrow in TOC: stay (prev item)
e.preventDefault();
const prev = (currentIdx - 1 + items.length) % items.length;
items[prev].focus();
@@ -319,12 +333,10 @@ function initHelpNavigation() {
e.preventDefault();
const targetId = items[currentIdx].getAttribute("href").substring(1);
scrollToSection(targetId);
// After scrolling, move focus to content
setTimeout(() => focusContent(), 400);
}
});
// Make nav items focusable
navList.querySelectorAll("li").forEach((li) => li.setAttribute("role", "none"));
// ── Keyboard navigation in content pane ──
@@ -332,7 +344,6 @@ function initHelpNavigation() {
helpContent.setAttribute("tabindex", "-1");
helpContent.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
// Left arrow in content: go back to TOC
e.preventDefault();
focusTOC();
} else if (e.key === "ArrowUp" && document.activeElement === helpContent) {
@@ -353,7 +364,7 @@ function initHelpNavigation() {
});
}
// ── Search: filter TOC + hide non-matching content sections ──
// ── Search: filter TOC + hide non-matching sections + highlight text ──
let currentQuery = "";
if (searchInput) {
searchInput.addEventListener("input", () => {
@@ -369,8 +380,57 @@ function initHelpNavigation() {
});
}
function clearHighlights() {
allSections.forEach(section => {
section.querySelectorAll(".help-search-highlight").forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
});
}
function highlightText(root, query) {
if (!query || query.length < 2) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
const textNodes = [];
while (walker.nextNode()) {
const node = walker.currentNode;
// Skip nodes inside script, style, or already-highlighted
if (node.parentElement && !node.parentElement.closest("script, style, .help-search-highlight, .help-nav")) {
textNodes.push(node);
}
}
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
textNodes.forEach(node => {
const text = node.textContent;
if (!regex.test(text)) return;
regex.lastIndex = 0;
const frag = document.createDocumentFragment();
let lastIdx = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIdx) {
frag.appendChild(document.createTextNode(text.slice(lastIdx, match.index)));
}
const mark = document.createElement("mark");
mark.className = "help-search-highlight";
mark.textContent = match[0];
frag.appendChild(mark);
lastIdx = regex.lastIndex;
if (match[0].length === 0) regex.lastIndex++;
}
if (lastIdx < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIdx)));
}
node.parentNode.replaceChild(frag, node);
});
}
function applySearchFilter() {
const q = currentQuery;
// Clear previous highlights
clearHighlights();
// Filter TOC links
allLinks.forEach((link) => {
const text = link.textContent.toLowerCase();
@@ -380,7 +440,7 @@ function initHelpNavigation() {
link.classList.add("hidden");
}
});
// Filter content sections
// Filter content sections + highlight
allSections.forEach((section) => {
if (!q) {
section.classList.remove("help-hidden");
@@ -390,12 +450,13 @@ function initHelpNavigation() {
const sectionTitle = (section.querySelector("h2, h3")?.textContent || '').toLowerCase();
if (sectionTitle.includes(q) || sectionText.includes(q)) {
section.classList.remove("help-hidden");
// Highlight matching text in this section
highlightText(section, q);
} else {
section.classList.add("help-hidden");
}
});
updateActiveLink();
// If no visible results, show a message
ensureNoResultsMessage(q);
}
@@ -415,26 +476,7 @@ function initHelpNavigation() {
}
}
// ── Mobile toggle ──
if (toggleBtn) {
toggleBtn.addEventListener("click", () => {
const wasCollapsed = nav.classList.contains("collapsed");
nav.classList.toggle("collapsed");
// If expanding, focus search
if (wasCollapsed && searchInput) {
setTimeout(() => searchInput.focus(), 150);
}
});
// Start expanded on mobile so user sees the TOC immediately
// (we use collapsed only after user taps a section or explicitly toggles)
window.addEventListener("resize", () => {
if (window.innerWidth > 768) {
nav.classList.remove("collapsed");
}
});
}
// ── Scroll spy (IntersectionObserver) ──
// ── Scroll spy ──
function updateActiveLink() {
const visibleLinks = allLinks.filter(l => !l.classList.contains("hidden"));
const visibleSections = allSections.filter(s => !s.classList.contains("help-hidden"));