From ec7f4e63de83e8af5425330c094d68ec30d7c248 Mon Sep 17 00:00:00 2001 From: Chris Arter Date: Fri, 8 May 2026 12:39:04 -0400 Subject: [PATCH] feat(components): port TOC and mobile TOC --- src/components/MobileTableOfContents.tsx | 152 +++++++++++++++++++++++ src/components/TableOfContents.tsx | 74 +++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/components/MobileTableOfContents.tsx create mode 100644 src/components/TableOfContents.tsx diff --git a/src/components/MobileTableOfContents.tsx b/src/components/MobileTableOfContents.tsx new file mode 100644 index 0000000..4653ce6 --- /dev/null +++ b/src/components/MobileTableOfContents.tsx @@ -0,0 +1,152 @@ +'use client' +// src/components/MobileTableOfContents.tsx +import { useEffect, useRef, useState } from 'react' +import { TableOfContents } from './TableOfContents' +import type { Heading } from '@/lib/lexical-headings' + +interface MobileTableOfContentsProps { + headings: Heading[] + title: string + url: string +} + +function flatten( + list: Heading[], + acc: Array = [], +): Array { + for (const h of list) { + acc.push(h) + if (h.children?.length) flatten(h.children, acc) + } + return acc +} + +export function MobileTableOfContents({ headings, title, url }: MobileTableOfContentsProps) { + const [open, setOpen] = useState(false) + const [currentText, setCurrentText] = useState('Jump to section') + const panelRef = useRef(null) + const backdropRef = useRef(null) + + const flat = flatten(headings) + + // Scroll spy: track current heading + useEffect(() => { + const allHeadingEls = flat + .map((h) => document.getElementById(h.id)) + .filter((el): el is HTMLElement => !!el) + + function updateCurrent() { + const y = window.scrollY + 140 + let current: HTMLElement | null = null + for (const el of allHeadingEls) { + if (el.offsetTop <= y) current = el + } + setCurrentText( + (current && current.textContent && current.textContent.trim()) || 'Jump to section', + ) + } + + let ticking = false + function onScroll() { + if (ticking) return + ticking = true + requestAnimationFrame(() => { + updateCurrent() + ticking = false + }) + } + + window.addEventListener('scroll', onScroll, { passive: true }) + updateCurrent() + + return () => window.removeEventListener('scroll', onScroll) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [headings]) + + // Close on Escape + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape' && open) setOpen(false) + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [open]) + + // Lock body scroll when open + useEffect(() => { + document.body.style.overflow = open ? 'hidden' : '' + return () => { + document.body.style.overflow = '' + } + }, [open]) + + return ( +
+ {/* Toggle pill */} + + + {/* Backdrop */} + + ) +} diff --git a/src/components/TableOfContents.tsx b/src/components/TableOfContents.tsx new file mode 100644 index 0000000..ef90ccd --- /dev/null +++ b/src/components/TableOfContents.tsx @@ -0,0 +1,74 @@ +// src/components/TableOfContents.tsx +import type { Heading } from '@/lib/lexical-headings' + +interface TableOfContentsProps { + headings: Heading[] + title?: string + showTitle?: boolean +} + +function flatten( + list: Heading[], + depth = 0, + acc: Array = [], +): Array { + for (const h of list) { + acc.push({ ...h, depth }) + if (h.children?.length) flatten(h.children, depth + 1, acc) + } + return acc +} + +export function TableOfContents({ + headings, + title = 'Contents', + showTitle = true, +}: TableOfContentsProps) { + const flat = flatten(headings) + if (flat.length === 0) return null + + return ( + + ) +} -- 2.51.2