From 779e51ca534ccfc2d83927a5510cd04f3cb315c5 Mon Sep 17 00:00:00 2001 From: Anchal Sahani Date: Thu, 29 Jan 2026 14:13:19 +0530 Subject: [PATCH 1/4] fix: make TOC sidebar scrollable on initial load Signed-off-by: unknown Signed-off-by: Anchal Sahani --- components/TableContents.tsx | 270 ++++++++++++++++++----------------- 1 file changed, 138 insertions(+), 132 deletions(-) diff --git a/components/TableContents.tsx b/components/TableContents.tsx index 17906219..58db3d2c 100644 --- a/components/TableContents.tsx +++ b/components/TableContents.tsx @@ -6,38 +6,46 @@ function TOCItem({ title, type, onClick, + activeId, }: { id: string; title: string; type: string; + activeId: string; onClick: (id: string) => void; }) { - const itemClasses = "mb-1 text-slate-600 space-y-1"; + let marginLeft = "2rem"; - // Calculate margin left based on heading type - let marginLeft; switch (type) { case "h1": - marginLeft = 0; + marginLeft = "0"; break; case "h2": - marginLeft = "1rem"; // Adjust as needed + marginLeft = "1rem"; break; case "h3": - marginLeft = "1.5rem"; // Adjust as needed + marginLeft = "1.5rem"; break; case "h4": - marginLeft = "2rem"; // Adjust as needed + marginLeft = "2rem"; break; - default: - marginLeft = "2rem"; // Default to h4 margin } + const isActive = activeId === id; + return ( -
  • +
  • @@ -45,123 +53,105 @@ function TOCItem({ ); } -export default function TOC({ headings, isList, setIsList }) { - const tocRef = useRef(null); +export default function TOC({ headings, isList }) { + const tocContainerRef = useRef(null); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [activeId, setActiveId] = useState(""); + const [isSmallScreen, setIsSmallScreen] = useState(false); + /* ---------------- Scroll Spy ---------------- */ useEffect(() => { - if (!tocRef.current) return; - - const container = tocRef.current; + if (!headings?.length) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries.find((e) => e.isIntersecting); + if (visible) { + setActiveId(visible.target.id); + } + }, + { rootMargin: "-40% 0px -40% 0px", threshold: 0.1 } + ); + + headings.forEach((h) => { + const id = sanitizeStringForURL(h.id, true); + const el = document.getElementById(id); + if (el) observer.observe(el); + }); - function resizeHandler() { - setIsList(container.clientHeight > window.innerHeight * 0.8); - } + return () => observer.disconnect(); + }, [headings]); - resizeHandler() - window.addEventListener("resize", resizeHandler) + /* ---------------- Auto-scroll TOC to active item ---------------- */ + useEffect(() => { + if (!activeId || !tocContainerRef.current) return; - return () => { window.removeEventListener("resize", resizeHandler) } + const activeEl = tocContainerRef.current.querySelector( + `[data-toc-id="${activeId}"]` + ) as HTMLElement | null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + if (activeEl) { + activeEl.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [activeId]); + /* ---------------- Scroll on click ---------------- */ + const handleItemClick = (id: string) => { + const sanitizedId = sanitizeStringForURL(id, true); + const element = document.getElementById(sanitizedId); + if (!element) return; - const handleItemClick = (id) => { - const sanitizedId = sanitizeStringForURL(id, true); - const element = document.getElementById(sanitizedId); - if (element) { - const offset = 80; - const offsetPosition = element.offsetTop - offset; + const offset = 80; window.scrollTo({ - top: offsetPosition, + top: element.offsetTop - offset, behavior: "smooth", }); - window.history.replaceState(null, null, `#${sanitizedId}`); - } -}; - - // State to track screen width - const [isSmallScreen, setIsSmallScreen] = useState(false); - - // Function to check if screen width is small - const checkScreenSize = () => { - setIsSmallScreen(window.innerWidth < 1024); // Adjust breakpoint as needed + window.history.replaceState(null, "", `#${sanitizedId}`); + setActiveId(sanitizedId); }; + /* ---------------- Screen size ---------------- */ useEffect(() => { - checkScreenSize(); // Initial check - window.addEventListener("resize", checkScreenSize); // Event listener for screen resize - return () => { - window.removeEventListener("resize", checkScreenSize); // Cleanup on component unmount - }; + const resize = () => setIsSmallScreen(window.innerWidth < 1024); + resize(); + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); }, []); - // Render dropdown if on a small screen, otherwise render regular TOC - return isSmallScreen ? ( - <> + /* ---------------- Mobile ---------------- */ + if (isSmallScreen) { + return (
    -
    - -
    + {isDropdownOpen && ( -
    +
      - {headings.map((item, index) => { - let indent = ""; - switch (item.type) { - case "h1": - indent = "ml-0"; - break; - case "h2": - indent = "ml-4"; - break; - case "h3": - indent = "ml-8"; - break; - case "h4": - indent = "ml-12"; - break; - default: - indent = "ml-0"; - } - + {headings.map((item) => { + const id = sanitizeStringForURL(item.id, true); return ( -
    • +
    • @@ -172,47 +162,63 @@ export default function TOC({ headings, isList, setIsList }) {
    )}
    - - ) : ( + ); + } + + /* ---------------- Desktop ---------------- */ + return ( <> -
    -
    Table of Contents
    - -
    -
    + {/* Page-scoped scrollbar styling */} + + +
    Table of Contents
    + {isList ? ( ) : ( -