blog.z0x.ca/src/components/TableOfContents.astro
2024-10-02 01:04:06 -07:00

111 lines
2.9 KiB
Text

---
import { ScrollArea } from '@/components/ui/scroll-area'
import { Icon } from 'astro-icon/components'
import TableOfContentsHeading from './TableOfContentsHeading.astro'
export interface Heading {
depth: number
slug: string
text: string
subheadings: Heading[]
}
const { headings } = Astro.props
const toc = buildToc(headings)
function buildToc(headings: Heading[]): Heading[] {
const toc: Heading[] = []
const stack: Heading[] = []
headings.forEach((h) => {
const heading = { ...h, subheadings: [] }
while (stack.length > 0 && stack[stack.length - 1].depth >= heading.depth) {
stack.pop()
}
if (stack.length === 0) {
toc.push(heading)
} else {
stack[stack.length - 1].subheadings.push(heading)
}
stack.push(heading)
})
return toc
}
---
<details open class="group block rounded-xl border p-4 xl:hidden">
<summary
class="flex cursor-pointer items-center justify-between text-xl font-semibold"
>
Table of Contents
<Icon
name="lucide:chevron-down"
class="size-5 transition-transform group-open:rotate-180"
/>
</summary>
<ScrollArea client:load className="flex max-h-64 flex-col overflow-y-auto" type="always">
<nav>
<ul class="pt-3">
{toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
</ul>
</nav>
</ScrollArea>
</details>
<nav
class="sticky top-[5.5rem] hidden h-0 w-[calc(50vw-50%-4rem)] translate-x-[calc(-100%-2em)] text-xs leading-4 xl:block"
>
<div class="flex justify-end pr-6">
<ScrollArea client:load className="max-h-[calc(100vh-8rem)]" type="always">
<ul
class="flex flex-col justify-end gap-y-2 overflow-y-auto pr-8"
id="toc-container"
>
<li>
<h2 class="mb-2 text-lg font-semibold">Table of Contents</h2>
</li>
{toc.map((heading) => <TableOfContentsHeading heading={heading} />)}
</ul>
</ScrollArea>
</div>
</nav>
<script>
function setupToc() {
const header = document.querySelector('header')
const headerHeight = header ? header.offsetHeight : 0
const observer = new IntersectionObserver(
(sections) => {
sections.forEach((section) => {
const heading = section.target.querySelector('h2, h3, h4, h5, h6')
if (!heading) return
const id = heading.getAttribute('id')
const link = document.querySelector(
`#toc-container li a[href="#${id}"]`,
)
if (!link) return
const addRemove = section.isIntersecting ? 'add' : 'remove'
link.classList[addRemove]('text-foreground')
})
},
{
rootMargin: `-${headerHeight}px 0px 0px 0px`,
},
)
const sections = document.querySelectorAll('.prose section')
sections.forEach((section) => {
observer.observe(section)
})
}
document.addEventListener('astro:page-load', setupToc)
document.addEventListener('astro:after-swap', setupToc)
</script>