import React, { useState, useRef, useEffect, useLayoutEffect, 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 { 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<{ className?: string }>; /** Click handler */ onClick?: (item: unknown) => 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<{ className?: string }>; /** React component for the route (optional) */ component?: React.ComponentType<{ className?: string }>; /** 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?: React.ComponentType<{ className?: string }>; 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?: React.ComponentType<{ className?: string }> | React.ReactElement; 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<{ className?: string }>; /** 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; /** * Assistant-only: called when the "Exit Assistant" button is clicked. This * button renders at the bottom of the sidebar, below the navigation, like * a footer. Defaults to `window.history.back()` (SSR-guarded) when omitted. * Has no effect when `variant` is `'default'`. */ onExitAssistant?: () => void; /** * Assistant-only: whether to render the "Exit Assistant" button. Optional — * defaults to `true` when `variant === 'assistant'`; never renders when * `variant === 'default'`, regardless of this prop's value. */ showExitAssistant?: boolean; } // ───────────────────────────────────────────────────────────────────────────── // 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 ?? 320); 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 ( ); })}
); })}
)}
); } /** * Off-screen clone of the full (unsliced) group list plus a sample "more" * button, used purely to measure real rendered heights via `getBoundingClientRect`. * Rendering it with the exact same markup/classes as the visible list (instead * of estimating heights from constants) keeps overflow detection correct * regardless of theme, font, or translation-driven label wrapping changes. */ function MeasurementClone({ measureWrapRef, groupMeasureRefs, itemMeasureRefs, moreButtonMeasureRef, navigationGroups, navigationItems, renderDefaultGroup, renderDefaultItem, expanded, moreOptionsLabel, }: { measureWrapRef: React.RefObject; groupMeasureRefs: React.MutableRefObject>; itemMeasureRefs: React.MutableRefObject>; moreButtonMeasureRef: React.RefObject; navigationGroups: RouteGroup[]; navigationItems: NavigationItem[]; renderDefaultGroup: (group: RouteGroup) => React.ReactNode; renderDefaultItem: (item: NavigationItem) => React.ReactNode; expanded: boolean; moreOptionsLabel: string; }) { const hasGroups = navigationGroups && navigationGroups.length > 0; if (!hasGroups && (!navigationItems || navigationItems.length === 0)) return null; const moreButton = (
{expanded && {moreOptionsLabel}}
); // Pinned (via absolute positioning) to the top of the nearest positioned // ancestor's padding box — the enclosing