import React, { useState, useRef, useEffect, createContext, useContext, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { useTranslation } from 'react-i18next'; import { Menu, ArrowLeft, LogOut, Settings, MoreVertical, ChevronRight, Filter, } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '../../ui/avatar'; import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover'; import { Tooltip, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal, } from '../../ui/dropdown-menu'; import { Input } from '../../ui/input'; import { Search as SearchIcon } from 'lucide-react'; import { cn } from '../../shared/utils'; import { XerticaLogo } from '../../brand/xertica-logo'; import { XerticaXLogo } from '../../brand/xertica-xlogo'; import { Button } from '../../ui/button'; import { useOptionalLayout } from '../../../contexts/LayoutContext'; import { useSidebar } from './use-sidebar'; import { CustomTooltipContent as SidebarTooltipContent } from '../../shared/CustomTooltipContent'; // ───────────────────────────────────────────────────────────────────────────── // Public interfaces // ───────────────────────────────────────────────────────────────────────────── /** * Contextual action menu item for routes and groups. */ export interface ActionMenuItem { /** Text label */ label: string; /** Optional icon component */ icon?: React.ComponentType; /** Click handler */ onClick?: (item: any) => void; /** Visual variant */ variant?: 'default' | 'destructive'; /** Nested sub-actions */ children?: ActionMenuItem[]; } /** * Navigation route configuration. */ export interface RouteConfig { /** Relative or absolute path */ path: string; /** Navigation label */ label: string; /** Icon component */ icon?: React.ComponentType; /** React component for the route (optional) */ component?: React.ComponentType; /** Hover action menu items (ellipsis) */ actions?: ActionMenuItem[]; /** Auxiliary content shown when the route is selected */ description?: React.ReactNode; /** Child routes exposed via contextual menu button at the end of the item */ children?: RouteConfig[]; } export interface NavigationItem { path: string; label: string; icon?: any; active: boolean; children?: RouteConfig[]; actions?: ActionMenuItem[]; } export interface SidebarFilterConfig { show: boolean; content?: React.ReactNode; icon?: React.ReactNode; } export interface SidebarSearchConfig { show: boolean; placeholder?: string; value?: string; onChange?: (value: string) => void; filter?: SidebarFilterConfig; } export interface SidebarFixedAreaConfig { show: boolean; content?: React.ReactNode; icon?: any; onClick?: () => void; } export interface SidebarFooterConfig { showUser?: boolean; showSettings?: boolean; showLogout?: boolean; } /** * Logical grouping of navigation routes (e.g., in Assistant variant). */ export interface RouteGroup { /** Unique ID */ id: string; /** Group title */ label?: string; /** Group icon */ icon?: React.ComponentType; /** Navigational items in the group */ items: RouteConfig[]; /** Context menu for the entire group */ actions?: ActionMenuItem[]; } /** * Navigation Sidebar component. */ export interface SidebarProps { /** Whether the sidebar is expanded (defaults to LayoutContext state if available) */ expanded?: boolean; /** Callback to toggle expansion state (defaults to LayoutContext toggle if available) */ onToggle?: () => void; /** Authenticated user info */ user?: { name?: string; email?: string; avatar?: string; } | null; /** Logout callback */ onLogout?: () => void; /** Settings callback */ onSettingsClick?: () => void; /** Current location for active state detection (defaults to window.location if missing) */ location?: { pathname: string }; /** Navigation callback (defaults to window.location.href if missing) */ navigate?: (path: string) => void; /** Flat list of navigation routes */ routes?: RouteConfig[]; /** Logo shown in expanded state */ logo?: React.ReactNode; /** Logo shown in collapsed state */ logoCollapsed?: React.ReactNode; /** Visual variant */ variant?: 'default' | 'assistant'; /** Assistant-only fixed area configuration */ fixedArea?: SidebarFixedAreaConfig; /** Assistant-only search bar configuration */ search?: SidebarSearchConfig; /** Grouped navigation items */ navigationGroups?: RouteGroup[]; /** Footer content configuration */ footer?: SidebarFooterConfig; /** Whether to show the footer (defaults: true for 'default', false for 'assistant') */ showFooter?: boolean; /** Pixel width when expanded (desktop) */ width?: number; } // ───────────────────────────────────────────────────────────────────────────── // Compound Component Context // ───────────────────────────────────────────────────────────────────────────── interface SidebarContextValue { expanded: boolean; isMobileViewport: boolean; onToggle: () => void; navigate: (path: string) => void; location: { pathname: string }; width: number; } const SidebarContext = createContext(null); function useSidebarContext() { const ctx = useContext(SidebarContext); if (!ctx) { throw new Error('Sidebar compound components must be used within '); } return ctx; } // ───────────────────────────────────────────────────────────────────────────── // Compound Sub-components // ───────────────────────────────────────────────────────────────────────────── /** * Root container for the Sidebar. Provides context to all sub-components. * Use this when building a fully custom sidebar layout. * * @example * * } /> * * * */ function SidebarRoot({ expanded: expandedProp, onToggle: onToggleProp, navigate: navigateProp, location: locationProp, width: widthProp, children, className, }: { expanded?: boolean; onToggle?: () => void; navigate?: (path: string) => void; location?: { pathname: string }; width?: number; children: React.ReactNode; className?: string; }) { const layoutContext = useOptionalLayout(); const [localExpanded, setLocalExpanded] = useState(false); const [isMobileViewport, setIsMobileViewport] = useState(false); const expanded = expandedProp !== undefined ? expandedProp : (layoutContext?.sidebarExpanded ?? localExpanded); const onToggle = onToggleProp || layoutContext?.toggleSidebar || (() => setLocalExpanded(prev => !prev)); const width = widthProp !== undefined ? widthProp : (layoutContext?.sidebarWidth ?? 280); const navigate = navigateProp || ((path: string) => { if (typeof window !== 'undefined') window.location.href = path; }); const location = locationProp || (typeof window !== 'undefined' ? window.location : { pathname: '/' }); useEffect(() => { const checkViewport = () => setIsMobileViewport(window.innerWidth < 768); checkViewport(); window.addEventListener('resize', checkViewport); return () => window.removeEventListener('resize', checkViewport); }, []); return (
{children}
); } /** * Toggle button + logo header area for the Sidebar. */ function SidebarHeader({ logo, logoCollapsed, }: { logo?: React.ReactNode; logoCollapsed?: React.ReactNode; }) { const { expanded, onToggle } = useSidebarContext(); const { t } = useTranslation(); return ( <> {/* Menu Toggle Button */}
{/* Logo */}
{expanded ? logo || : logoCollapsed || }
); } /** * Accordion inline para "mais opções" no mobile. * Abre abaixo do botão, igual ao padrão dos subitens mobile. */ function OverflowGroupsAccordion({ expanded, overflowGroups, location, handleNavigate, moreOptionsLabel, onOpenChange, }: { expanded: boolean; overflowGroups: RouteGroup[]; location: { pathname: string }; handleNavigate: (path: string) => void; moreOptionsLabel: string; onOpenChange?: (open: boolean) => void; }) { const [isOpen, setIsOpen] = useState(false); const toggle = () => { const next = !isOpen; setIsOpen(next); onOpenChange?.(next); }; return (
{isOpen && (
{overflowGroups.map(group => { const GroupIcon = group.icon; return (
{(group.label || GroupIcon) && expanded && (
{GroupIcon && (React.isValidElement(GroupIcon) ? ( GroupIcon ) : ( ))} {group.label && ( {group.label} )}
)}
{group.items.map(item => { const Icon = item.icon; const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/'); return ( ); })}
); })}
)}
); } /** * Navigation area for the Sidebar (default variant). * Renders grouped or flat navigation items with overflow handling. */ function SidebarNav({ navigationGroups = [], routes = [], variant = 'default', }: { navigationGroups?: RouteGroup[]; routes?: RouteConfig[]; variant?: 'default' | 'assistant'; }) { const { expanded, isMobileViewport, navigate, location, onToggle } = useSidebarContext(); const { t } = useTranslation(); const navRef = useRef(null); const [localActiveItem, setLocalActiveItem] = useState(null); const [hasOverflow, setHasOverflow] = useState(false); const [visibleItems, setVisibleItems] = useState([]); const [overflowItems, setOverflowItems] = useState([]); const [openSubmenus, setOpenSubmenus] = useState>(new Set()); const [hasGroupOverflow, setHasGroupOverflow] = useState(false); const [visibleGroups, setVisibleGroups] = useState([]); const [overflowGroups, setOverflowGroups] = useState([]); const [isOverflowAccordionOpen, setIsOverflowAccordionOpen] = useState(false); const toggleSubmenu = (path: string) => { setOpenSubmenus(prev => { const next = new Set(prev); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }; const labelTranslations = useMemo>( () => ({ home: 'Início', dashboard: 'Painel', components: 'Componentes', }), [] ); const navigationItems = useMemo( () => (routes || []).map(route => ({ ...route, label: labelTranslations[route.label.toLowerCase()] || route.label, active: location.pathname === route.path || location.pathname.startsWith(route.path + '/'), children: route.children, })), [routes, location.pathname, labelTranslations] ); useEffect(() => { if (typeof window === 'undefined') return; const checkOverflow = () => { if (!navRef.current) return; if (variant === 'assistant') return; const navHeight = navRef.current.clientHeight; const itemHeight = 44; const maxVisibleItems = Math.floor(navHeight / itemHeight); if (navigationItems.length > maxVisibleItems) { setHasOverflow(true); setVisibleItems(navigationItems.slice(0, maxVisibleItems - 1)); setOverflowItems(navigationItems.slice(maxVisibleItems - 1)); } else { setHasOverflow(false); setVisibleItems(navigationItems); setOverflowItems([]); } }; checkOverflow(); window.addEventListener('resize', checkOverflow); return () => window.removeEventListener('resize', checkOverflow); }, [navigationItems.length, variant]); useEffect(() => { if (typeof window === 'undefined') return; if (variant === 'assistant') return; if (!navigationGroups || navigationGroups.length === 0) return; const checkGroupOverflow = () => { if (!navRef.current) return; const containerHeight = navRef.current.clientHeight; const itemHeight = 40; const groupHeaderHeight = 32; const groupSpacing = 12; const moreButtonHeight = 44; const padding = 32; let currentHeight = padding; let visibleCount = 0; for (let i = 0; i < navigationGroups.length; i++) { const group = navigationGroups[i]; let groupHeight = 0; if (group.label) groupHeight += groupHeaderHeight; groupHeight += group.items.length * itemHeight; if (i > 0) groupHeight += groupSpacing; const wouldExceed = currentHeight + groupHeight + (visibleCount < navigationGroups.length - 1 ? moreButtonHeight : 0) > containerHeight; if (wouldExceed && visibleCount > 0) break; currentHeight += groupHeight; visibleCount++; } if (visibleCount < navigationGroups.length) { setHasGroupOverflow(true); setVisibleGroups(navigationGroups.slice(0, visibleCount)); setOverflowGroups(navigationGroups.slice(visibleCount)); } else { setHasGroupOverflow(false); setVisibleGroups(navigationGroups); setOverflowGroups([]); } }; checkGroupOverflow(); window.addEventListener('resize', checkGroupOverflow); return () => window.removeEventListener('resize', checkGroupOverflow); }, [navigationGroups, expanded, variant]); const handleNavigate = (path: string) => { setLocalActiveItem(path); navigate(path); if (typeof window !== 'undefined' && window.innerWidth < 768) { onToggle(); } }; const toNavItem = (route: RouteConfig): NavigationItem => ({ path: route.path, label: labelTranslations[route.label.toLowerCase()] || route.label, icon: route.icon, active: location.pathname === route.path || location.pathname.startsWith(route.path + '/'), children: route.children, actions: route.actions, }); const renderActionItems = (actions: ActionMenuItem[]): React.ReactNode => { return actions.map((action, idx) => { const Icon = action.icon; if (action.children && action.children.length > 0) { return ( {Icon && } {action.label} {renderActionItems(action.children)} ); } return ( { e.stopPropagation(); action.onClick?.(null); }} > {Icon && } {action.label} ); }); }; const renderAssistantActionMenu = (actions?: ActionMenuItem[], isHeader: boolean = false) => { if (!actions || actions.length === 0) return null; return ( {renderActionItems(actions)} ); }; const renderDefaultItem = (item: NavigationItem) => { const Icon = item.icon; const hasChildren = item.children && item.children.length > 0; const activeClass = item.active ? 'bg-sidebar-foreground/15 text-sidebar-foreground shadow-sm' : 'text-sidebar-foreground/80 hover:bg-sidebar-foreground/15 hover:text-sidebar-foreground'; if (!expanded) { return (

{item.label}

); } // Mobile: accordion inline — subitens abrem abaixo do item pai if (isMobileViewport && hasChildren) { const isOpen = openSubmenus.has(item.path); return (
{isOpen && (
{item.children!.map(child => { const ChildIcon = child.icon; const isChildActive = location.pathname === child.path || location.pathname.startsWith(child.path + '/'); return ( ); })}
)}
); } // Desktop: dropdown lateral (comportamento original) return (
{hasChildren && ( {item.children!.map(child => { const ChildIcon = child.icon; const isChildActive = location.pathname === child.path || location.pathname.startsWith(child.path + '/'); return ( handleNavigate(child.path)} className={cn( 'flex items-center gap-2 cursor-pointer', isChildActive && 'bg-accent text-accent-foreground' )} > {ChildIcon && } {child.label} ); })} )}
); }; const renderDefaultGroup = (group: RouteGroup) => { const GroupIcon = group.icon; if (!expanded) { return (
{group.items.map(item => renderDefaultItem(toNavItem(item)))}
); } return (
{(group.label || GroupIcon) && (
{GroupIcon && (React.isValidElement(GroupIcon) ? ( GroupIcon ) : ( ))} {group.label && ( {group.label} )}
)}
{group.items.map(item => renderDefaultItem(toNavItem(item)))}
); }; const renderAssistantGroup = (group: RouteGroup) => { const isAnyItemActive = group.items.some( item => location.pathname === item.path || location.pathname.startsWith(item.path + '/') ); const GroupIcon = group.icon; if (!expanded) { if (!GroupIcon) return null; return (

{group.label}

); } return (
{(group.label || group.icon) && (
{GroupIcon && (
{React.isValidElement(GroupIcon) ? GroupIcon : }
)} {group.label && {group.label}}
{renderAssistantActionMenu(group.actions, true)}
)}
{group.items.map(item => { const isRouteActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/'); const isActive = isRouteActive || localActiveItem === item.path; const Icon = item.icon; return (
handleNavigate(item.path)} >
{Icon && (
{React.isValidElement(Icon) ? Icon : }
)} {item.label}
{isActive && item.description && (
{item.description}
)}
{renderAssistantActionMenu(item.actions)}
); })}
); }; if (variant === 'assistant') { return (
{navigationGroups.map(group => renderAssistantGroup(group))}
); } if (isMobileViewport && isOverflowAccordionOpen) { return (
); } return (
); } /** * Assistant-specific search + fixed area header for the Sidebar. */ function SidebarSearch({ fixedArea, search, }: { fixedArea?: SidebarFixedAreaConfig; search?: SidebarSearchConfig; }) { const { expanded } = useSidebarContext(); const { t } = useTranslation(); const [isFilterOpen, setIsFilterOpen] = useState(false); if (!((fixedArea && fixedArea.show) || (search && search.show))) return null; return (
{fixedArea?.show && fixedArea.content && expanded && (
{fixedArea.content}
)} {search?.show && expanded && ( <>
search.onChange?.(e.target.value)} className="w-full h-9 bg-sidebar-foreground/10 border-sidebar-border text-sidebar-foreground placeholder:text-sidebar-foreground/50 pl-9 focus-visible:ring-1 focus-visible:ring-sidebar-foreground/30 focus-visible:ring-offset-0" />
{search.filter?.show && search.filter.content && ( )}
{isFilterOpen && search.filter?.show && search.filter.content && (
{search.filter.content}
)}
)} {!expanded && (fixedArea?.show || search?.show) && (
{fixedArea?.show && fixedArea.icon && ( {t('assistant.newConversation')} )} {search?.show && ( {t('sidebar.search')} )}
)}
); } /** * Footer area for the Sidebar with user info, settings, and logout. */ function SidebarFooter({ user, onLogout = () => {}, onSettingsClick, showUser = true, showSettings = true, showLogout = true, }: { user?: { name?: string; email?: string; avatar?: string } | null; onLogout?: () => void; onSettingsClick?: () => void; showUser?: boolean; showSettings?: boolean; showLogout?: boolean; }) { const { expanded, navigate, location, onToggle } = useSidebarContext(); const { t } = useTranslation(); const isSettingsActive = location.pathname === '/settings'; const handleSettingsClick = () => { if (onSettingsClick) { onSettingsClick(); } else { navigate('/settings'); } if (typeof window !== 'undefined' && window.innerWidth < 768) { onToggle(); } }; return (
{showUser && (!expanded ? (

{user?.name || t('sidebar.profile')}

) : ( ))} {showSettings && (!expanded ? (

{t('nav.settings')}

) : ( ))} {showLogout && (!expanded ? (

{t('sidebar.logout')}

) : ( ))}
); } // ───────────────────────────────────────────────────────────────────────────── // Main Sidebar component (backward-compatible monolithic API) // ───────────────────────────────────────────────────────────────────────────── /** * Primary navigation sidebar component. * * @description * Manages desktop/mobile responsive navigation rendering with two variants: * - `"default"` — simple flat or grouped route list. * - `"assistant"` — advanced variant with fixed areas, search, filters, and grouped navigation. * * This component is autonomous: it works out-of-the-box using local state or * integrates automatically with `LayoutContext` if wrapped in `LayoutProvider`. * * For advanced customization, use the Compound Component API: * ``, ``, ``, ``, `` * * @ai-rules * 1. NEVER recreate the sidebar with raw Tailwind classes — always use this component. * 2. Use `variant="assistant"` for AI/tool sidebars; use `variant="default"` for standard navigation. * 3. Supports `Ctrl+B` keyboard shortcut automatically via `LayoutProvider`. */ export function Sidebar({ expanded: expandedProp, onToggle: onToggleProp, user, onLogout = () => {}, onSettingsClick, location: locationProp, navigate: navigateProp, routes, logo, logoCollapsed, variant = 'default', fixedArea, search, navigationGroups = [], footer, showFooter, width: widthProp, }: SidebarProps) { const { showUser = true, showSettings = true, showLogout = true } = footer || {}; const displayFooter = showFooter !== undefined ? showFooter : variant === 'default'; return ( {variant === 'assistant' && } {displayFooter && (showUser || showSettings || showLogout) && ( )} ); } // ───────────────────────────────────────────────────────────────────────────── // Attach Compound Components to Sidebar namespace // ───────────────────────────────────────────────────────────────────────────── Sidebar.Root = SidebarRoot; Sidebar.Header = SidebarHeader; Sidebar.Search = SidebarSearch; Sidebar.Nav = SidebarNav; Sidebar.Footer = SidebarFooter; // Re-export hook for headless usage export { useSidebar } from './use-sidebar'; export type { UseSidebarProps } from './use-sidebar';