blog.hangerthem.com/components/common/TableOfContents.tsx

60 lines
1.7 KiB
TypeScript

'use client'
import { useEffect, useRef, useState } from 'react'
type TocItem = { text: string; id: string; level: number }
const indentByLevel: Record<number, string> = {
2: 'pl-0',
3: 'pl-4',
}
export function TableOfContents({ items }: { items: TocItem[] }) {
const [activeId, setActiveId] = useState<string | null>(null)
const observer = useRef<IntersectionObserver | null>(null)
useEffect(() => {
if (items.length === 0) return
const headingElements = items
.map((item) => document.getElementById(item.id))
.filter((el): el is HTMLElement => el !== null)
observer.current = new IntersectionObserver(
(entries) => {
const visible = entries.filter((entry) => entry.isIntersecting)
if (visible.length > 0) {
setActiveId(visible[0].target.id)
}
},
{ rootMargin: '0px 0px -70% 0px', threshold: 1.0 },
)
headingElements.forEach((el) => observer.current?.observe(el))
return () => observer.current?.disconnect()
}, [items])
if (items.length === 0) return null
return (
<nav aria-label="Table of contents" className="sticky top-20">
<h2 className="text-2xl font-bold mb-4">Table of Contents</h2>
<ul className="space-y-1 text-sm">
{items.map((item) => (
<li key={item.id} className={indentByLevel[item.level] ?? 'pl-0'}>
<a
href={`#${item.id}`}
className={`block truncate transition-colors ${
activeId === item.id ? 'text-accent font-medium' : 'text-fg-dim hover:text-fg'
}`}
aria-current={activeId === item.id ? 'location' : undefined}
>
{item.text}
</a>
</li>
))}
</ul>
</nav>
)
}