- "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
79 lines
2.8 KiB
TypeScript
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>
|
|
);
|
|
}
|