Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/web/src/components/ui/sidebar/SidebarScrollMemory.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
/**
* Remembers the documentation sidebar rail's scroll position across full-page
* navigations and reveals the active entry on a cold load — so following a link
* never dumps the reader back at the top of a long tree.
*
* Docs and the API Reference share one scrolling rail (owned by `DocShell`) but
* render different trees, so each variant passes its own `storageKey` to keep
* their saved offsets apart. It targets the rail via `data-doc-sidebar-rail`,
* set by `DocShell`.
*
* The script is intentionally `is:inline`: it must set `scrollTop` before the
* first paint to avoid a visible jump, which a bundled (deferred) module script
* cannot guarantee. `define:vars` injects `storageKey` as a leading `const`.
*/
interface Props {
readonly storageKey: string
}

const { storageKey } = Astro.props
---

<script is:inline define:vars={{ storageKey }}>
;(() => {
const rail = document.querySelector("[data-doc-sidebar-rail]")
if (rail === null) {
return
}

const persist = () => {
sessionStorage.setItem(storageKey, String(rail.scrollTop))
}

// Restore the offset saved on the previous page of this session.
const saved = sessionStorage.getItem(storageKey)
if (saved !== null) {
rail.scrollTop = Number.parseInt(saved, 10)
}

// Match on href so the version switcher's own aria-current="page" link is
// ignored and we target the actual current-page entry.
const active = rail.querySelector('a[aria-current="page"][href="' + location.pathname + '"]')
if (active !== null) {
const railBox = rail.getBoundingClientRect()
const itemBox = active.getBoundingClientRect()
const offScreen = itemBox.top < railBox.top || itemBox.bottom > railBox.bottom

// Center the active entry when nothing was restored or the restore left it
// out of view: a fresh visit, a version switch, or a stale saved offset
// from a differently shaped tree.
if (saved === null || offScreen) {
const delta = itemBox.top - railBox.top - rail.clientHeight / 2 + active.clientHeight / 2
rail.scrollTop += delta
}
}

// Save on scroll, throttled to one write per animation frame.
let frame = 0
rail.addEventListener(
"scroll",
() => {
if (frame !== 0) {
return
}
frame = requestAnimationFrame(() => {
frame = 0
persist()
})
},
{ passive: true },
)

// pagehide covers link navigation and bfcache unload alike.
window.addEventListener("pagehide", persist)
})()
</script>
7 changes: 5 additions & 2 deletions apps/web/src/layouts/DocShell.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import Footer from "@/components/Footer.astro"
import Navigation from "@/components/navigation/Navigation.astro"
import SidebarScrollMemory from "@/components/ui/sidebar/SidebarScrollMemory.astro"
import BaseLayout from "@/layouts/BaseLayout.astro"
import { PAGE_TITLE_ID } from "@/lib/constants/skip-link"
import type { NavigationActiveSlug } from "@/lib/navigation"
Expand Down Expand Up @@ -48,7 +49,7 @@ const railClass = "sticky top-16 max-h-[calc(100vh-4rem)] overflow-y-auto px-6 p
{
hasSidebar && (
<aside class="hidden border-r border-border lg:block">
<div data-doc-rail class={railClass}>
<div data-doc-sidebar-rail class={railClass}>
<slot name="sidebar" />
</div>
</aside>
Expand All @@ -62,7 +63,7 @@ const railClass = "sticky top-16 max-h-[calc(100vh-4rem)] overflow-y-auto px-6 p
{
hasToc && (
<aside class="hidden border-l border-border xl:block">
<div data-doc-rail class={railClass}>
<div class={railClass}>
<slot name="toc" />
</div>
</aside>
Expand All @@ -72,4 +73,6 @@ const railClass = "sticky top-16 max-h-[calc(100vh-4rem)] overflow-y-auto px-6 p

<Footer />
</div>

{hasSidebar && <SidebarScrollMemory storageKey={`docShellSidebarScroll:${activeSlug}`} />}
</BaseLayout>
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ const sidebarSections = sections.map((section) => ({
href: moduleHref(candidate.data.modulePath),
label: sidebarModuleName(candidate.data.modulePath, group),
active,
data: { "data-current-module": active ? "" : undefined },
}
}),
})),
Expand Down Expand Up @@ -178,7 +177,7 @@ const tocItems = apiModule.groups.map((group) => ({
modules={mobileModules}
/>

<nav slot="sidebar" aria-label="API reference navigation" data-api-sidebar data-module-sidebar>
<nav slot="sidebar" aria-label="API reference navigation" data-api-sidebar>
{
showVersionSwitch && (
<VersionSwitch versions={sidebarVersions} aria-label="API reference version" />
Expand Down Expand Up @@ -282,28 +281,3 @@ const tocItems = apiModule.groups.map((group) => ({

{tocItems.length > 0 && <TableOfContents slot="toc" items={tocItems} />}
</DocShell>

<script>
const moduleSidebar = document.querySelector<HTMLElement>("[data-module-sidebar]")
const currentModuleLink = moduleSidebar?.querySelector<HTMLElement>("[data-current-module]")

if (moduleSidebar !== null && currentModuleLink !== undefined && currentModuleLink !== null) {
const details = currentModuleLink.closest("details")
if (details instanceof HTMLDetailsElement) details.open = true

// DocShell owns the scrolling rail; use it rather than the navigation
// element so the active module is centered after the shared layout change.
const sidebarScroller = moduleSidebar.closest<HTMLElement>("[data-doc-rail]") ?? moduleSidebar

requestAnimationFrame(() => {
const sidebarBounds = sidebarScroller.getBoundingClientRect()
const linkBounds = currentModuleLink.getBoundingClientRect()
const isVisible =
linkBounds.top >= sidebarBounds.top && linkBounds.bottom <= sidebarBounds.bottom
if (isVisible) return

sidebarScroller.scrollTop +=
linkBounds.top - sidebarBounds.top - (sidebarScroller.clientHeight - linkBounds.height) / 2
})
}
</script>
Loading