215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { useState, useEffect, useLayoutEffect, useRef } from "react";
|
|
import { ROUTES, EachRoute } from "@/lib/routes";
|
|
import { cn } from "@/lib/utils";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Button } from "@/components/ui/button";
|
|
import * as LucideIcons from "lucide-react";
|
|
import { ChevronsUpDown, Check, type LucideIcon } from "lucide-react";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
interface ContextPopoverProps {
|
|
className?: string;
|
|
}
|
|
|
|
// Get all root-level routes with context
|
|
function getContextRoutes(): EachRoute[] {
|
|
return ROUTES.filter(route => route.context);
|
|
}
|
|
|
|
// Get the first item's href from a route
|
|
function getFirstItemHref(route: EachRoute): string {
|
|
return route.items?.[0]?.href ? `${route.href}${route.items[0].href}` : route.href;
|
|
}
|
|
|
|
// Get the active context route from the current path
|
|
function getActiveContextRoute(path: string): EachRoute | undefined {
|
|
if (!path.startsWith('/docs')) return undefined;
|
|
const docPath = path.replace(/^\/docs/, '');
|
|
return getContextRoutes().find(route => docPath.startsWith(route.href));
|
|
}
|
|
|
|
// Get icon component by name
|
|
function getIcon(name: string) {
|
|
const Icon = LucideIcons[name as keyof typeof LucideIcons] as LucideIcon | undefined;
|
|
if (!Icon) return <LucideIcons.FileQuestion className="h-4 w-4" />;
|
|
return <Icon className="h-4 w-4" />;
|
|
}
|
|
|
|
export default function ContextPopover({ className }: ContextPopoverProps) {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const [mounted, setMounted] = useState(false);
|
|
const [activeRoute, setActiveRoute] = useState<EachRoute>();
|
|
const [useDefaultTitle, setUseDefaultTitle] = useState(false);
|
|
const [triggerWidth, setTriggerWidth] = useState<number | null>(null);
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const contextRoutes = getContextRoutes();
|
|
const fallbackRoute = ROUTES[0];
|
|
const displayRoute = useDefaultTitle ? fallbackRoute : activeRoute;
|
|
|
|
useEffect(() => {
|
|
// Mount-only state (used for client-only rendering) and intentionally set after first render.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (pathname.startsWith("/docs")) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setActiveRoute(getActiveContextRoute(pathname));
|
|
} else {
|
|
setActiveRoute(undefined);
|
|
}
|
|
}, [pathname]);
|
|
|
|
useEffect(() => {
|
|
const hasTitle = Boolean(
|
|
activeRoute?.context?.title ||
|
|
activeRoute?.title
|
|
);
|
|
|
|
if (!hasTitle) {
|
|
const timer = window.setTimeout(() => {
|
|
setUseDefaultTitle(true);
|
|
}, 300);
|
|
|
|
return () => window.clearTimeout(timer);
|
|
}
|
|
|
|
// Avoid calling setState synchronously inside the effect body.
|
|
// Using a micro task to reset state avoids the react-hooks/set-state-in-effect lint error.
|
|
const resetTimer = window.setTimeout(() => {
|
|
setUseDefaultTitle(false);
|
|
}, 0);
|
|
|
|
return () => window.clearTimeout(resetTimer);
|
|
}, [activeRoute?.context?.title, activeRoute?.title]);
|
|
|
|
// Keep the popover width in sync with the trigger width when the trigger text changes
|
|
// (e.g. when navigating between docs contexts) and when the window/resizing changes.
|
|
useLayoutEffect(() => {
|
|
if (!triggerRef.current) return;
|
|
|
|
const updateWidth = () => {
|
|
if (triggerRef.current) {
|
|
setTriggerWidth(triggerRef.current.offsetWidth);
|
|
}
|
|
};
|
|
|
|
// Make sure the width is updated when the trigger text/route changes.
|
|
updateWidth();
|
|
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
const observer = new ResizeObserver(() => {
|
|
updateWidth();
|
|
});
|
|
observer.observe(triggerRef.current);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
};
|
|
} else {
|
|
const handleResize = () => {
|
|
updateWidth();
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleResize);
|
|
};
|
|
}
|
|
}, [displayRoute]);
|
|
|
|
if (!mounted || !pathname.startsWith("/docs") || contextRoutes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
ref={triggerRef}
|
|
variant="ghost"
|
|
className={cn(
|
|
"w-full cursor-pointer flex items-center justify-between font-semibold text-foreground px-2 py-4 border border-muted",
|
|
"hover:bg-transparent hover:text-foreground",
|
|
className
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{displayRoute?.context?.icon && (
|
|
<span className="text-primary bg-primary/10 border border-primary dark:border dark:border-accent dark:bg-accent/10 dark:text-accent rounded p-0.5">
|
|
{getIcon(displayRoute.context.icon)}
|
|
</span>
|
|
)}
|
|
<span className="truncate text-sm">
|
|
{displayRoute?.context?.title ||
|
|
displayRoute?.title ||
|
|
(useDefaultTitle
|
|
? fallbackRoute?.context?.title || fallbackRoute?.title
|
|
: <Skeleton className="h-3.5 w-24" />)}
|
|
</span>
|
|
</div>
|
|
<ChevronsUpDown className="h-4 w-4 text-foreground/50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-2"
|
|
align="start"
|
|
sideOffset={6}
|
|
style={{
|
|
width: triggerWidth !== null ? `${triggerWidth}px` : "auto"
|
|
}}
|
|
>
|
|
<div className="space-y-1">
|
|
{contextRoutes.map((route) => {
|
|
const isActive = activeRoute?.href === route.href;
|
|
const firstItemPath = getFirstItemHref(route);
|
|
const contextPath = `/docs${firstItemPath}`;
|
|
|
|
return (
|
|
<button
|
|
key={route.href}
|
|
onClick={() => router.push(contextPath)}
|
|
className={cn(
|
|
"relative flex w-full items-center gap-2 cursor-pointer rounded px-2 py-1.5 text-sm",
|
|
"text-left outline-none transition-colors",
|
|
isActive
|
|
? "bg-primary/20 text-primary dark:bg-accent/20 dark:text-accent"
|
|
: "text-foreground/80 hover:bg-primary/20 dark:text-foreground/60 dark:hover:bg-accent/20"
|
|
)}
|
|
>
|
|
{route.context?.icon && (
|
|
<span className={cn(
|
|
"flex h-4 w-4 items-center justify-center",
|
|
isActive ? "text-primary dark:text-accent" : "text-foreground/60"
|
|
)}>
|
|
{getIcon(route.context.icon)}
|
|
</span>
|
|
)}
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<div className="truncate font-medium">
|
|
{route.context?.title || route.title}
|
|
</div>
|
|
{route.context?.description && (
|
|
<div className="text-xs text-muted-foreground truncate text-ellipsis overflow-hidden max-w-full">
|
|
{route.context.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isActive && (
|
|
<Check className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|