60 lines
1.7 KiB
TypeScript
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>
|
|
)
|
|
}
|