v.1.13.0 add: context menu
This commit is contained in:
133
components/context-popover.tsx
Normal file
133
components/context-popover.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ROUTES, EachRoute } from "@/lib/routes-config";
|
||||
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";
|
||||
|
||||
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 [activeRoute, setActiveRoute] = useState<EachRoute>();
|
||||
const contextRoutes = getContextRoutes();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/docs")) {
|
||||
setActiveRoute(getActiveContextRoute(pathname));
|
||||
} else {
|
||||
setActiveRoute(undefined);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
if (!pathname.startsWith("/docs") || contextRoutes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"w-full max-w-[240px] flex items-center justify-between font-semibold text-foreground px-0 pt-8",
|
||||
"hover:bg-transparent hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeRoute?.context?.icon && (
|
||||
<span className="text-primary bg-primary/10 border border-primary rounded p-0.5">
|
||||
{getIcon(activeRoute.context.icon)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm">
|
||||
{activeRoute?.context?.title || activeRoute?.title || 'Select context'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="h-4 w-4 text-foreground/50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-64 p-2"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
<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 rounded px-2 py-1.5 text-sm",
|
||||
"text-left outline-none transition-colors",
|
||||
isActive
|
||||
? "bg-primary/20 text-primary"
|
||||
: "text-foreground/80 hover:bg-primary/20"
|
||||
)}
|
||||
>
|
||||
{route.context?.icon && (
|
||||
<span className={cn(
|
||||
"flex h-4 w-4 items-center justify-center",
|
||||
isActive ? "text-primary" : "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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { ROUTES } from "@/lib/routes-config";
|
||||
import { ROUTES, EachRoute } from "@/lib/routes-config";
|
||||
import SubLink from "./sublink";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DocsMenuProps {
|
||||
isSheet?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Get the current context from the path
|
||||
function getCurrentContext(path: string): string | undefined {
|
||||
if (!path.startsWith('/docs')) return undefined;
|
||||
|
||||
// Extract the first segment after /docs/
|
||||
const match = path.match(/^\/docs\/([^\/]+)/);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
// Get the route that matches the current context
|
||||
function getContextRoute(contextPath: string): EachRoute | undefined {
|
||||
return ROUTES.find(route => {
|
||||
const normalizedHref = route.href.replace(/^\/+|\/+$/g, '');
|
||||
return normalizedHref === contextPath;
|
||||
});
|
||||
}
|
||||
|
||||
export default function DocsMenu({ isSheet = false, className = "" }: DocsMenuProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Skip rendering if not on a docs page
|
||||
if (!pathname.startsWith("/docs")) return null;
|
||||
|
||||
// Get the current context
|
||||
const currentContext = getCurrentContext(pathname);
|
||||
|
||||
// Get the route for the current context
|
||||
const contextRoute = currentContext ? getContextRoute(currentContext) : undefined;
|
||||
|
||||
// If no context route is found, don't render anything
|
||||
if (!contextRoute) return null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Documentation navigation"
|
||||
className={className}
|
||||
className={cn("transition-all duration-200", className)}
|
||||
>
|
||||
<ul className="flex flex-col gap-3.5 mt-5 pr-2 pb-6">
|
||||
{ROUTES.map((item, index) => {
|
||||
// Normalize href - hapus leading/trailing slashes
|
||||
const normalizedHref = `/${item.href.replace(/^\/+|\/+$/g, '')}`;
|
||||
const itemHref = `/docs${normalizedHref}`;
|
||||
|
||||
const modifiedItems = {
|
||||
...item,
|
||||
href: itemHref,
|
||||
level: 0,
|
||||
isSheet,
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={`${item.title}-${index}`}>
|
||||
<SubLink {...modifiedItems} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<ul className="flex flex-col gap-1.5 py-4">
|
||||
{/* Display only the items from the current context */}
|
||||
<li key={contextRoute.title}>
|
||||
<SubLink
|
||||
{...contextRoute}
|
||||
href={`/docs${contextRoute.href}`}
|
||||
level={0}
|
||||
isSheet={isSheet}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import DocsMenu from "@/components/docs-menu";
|
||||
import { ModeToggle } from "@/components/theme-toggle";
|
||||
import ContextPopover from "@/components/context-popover";
|
||||
|
||||
// Toggle Button Component
|
||||
export function ToggleButton({
|
||||
@@ -51,9 +52,14 @@ export function Leftbar() {
|
||||
${collapsed ? "w-[24px]" : "w-[280px]"} flex flex-col pr-2`}
|
||||
>
|
||||
<ToggleButton collapsed={collapsed} onToggle={toggleCollapse} />
|
||||
{/* Scrollable DocsMenu */}
|
||||
{/* Scrollable Content */}
|
||||
<ScrollArea className="flex-1 px-0.5 pb-4">
|
||||
{!collapsed && <DocsMenu />}
|
||||
{!collapsed && (
|
||||
<div className="space-y-2">
|
||||
<ContextPopover />
|
||||
<DocsMenu />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
);
|
||||
@@ -81,7 +87,8 @@ export function SheetLeftbar() {
|
||||
<div className="flex flex-col gap-2.5 mt-3 mx-2 px-5">
|
||||
<NavMenu isSheet />
|
||||
</div>
|
||||
<div className="mx-2 px-5">
|
||||
<div className="mx-2 px-5 space-y-2">
|
||||
<ContextPopover />
|
||||
<DocsMenu isSheet />
|
||||
</div>
|
||||
<div className="flex w-2/4 px-5">
|
||||
|
||||
Reference in New Issue
Block a user