Last active
April 30, 2026 11:15
-
-
Save noxify/c72782bc61df644d4a32ed82e0956838 to your computer and use it in GitHub Desktop.
renoun toc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // components/toc/index.ts | |
| export { | |
| TableOfContents, | |
| TableOfContentsScript, | |
| type TableOfContentsSection, | |
| type TableOfContentsProps, | |
| type TableOfContentsComponents, | |
| } from "./TableOfContents" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // app/[...slug]/layout.tsx | |
| import { TableOfContentsScript } from "@/components/toc" | |
| export default async function DocsSlugLayout({ | |
| children, | |
| params, | |
| }: { | |
| children: React.ReactNode | |
| params: Promise<{ slug: string[] }> | |
| }) { | |
| return ( | |
| <div className="relative w-full" > | |
| {/* | |
| Here we call TOC script which was removed from TOC register component. | |
| This solved the react error and it seems it also fixed the `aria-current` issue | |
| which wasn't updated on scroll in the original implementation. | |
| */} | |
| <TableOfContentsScript /> | |
| {children} | |
| </div> | |
| ) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // components/toc/Register.tsx | |
| "use client" | |
| import { useLayoutEffect } from "react" | |
| /** | |
| * A component that registers heading ids with the `TableOfContentsScript`. | |
| * @internal | |
| */ | |
| export function Register({ ids }: { ids: string[] }) { | |
| useLayoutEffect(() => { | |
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | |
| window.__TableOfContents__?.register(ids) | |
| }, [ids]) | |
| return null | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // components/toc/script.ts | |
| declare global { | |
| interface Window { | |
| __TableOfContents__: { | |
| register: (ids: string[]) => void | |
| } | |
| } | |
| } | |
| /** | |
| * Script to manage active target state in the table of contents. | |
| * @internal | |
| */ | |
| export default function ({ | |
| activationRatio = 0.333, | |
| }: { | |
| /** A number between `0` and `1` representing which portion of the viewport height from top the target becomes active. */ | |
| activationRatio?: number | |
| }): void { | |
| const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches | |
| const smoothScrollBehavior: ScrollBehavior = prefersReducedMotion ? "auto" : "smooth" | |
| const OVERFLOW_REGEX = /(auto|scroll)/ | |
| const viewportCache = new WeakMap<HTMLElement, HTMLElement>() | |
| let previousActiveLink: HTMLAnchorElement | null = null | |
| let previousLastSectionInView = false | |
| let isScrollingIntoView = false | |
| let lastScrollY = 0 | |
| let rafId = 0 | |
| let dispose: (() => void) | null = null | |
| function cancelFrame(): void { | |
| if (rafId) cancelAnimationFrame(rafId) | |
| rafId = 0 | |
| } | |
| function getLink(id: string): HTMLAnchorElement | null { | |
| return document.querySelector<HTMLAnchorElement>(`:is(ol, ul) a[href="#${id}"]`) | |
| } | |
| function getClosestViewport(node: HTMLElement): HTMLElement { | |
| const cached = viewportCache.get(node) | |
| if (cached) { | |
| return cached | |
| } | |
| let current: ParentNode | null = node.parentNode | |
| while (current) { | |
| if (current === document.body) { | |
| return document.body | |
| } | |
| if (current instanceof HTMLElement) { | |
| const { overflow, overflowX, overflowY } = getComputedStyle(current) | |
| if (OVERFLOW_REGEX.test(overflow + overflowX + overflowY)) { | |
| viewportCache.set(node, current) | |
| return current | |
| } | |
| } | |
| current = current.parentNode | |
| } | |
| viewportCache.set(node, document.body) | |
| return document.body | |
| } | |
| function setActiveLink(target: HTMLElement): void { | |
| isScrollingIntoView = true | |
| target.scrollIntoView({ behavior: smoothScrollBehavior, block: "start" }) | |
| const nextActiveLink = getLink(target.id) | |
| if (nextActiveLink) { | |
| nextActiveLink.setAttribute("aria-current", "location") | |
| history.pushState(null, "", "#" + target.id) | |
| if (previousActiveLink && previousActiveLink !== nextActiveLink) { | |
| previousActiveLink.removeAttribute("aria-current") | |
| } | |
| previousActiveLink = nextActiveLink | |
| } | |
| if ("onscrollend" in window) { | |
| window.addEventListener( | |
| "scrollend", | |
| () => { | |
| isScrollingIntoView = false | |
| }, | |
| { passive: true, once: true }, | |
| ) | |
| } else { | |
| cancelFrame() | |
| let still = 0 | |
| function step(): void { | |
| const y = window.scrollY | |
| if (Math.abs(y - lastScrollY) < 1) { | |
| if (++still > 4) { | |
| isScrollingIntoView = false | |
| cancelFrame() | |
| return | |
| } | |
| } else { | |
| still = 0 | |
| } | |
| lastScrollY = y | |
| rafId = requestAnimationFrame(step) | |
| } | |
| rafId = requestAnimationFrame(step) | |
| } | |
| } | |
| document.addEventListener("click", (event) => { | |
| if (!(event.target instanceof HTMLAnchorElement)) return | |
| const href = event.target.href | |
| // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | |
| if (!href?.includes("#")) return | |
| const id = href.slice(href.indexOf("#") + 1) | |
| const section = document.getElementById(id) | |
| if (!section) return | |
| event.preventDefault() | |
| setActiveLink(section) | |
| }) | |
| window.__TableOfContents__ = { | |
| register: (targetIds: string[]) => { | |
| dispose?.() | |
| const targetElements = targetIds | |
| .map((id) => document.getElementById(id)) | |
| .filter(Boolean) as HTMLElement[] | |
| const linkFor = new Map<HTMLElement, HTMLAnchorElement | null>( | |
| targetElements.map((target) => [target, getLink(target.id)]), | |
| ) | |
| const lastIndex = targetElements.length - 1 | |
| const lastTarget = targetElements[lastIndex] | |
| const lastLink = lastTarget ? linkFor.get(lastTarget) : null | |
| let scrollTimeout: number | null = null | |
| const scrollDelay = 100 | |
| function update(): void { | |
| if (isScrollingIntoView) return | |
| const vh = window.innerHeight || document.documentElement.clientHeight | |
| const vw = window.innerWidth || document.documentElement.clientWidth | |
| const offsetTop = vh * activationRatio | |
| let bestIndex = 0 | |
| let bestTop = -Infinity | |
| let lastSectionInView = false | |
| for (let index = 0; index < targetElements.length; index++) { | |
| // eslint-disable-next-line @typescript-eslint/no-non-null-assertion | |
| const rect = targetElements[index]!.getBoundingClientRect() | |
| if (rect.top <= offsetTop && rect.top > bestTop) { | |
| bestTop = rect.top | |
| bestIndex = index | |
| } | |
| if ( | |
| index === lastIndex && | |
| rect.bottom > 0 && | |
| rect.right > 0 && | |
| rect.top < vh && | |
| rect.left < vw | |
| ) { | |
| lastSectionInView = true | |
| } | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-non-null-assertion | |
| const targetElement = targetElements[bestIndex]! | |
| const nextActiveLink = linkFor.get(targetElement) ?? null | |
| if (nextActiveLink !== previousActiveLink) { | |
| if (previousActiveLink) { | |
| previousActiveLink.removeAttribute("aria-current") | |
| } | |
| if (nextActiveLink) { | |
| nextActiveLink.setAttribute("aria-current", "location") | |
| } | |
| previousActiveLink = nextActiveLink | |
| } | |
| if (lastSectionInView) { | |
| if (!previousLastSectionInView && bestIndex !== lastIndex && lastLink) { | |
| if (scrollTimeout) clearTimeout(scrollTimeout) | |
| scrollTimeout = window.setTimeout(() => { | |
| const viewport = getClosestViewport(lastLink) | |
| if (viewport !== document.body) { | |
| viewport.scrollTo({ | |
| top: viewport.scrollHeight, | |
| behavior: smoothScrollBehavior, | |
| }) | |
| } | |
| }, scrollDelay) | |
| } | |
| } else if (previousActiveLink) { | |
| if (scrollTimeout) clearTimeout(scrollTimeout) | |
| scrollTimeout = window.setTimeout(() => { | |
| // eslint-disable-next-line @typescript-eslint/no-non-null-assertion | |
| const viewport = getClosestViewport(previousActiveLink!) | |
| if (viewport !== document.body) { | |
| // eslint-disable-next-line @typescript-eslint/no-non-null-assertion | |
| previousActiveLink!.scrollIntoView({ | |
| behavior: smoothScrollBehavior, | |
| block: "nearest", | |
| }) | |
| } | |
| }, scrollDelay) | |
| } | |
| previousLastSectionInView = lastSectionInView | |
| } | |
| const intersectionObserver = new IntersectionObserver(update, { | |
| root: null, | |
| rootMargin: `-${activationRatio * 100}% 0px 0px 0px`, | |
| threshold: [0, 1], | |
| }) | |
| targetElements.forEach((target) => intersectionObserver.observe(target)) | |
| update() | |
| dispose = () => { | |
| intersectionObserver.disconnect() | |
| cancelFrame() | |
| isScrollingIntoView = false | |
| previousLastSectionInView = false | |
| if (previousActiveLink) { | |
| previousActiveLink.removeAttribute("aria-current") | |
| previousActiveLink = null | |
| } | |
| } | |
| }, | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // components/toc/TableOfContents.tsx | |
| import type { ContentSection, Section } from "renoun" | |
| import React, { useId } from "react" | |
| import { Script } from "renoun/components" | |
| import NextScript from "next/script" | |
| import tocScript from "./script" | |
| import { Register } from "./Register" | |
| /** A section for the table of contents (either Section or ContentSection). */ | |
| export type TableOfContentsSection = Section | ContentSection | |
| export interface TableOfContentsComponents { | |
| /** Root navigation element. */ | |
| Root: React.ComponentType<{ | |
| children?: React.ReactNode | |
| "aria-labelledby"?: string | |
| }> | |
| /** Title heading. */ | |
| Title: React.ComponentType<{ | |
| id?: string | |
| children?: React.ReactNode | |
| }> | |
| /** Ordered list of items. */ | |
| List: React.ComponentType<{ | |
| depth: number | |
| children?: React.ReactNode | |
| }> | |
| /** Individual list item. */ | |
| Item: React.ComponentType<{ | |
| children?: React.ReactNode | |
| }> | |
| /** Anchor link to a heading. */ | |
| Link: React.ComponentType<{ | |
| children?: React.ReactNode | |
| href: string | |
| suppressHydrationWarning?: boolean | |
| "aria-current"?: React.AriaAttributes["aria-current"] | |
| }> | |
| } | |
| export interface TableOfContentsProps { | |
| /** The sections to display within the table of contents. */ | |
| sections: TableOfContentsSection[] | |
| /** Override the default component renderers. */ | |
| components?: Partial<TableOfContentsComponents> | |
| /** Optional content rendered after the section links. */ | |
| children?: React.ReactNode | |
| } | |
| const defaultComponents: TableOfContentsComponents = { | |
| Root: (props) => <nav {...props} />, | |
| Title: ({ children = "On this page", ...props }) => <h4 {...props}>{children}</h4>, | |
| List: (props) => <ol {...props} />, | |
| Item: (props) => <li {...props} />, | |
| Link: (props) => <a {...props} />, | |
| } | |
| /** | |
| * Script to manage active heading state in the table of contents. | |
| * @internal | |
| */ | |
| export function TableOfContentsScript_OLD({ nonce }: { nonce?: string }) { | |
| // @ts-expect-error – renoun Script API uses a dynamic import pattern not recognized by tsc | |
| return <Script nonce={nonce}>{import("./script.ts")}</Script> | |
| } | |
| // it seems that the renoun `Script` component isn't working or it's just me 😶🌫️ | |
| // but using the `NextScript` solved the problem finally. | |
| // this doesn't work with other react frameworks, but as workaround it's ok | |
| export function TableOfContentsScript({ nonce }: { nonce?: string }) { | |
| const code = `void (${Function.prototype.toString.call(tocScript)})(${JSON.stringify({ activationRatio: 0.333 })});` | |
| return ( | |
| <NextScript | |
| id="renoun-toc-script" | |
| nonce={nonce} | |
| strategy="afterInteractive" | |
| dangerouslySetInnerHTML={{ __html: code.replace(/<\/script/gi, "<\\/script") }} | |
| /> | |
| ) | |
| } | |
| /** Check if a section has a depth property (is ContentSection). */ | |
| function hasDepth(section: TableOfContentsSection): section is ContentSection { | |
| return "depth" in section && typeof section.depth === "number" | |
| } | |
| /** Collect all section IDs recursively. */ | |
| function collectSectionIds(sections: TableOfContentsSection[], ids: Set<string>): void { | |
| for (const section of sections) { | |
| ids.add(section.id) | |
| if (section.children) { | |
| collectSectionIds(section.children, ids) | |
| } | |
| } | |
| } | |
| /** A table of contents that displays links to the sections in the current document. */ | |
| export function TableOfContents({ sections, components = {}, children }: TableOfContentsProps) { | |
| const rootId = useId() | |
| const sectionIds = new Set<string>() | |
| const { Root, Title, List, Item, Link }: TableOfContentsComponents = { | |
| ...defaultComponents, | |
| ...components, | |
| } | |
| // Filter to only show sections with depth > 1 (skip h1) for ContentSection, | |
| // or include all sections for Section (no depth property) | |
| const filteredSections = sections.filter((section) => !hasDepth(section) || section.depth > 1) | |
| // Collect all section IDs for scroll tracking | |
| collectSectionIds(filteredSections, sectionIds) | |
| function renderSections(sections: TableOfContentsSection[], depth = 0): React.ReactNode { | |
| if (sections.length === 0) { | |
| return null | |
| } | |
| return ( | |
| <List depth={depth}> | |
| {sections.map((section) => ( | |
| <Item key={section.id}> | |
| <Link href={`#${section.id}`} suppressHydrationWarning> | |
| {"jsx" in section && section.jsx !== undefined ? section.jsx : section.title} | |
| </Link> | |
| {section.children && section.children.length > 0 | |
| ? renderSections(section.children, depth + 1) | |
| : null} | |
| </Item> | |
| ))} | |
| </List> | |
| ) | |
| } | |
| if (filteredSections.length === 0 && !children) { | |
| return null | |
| } | |
| return ( | |
| <Root aria-labelledby={rootId}> | |
| <Title id={rootId} /> | |
| {renderSections(filteredSections)} | |
| {children} | |
| <Register ids={Array.from(sectionIds)} /> | |
| </Root> | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment