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