Files
jsdoc-cheatsheet/components/TableOfContents.tsx
Luke Hagar d2c72f1250 feat: enhance JSDoc cheatsheet with quick reference section and improve layout
Add a dedicated quick reference section to the JSDoc cheatsheet, streamline the layout for better readability, and adjust sidebar and table of contents styles for consistency.
2025-09-19 17:44:47 +00:00

178 lines
6.0 KiB
TypeScript

"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { List, ChevronRight, Hash } from "lucide-react"
interface TOCItem {
id: string
title: string
level: number
}
interface TableOfContentsProps {
items: TOCItem[]
className?: string
}
export default function TableOfContents({ items, className = "" }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>("")
const [isVisible, setIsVisible] = useState(false)
// Read initial hash from URL on mount
useEffect(() => {
const hash = window.location.hash.slice(1) // Remove the # symbol
if (hash && items.some(item => item.id === hash)) {
setActiveId(hash)
}
}, [items])
// Listen for hash changes
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1) // Remove the # symbol
if (hash && items.some(item => item.id === hash)) {
setActiveId(hash)
}
}
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [items])
useEffect(() => {
if (items.length === 0) return
// Calculate header height dynamically
const header = document.querySelector('header')
const headerHeight = header ? header.offsetHeight + 20 : 100 // 20px buffer
const rootMargin = `-${headerHeight}px 0% -35% 0%`
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id)
// Update URL hash when scrolling to a section
if (window.location.hash !== `#${entry.target.id}`) {
window.history.replaceState(null, '', `#${entry.target.id}`)
}
}
})
},
{
rootMargin,
threshold: 0.1,
}
)
// Observe all TOC items
items.forEach((item) => {
const element = document.getElementById(item.id)
if (element) {
observer.observe(element)
}
})
return () => {
items.forEach((item) => {
const element = document.getElementById(item.id)
if (element) {
observer.unobserve(element)
}
})
}
}, [items])
useEffect(() => {
// Show TOC if there are items
setIsVisible(items.length > 0)
}, [items])
const scrollToSection = (id: string) => {
const element = document.getElementById(id)
if (element) {
// Update URL hash immediately
window.history.pushState(null, '', `#${id}`)
setActiveId(id)
// Get the actual header height dynamically
const header = document.querySelector('header')
const headerHeight = header ? header.offsetHeight + 20 : 100 // 20px buffer
const elementPosition = element.offsetTop - headerHeight
// Add a small delay to ensure smooth scrolling
setTimeout(() => {
window.scrollTo({
top: Math.max(0, elementPosition), // Ensure we don't scroll to negative position
behavior: "smooth"
})
}, 10)
}
}
if (!isVisible) return null
return (
<aside className={`w-72 fixed right-0 top-16 h-[calc(100vh-4rem)] z-40 overflow-y-auto hidden xl:block ${className}`}>
<div className="p-2">
<Card className="bg-card/80 border-border/30 shadow-lg backdrop-blur-sm">
<CardHeader className="pb-4 border-b border-border/20">
<CardTitle className="text-base text-foreground flex items-center gap-3 font-semibold">
<div className="p-2 rounded-lg bg-primary/10">
<List className="h-4 w-4 text-primary" />
</div>
<div>
<div>Table of Contents</div>
<div className="text-xs text-muted-foreground font-normal mt-1">
{items.length} {items.length === 1 ? 'section' : 'sections'}
</div>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<nav className="space-y-1 p-4">
{items.map((item, index) => {
const isActive = activeId === item.id
const indentClass = item.level > 1 ? `ml-${(item.level - 1) * 6}` : ""
return (
<div key={item.id} className="group">
<Button
variant="ghost"
size="sm"
className={`w-full justify-start gap-3 text-sm transition-all duration-300 group-hover:translate-x-1 ${
isActive
? "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md"
: "hover:bg-primary/5 text-muted-foreground hover:text-foreground"
} ${indentClass} h-10 px-3`}
onClick={() => scrollToSection(item.id)}
>
<div className={`flex items-center gap-2 flex-1 min-w-0`}>
{isActive ? (
<div className="w-2 h-2 rounded-full bg-primary-foreground flex-shrink-0" />
) : (
<Hash className="h-3 w-3 flex-shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
)}
<span className="truncate font-medium">{item.title}</span>
</div>
{isActive && (
<ChevronRight className="h-3 w-3 flex-shrink-0 animate-pulse" />
)}
</Button>
{index < items.length - 1 && (
<div className="h-px bg-border/30 mx-3 my-1" />
)}
</div>
)
})}
</nav>
</CardContent>
</Card>
</div>
</aside>
)
}