206 lines
7.1 KiB
TypeScript
206 lines
7.1 KiB
TypeScript
"use client"
|
|
|
|
import { ChevronDown, ChevronUp, PanelRight, MoreVertical } from "lucide-react"
|
|
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTrigger } from "@/components/ui/sheet"
|
|
import DocsMenu from "@/components/DocsMenu"
|
|
import { ModeToggle } from "@/components/ThemeToggle"
|
|
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
|
import ContextPopover from "@/components/ContextPopover"
|
|
import TocObserver from "./TocObserver"
|
|
import * as React from "react"
|
|
import { useRef, useMemo } from "react"
|
|
import { usePathname } from "next/navigation"
|
|
import { Button } from "./ui/button"
|
|
import { motion, AnimatePresence } from "framer-motion"
|
|
import { useActiveSection } from "@/hooks"
|
|
import { TocItem } from "@/lib/toc"
|
|
import Search from "@/components/SearchBox"
|
|
import { NavMenu } from "@/components/navbar"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
|
|
interface MobTocProps {
|
|
tocs: TocItem[]
|
|
title?: string
|
|
}
|
|
|
|
const useClickOutside = (ref: React.RefObject<HTMLElement | null>, callback: () => void) => {
|
|
const handleClick = React.useCallback(
|
|
(event: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
|
callback()
|
|
}
|
|
},
|
|
[ref, callback]
|
|
)
|
|
|
|
React.useEffect(() => {
|
|
document.addEventListener("mousedown", handleClick)
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClick)
|
|
}
|
|
}, [handleClick])
|
|
}
|
|
|
|
export default function MobToc({ tocs, title }: MobTocProps) {
|
|
const pathname = usePathname()
|
|
const [isExpanded, setIsExpanded] = React.useState(false)
|
|
const tocRef = useRef<HTMLDivElement>(null)
|
|
const contentRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Use custom hooks
|
|
const { activeId, setActiveId } = useActiveSection(tocs)
|
|
|
|
// Only show on /docs pages
|
|
const isDocsPage = useMemo(() => pathname?.startsWith("/docs"), [pathname])
|
|
|
|
// Get title from active section if available, otherwise document title
|
|
const activeSection = useMemo(() => {
|
|
return tocs.find((toc) => toc.href.slice(1) === activeId)
|
|
}, [tocs, activeId])
|
|
|
|
const displayTitle = activeSection?.text || title || "On this page"
|
|
|
|
const [mounted, setMounted] = React.useState(false)
|
|
|
|
React.useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
// Toggle expanded state
|
|
const toggleExpanded = React.useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setIsExpanded((prev) => !prev)
|
|
}, [])
|
|
|
|
// Close TOC when clicking outside
|
|
useClickOutside(tocRef, () => {
|
|
if (isExpanded) {
|
|
setIsExpanded(false)
|
|
}
|
|
})
|
|
|
|
// Handle body overflow when TOC is expanded
|
|
React.useEffect(() => {
|
|
if (isExpanded) {
|
|
document.body.style.overflow = "hidden"
|
|
} else {
|
|
document.body.style.overflow = ""
|
|
}
|
|
|
|
return () => {
|
|
document.body.style.overflow = ""
|
|
}
|
|
}, [isExpanded])
|
|
|
|
// Don't render anything if not on docs page
|
|
if (!isDocsPage || !mounted) return null
|
|
|
|
const chevronIcon = isExpanded ? (
|
|
<ChevronUp className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
) : (
|
|
<ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
)
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
ref={tocRef}
|
|
className="sticky top-0 z-50 -mx-4 -mt-4 mb-4 lg:hidden"
|
|
initial={{ y: -100, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: -100, opacity: 0 }}
|
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
|
>
|
|
<div className="bg-background/95 border-muted dark:border-foreground/10 dark:bg-background w-full border-b shadow-sm backdrop-blur-sm">
|
|
<div className="p-2">
|
|
<div className="flex items-center gap-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
aria-label="Navigation menu"
|
|
>
|
|
<MoreVertical className="text-muted-foreground h-5 w-5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="flex min-w-[160px] flex-col gap-1 p-2"
|
|
>
|
|
<NavMenu />
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="-mx-1 h-auto flex-1 justify-between rounded-md px-2 py-2 hover:bg-transparent hover:text-inherit"
|
|
onClick={toggleExpanded}
|
|
aria-label={isExpanded ? "Collapse table of contents" : "Expand table of contents"}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium capitalize">{displayTitle}</span>
|
|
</div>
|
|
{chevronIcon}
|
|
</Button>
|
|
<Search />
|
|
<Sheet>
|
|
<SheetTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="hidden max-lg:flex">
|
|
<PanelRight className="text-muted-foreground h-6 w-6 shrink-0" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent className="flex w-full flex-col gap-4 px-0 lg:w-auto" side="right">
|
|
<DialogTitle className="sr-only">Navigation Menu</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
Main navigation menu with links to different sections
|
|
</DialogDescription>
|
|
<SheetHeader>
|
|
<SheetClose className="px-4" asChild>
|
|
<div className="flex items-center justify-between">
|
|
<div className="mr-8">
|
|
<ModeToggle />
|
|
</div>
|
|
</div>
|
|
</SheetClose>
|
|
</SheetHeader>
|
|
<div className="flex flex-col gap-4 overflow-y-auto">
|
|
<div className="mx-2 space-y-2 px-5">
|
|
<ContextPopover />
|
|
<DocsMenu isSheet />
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
ref={contentRef}
|
|
className="-mx-1 mt-2 max-h-[60vh] overflow-y-auto px-1 pb-2"
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
|
>
|
|
{tocs?.length ? (
|
|
<TocObserver data={tocs} activeId={activeId} onActiveIdChange={setActiveId} />
|
|
) : (
|
|
<p className="text-muted-foreground py-2 text-sm">No headings</p>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
)
|
|
}
|