2026-02-07 20:17:46 -05:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
type Heading = {
|
|
|
|
|
id: string;
|
|
|
|
|
text: string;
|
|
|
|
|
level: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function TableOfContents({ headings }: { headings: Heading[] }) {
|
2026-05-25 09:49:40 -04:00
|
|
|
const [activeId, setActiveId] = useState<string>(() => headings[0]?.id ?? '');
|
2026-02-07 20:17:46 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-25 09:49:40 -04:00
|
|
|
const elements = headings
|
|
|
|
|
.map((heading) => document.getElementById(heading.id))
|
|
|
|
|
.filter((element): element is HTMLElement => Boolean(element));
|
2026-02-07 20:17:46 -05:00
|
|
|
|
2026-05-25 09:49:40 -04:00
|
|
|
if (elements.length === 0) return;
|
2026-02-07 20:17:46 -05:00
|
|
|
|
2026-05-25 09:49:40 -04:00
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
|
|
|
const visible = entries
|
|
|
|
|
.filter((entry) => entry.isIntersecting)
|
|
|
|
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
2026-02-07 20:17:46 -05:00
|
|
|
|
2026-05-25 09:49:40 -04:00
|
|
|
if (visible[0]?.target.id) {
|
|
|
|
|
setActiveId(visible[0].target.id);
|
2026-02-07 20:17:46 -05:00
|
|
|
}
|
2026-05-25 09:49:40 -04:00
|
|
|
}, {
|
|
|
|
|
rootMargin: '-20% 0px -65% 0px',
|
|
|
|
|
threshold: 0,
|
|
|
|
|
});
|
2026-02-07 20:17:46 -05:00
|
|
|
|
2026-05-25 09:49:40 -04:00
|
|
|
elements.forEach((element) => observer.observe(element));
|
|
|
|
|
return () => observer.disconnect();
|
2026-02-07 20:17:46 -05:00
|
|
|
}, [headings]);
|
|
|
|
|
|
|
|
|
|
if (headings.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-11 23:27:29 -04:00
|
|
|
<nav aria-label="Table of contents" className="text-left">
|
|
|
|
|
<h4 className="eyebrow mb-4">Contents</h4>
|
|
|
|
|
<ul className="space-y-2">
|
2026-02-07 20:17:46 -05:00
|
|
|
{Array.isArray(headings) && headings.map((heading) => (
|
2026-05-25 09:49:40 -04:00
|
|
|
<li key={heading.id} className={heading.level > 2 ? 'pl-3' : undefined}>
|
2026-02-07 20:17:46 -05:00
|
|
|
<a
|
|
|
|
|
href={`#${heading.id}`}
|
2026-04-11 23:27:29 -04:00
|
|
|
className={`block text-[0.82rem] leading-6 transition-colors ${activeId === heading.id
|
|
|
|
|
? 'text-ink'
|
|
|
|
|
: 'text-muted hover:text-ink'
|
2026-02-07 20:17:46 -05:00
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{heading.text}
|
|
|
|
|
</a>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</nav>
|
|
|
|
|
);
|
|
|
|
|
}
|