chrysopedia/frontend/src/components/TableOfContents.tsx
jlightner 0c99b1a8b7 feat: Split 945-line public-client.ts into 10 domain API modules with s…
- "frontend/src/api/client.ts"
- "frontend/src/api/index.ts"
- "frontend/src/api/search.ts"
- "frontend/src/api/techniques.ts"
- "frontend/src/api/creators.ts"
- "frontend/src/api/topics.ts"
- "frontend/src/api/stats.ts"
- "frontend/src/api/reports.ts"

GSD-Task: S05/T01
2026-04-03 23:04:56 +00:00

79 lines
2.8 KiB
TypeScript

/**
* Table of Contents for v2 technique pages with nested sections.
*
* Renders a nested list of anchor links matching the H2/H3 section structure.
* Uses slugified headings as IDs for scroll targeting.
* Receives activeId from parent (TechniquePage) which owns the IntersectionObserver.
* Smooth-scrolls to target on click with offset for the sticky title bar.
*/
import { useCallback } from "react";
import type { BodySectionV2 } from "../api";
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
interface TableOfContentsProps {
sections: BodySectionV2[];
activeId: string;
}
export default function TableOfContents({ sections, activeId }: TableOfContentsProps) {
const handleClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, targetId: string) => {
e.preventDefault();
const el = document.getElementById(targetId);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
// Update URL hash without jumping
window.history.replaceState(null, "", `#${targetId}`);
}
}, []);
if (sections.length === 0) return null;
return (
<nav className="technique-toc" aria-label="Table of contents">
<h3 className="technique-toc__title">On this page</h3>
<ul className="technique-toc__list">
{sections.map((section) => {
const sectionSlug = slugify(section.heading);
const isActive = activeId === sectionSlug;
return (
<li key={sectionSlug} className="technique-toc__item">
<a
href={`#${sectionSlug}`}
onClick={(e) => handleClick(e, sectionSlug)}
className={`technique-toc__link${isActive ? " technique-toc__link--active" : ""}`}
>
{section.heading}
</a>
{section.subsections.length > 0 && (
<ul className="technique-toc__sublist">
{section.subsections.map((sub) => {
const subSlug = `${sectionSlug}--${slugify(sub.heading)}`;
const isSubActive = activeId === subSlug;
return (
<li key={subSlug} className="technique-toc__subitem">
<a
href={`#${subSlug}`}
onClick={(e) => handleClick(e, subSlug)}
className={`technique-toc__sublink${isSubActive ? " technique-toc__sublink--active" : ""}`}
>
{sub.heading}
</a>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</nav>
);
}