65 lines
1.7 KiB
TypeScript
65 lines
1.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import type { Heading } from "@/lib/posts";
|
|
|
|
interface TableOfContentsProps {
|
|
headings: Heading[];
|
|
}
|
|
|
|
export default function TableOfContents({ headings }: TableOfContentsProps) {
|
|
const [activeSlug, setActiveSlug] = useState(() => headings[0]?.slug ?? "");
|
|
|
|
useEffect(() => {
|
|
if (headings.length === 0) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
setActiveSlug(entry.target.id);
|
|
}
|
|
}
|
|
},
|
|
{ rootMargin: "0% 0% -70% 0%", threshold: 0 }
|
|
);
|
|
|
|
headings.forEach(({ slug }) => {
|
|
const el = document.getElementById(slug);
|
|
if (el) observer.observe(el);
|
|
});
|
|
|
|
return () => observer.disconnect();
|
|
}, [headings]);
|
|
|
|
if (headings.length === 0) return null;
|
|
|
|
return (
|
|
<nav className="bg-white/20 backdrop-blur border border-white/30 rounded-lg p-4 lg:sticky lg:top-8">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-blue-800 mb-3">
|
|
Contents
|
|
</p>
|
|
<ul className="space-y-1.5">
|
|
{headings.map(({ slug, text, level }) => (
|
|
<li
|
|
key={slug}
|
|
className={level === 3 ? "pl-3" : ""}
|
|
>
|
|
<a
|
|
href={`#${slug}`}
|
|
aria-current={activeSlug === slug ? "true" : undefined}
|
|
className={`text-sm leading-snug transition-colors duration-150 ${
|
|
activeSlug === slug
|
|
? "text-blue-700 font-semibold"
|
|
: "text-gray-600 hover:text-blue-600"
|
|
}`}
|
|
>
|
|
{text}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
);
|
|
}
|