'use client' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' import { SvgHamburgerMenu1, SvgTaillessLineArrowDown1, } from '@chainlink/blocks-icons' import { useIsMobile } from '../../../hooks' import { SvgArrowExpandWindow, type SvgIcon } from '../../../icons' import { cn } from '../../../utils' import { Button } from '../../Button' import { Drawer, DrawerContent, DrawerHandle } from '../../Drawer' import { Input } from '../../Input' import { Separator } from '../../Separator' import { typographyVariants } from '../../Typography' import { ChainlinkIcon, ChainlinkLogoText } from './ChainlinkLogo' import { defaultNavItems } from './config' import { collectAllPaths, isPathActive, normalizeNavItems, normalizePathForMatching, } from './utils' const SIDEBAR_COOKIE_NAME = 'sidebar_state' const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_WIDTH = '224px' export const SIDEBAR_WIDTH_ICON = '73px' const SIDEBAR_KEYBOARD_SHORTCUT = 'b' type SidebarLinkProps = { href: string children: React.ReactNode onClick?: (event: React.MouseEvent) => void className?: string tabIndex?: number onKeyDown?: (event: React.KeyboardEvent) => void 'data-sidebar'?: string 'data-active'?: boolean | string style?: React.CSSProperties | undefined } type SidebarContextProps = { state: 'expanded' | 'collapsed' open: boolean setOpen: (open: boolean) => void openMobile: boolean setOpenMobile: (open: boolean) => void isMobile: boolean toggleSidebar: () => void openSubmenu: string | null setOpenSubmenu: (id: string | null) => void activeItem: string | null setActiveItem: (item: string | null) => void activeParentTitle?: string renderLink: (props: SidebarLinkProps) => React.ReactNode navItems?: SidebarNavItem[][] allPaths: Set } const SidebarContext = React.createContext(null) function useSidebar() { const context = React.useContext(SidebarContext) if (!context) { throw new Error('useSidebar must be used within a SidebarProvider.') } return context } function useSidebarContext() { return React.useContext(SidebarContext) } type SidebarNavItem = { title: string icon?: React.ComponentType<{ color?: SvgIcon['color'] }> href?: string items?: { title: string; href: string; external?: boolean }[] onClick?: () => void external?: boolean } type SidebarProviderProps = React.ComponentProps<'div'> & { defaultOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void currentPath?: string | null baseUrl?: string navItems?: SidebarNavItem[][] renderLink: (props: SidebarLinkProps) => React.ReactNode ref?: React.Ref } const SidebarProvider = ({ defaultOpen = false, open: openProp, onOpenChange: setOpenProp, className, currentPath, baseUrl, navItems = defaultNavItems, style, children, ref, renderLink, ...props }: SidebarProviderProps) => { const isMobile = useIsMobile() ?? false const [openMobile, setOpenMobile] = React.useState(false) // Normalize navItems based on baseUrl const normalizedNavItems = React.useMemo( () => normalizeNavItems(navItems, baseUrl), [navItems, baseUrl], ) // Collect all defined paths for specificity checking const allPaths = React.useMemo( () => collectAllPaths(normalizedNavItems), [normalizedNavItems], ) const [activeItem, setActiveItem] = React.useState( currentPath ? normalizePathForMatching(currentPath) : null, ) // Sync activeItem with currentPath prop when it changes React.useEffect(() => { if (currentPath !== undefined) { setActiveItem(normalizePathForMatching(currentPath)) } }, [currentPath]) // Calculate activeParentTitle and openSubmenu based on activeItem and specific matching rules const activeParentTitle = React.useMemo(() => { if (!activeItem) return undefined for (const group of normalizedNavItems) { for (const item of group) { // Check if any child matches the active item if (item.items) { for (const subItem of item.items) { if ( subItem.href && isPathActive(activeItem, subItem.href, allPaths) ) { return item.title } } // If no child matched, check if the parent itself matches // This handles cases where we are at a path that matches the parent's href (e.g. /) // but doesn't match any specific child path if (item.href && isPathActive(activeItem, item.href, allPaths)) { return item.title } } } } return undefined }, [activeItem, normalizedNavItems, allPaths]) const [openSubmenu, setOpenSubmenu] = React.useState( activeParentTitle ?? null, ) // Sync openSubmenu when activeParentTitle changes React.useEffect(() => { if (activeParentTitle !== undefined) { setOpenSubmenu(activeParentTitle) } }, [activeParentTitle]) // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen) const open = openProp ?? _open const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === 'function' ? value(open) : value if (setOpenProp) { setOpenProp(openState) } else { _setOpen(openState) } // This sets the cookie to keep the sidebar state. document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` }, [setOpenProp, open], ) // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) }, [isMobile, setOpen, setOpenMobile]) // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault() toggleSidebar() } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [toggleSidebar]) // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? 'expanded' : 'collapsed' // Update activeItem when active prop changes React.useEffect(() => { if (currentPath !== undefined) { setActiveItem(currentPath) } }, [currentPath]) const contextValue = React.useMemo( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, openSubmenu, setOpenSubmenu, activeItem, setActiveItem, activeParentTitle, renderLink, navItems: normalizedNavItems, allPaths, }), [ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, openSubmenu, setOpenSubmenu, activeItem, setActiveItem, activeParentTitle, renderLink, normalizedNavItems, allPaths, ], ) return (
{children}
) } SidebarProvider.displayName = 'SidebarProvider' type SidebarProps = React.ComponentProps<'div'> & { side?: 'left' | 'right' variant?: 'sidebar' | 'floating' | 'inset' collapsible?: 'offcanvas' | 'icon' | 'none' ref?: React.Ref } const Sidebar = ({ side = 'left', variant = 'sidebar', collapsible = 'icon', className, children, ref, ...props }: SidebarProps) => { const { isMobile, state, openMobile, setOpenMobile, setOpen } = useSidebar() const sidebarRef = React.useRef(null) const isCollapsed = state === 'collapsed' // Combine refs React.useImperativeHandle(ref, () => sidebarRef.current as HTMLDivElement) // Handle focus events for keyboard navigation on the entire sidebar React.useEffect(() => { const element = sidebarRef.current if (!element || isMobile) return const handleFocusIn = () => { // If sidebar is collapsed and focus enters, expand it if (isCollapsed) { setOpen(true) } } const handleFocusOut = (event: FocusEvent) => { // Check if focus is moving outside of the sidebar const relatedTarget = event.relatedTarget as Node | null // If relatedTarget is null, focus is leaving the document/window // If relatedTarget exists but is not contained in the sidebar, focus is leaving if (!relatedTarget || !element.contains(relatedTarget)) { // Use a small timeout to allow focus to settle, in case focus is moving // to another element within the sidebar setTimeout(() => { // Double-check: if the active element is not within the sidebar, collapse const activeElement = document.activeElement if (activeElement && !element.contains(activeElement)) { // Focus has left the sidebar, collapse it // But only if it's not already collapsed if (!isCollapsed) { setOpen(false) } } }, 0) } } element.addEventListener('focusin', handleFocusIn) element.addEventListener('focusout', handleFocusOut) return () => { element.removeEventListener('focusin', handleFocusIn) element.removeEventListener('focusout', handleFocusOut) } }, [isCollapsed, setOpen, isMobile]) // Conditional returns after all hooks if (collapsible === 'none') { return (
{children}
) } if (isMobile) { return (
{children}
) } const handleMouseEnter = () => { if (!isMobile && state === 'collapsed') { setOpen(true) } } const handleMouseLeave = () => { if (!isMobile && state === 'expanded') { // Check if OrgSwitcher is open by looking for open select content with org-switcher attribute const openOrgSwitcher = document.querySelector( '[data-slot="select-content"][data-state="open"][data-org-switcher="true"]', ) if (openOrgSwitcher) { return } setOpen(false) } } return (
) } Sidebar.displayName = 'Sidebar' type SidebarTriggerProps = React.ComponentProps & { ref?: React.Ref> } const SidebarTrigger = ({ className, onClick, ref, ...props }: SidebarTriggerProps) => { const { toggleSidebar } = useSidebar() return ( ) } SidebarTrigger.displayName = 'SidebarTrigger' type SidebarRailProps = React.ComponentProps<'button'> & { ref?: React.Ref } const SidebarRail = ({ className, ref, ...props }: SidebarRailProps) => { const { toggleSidebar } = useSidebar() return ( ) } // Internal link - use renderLink if (href.startsWith('/')) { return renderLink({ href, tabIndex: shouldBeFocusable ? undefined : -1, onClick: handleClick, 'data-sidebar': 'menu-sub-button', 'data-active': isActive, className: cn( '!outline-none focus-visible:bg-platform-navigation-hover focus-visible:outline-none', buttonClasses, isActive ? 'text-platform-navigation-active-foreground' : 'text-platform-navigation-foreground', ), style: { color: isActive ? 'var(--platform-navigation-active-foreground)' : 'var(--platform-navigation-foreground)', }, children: title, }) } // External link return ( {title} {external && ( )} ) } SidebarMenuSubButton.displayName = 'SidebarMenuSubButton' type SidebarMenuSubProps = React.ComponentProps<'ul'> & { parentTitle?: string ref?: React.Ref } const SidebarMenuSub = ({ className, parentTitle, ref, ...props }: SidebarMenuSubProps) => { const [hoveredIndex, setHoveredIndex] = React.useState(null) const { activeItem, openSubmenu, allPaths } = useSidebar() // Find the active index by looking at children's href prop const activeIndex = React.useMemo(() => { return React.Children.toArray(props.children).findIndex((child) => { if (React.isValidElement(child)) { // Look for SidebarMenuSubButton with matching href const subButton = React.Children.toArray( (child.props as any).children, ).find( (subChild) => React.isValidElement(subChild) && (subChild.props as any).href && isPathActive( activeItem || '', (subChild.props as any).href, allPaths, ), ) return !!subButton } return false }) }, [props.children, activeItem, allPaths]) const currentIndex = hoveredIndex ?? activeIndex // Determine if this submenu should be open based on parent title const isOpen = parentTitle ? openSubmenu === parentTitle : false // The content of the submenu is wrapped in a div to prevent margin collapse // and ensure the grid animation works correctly. return (
    {/* Vertical line indicator - positioned outside overflow container */} {currentIndex >= 0 && (
    )}
    {React.Children.map(props.children, (child, index) => { if (React.isValidElement(child)) { return React.cloneElement(child, { onMouseEnter: () => setHoveredIndex(index), onMouseLeave: () => setHoveredIndex(null), } as any) } return child })}
) } SidebarMenuSub.displayName = 'SidebarMenuSub' const sidebarMenuButtonVariants = cva( 'peer/menu-button flex w-full items-center gap-3.5 overflow-hidden rounded-md p-3 text-left text-sm text-platform-navigation-foreground outline-none ring-ring transition-[width,height,padding] hover:bg-platform-navigation-hover focus-visible:bg-platform-navigation-hover focus-visible:outline-none active:bg-platform-navigation-hover 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-platform-navigation-active !font-semibold data-[state=open]:hover:bg-platform-navigation-hover data-[state=open]:hover:text-platform-navigation-hover-foreground group-data-[collapsible=icon]:!size-12 py-3 px-3.5 [&>span:last-child]:truncate [&_svg]:size-5 [&_svg]:shrink-0 whitespace-nowrap h-12', ) type SidebarMenuButtonProps = React.ComponentProps<'button'> & SidebarNavItem & { ref?: React.Ref } const SidebarMenuButton = ({ title, href, icon, className, onClick, items, external = false, ref, }: SidebarMenuButtonProps) => { const { openSubmenu, setOpenSubmenu, activeItem, setActiveItem, activeParentTitle, renderLink, setOpenMobile, state, setOpen, allPaths, } = useSidebar() const isCollapsed = state === 'collapsed' // Check if this item is active const hasSubmenu = !!items const isActive = (hasSubmenu && activeParentTitle === title) || // Parent is active if it contains the active child (!hasSubmenu && href && isPathActive(activeItem || '', href, allPaths)) // Item is active if it matches path logic const Icon = icon const handleClick = (event: React.MouseEvent) => { // If item has submenu, toggle it (don't navigate) if (hasSubmenu) { event.preventDefault() // If sidebar is collapsed, expand it first if (isCollapsed) { setOpen(true) } setOpenSubmenu(openSubmenu === title ? null : title) return } // If item has href, handle navigation if (title && href) { // Handle navigation - set active optimistically only for internal links if (href.startsWith('/')) { setActiveItem(normalizePathForMatching(href)) } // Close any open submenu when clicking on an item setOpenSubmenu(null) } setOpenMobile(false) // Call the original onClick if provided (no event parameter) onClick?.() } const handleKeyDown = (event: React.KeyboardEvent) => { // If Enter or Space is pressed on a collapsed item with submenu, expand sidebar and open submenu if ( (event.key === 'Enter' || event.key === ' ') && hasSubmenu && isCollapsed ) { event.preventDefault() setOpen(true) setOpenSubmenu(openSubmenu === title ? null : title) } } // No href - render NonNavigatingItem (or parent with submenu) if (hasSubmenu || !href) { const isOpen = openSubmenu === title return ( <> {hasSubmenu && ( {items.map((subItem) => ( ))} )} ) } // Internal link - use renderLink with InternalLink structure if (href.startsWith('/')) { return renderLink({ href, onKeyDown: handleKeyDown, onClick: handleClick, 'data-sidebar': 'menu-button', 'data-active': isActive, className: cn( '!outline-none focus-visible:bg-platform-navigation-hover focus-visible:outline-none', sidebarMenuButtonVariants(), isActive && '[&>svg:not([data-sidebar=external-link-icon])]:text-brand', className, ), children: ( <> {Icon && } {title} ), }) } // External link - render with external icon return ( svg:not([data-sidebar=external-link-icon])]:text-brand', external && 'group/link', className, )} onClick={handleClick} onKeyDown={handleKeyDown} > {Icon && } {title} {external && ( )} ) } SidebarMenuButton.displayName = 'SidebarMenuButton' type SidebarMenuItemProps = React.ComponentProps<'li'> & { ref?: React.Ref } const SidebarMenuItem = ({ className, ref, ...props }: SidebarMenuItemProps) => (
  • ) SidebarMenuItem.displayName = 'SidebarMenuItem' type SidebarMenuActionProps = React.ComponentProps<'button'> & { asChild?: boolean showOnHover?: boolean ref?: React.Ref } const SidebarMenuAction = ({ className, asChild = false, showOnHover = false, ref, ...props }: SidebarMenuActionProps) => { const Comp = asChild ? Slot : 'button' return ( svg]:size-4 [&>svg]:shrink-0', // Increases the hit area of the button on mobile. 'after:absolute after:-inset-2 after:md:hidden', 'top-2.5', 'group-data-[collapsible=icon]:hidden', showOnHover && 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-platform-navigation-active-foreground md:opacity-0', className, )} {...props} /> ) } SidebarMenuAction.displayName = 'SidebarMenuAction' type SidebarMenuBadgeProps = React.ComponentProps<'div'> & { ref?: React.Ref } const SidebarMenuBadge = ({ className, ref, ...props }: SidebarMenuBadgeProps) => (
    ) SidebarMenuBadge.displayName = 'SidebarMenuBadge' type SidebarMenuSkeletonProps = React.ComponentProps<'div'> & { showIcon?: boolean ref?: React.Ref } const SidebarMenuSkeleton = ({ className, showIcon = false, ref, ...props }: SidebarMenuSkeletonProps) => { // Random width between 50 to 90%. const width = React.useMemo(() => { return `${Math.floor(Math.random() * 40) + 50}%` }, []) return (
    {showIcon && (
    )}
    ) } SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton' type SidebarLogoIconProps = React.ComponentProps<'div'> & { ref?: React.Ref } const SidebarLogoIcon = ({ className, children, ref, ...props }: SidebarLogoIconProps) => { return (
    {children}
    ) } SidebarLogoIcon.displayName = 'SidebarLogoIcon' type SidebarLogoTextProps = React.ComponentProps<'div'> & Pick, 'variant' | 'color'> & { ref?: React.Ref } const SidebarLogoText = ({ className, children, variant = 'body-semi-xl', color = 'brand', ref, ...props }: SidebarLogoTextProps) => { return (
    {children}
    ) } SidebarLogoText.displayName = 'SidebarLogoText' type SidebarLogoProps = React.ComponentProps<'div'> & { href?: string ref?: React.Ref } const SidebarLogo = ({ children, className, href = '/', ref, }: SidebarLogoProps) => { const { renderLink, setActiveItem, setOpenSubmenu } = useSidebar() const handleClick = () => { // Set active item optimistically for internal links setActiveItem(href) // Close any open submenus setOpenSubmenu(null) } return ( {renderLink({ href, onClick: handleClick, tabIndex: 0, 'data-sidebar': 'menu-button', className: 'flex cursor-pointer !outline-none focus-visible:!bg-platform-navigation-hover focus-visible:outline-none', children, })} ) } SidebarLogo.displayName = 'SidebarLogo' type SidebarLinksProps = { navItems?: SidebarNavItem[][] } const SidebarLinks = ({ navItems: propNavItems }: SidebarLinksProps) => { const { navItems: defaultNavItems } = useSidebar() const navItems = propNavItems ?? defaultNavItems if (!navItems) return null return ( <> {navItems.map((group, groupIndex) => ( {groupIndex > 0 && } {group.map((item) => ( ))} ))} ) } SidebarLinks.displayName = 'SidebarLinks' type SidebarChainlinkLogoProps = React.ComponentProps<'div'> & { href?: string ref?: React.Ref } const SidebarChainlinkLogo = ({ href = '/', className, ref, ...props }: SidebarChainlinkLogoProps) => { return ( ) } SidebarChainlinkLogo.displayName = 'SidebarChainlinkLogo' export { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarInset, SidebarLogo, SidebarLogoIcon, SidebarLogoText, SidebarChainlinkLogo, SidebarLinks, SidebarMenu, SidebarMenuAction, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarMenuSkeleton, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, SidebarTrigger, SidebarContext, useSidebar, useSidebarContext, type SidebarNavItem, type SidebarLinkProps, }