SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
3.2 KB · 88 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { usePathname } from "next/navigation";4import { cn } from "@/lib/utils";56interface TocItem {7  id: string;8  text: string;9  level: 2 | 3;10}1112/**13 * "On this page" table of contents. Reads h2/h3 headings from the rendered14 * `[data-docs-article]` element and highlights the section currently in view.15 */16export function DocsToc({ className }: { className?: string }) {17  const pathname = usePathname();18  const [items, setItems] = React.useState<TocItem[]>([]);19  const [active, setActive] = React.useState<string | null>(null);2021  React.useEffect(() => {22    // The article is rendered by a sibling server component; scan it once the frame has painted23    // so the DOM is complete, and subscribe to scroll position through an IntersectionObserver.24    let observer: IntersectionObserver | null = null;25    const frame = window.requestAnimationFrame(() => {26      const article = document.querySelector<HTMLElement>("[data-docs-article]");27      const headings = article ? Array.from(article.querySelectorAll<HTMLHeadingElement>("h2[id], h3[id]")) : [];28      const next: TocItem[] = headings.map((h) => ({29        id: h.id,30        text: (h.textContent ?? "").replace(/#\s*$/, "").trim(),31        level: h.tagName === "H2" ? 2 : 3,32      }));33      setItems(next);34      setActive(next[0]?.id ?? null);35      if (!headings.length) return;3637      const visible = new Map<string, number>();38      observer = new IntersectionObserver(39        (entries) => {40          for (const e of entries) {41            if (e.isIntersecting) visible.set(e.target.id, e.boundingClientRect.top);42            else visible.delete(e.target.id);43          }44          if (visible.size) {45            const top = [...visible.entries()].sort((a, b) => a[1] - b[1])[0]![0];46            setActive(top);47          } else {48            // Nothing intersecting: pick the last heading above the viewport.49            const above = headings.filter((h) => h.getBoundingClientRect().top < 120);50            if (above.length) setActive(above[above.length - 1]!.id);51          }52        },53        { rootMargin: "-80px 0px -60% 0px", threshold: [0, 1] },54      );55      headings.forEach((h) => observer!.observe(h));56    });57    return () => {58      window.cancelAnimationFrame(frame);59      observer?.disconnect();60    };61  }, [pathname]);6263  if (items.length < 2) return null;6465  return (66    <nav aria-label="On this page" className={cn("text-[12.5px]", className)}>67      <div className="mb-2 text-[11.5px] font-semibold uppercase tracking-wide text-fg-subtle">On this page</div>68      <ul className="space-y-px border-l border-border">69        {items.map((it) => (70          <li key={it.id}>71            <a72              href={`#${it.id}`}73              onClick={() => setActive(it.id)}74              className={cn(75                "-ml-px block border-l py-1 pr-2 leading-snug transition-colors",76                it.level === 3 ? "pl-6" : "pl-3",77                active === it.id ? "border-accent font-medium text-fg" : "border-transparent text-fg-muted hover:border-border-strong hover:text-fg",78              )}79            >80              {it.text}81            </a>82          </li>83        ))}84      </ul>85    </nav>86  );87}88