SPB Git

spb/vquant Public MIT

VibeQuant — AI-powered institutional-grade financial intelligence platform.

TypeScript 84.3% Python 11.7% JavaScript 1.6% CSS 1.5% HTML 0.7%
21.9 KB · 744 lines tsx
Raw Blame History
1/*2 * =============================================================================3 *  VibeQuant (vquant) — AI-Powered Financial Intelligence Platform4 * -----------------------------------------------------------------------------5 *  File:      client/src/components/ui/sidebar.tsx6 *7 *  Author:    Simon-Pierre Boucher8 *  Contact:   contact@spboucher.ai9 *  Website:   https://www.spboucher.ai10 *  Demo:      https://www.vquant.ai11 *  License:   MIT (see LICENSE)12 *13 *  Copyright © 2026 Simon-Pierre Boucher. All rights reserved.14 * =============================================================================15 */1617"use client"1819import * as React from "react"20import { Slot } from "@radix-ui/react-slot"21import { cva, VariantProps } from "class-variance-authority"22import { PanelLeftIcon } from "lucide-react"2324import { useIsMobile } from "@/hooks/use-mobile"25import { cn } from "@/lib/utils"26import { Button } from "@/components/ui/button"27import { Input } from "@/components/ui/input"28import { Separator } from "@/components/ui/separator"29import {30  Sheet,31  SheetContent,32  SheetDescription,33  SheetHeader,34  SheetTitle,35} from "@/components/ui/sheet"36import { Skeleton } from "@/components/ui/skeleton"37import {38  Tooltip,39  TooltipContent,40  TooltipProvider,41  TooltipTrigger,42} from "@/components/ui/tooltip"4344const SIDEBAR_COOKIE_NAME = "sidebar_state"45const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 746const SIDEBAR_WIDTH = "16rem"47const SIDEBAR_WIDTH_MOBILE = "18rem"48const SIDEBAR_WIDTH_ICON = "3rem"49const SIDEBAR_KEYBOARD_SHORTCUT = "b"5051type SidebarContextProps = {52  state: "expanded" | "collapsed"53  open: boolean54  setOpen: (open: boolean) => void55  openMobile: boolean56  setOpenMobile: (open: boolean) => void57  isMobile: boolean58  toggleSidebar: () => void59}6061const SidebarContext = React.createContext<SidebarContextProps | null>(null)6263function useSidebar() {64  const context = React.useContext(SidebarContext)65  if (!context) {66    throw new Error("useSidebar must be used within a SidebarProvider.")67  }6869  return context70}7172function SidebarProvider({73  defaultOpen = true,74  open: openProp,75  onOpenChange: setOpenProp,76  className,77  style,78  children,79  ...props80}: React.ComponentProps<"div"> & {81  defaultOpen?: boolean82  open?: boolean83  onOpenChange?: (open: boolean) => void84}) {85  const isMobile = useIsMobile()86  const [openMobile, setOpenMobile] = React.useState(false)8788  // This is the internal state of the sidebar.89  // We use openProp and setOpenProp for control from outside the component.90  const [_open, _setOpen] = React.useState(defaultOpen)91  const open = openProp ?? _open92  const setOpen = React.useCallback(93    (value: boolean | ((value: boolean) => boolean)) => {94      const openState = typeof value === "function" ? value(open) : value95      if (setOpenProp) {96        setOpenProp(openState)97      } else {98        _setOpen(openState)99      }100101      // This sets the cookie to keep the sidebar state.102      document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`103    },104    [setOpenProp, open]105  )106107  // Helper to toggle the sidebar.108  const toggleSidebar = React.useCallback(() => {109    return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)110  }, [isMobile, setOpen, setOpenMobile])111112  // Adds a keyboard shortcut to toggle the sidebar.113  React.useEffect(() => {114    const handleKeyDown = (event: KeyboardEvent) => {115      if (116        event.key === SIDEBAR_KEYBOARD_SHORTCUT &&117        (event.metaKey || event.ctrlKey)118      ) {119        event.preventDefault()120        toggleSidebar()121      }122    }123124    window.addEventListener("keydown", handleKeyDown)125    return () => window.removeEventListener("keydown", handleKeyDown)126  }, [toggleSidebar])127128  // We add a state so that we can do data-state="expanded" or "collapsed".129  // This makes it easier to style the sidebar with Tailwind classes.130  const state = open ? "expanded" : "collapsed"131132  const contextValue = React.useMemo<SidebarContextProps>(133    () => ({134      state,135      open,136      setOpen,137      isMobile,138      openMobile,139      setOpenMobile,140      toggleSidebar,141    }),142    [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]143  )144145  return (146    <SidebarContext.Provider value={contextValue}>147      <TooltipProvider delayDuration={0}>148        <div149          data-slot="sidebar-wrapper"150          style={151            {152              "--sidebar-width": SIDEBAR_WIDTH,153              "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,154              ...style,155            } as React.CSSProperties156          }157          className={cn(158            "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",159            className160          )}161          {...props}162        >163          {children}164        </div>165      </TooltipProvider>166    </SidebarContext.Provider>167  )168}169170function Sidebar({171  side = "left",172  variant = "sidebar",173  collapsible = "offcanvas",174  className,175  children,176  ...props177}: React.ComponentProps<"div"> & {178  side?: "left" | "right"179  variant?: "sidebar" | "floating" | "inset"180  collapsible?: "offcanvas" | "icon" | "none"181}) {182  const { isMobile, state, openMobile, setOpenMobile } = useSidebar()183184  if (collapsible === "none") {185    return (186      <div187        data-slot="sidebar"188        className={cn(189          "bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",190          className191        )}192        {...props}193      >194        {children}195      </div>196    )197  }198199  if (isMobile) {200    return (201      <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>202        <SheetContent203          data-sidebar="sidebar"204          data-slot="sidebar"205          data-mobile="true"206          className="bg-sidebar text-sidebar-foreground w-[var(--sidebar-width)] p-0 [&>button]:hidden"207          style={208            {209              "--sidebar-width": SIDEBAR_WIDTH_MOBILE,210            } as React.CSSProperties211          }212          side={side}213        >214          <SheetHeader className="sr-only">215            <SheetTitle>Sidebar</SheetTitle>216            <SheetDescription>Displays the mobile sidebar.</SheetDescription>217          </SheetHeader>218          <div className="flex h-full w-full flex-col">{children}</div>219        </SheetContent>220      </Sheet>221    )222  }223224  return (225    <div226      className="group peer text-sidebar-foreground hidden md:block"227      data-state={state}228      data-collapsible={state === "collapsed" ? collapsible : ""}229      data-variant={variant}230      data-side={side}231      data-slot="sidebar"232    >233      {/* This is what handles the sidebar gap on desktop */}234      <div235        data-slot="sidebar-gap"236        className={cn(237          "relative w-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear",238          "group-data-[collapsible=offcanvas]:w-0",239          "group-data-[side=right]:rotate-180",240          variant === "floating" || variant === "inset"241            ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4))]"242            : "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]"243        )}244      />245      <div246        data-slot="sidebar-container"247        className={cn(248          "fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex",249          side === "left"250            ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"251            : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",252          // Adjust the padding for floating and inset variants.253          variant === "floating" || variant === "inset"254            ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4)+2px)]"255            : "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l",256          className257        )}258        {...props}259      >260        <div261          data-sidebar="sidebar"262          data-slot="sidebar-inner"263          className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"264        >265          {children}266        </div>267      </div>268    </div>269  )270}271272function SidebarTrigger({273  className,274  onClick,275  ...props276}: React.ComponentProps<typeof Button>) {277  const { toggleSidebar } = useSidebar()278279  return (280    <Button281      data-sidebar="trigger"282      data-slot="sidebar-trigger"283      variant="ghost"284      size="icon"285      className={cn("h-7 w-7", className)}286      onClick={(event) => {287        onClick?.(event)288        toggleSidebar()289      }}290      {...props}291    >292      <PanelLeftIcon />293      <span className="sr-only">Toggle Sidebar</span>294    </Button>295  )296}297298function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {299  const { toggleSidebar } = useSidebar()300301  // Note: Tailwind v3.4 doesn't support "in-" selectors. So the rail won't work perfectly.302  return (303    <button304      data-sidebar="rail"305      data-slot="sidebar-rail"306      aria-label="Toggle Sidebar"307      tabIndex={-1}308      onClick={toggleSidebar}309      title="Toggle Sidebar"310      className={cn(311        "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",312        "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",313        "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",314        "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",315        "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",316        "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",317        className318      )}319      {...props}320    />321  )322}323324function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {325  return (326    <main327      data-slot="sidebar-inset"328      className={cn(329        "bg-background relative flex w-full flex-1 flex-col",330        "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",331        className332      )}333      {...props}334    />335  )336}337338function SidebarInput({339  className,340  ...props341}: React.ComponentProps<typeof Input>) {342  return (343    <Input344      data-slot="sidebar-input"345      data-sidebar="input"346      className={cn("bg-background h-8 w-full shadow-none", className)}347      {...props}348    />349  )350}351352function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {353  return (354    <div355      data-slot="sidebar-header"356      data-sidebar="header"357      className={cn("flex flex-col gap-2 p-2", className)}358      {...props}359    />360  )361}362363function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {364  return (365    <div366      data-slot="sidebar-footer"367      data-sidebar="footer"368      className={cn("flex flex-col gap-2 p-2", className)}369      {...props}370    />371  )372}373374function SidebarSeparator({375  className,376  ...props377}: React.ComponentProps<typeof Separator>) {378  return (379    <Separator380      data-slot="sidebar-separator"381      data-sidebar="separator"382      className={cn("bg-sidebar-border mx-2 w-auto", className)}383      {...props}384    />385  )386}387388function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {389  return (390    <div391      data-slot="sidebar-content"392      data-sidebar="content"393      className={cn(394        "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",395        className396      )}397      {...props}398    />399  )400}401402function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {403  return (404    <div405      data-slot="sidebar-group"406      data-sidebar="group"407      className={cn("relative flex w-full min-w-0 flex-col p-2", className)}408      {...props}409    />410  )411}412413function SidebarGroupLabel({414  className,415  asChild = false,416  ...props417}: React.ComponentProps<"div"> & { asChild?: boolean }) {418  const Comp = asChild ? Slot : "div"419420  return (421    <Comp422      data-slot="sidebar-group-label"423      data-sidebar="group-label"424      className={cn(425        "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0",426        "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",427        className428      )}429      {...props}430    />431  )432}433434function SidebarGroupAction({435  className,436  asChild = false,437  ...props438}: React.ComponentProps<"button"> & { asChild?: boolean }) {439  const Comp = asChild ? Slot : "button"440441  return (442    <Comp443      data-slot="sidebar-group-action"444      data-sidebar="group-action"445      className={cn(446        "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",447        // Increases the hit area of the button on mobile.448        "after:absolute after:-inset-2 md:after:hidden",449        "group-data-[collapsible=icon]:hidden",450        className451      )}452      {...props}453    />454  )455}456457function SidebarGroupContent({458  className,459  ...props460}: React.ComponentProps<"div">) {461  return (462    <div463      data-slot="sidebar-group-content"464      data-sidebar="group-content"465      className={cn("w-full text-sm", className)}466      {...props}467    />468  )469}470471function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {472  return (473    <ul474      data-slot="sidebar-menu"475      data-sidebar="menu"476      className={cn("flex w-full min-w-0 flex-col gap-1", className)}477      {...props}478    />479  )480}481482function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {483  return (484    <li485      data-slot="sidebar-menu-item"486      data-sidebar="menu-item"487      className={cn("group/menu-item relative", className)}488      {...props}489    />490  )491}492493const sidebarMenuButtonVariants = cva(494  "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:w-8! group-data-[collapsible=icon]:h-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",495  {496    variants: {497      variant: {498        default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",499        outline:500          "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",501      },502      size: {503        default: "h-8 text-sm",504        sm: "h-7 text-xs",505        lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",506      },507    },508    defaultVariants: {509      variant: "default",510      size: "default",511    },512  }513)514515function SidebarMenuButton({516  asChild = false,517  isActive = false,518  variant = "default",519  size = "default",520  tooltip,521  className,522  ...props523}: React.ComponentProps<"button"> & {524  asChild?: boolean525  isActive?: boolean526  tooltip?: string | React.ComponentProps<typeof TooltipContent>527} & VariantProps<typeof sidebarMenuButtonVariants>) {528  const Comp = asChild ? Slot : "button"529  const { isMobile, state } = useSidebar()530531  const button = (532    <Comp533      data-slot="sidebar-menu-button"534      data-sidebar="menu-button"535      data-size={size}536      data-active={isActive}537      className={cn(sidebarMenuButtonVariants({ variant, size }), className)}538      {...props}539    />540  )541542  if (!tooltip) {543    return button544  }545546  if (typeof tooltip === "string") {547    tooltip = {548      children: tooltip,549    }550  }551552  return (553    <Tooltip>554      <TooltipTrigger asChild>{button}</TooltipTrigger>555      <TooltipContent556        side="right"557        align="center"558        hidden={state !== "collapsed" || isMobile}559        {...tooltip}560      />561    </Tooltip>562  )563}564565function SidebarMenuAction({566  className,567  asChild = false,568  showOnHover = false,569  ...props570}: React.ComponentProps<"button"> & {571  asChild?: boolean572  showOnHover?: boolean573}) {574  const Comp = asChild ? Slot : "button"575576  return (577    <Comp578      data-slot="sidebar-menu-action"579      data-sidebar="menu-action"580      className={cn(581        "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",582        // Increases the hit area of the button on mobile.583        "after:absolute after:-inset-2 md:after:hidden",584        "peer-data-[size=sm]/menu-button:top-1",585        "peer-data-[size=default]/menu-button:top-1.5",586        "peer-data-[size=lg]/menu-button:top-2.5",587        "group-data-[collapsible=icon]:hidden",588        showOnHover &&589          "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",590        className591      )}592      {...props}593    />594  )595}596597function SidebarMenuBadge({598  className,599  ...props600}: React.ComponentProps<"div">) {601  return (602    <div603      data-slot="sidebar-menu-badge"604      data-sidebar="menu-badge"605      className={cn(606        "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",607        "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",608        "peer-data-[size=sm]/menu-button:top-1",609        "peer-data-[size=default]/menu-button:top-1.5",610        "peer-data-[size=lg]/menu-button:top-2.5",611        "group-data-[collapsible=icon]:hidden",612        className613      )}614      {...props}615    />616  )617}618619function SidebarMenuSkeleton({620  className,621  showIcon = false,622  ...props623}: React.ComponentProps<"div"> & {624  showIcon?: boolean625}) {626  // Random width between 50 to 90%.627  const width = React.useMemo(() => {628    return `${Math.floor(Math.random() * 40) + 50}%`629  }, [])630631  return (632    <div633      data-slot="sidebar-menu-skeleton"634      data-sidebar="menu-skeleton"635      className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}636      {...props}637    >638      {showIcon && (639        <Skeleton640          className="size-4 rounded-md"641          data-sidebar="menu-skeleton-icon"642        />643      )}644      <Skeleton645        className="h-4 max-w-[var(--skeleton-width)] flex-1"646        data-sidebar="menu-skeleton-text"647        style={648          {649            "--skeleton-width": width,650          } as React.CSSProperties651        }652      />653    </div>654  )655}656657function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {658  return (659    <ul660      data-slot="sidebar-menu-sub"661      data-sidebar="menu-sub"662      className={cn(663        "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",664        "group-data-[collapsible=icon]:hidden",665        className666      )}667      {...props}668    />669  )670}671672function SidebarMenuSubItem({673  className,674  ...props675}: React.ComponentProps<"li">) {676  return (677    <li678      data-slot="sidebar-menu-sub-item"679      data-sidebar="menu-sub-item"680      className={cn("group/menu-sub-item relative", className)}681      {...props}682    />683  )684}685686function SidebarMenuSubButton({687  asChild = false,688  size = "md",689  isActive = false,690  className,691  ...props692}: React.ComponentProps<"a"> & {693  asChild?: boolean694  size?: "sm" | "md"695  isActive?: boolean696}) {697  const Comp = asChild ? Slot : "a"698699  return (700    <Comp701      data-slot="sidebar-menu-sub-button"702      data-sidebar="menu-sub-button"703      data-size={size}704      data-active={isActive}705      className={cn(706        "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline outline-2 outline-transparent outline-offset-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",707        "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",708        size === "sm" && "text-xs",709        size === "md" && "text-sm",710        "group-data-[collapsible=icon]:hidden",711        className712      )}713      {...props}714    />715  )716}717718export {719  Sidebar,720  SidebarContent,721  SidebarFooter,722  SidebarGroup,723  SidebarGroupAction,724  SidebarGroupContent,725  SidebarGroupLabel,726  SidebarHeader,727  SidebarInput,728  SidebarInset,729  SidebarMenu,730  SidebarMenuAction,731  SidebarMenuBadge,732  SidebarMenuButton,733  SidebarMenuItem,734  SidebarMenuSkeleton,735  SidebarMenuSub,736  SidebarMenuSubButton,737  SidebarMenuSubItem,738  SidebarProvider,739  SidebarRail,740  SidebarSeparator,741  SidebarTrigger,742  useSidebar,743}744