Simplification per spec: the menu tracks state through the URL alone. Land on /docs: fully closed, headers only (an explicit home rule now overrides the contains-URL rule, since the root overview doc lives in Getting Started and would otherwise auto-open it). Land on any doc page: its containing section + group render open at first paint, current link bold — no JS, no jump, arrows consistent. Navigate elsewhere: old closes, new opens. Deleted: docs_nav cookie const/parse, ApplyNavOverrides, cookie write + collect/persist helpers in web/src/docs.ts, data-key attributes in the sidebar templ, and the first-section-open fallback (docs home used to force-open the first section — spec'd as closed). toggleCollapsible remains for in-page peeking; purely ephemeral. The [data-collapsed=true] CSS from the previous round stays — it is what makes the server-rendered state actually hide sections at paint (the original bug was that this CSS never existed). Tests: TestBuildNavigationNothingCurrent now asserts zero sections open on unmatched paths; TestBuildNavigation asserts Getting Started closed on a device page (no site-wide pin). make test green; 146 vitests; live verified: home 0 expanded, doc pages exactly 2 (section+group).
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
|
|
// Documentation site behavior. Rendering ownership (AGENTS.md): the server
|
|
// renders what things look like — nav markup, search results — and this
|
|
// module only orchestrates client-local state (open/closed panels, the
|
|
// search dropdown) and swaps in the server-rendered search partial.
|
|
|
|
function toggleSidebar(): void {
|
|
const sidebar = document.getElementById("docs-sidebar");
|
|
const overlay = document.getElementById("docs-overlay");
|
|
if (!sidebar || !overlay) return;
|
|
|
|
sidebar.classList.toggle("translate-x-0");
|
|
sidebar.classList.toggle("-translate-x-full");
|
|
overlay.classList.toggle("hidden");
|
|
}
|
|
|
|
// Docs sidebar sections toggle open/closed in-page (purely client-local,
|
|
// ephemeral — the SERVER decides what is expanded on each page load from
|
|
// navigation defaults, so there is no saved state and nothing to jump).
|
|
// The data-collapsed attribute is the single source of truth: the
|
|
// stylesheet hides on it, and the arrow rotates relative to it.
|
|
|
|
function applyCollapsed(items: HTMLElement, collapsed: boolean): void {
|
|
items.setAttribute("data-collapsed", collapsed ? "true" : "false");
|
|
const arrow = items.previousElementSibling?.querySelector<HTMLElement>(".section-arrow");
|
|
if (arrow) {
|
|
arrow.style.transform = collapsed ? "rotate(-90deg)" : "rotate(0deg)";
|
|
}
|
|
}
|
|
|
|
function toggleCollapsible(header: HTMLElement): void {
|
|
const items = header.nextElementSibling as HTMLElement | null;
|
|
if (!items) return;
|
|
|
|
const isCollapsed = items.getAttribute("data-collapsed") === "true";
|
|
applyCollapsed(items, !isCollapsed);
|
|
}
|
|
|
|
let docsSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let docsSearchAbort: AbortController | null = null;
|
|
|
|
function initializeDocsSearch(): void {
|
|
const searchInput = document.getElementById("docs-search") as HTMLInputElement | null;
|
|
const searchResults = document.getElementById("docs-search-results");
|
|
if (!searchInput || !searchResults) return;
|
|
|
|
searchInput.addEventListener("input", () => {
|
|
const query = searchInput.value.trim();
|
|
|
|
if (docsSearchTimeout) {
|
|
clearTimeout(docsSearchTimeout);
|
|
}
|
|
|
|
if (query.length < 2) {
|
|
hideDocsSearchResults();
|
|
return;
|
|
}
|
|
|
|
docsSearchTimeout = setTimeout(() => {
|
|
void performDocsSearch(query);
|
|
}, 250);
|
|
});
|
|
}
|
|
|
|
function hideDocsSearchResults(): void {
|
|
const searchResults = document.getElementById("docs-search-results");
|
|
if (!searchResults) return;
|
|
searchResults.replaceChildren();
|
|
searchResults.classList.add("hidden");
|
|
}
|
|
|
|
// performDocsSearch fetches the server-rendered results partial and swaps it
|
|
// in verbatim. The partial is templ-rendered (DocsSearchResults); this module
|
|
// never builds result markup itself.
|
|
async function performDocsSearch(query: string): Promise<void> {
|
|
const searchResults = document.getElementById("docs-search-results");
|
|
if (!searchResults) return;
|
|
|
|
docsSearchAbort?.abort();
|
|
docsSearchAbort = new AbortController();
|
|
|
|
try {
|
|
const response = await fetch(`/docs/search/partial?q=${encodeURIComponent(query)}`, {
|
|
signal: docsSearchAbort.signal,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`search failed: ${response.status}`);
|
|
}
|
|
searchResults.innerHTML = await response.text();
|
|
searchResults.classList.remove("hidden");
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
console.error("Docs search error:", error);
|
|
searchResults.textContent = "Search failed — try again.";
|
|
searchResults.classList.add("px-4", "py-3", "text-sm");
|
|
searchResults.style.color = "var(--text-secondary)";
|
|
searchResults.classList.remove("hidden");
|
|
}
|
|
}
|
|
|
|
function initializeCodeCopyButtons(): void {
|
|
document.querySelectorAll(".prose pre").forEach((pre) => {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "relative group";
|
|
pre.parentNode?.insertBefore(wrapper, pre);
|
|
wrapper.appendChild(pre);
|
|
|
|
const button = document.createElement("button");
|
|
button.className =
|
|
"absolute top-2 right-2 btn px-2 py-1 rounded-lg text-xs opacity-0 group-hover:opacity-100 transition-opacity";
|
|
button.textContent = "Copy";
|
|
button.addEventListener("click", async () => {
|
|
await navigator.clipboard.writeText(pre.textContent || "");
|
|
button.textContent = "Copied!";
|
|
setTimeout(() => {
|
|
button.textContent = "Copy";
|
|
}, 2000);
|
|
});
|
|
wrapper.appendChild(button);
|
|
});
|
|
}
|
|
|
|
Alpine.data("docs", () => ({
|
|
toggleSidebar,
|
|
toggleCollapsible,
|
|
initializeSearch: initializeDocsSearch,
|
|
initializeCodeCopyButtons,
|
|
}));
|