{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <edu@smoothui.dev>","css":{},"dependencies":["motion"],"description":"Collapsible app sidebar shell for SmoothUI with provider and menu parts.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { type HTMLMotionProps, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  type UseFluidHoverReturn,\n  useFluidHover,\n  useRegisterFluidHoverItem,\n} from \"@/components/smoothui/lib/use-fluid-hover\";\nimport { spring } from \"@/components/smoothui/lib/springs\";\nimport { FluidHoverHighlight } from \"../fluid-hover-highlight\";\n\ntype SidebarContextValue = {\n  collapsed: boolean;\n  setCollapsed: (collapsed: boolean) => void;\n  toggle: () => void;\n};\n\ntype FluidHoverListContextValue = {\n  claimIndex: () => number;\n  registerItem: UseFluidHoverReturn[\"registerItem\"];\n};\n\nconst SidebarContext = createContext<SidebarContextValue | null>(null);\nconst FluidHoverListContext = createContext<FluidHoverListContextValue | null>(\n  null\n);\n\nexport const useSidebar = () => {\n  const ctx = useContext(SidebarContext);\n  if (!ctx) {\n    throw new Error(\"useSidebar must be used within SidebarProvider\");\n  }\n  return ctx;\n};\n\nexport interface SidebarProviderProps {\n  children?: ReactNode;\n  /** Controlled collapsed state */\n  collapsed?: boolean;\n  defaultCollapsed?: boolean;\n  onCollapsedChange?: (collapsed: boolean) => void;\n}\n\nexport interface SidebarProps extends ComponentProps<\"aside\"> {\n  children?: ReactNode;\n}\n\nexport interface SidebarHeaderProps extends ComponentProps<\"div\"> {\n  children?: ReactNode;\n}\n\nexport interface SidebarContentProps extends ComponentProps<\"div\"> {\n  children?: ReactNode;\n}\n\nexport interface SidebarFooterProps extends ComponentProps<\"div\"> {\n  children?: ReactNode;\n}\n\nexport interface SidebarMenuProps extends ComponentProps<\"ul\"> {\n  children?: ReactNode;\n}\n\nexport interface SidebarMenuItemProps extends ComponentProps<\"li\"> {\n  children?: ReactNode;\n}\n\nexport interface SidebarMenuButtonProps extends ComponentProps<\"button\"> {\n  active?: boolean;\n  children?: ReactNode;\n  /** Leading icon — required for a usable collapsed (icon-rail) state */\n  icon?: ReactNode;\n}\n\nexport interface SidebarLabelProps extends ComponentProps<\"span\"> {\n  children?: ReactNode;\n}\n\nexport interface SidebarTriggerProps extends ComponentProps<\"button\"> {\n  children?: ReactNode;\n}\n\n/** Label fade duration; matches the `duration-200` class on the label. */\nconst LABEL_FADE_MS = 200;\nconst EXPANDED_WIDTH = 240;\nconst COLLAPSED_WIDTH = 56;\n\nexport const SidebarProvider = ({\n  children,\n  collapsed: collapsedProp,\n  defaultCollapsed = false,\n  onCollapsedChange,\n}: SidebarProviderProps) => {\n  const [uncontrolled, setUncontrolled] = useState(defaultCollapsed);\n  const isControlled = collapsedProp !== undefined;\n  const collapsed = isControlled ? collapsedProp : uncontrolled;\n\n  const setCollapsed = useCallback(\n    (next: boolean) => {\n      if (!isControlled) {\n        setUncontrolled(next);\n      }\n      onCollapsedChange?.(next);\n    },\n    [isControlled, onCollapsedChange]\n  );\n\n  const value = useMemo(\n    () => ({\n      collapsed,\n      setCollapsed,\n      toggle: () => setCollapsed(!collapsed),\n    }),\n    [collapsed, setCollapsed]\n  );\n\n  return (\n    <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>\n  );\n};\n\n/**\n * SmoothUI Sidebar — lean collapsible app rail. SmoothUI-owned.\n * Delphi Sidebar / Astryx Side Nav are visual refs — not a full shadcn port.\n */\nexport default function Sidebar({\n  children,\n  className,\n  ...props\n}: SidebarProps) {\n  const { collapsed } = useSidebar();\n  const shouldReduceMotion = useReducedMotion();\n\n  return (\n    <motion.aside\n      animate={{\n        width: collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH,\n      }}\n      aria-label=\"Sidebar\"\n      className={cn(\n        \"flex h-full shrink-0 flex-col overflow-hidden border-foreground/10 border-r bg-background\",\n        className\n      )}\n      data-collapsed={collapsed || undefined}\n      data-slot=\"sidebar\"\n      initial={false}\n      transition={shouldReduceMotion ? { duration: 0 } : spring.moderate}\n      {...(props as Omit<HTMLMotionProps<\"aside\">, \"ref\">)}\n    >\n      {children}\n    </motion.aside>\n  );\n}\n\nexport const SidebarHeader = ({\n  children,\n  className,\n  ...props\n}: SidebarHeaderProps) => {\n  const { collapsed } = useSidebar();\n\n  return (\n    <div\n      className={cn(\n        \"flex h-12 shrink-0 items-center gap-2 px-3\",\n        collapsed && \"justify-center px-2\",\n        className\n      )}\n      data-slot=\"sidebar-header\"\n      {...props}\n    >\n      {children}\n    </div>\n  );\n};\n\nexport const SidebarContent = ({\n  children,\n  className,\n  ...props\n}: SidebarContentProps) => (\n  <div\n    className={cn(\n      \"scroll-fade flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2\",\n      className\n    )}\n    data-slot=\"sidebar-content\"\n    {...props}\n  >\n    {children}\n  </div>\n);\n\nexport const SidebarFooter = ({\n  children,\n  className,\n  ...props\n}: SidebarFooterProps) => (\n  <div\n    className={cn(\n      \"mt-auto flex shrink-0 flex-col gap-1 border-foreground/10 border-t p-2\",\n      className\n    )}\n    data-slot=\"sidebar-footer\"\n    {...props}\n  >\n    {children}\n  </div>\n);\n\n/**\n * Menu list with Fluid Functionalism nearest-item hover.\n * Highlight glides between rows instead of blinking on `:hover`.\n */\nexport const SidebarMenu = ({\n  children,\n  className,\n  ...props\n}: SidebarMenuProps) => {\n  const containerRef = useRef<HTMLUListElement>(null);\n  const hover = useFluidHover(containerRef as RefObject<HTMLElement | null>);\n  const nextIndexRef = useRef(0);\n\n  const claimIndex = useCallback(() => {\n    const index = nextIndexRef.current;\n    nextIndexRef.current += 1;\n    return index;\n  }, []);\n\n  const listCtx = useMemo(\n    () => ({\n      claimIndex,\n      registerItem: hover.registerItem,\n    }),\n    [claimIndex, hover.registerItem]\n  );\n\n  return (\n    <FluidHoverListContext.Provider value={listCtx}>\n      <ul\n        className={cn(\"relative flex flex-col gap-0.5\", className)}\n        data-slot=\"sidebar-menu\"\n        ref={containerRef}\n        {...hover.handlers}\n        {...props}\n      >\n        <FluidHoverHighlight\n          className=\"z-0 rounded-md bg-foreground/5\"\n          hover={hover}\n        />\n        {children}\n      </ul>\n    </FluidHoverListContext.Provider>\n  );\n};\n\nexport const SidebarMenuItem = ({\n  children,\n  className,\n  ...props\n}: SidebarMenuItemProps) => {\n  const list = useContext(FluidHoverListContext);\n  const itemRef = useRef<HTMLLIElement>(null);\n  const [index] = useState(() => list?.claimIndex() ?? 0);\n\n  useRegisterFluidHoverItem(\n    list?.registerItem,\n    list ? index : undefined,\n    itemRef\n  );\n\n  return (\n    <li\n      className={cn(\"relative z-10 list-none\", className)}\n      data-slot=\"sidebar-menu-item\"\n      ref={itemRef}\n      {...props}\n    >\n      {children}\n    </li>\n  );\n};\n\n/**\n * Text that hides when the sidebar collapses. It fades out with the width and\n * only then becomes `sr-only`, so it stays readable to assistive tech and does\n * not pop out while the rail is still narrowing.\n */\nexport const SidebarLabel = ({\n  children,\n  className,\n  ...props\n}: SidebarLabelProps) => {\n  const { collapsed } = useSidebar();\n  const shouldReduceMotion = useReducedMotion();\n  const [fadedOut, setFadedOut] = useState(collapsed);\n  const [wasCollapsed, setWasCollapsed] = useState(collapsed);\n  // On expand the label starts transparent and fades in while the rail widens.\n  const [entering, setEntering] = useState(false);\n\n  if (wasCollapsed !== collapsed) {\n    setWasCollapsed(collapsed);\n    setEntering(!(collapsed || shouldReduceMotion));\n  }\n\n  useEffect(() => {\n    if (!entering) {\n      return;\n    }\n    // Two frames so the transparent state is painted before it is cleared.\n    let inner = 0;\n    const outer = requestAnimationFrame(() => {\n      inner = requestAnimationFrame(() => setEntering(false));\n    });\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [entering]);\n\n  useEffect(() => {\n    if (!collapsed) {\n      setFadedOut(false);\n      return;\n    }\n    const timer = setTimeout(() => setFadedOut(true), LABEL_FADE_MS);\n    return () => clearTimeout(timer);\n  }, [collapsed]);\n\n  const hidden = collapsed && (shouldReduceMotion || fadedOut);\n\n  return (\n    <span\n      className={cn(\n        hidden ? \"sr-only\" : \"truncate\",\n        !shouldReduceMotion &&\n          \"transition-opacity duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n        ((collapsed && !hidden) || entering) && \"opacity-0\",\n        className\n      )}\n      data-slot=\"sidebar-label\"\n      {...props}\n    >\n      {children}\n    </span>\n  );\n};\n\nexport const SidebarMenuButton = ({\n  \"aria-label\": ariaLabel,\n  active = false,\n  children,\n  className,\n  icon,\n  ...props\n}: SidebarMenuButtonProps) => {\n  const { collapsed } = useSidebar();\n  const textLabel =\n    typeof children === \"string\" || typeof children === \"number\"\n      ? String(children)\n      : undefined;\n\n  return (\n    <button\n      aria-current={active ? \"page\" : undefined}\n      aria-label={ariaLabel ?? (collapsed ? textLabel : undefined)}\n      className={cn(\n        \"ease flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n        active\n          ? \"bg-foreground/10 font-medium text-foreground\"\n          : \"text-muted-foreground\",\n        collapsed && \"justify-center px-0\",\n        className\n      )}\n      data-active={active || undefined}\n      data-slot=\"sidebar-menu-button\"\n      type=\"button\"\n      {...props}\n    >\n      {icon ? (\n        <span\n          aria-hidden\n          className=\"flex size-4 shrink-0 items-center justify-center [&_svg]:size-4\"\n          data-slot=\"sidebar-menu-icon\"\n        >\n          {icon}\n        </span>\n      ) : null}\n      {children === null || children === undefined ? null : (\n        <SidebarLabel>{children}</SidebarLabel>\n      )}\n    </button>\n  );\n};\n\nexport const SidebarTrigger = ({\n  children,\n  className,\n  onClick,\n  ...props\n}: SidebarTriggerProps) => {\n  const { collapsed, toggle } = useSidebar();\n\n  return (\n    <button\n      aria-expanded={!collapsed}\n      aria-label={collapsed ? \"Expand sidebar\" : \"Collapse sidebar\"}\n      className={cn(\n        \"inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground text-sm hover:bg-foreground/5 hover:text-foreground\",\n        className\n      )}\n      data-slot=\"sidebar-trigger\"\n      onClick={(event) => {\n        toggle();\n        onClick?.(event);\n      }}\n      type=\"button\"\n      {...props}\n    >\n      {children ?? (collapsed ? \"»\" : \"«\")}\n    </button>\n  );\n};\n","path":"index.tsx","target":"components/smoothui/sidebar/index.tsx","type":"registry:ui"}],"name":"sidebar","registryDependencies":["https://smoothui.dev/r/fluid-hover-highlight.json","https://smoothui.dev/r/lib.json"],"title":"Sidebar","type":"registry:ui"}