/** * SidebarNav — WealthX Design System * * Presentational sidebar navigation organism. * Used by both Backoffice (Wealth Pro) and Frontend (WealthX app). * * Background: bg-brand-secondary (tenant dark navy — set via ThemeProvider). * Text: text-brand-secondary-foreground (white on dark navy). * * - All icons must be Lucide icons (LucideIcon type). * - Supports collapsible sub-items (accordion). * - Hover mode (default): sidebar is icon-only; hovering expands it temporarily. * - Lock mode: clicking the Pin button keeps the sidebar expanded after mouse leaves. * - metricsGroups: optional financial summary rows (Frontend sidebar only). * - No internal navigation — consumers wire onNavigate / onLogout. */ import * as React from "react"; import { ChevronDown, ChevronRight, Info, LogOut, Pin, PinOff, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"; import { cn, getInitials } from "@/lib/utils"; import { formatCurrency } from "@/lib/format-currency"; import { Accordion, AccordionContent, AccordionItem } from "./accordion"; import { Button } from "./button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; // ─── Types ───────────────────────────────────────────────────────────────────── export interface SidebarNavSubItem { title: string; href: string; isActive?: boolean; } export interface SidebarNavItem { /** Lucide icon component */ icon: LucideIcon; title: string; href: string; isActive?: boolean; /** When true, renders an expandable group with subItems */ isCollapsible?: boolean; subItems?: SidebarNavSubItem[]; } export interface SidebarNavMetricItem { name: string; /** Numeric value in dollars — formatted as currency automatically */ value: number; /** Net items show +/- prefix and a primary-color underline */ isNetItem?: boolean; /** Optional tooltip text shown via Info icon */ info?: string; } export interface SidebarNavMetricsGroup { items: SidebarNavMetricItem[]; } export interface SidebarNavProps { items: SidebarNavItem[]; /** Display name for the current user */ userName?: string; /** * Optional logo URL rendered at the top of the sidebar. * Hidden when collapsed (unless logoCollapsed is provided). * Use a horizontal/landscape logo (max-height 32px). * The image is rendered white via CSS filter — use a logo with non-transparent paths. */ logo?: string; /** * Optional icon-only logo URL shown when the sidebar is collapsed. * Use a square icon variant of the logo (rendered at 32×32px, white filter applied). */ logoCollapsed?: string; /** * Optional financial metric groups rendered between the user section and * nav items. Hidden when sidebar is collapsed. Used by the Frontend (WealthX app) sidebar. */ metricsGroups?: SidebarNavMetricsGroup[]; onNavigate?: (href: string) => void; onLogout?: () => void; /** * Initial locked (pinned) state. When true, sidebar starts expanded and stays * open even without hover. Defaults to false (hover-to-expand mode). */ defaultLocked?: boolean; /** * Called when the user toggles the Pin/Unpin button. * Use to persist the preference (e.g. localStorage). */ onLockedChange?: (locked: boolean) => void; className?: string; } // ─── Helpers ─────────────────────────────────────────────────────────────────── function navIconCn(isActive: boolean): string { return cn( "shrink-0 transition-colors", isActive ? "text-primary" : "text-brand-secondary-foreground/50 group-hover:text-brand-secondary-foreground", ); } // ─── NavTooltip ──────────────────────────────────────────────────────────────── // Wraps content in a right-side tooltip only when the sidebar is collapsed. interface NavTooltipProps { label: string; collapsed: boolean; children: React.ReactElement; } function NavTooltip({ label, collapsed, children }: NavTooltipProps) { if (!collapsed) return children; return ( {label} ); } // ─── MetricsGroup ────────────────────────────────────────────────────────────── interface MetricsGroupProps { group: SidebarNavMetricsGroup; } function MetricsGroup({ group }: MetricsGroupProps) { return (
{group.items.map((item) => (
{item.name} {item.info && ( )}
{formatCurrency(item.value, { showSign: item.isNetItem })}
))}
); } // ─── SidebarNavItemView (single link) ────────────────────────────────────────── interface SidebarNavItemViewProps { item: SidebarNavItem; collapsed: boolean; onNavigate?: (href: string) => void; } function SidebarNavItemView({ item, collapsed, onNavigate, }: SidebarNavItemViewProps) { const Icon = item.icon; return ( ); } // ─── CollapsibleNavItem (accordion group) ────────────────────────────────────── interface CollapsibleNavItemProps { item: SidebarNavItem; collapsed: boolean; onNavigate?: (href: string) => void; } function CollapsibleNavItem({ item, collapsed, onNavigate, }: CollapsibleNavItemProps) { const Icon = item.icon; const hasActiveChild = item.subItems?.some((sub) => sub.isActive) ?? false; // The group is highlighted when its own landing page OR one of its sub-items // is active — not only when a child is selected. const isGroupActive = hasActiveChild || (item.isActive ?? false); const [open, setOpen] = React.useState(isGroupActive); React.useEffect(() => { if (isGroupActive) setOpen(true); }, [isGroupActive]); if (collapsed) { return ( ); } return ( setOpen(values.length > 0)} > onNavigate?.(item.href)} className={cn( "group flex h-auto w-full items-center justify-start gap-3 px-3 py-2.5 text-base font-medium transition-colors", "text-brand-secondary-foreground/70 hover:bg-white/10 hover:text-brand-secondary-foreground", "border-l-4 border-transparent", isGroupActive && "bg-white/15 text-brand-secondary-foreground border-primary", )} > {item.title} {item.subItems && (
{item.subItems.map((sub) => ( ))}
)}
); } // ─── SidebarNav (root) ───────────────────────────────────────────────────────── export function SidebarNav({ items, userName = "Anonymous User", logo, logoCollapsed, metricsGroups, onNavigate, onLogout, defaultLocked = false, onLockedChange, className, }: SidebarNavProps) { const [isLocked, setIsLocked] = React.useState(defaultLocked); const [isHovered, setIsHovered] = React.useState(false); // Sidebar is expanded when pinned OR when the user is hovering over it. const isExpanded = isLocked || isHovered; const [userMenuOpen, setUserMenuOpen] = React.useState(false); const navScrollRef = React.useRef(null); const expandedScrollRef = React.useRef(0); const handleLockToggle = () => { const next = !isLocked; setIsLocked(next); onLockedChange?.(next); }; React.useEffect(() => { if (!isExpanded) setUserMenuOpen(false); }, [isExpanded]); // Preserve nav items scroll position across collapse/expand transitions. // Cleanup saves scrollTop before the DOM changes; setup restores it when expanding. React.useLayoutEffect(() => { const nav = navScrollRef.current; if (!nav) return; if (isExpanded) { nav.scrollTop = expandedScrollRef.current; } return () => { if (isExpanded && nav) { expandedScrollRef.current = nav.scrollTop; } }; }, [isExpanded]); return ( ); }