Files
bookhoard/web/src/docs.ts
T
John O'Keefe 73e8cdef4b docs(ui): drop sidebar saved-state — server defaults already ARE the standard docs behavior
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).
2026-10-24 21:17:55 -04:00

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,
}));