From dd2ab4ea96a739d93290d97242488ebc5a553b1b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 24 Oct 2026 22:04:06 -0400 Subject: [PATCH] docs(ui): center the active nav link in the sidebar instead of edge-clamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clamp put deep-linked highlights flush against the sidebar's bottom edge. Centering is simpler AND handles the max-scroll case for free: scrollTop carries the item's center to the scrollport center, and the browser clamps to the scroll extent — items near the nav's top/bottom land as centered as the content allows (scroll pinned fully up/down), which is the requested fallback. --- web/src/docs.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/web/src/docs.ts b/web/src/docs.ts index 5eb65a8d..57510356 100644 --- a/web/src/docs.ts +++ b/web/src/docs.ts @@ -45,23 +45,27 @@ function scrollSidebarToActive(): void { const sidebar = document.getElementById("docs-sidebar"); if (!sidebar) return; - // Skip the off-canvas mobile drawer (translated off screen). - if (sidebar.classList.contains("-translate-x-full")) return; + // The class list ALWAYS carries -translate-x-full on desktop too + // (lg:translate-x-0 overrides it only at the media-query level), so + // classList can't tell viewports apart — match the tailwind lg + // breakpoint explicitly: below it the sidebar is the off-canvas drawer. + if (!window.matchMedia("(min-width: 1024px)").matches) return; const active = sidebar.querySelector( `a[href="${location.pathname}"], a[href="${location.pathname.replace(/\.md$/, "")}"]`, ); if (!active) return; - const sb = sidebar.getBoundingClientRect(); - const ar = active.getBoundingClientRect(); - if (ar.top >= sb.top && ar.bottom <= sb.bottom) return; // already visible - - if (ar.top < sb.top) { - sidebar.scrollTop += ar.top - sb.top; - } else { - sidebar.scrollTop += ar.bottom - sb.bottom; - } + // Run once the browser has applied styles and laid the sidebar out. + requestAnimationFrame(() => { + const sb = sidebar.getBoundingClientRect(); + const ar = active.getBoundingClientRect(); + // Center the active item in the sidebar's visible area. The browser + // clamps scrollTop to the scroll extent, so items near the top or + // bottom of the nav land "as centered as possible" (scroll pinned all + // the way up/down) — no manual edge-clamping needed. + sidebar.scrollTop += ar.top - sb.top - sb.height / 2 + ar.height / 2; + }); } let docsSearchTimeout: ReturnType | null = null; @@ -153,5 +157,5 @@ Alpine.data("docs", () => ({ toggleCollapsible, initializeSearch: initializeDocsSearch, initializeCodeCopyButtons, - scrollSidebarToActive, + scrollSidebarToActive: () => requestAnimationFrame(scrollSidebarToActive), }));