File size: 4,481 Bytes
c09f67c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | "use client";
import { cn } from "@midday/ui/cn";
import { Icons } from "@midday/ui/icons";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { type RefObject, useEffect, useRef, useState } from "react";
import type { docsNavigation } from "@/lib/docs";
type DocsNavPanelProps = {
navigation: typeof docsNavigation;
isOpen: boolean;
onClose: () => void;
triggerRef?: RefObject<HTMLButtonElement | null>;
};
export function DocsNavPanel({
navigation,
isOpen,
onClose,
triggerRef,
}: DocsNavPanelProps) {
const pathname = usePathname();
const panelRef = useRef<HTMLDivElement>(null);
// Only expand the first section by default
const [expandedSections, setExpandedSections] = useState<string[]>(
navigation.length > 0 ? [navigation[0].slug] : [],
);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
// Close on click outside (but not on trigger button)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
isOpen &&
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
!triggerRef?.current?.contains(e.target as Node)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose, triggerRef]);
const toggleSection = (slug: string) => {
setExpandedSections((prev) =>
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug],
);
};
const currentSlug = pathname.split("/").pop();
return (
<AnimatePresence>
{isOpen && (
<motion.div
ref={panelRef}
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
className={cn(
"absolute bottom-full left-0 right-0 mb-2 w-full z-[100]",
"bg-[rgba(247,247,247,0.85)] dark:bg-[rgba(19,19,19,0.7)] backdrop-blur-lg",
"max-h-80 overflow-y-auto",
"[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
)}
>
{/* Navigation */}
<div className="p-3 space-y-1">
{navigation.map((section) => (
<div key={section.slug}>
<button
type="button"
onClick={() => toggleSection(section.slug)}
className="w-full flex items-center justify-between px-2 py-1.5 text-sm font-medium text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<span>{section.title}</span>
<Icons.ChevronDown
className={cn(
"w-4 h-4 text-muted-foreground transition-transform duration-200",
expandedSections.includes(section.slug)
? "rotate-180"
: "",
)}
/>
</button>
{expandedSections.includes(section.slug) && (
<div className="mt-1 mb-2 ml-2 pl-3 border-l border-border/50">
{section.docs.map((doc) => (
<Link
key={doc.slug}
href={`/docs/${doc.slug}`}
onClick={onClose}
className={cn(
"block px-2 py-1 text-sm transition-colors",
currentSlug === doc.slug
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
{doc.title}
</Link>
))}
</div>
)}
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
);
}
|