diff --git a/src/app/(docs)/brand/layout.tsx b/src/app/(docs)/brand/layout.tsx index d84c1e4c7..711e53182 100644 --- a/src/app/(docs)/brand/layout.tsx +++ b/src/app/(docs)/brand/layout.tsx @@ -45,7 +45,10 @@ export default async function DocPage({ children }: { children: React.ReactNode
-
+
diff --git a/src/app/(docs)/docs/[slug]/page.tsx b/src/app/(docs)/docs/[slug]/page.tsx index 51dd45df3..1d558bb68 100644 --- a/src/app/(docs)/docs/[slug]/page.tsx +++ b/src/app/(docs)/docs/[slug]/page.tsx @@ -91,7 +91,10 @@ export default async function DocPage(props: Props) {
-
+
diff --git a/src/components/nav-list.tsx b/src/components/nav-list.tsx index 325b20071..084a13a53 100644 --- a/src/components/nav-list.tsx +++ b/src/components/nav-list.tsx @@ -1,10 +1,11 @@ import { CloseButton } from "@headlessui/react"; import clsx from "clsx"; +import { forwardRef } from "react"; import Link from "next/link"; -export function NavList({ children, ...rest }: React.PropsWithChildren) { +export function NavList({ children, className, ...rest }: React.ComponentPropsWithoutRef<"div">) { return ( -
+
{children}
); @@ -37,15 +38,12 @@ export function NavListItem({ children }: React.PropsWithChildren) { return
  • {children}
  • ; } -export function NavListLink({ - href, - children, - nested = false, - ...props -}: React.PropsWithChildren<{ href: string; nested?: boolean }>) { - return ( +export const NavListLink = forwardRef>( + function NavListLink({ href, children, nested = false, ...props }, ref) { + return ( {children} - ); -} + ); + }, +); diff --git a/src/components/table-of-contents.tsx b/src/components/table-of-contents.tsx index 17ea88203..5a1bb37de 100644 --- a/src/components/table-of-contents.tsx +++ b/src/components/table-of-contents.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { NavList, NavListHeading, NavListItem, NavListItems, NavListLink } from "./nav-list"; export type TOCEntry = { @@ -12,6 +12,22 @@ export type TOCEntry = { export default function TableOfContents({ tableOfContents }: { tableOfContents: TOCEntry[] }) { let [activeSection, setActiveSection] = useState(null); + let activeLinkRef = useRef(null); + let userScrollLockRef = useRef(0); + let isAutoScrollingRef = useRef(false); + + function scrollActiveLinkIntoView(container: HTMLElement, activeLink: HTMLAnchorElement) { + let containerRect = container.getBoundingClientRect(); + let linkRect = activeLink.getBoundingClientRect(); + let nextTop = container.scrollTop + (linkRect.top - containerRect.top) - container.clientHeight / 2 + linkRect.height / 2; + + isAutoScrollingRef.current = true; + container.scrollTo({ top: nextTop, behavior: "smooth" }); + window.setTimeout(() => { + isAutoScrollingRef.current = false; + }, 250); + } + useEffect(() => { const root = document.querySelector('[data-content="true"]'); if (!root) return; @@ -51,20 +67,85 @@ export default function TableOfContents({ tableOfContents }: { tableOfContents: return () => observer.disconnect(); }, []); + useLayoutEffect(() => { + let container = document.querySelector('[data-toc-scroll-container="true"]'); + let activeLink = activeLinkRef.current; + + if (!container || !activeLink || !activeSection) return; + if (performance.now() < userScrollLockRef.current) return; + + let containerRect = container.getBoundingClientRect(); + let linkRect = activeLink.getBoundingClientRect(); + let edgeThreshold = 56; + + if (linkRect.top < containerRect.top + edgeThreshold || linkRect.bottom > containerRect.bottom - edgeThreshold) { + scrollActiveLinkIntoView(container, activeLink); + } + }, [activeSection]); + + useEffect(() => { + let container = document.querySelector('[data-toc-scroll-container="true"]'); + if (!container) return; + + let lockTimer: ReturnType | null = null; + + let lockUserScrolling = () => { + if (isAutoScrollingRef.current) return; + + userScrollLockRef.current = performance.now() + 800; + + if (lockTimer !== null) { + clearTimeout(lockTimer); + } + + lockTimer = setTimeout(() => { + userScrollLockRef.current = 0; + }, 800); + }; + + container.addEventListener("pointerdown", lockUserScrolling, { passive: true }); + container.addEventListener("pointermove", lockUserScrolling, { passive: true }); + container.addEventListener("pointerup", lockUserScrolling, { passive: true }); + container.addEventListener("pointercancel", lockUserScrolling, { passive: true }); + container.addEventListener("wheel", lockUserScrolling, { passive: true }); + container.addEventListener("touchstart", lockUserScrolling, { passive: true }); + return () => { + container.removeEventListener("pointerdown", lockUserScrolling); + container.removeEventListener("pointermove", lockUserScrolling); + container.removeEventListener("pointerup", lockUserScrolling); + container.removeEventListener("pointercancel", lockUserScrolling); + container.removeEventListener("wheel", lockUserScrolling); + container.removeEventListener("touchstart", lockUserScrolling); + + if (lockTimer !== null) { + clearTimeout(lockTimer); + } + }; + }, []); + return ( On this page {tableOfContents.map(({ text, slug, children }, i) => ( - + {text} {children.length > 0 && ( {children.map(({ text, slug }, i) => ( - + {text}