189 lines
7.0 KiB
TypeScript
189 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronDown, ChevronUp, PanelRight, MoreVertical, FileText } 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 { useActiveSection } from "@/hooks";
|
|
import { TocItem } from "@/lib/toc";
|
|
import Search from "@/components/SearchBox";
|
|
import GitHubButton from "@/components/Github";
|
|
import { NavMenu } from "@/components/navbar";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
interface MobTocProps {
|
|
tocs: TocItem[];
|
|
title?: string;
|
|
}
|
|
|
|
const subscribe = () => () => undefined;
|
|
const getMountedSnapshot = () => true;
|
|
const getServerSnapshot = () => false;
|
|
|
|
const useClickOutside = (ref: React.RefObject<HTMLElement | null>, callback: () => void) => {
|
|
const callbackRef = useRef(callback);
|
|
|
|
React.useEffect(() => {
|
|
callbackRef.current = callback;
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
const handleClick = (event: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
|
callbackRef.current();
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [ref]);
|
|
};
|
|
|
|
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 = React.useSyncExternalStore(subscribe, getMountedSnapshot, getServerSnapshot);
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
// 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 (
|
|
<div ref={tocRef} className="sticky top-0 z-50 -mx-4 -mt-4 mb-4 lg:hidden">
|
|
<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-40 flex-col gap-1 p-2">
|
|
<NavMenu />
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="-mx-1 h-auto min-w-0 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 min-w-0 items-center gap-2">
|
|
<span className="line-clamp-1 truncate 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">
|
|
<GitHubButton />
|
|
<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>
|
|
|
|
<div
|
|
className="grid transition-[grid-template-rows,opacity] duration-200 ease-in-out"
|
|
style={{
|
|
gridTemplateRows: isExpanded ? "1fr" : "0fr",
|
|
opacity: isExpanded ? 1 : 0,
|
|
}}
|
|
>
|
|
<div ref={contentRef} className="overflow-hidden">
|
|
<div className="mt-2 max-h-[60vh] overflow-y-auto px-4 pb-2">
|
|
{tocs?.length ? (
|
|
<TocObserver data={tocs} activeId={activeId} onActiveIdChange={setActiveId} />
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center px-2 py-8 text-center">
|
|
<FileText className="text-muted-foreground/40 mb-3 h-8 w-8" />
|
|
<p className="text-muted-foreground mb-1 text-sm font-medium">No headings</p>
|
|
<p className="text-muted-foreground/70 text-xs leading-relaxed">
|
|
{`This page doesn't have section headings yet.`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|