import { type ReactNode, useEffect, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router'; import { Badge as MethodBadge } from '@/components/docs/Badge'; import { resolveIcon } from '@/components/docs/utils'; import { ChevronRight, ExternalLink } from '@/components/icons'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { ScrollArea } from '@/components/ui/scroll-area'; import { flattenNav, isNodeHidden } from '@/lib/docs-config'; import { methodColor } from '@/lib/openapi'; import type { DocsTab, NavGroupNode, NavNode } from '@/lib/types'; import { cn } from '@/lib/utils'; const sectionLabelClass = 'flex min-w-0 items-center gap-[0.4rem] pb-2 pr-1 font-bold text-inherit'; const groupLabelClass = 'flex min-w-0 items-center gap-[0.4rem] py-2 pl-3 pr-1 font-medium text-inherit'; const selectedClass = 'border-l-sidebar-primary font-bold text-sidebar-primary hover:text-sidebar-primary'; export interface SideNavProps { tabs: DocsTab[]; navigation: Record; anchors: NavNode[]; activeTabId: string; isSticky?: boolean; drilldown?: boolean; navigationLabel: string; expandLabel: string; collapseLabel: string; } /** True when the group's root or any descendant page is the current page. */ function containsPage(node: NavGroupNode, pathname: string): boolean { if (node.root?.page.url === pathname) { return true; } return flattenNav(node.children).some(page => page.url === pathname); } /** * Group section. Groups with `collapsible: false` stay open; otherwise, * top-level groups start expanded and nested groups start collapsed unless * `expanded: true` or the current page is inside. * * Click behavior follows `interaction.drilldown`: * - true: expanding also navigates to the group's root or first page * - false: the header only expands/collapses * - unset: headers with a root page navigate, others toggle */ function CollapsibleGroup({ node, pathname, depth, drilldown, expandLabel, collapseLabel, }: { node: NavGroupNode; pathname: string; depth: number; drilldown?: boolean; expandLabel: string; collapseLabel: string; }) { const navigate = useNavigate(); const active = containsPage(node, pathname); const collapsible = node.collapsible !== false; // Top-level sections stay open by default so the sidebar is usable on first load. const [expanded, setExpanded] = useState( !collapsible || depth === 0 || !!node.expanded || active, ); const isExpanded = !collapsible || expanded; useEffect(() => { if (!collapsible || active) { setExpanded(true); } }, [active, collapsible]); const firstPage = node.root?.page || flattenNav(node.children).find(page => !page.hidden); const handleOpenChange = (next: boolean) => { setExpanded(next); if (next && drilldown === true && firstPage) { navigate(firstPage.url); } }; const chevron = ( ); const isTopLevel = depth === 0; const rootSelected = node.root?.page.url === pathname; const headerClass = isTopLevel ? 'flex min-w-0 items-center text-sidebar-foreground' : cn( 'flex items-center border-l border-l-sidebar-border text-muted-foreground hover:text-sidebar-accent-foreground', { 'pl-6': depth > 1 }, ); const fixedHeader = (
{node.root ? ( {resolveIcon(node.icon)} {node.label} ) : ( {resolveIcon(node.icon)} {node.label} )}
); // Navigating collapsible headers keep a separate chevron button so collapse stays reachable. const header = !collapsible ? ( fixedHeader ) : drilldown !== false && node.root ? (
{resolveIcon(node.icon)} {node.label} } > {chevron}
) : ( } > {resolveIcon(node.icon)} {node.label} {chevron} ); if (!collapsible) { return (
{fixedHeader}
); } return ( {header}
); } function NavNodes({ nodes, pathname, depth, drilldown, expandLabel, collapseLabel, }: { nodes: NavNode[]; pathname: string; depth: number; drilldown?: boolean; expandLabel: string; collapseLabel: string; }) { const rendered: ReactNode[] = []; nodes.forEach(node => { if (isNodeHidden(node)) { return; } if (node.kind === 'link') { rendered.push( 1 }, )} > {resolveIcon(node.icon)} {node.label} , ); return; } if (node.kind === 'page') { const { url, label, icon, tag, method } = node.page; const isSelected = url === pathname; rendered.push( 1, [selectedClass]: isSelected, 'text-muted-foreground hover:text-sidebar-accent-foreground': !isSelected, }, )} > {resolveIcon(icon)} {method ? ( {method === 'DELETE' ? 'DEL' : method} ) : null} {label} {!method && tag ? ( {tag} ) : null} , ); return; } rendered.push( , ); }); return <>{rendered}; } export function SideNav({ tabs, navigation, anchors, activeTabId, isSticky, drilldown, navigationLabel, expandLabel, collapseLabel, }: SideNavProps) { const { pathname } = useLocation(); const nodes = navigation[activeTabId] || navigation[tabs[0]?.id] || []; return ( ); }