This repository has no description
Something went wrong. Try again.
5.0 kB · 152 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153'use client'// src/components/MobileTableOfContents.tsximport { 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<Heading> = [],): Array<Heading> { 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<HTMLDivElement>(null) const backdropRef = useRef<HTMLDivElement>(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 ( <div className="md:hidden"> {/* Toggle pill */} <button type="button" className="border-hair-2 hover:border-ink fixed right-4 bottom-4 left-4 z-[60] grid cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-3.5 rounded-full border px-[18px] py-3 font-mono text-[11px] tracking-wider uppercase shadow-[0_6px_24px_rgba(0,0,0,0.4)] transition-colors" style={{ background: 'rgba(27,26,23,0.92)', backdropFilter: 'blur(10px)', WebkitBackdropFilter: 'blur(10px)', color: 'rgb(var(--ink))', }} aria-controls="mobile-toc-panel" aria-expanded={open} onClick={() => setOpen((v) => !v)} > <span className="text-ink-3">Contents</span> <span className="text-ink overflow-hidden text-left text-[12.5px] leading-tight tracking-[0.04em] text-ellipsis whitespace-nowrap normal-case"> {currentText} </span> <span className="text-ink-3 text-xs transition-transform" style={{ transform: open ? 'rotate(180deg)' : undefined, color: open ? 'rgb(var(--ink))' : undefined }} aria-hidden="true" > ↑ </span> </button>
{/* Backdrop */} <div ref={backdropRef} className="pointer-events-none fixed inset-0 z-[58] bg-black/55 transition-opacity" aria-hidden="true" style={{ opacity: open ? 1 : 0, pointerEvents: open ? 'auto' : 'none' }} onClick={() => setOpen(false)} />
{/* Panel */} <div id="mobile-toc-panel" ref={panelRef} className="bg-paper-2 border-hair-2 fixed inset-x-0 bottom-0 z-[61] flex max-h-[75vh] flex-col rounded-t-2xl border-t transition-transform" style={{ paddingBottom: 'env(safe-area-inset-bottom)', transform: open ? 'translateY(0)' : 'translateY(100%)', }} role="dialog" aria-modal={true} aria-label="Table of contents" aria-hidden={!open} > <div className="border-hair flex items-center justify-between border-b px-5 py-4"> <span className="text-ink-3 font-mono text-[11px] tracking-widest uppercase">Contents</span> <button type="button" className="border-hair-2 text-ink-2 hover:text-ink hover:border-ink inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border bg-transparent text-sm" aria-label="Close contents" onClick={() => setOpen(false)} > ✕ </button> </div> <div className="overflow-y-auto px-5 pt-4 pb-6"> <TableOfContents headings={headings} showTitle={false} /> </div> </div> </div> )}