{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-sidebar",
  "type": "registry:component",
  "title": "AI Sidebar",
  "description": "Slide-out AI assistant panel with provider, header / content / footer slots, and a standalone trigger.",
  "dependencies": [
    "@vllnt/ui@^0.2.1",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/ai-sidebar/ai-sidebar.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentPropsWithoutRef,\n  createContext,\n  forwardRef,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\n\nimport { Bot, MessageSquarePlus, X } from \"lucide-react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Button } from \"@vllnt/ui\";\n\nconst DEFAULT_WIDTH = 400;\nconst MIN_WIDTH = 280;\nconst MAX_WIDTH = 720;\n\n/**\n * Side of the viewport the sidebar attaches to.\n *\n * @public\n */\nexport type AISidebarPosition = \"left\" | \"right\";\n\n/**\n * Localizable strings for {@link AISidebar} subcomponents.\n *\n * @public\n */\nexport type AISidebarLabels = {\n  /** Aria-label for the close control. Defaults to `\"Close assistant\"`. */\n  close?: string;\n  /** Default heading text for {@link AISidebarTitle}. */\n  defaultTitle?: string;\n  /** Aria-label for the open trigger. Defaults to `\"Open AI assistant\"`. */\n  open?: string;\n};\n\nconst DEFAULT_LABELS = {\n  close: \"Close assistant\",\n  defaultTitle: \"AI Assistant\",\n  open: \"Open AI assistant\",\n} as const satisfies Required<AISidebarLabels>;\n\ntype AISidebarContextValue = {\n  close: () => void;\n  labels: Required<AISidebarLabels>;\n  open: () => void;\n  openState: boolean;\n  position: AISidebarPosition;\n  setOpen: (next: boolean) => void;\n  toggle: () => void;\n  width: number;\n};\n\nconst NO_OP = (): void => {\n  return;\n};\n\nconst DEFAULT_CONTEXT: AISidebarContextValue = {\n  close: NO_OP,\n  labels: DEFAULT_LABELS,\n  open: NO_OP,\n  openState: false,\n  position: \"right\",\n  setOpen: NO_OP,\n  toggle: NO_OP,\n  width: DEFAULT_WIDTH,\n};\n\nconst AISidebarContext = createContext<AISidebarContextValue>(DEFAULT_CONTEXT);\n\n/**\n * Hook for reading sidebar state from anywhere inside an\n * {@link AISidebarProvider}.\n *\n * @public\n */\nexport function useAISidebar(): AISidebarContextValue {\n  return useContext(AISidebarContext);\n}\n\n/**\n * Props for {@link AISidebarProvider}.\n *\n * @public\n */\nexport type AISidebarProviderProps = {\n  children?: ReactNode;\n  /** Initial open state when uncontrolled. Defaults to `false`. */\n  defaultOpen?: boolean;\n  /** Initial position. Defaults to `\"right\"`. */\n  defaultPosition?: AISidebarPosition;\n  /** Initial width in px. Defaults to `400`. */\n  defaultWidth?: number;\n  /** Localizable strings. */\n  labels?: AISidebarLabels;\n  /** Fires when the open state changes (controlled or uncontrolled). */\n  onOpenChange?: (open: boolean) => void;\n  /** Controlled open state. */\n  open?: boolean;\n};\n\nfunction clampWidth(value: number): number {\n  return Math.min(Math.max(value, MIN_WIDTH), MAX_WIDTH);\n}\n\n/**\n * Provider for the AI sidebar context. Wrap your app shell with this so\n * {@link AISidebar}, {@link AISidebarTrigger}, and {@link useAISidebar}\n * share the same state.\n *\n * @public\n */\nexport function AISidebarProvider({\n  children,\n  defaultOpen = false,\n  defaultPosition = \"right\",\n  defaultWidth = DEFAULT_WIDTH,\n  labels,\n  onOpenChange,\n  open: controlledOpen,\n}: AISidebarProviderProps): ReactNode {\n  const resolvedLabels = useMemo(\n    () => ({ ...DEFAULT_LABELS, ...labels }),\n    [labels],\n  );\n  const [uncontrolled, setUncontrolled] = useState(defaultOpen);\n  const isControlled = controlledOpen !== undefined;\n  const openState = isControlled ? controlledOpen : uncontrolled;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!isControlled) setUncontrolled(next);\n      onOpenChange?.(next);\n    },\n    [isControlled, onOpenChange],\n  );\n\n  const open = useCallback(() => {\n    setOpen(true);\n  }, [setOpen]);\n  const close = useCallback(() => {\n    setOpen(false);\n  }, [setOpen]);\n  const toggle = useCallback(() => {\n    setOpen(!openState);\n  }, [openState, setOpen]);\n\n  const value = useMemo<AISidebarContextValue>(\n    () => ({\n      close,\n      labels: resolvedLabels,\n      open,\n      openState,\n      position: defaultPosition,\n      setOpen,\n      toggle,\n      width: clampWidth(defaultWidth),\n    }),\n    [\n      close,\n      defaultPosition,\n      defaultWidth,\n      open,\n      openState,\n      resolvedLabels,\n      setOpen,\n      toggle,\n    ],\n  );\n\n  return (\n    <AISidebarContext.Provider value={value}>\n      {children}\n    </AISidebarContext.Provider>\n  );\n}\n\n/**\n * Props for {@link AISidebar}.\n *\n * @public\n */\nexport type AISidebarProps = {\n  /** When true, pressing Escape closes the sidebar. Defaults to `true`. */\n  closeOnEscape?: boolean;\n} & ComponentPropsWithoutRef<\"aside\">;\n\nfunction useEscapeToClose(\n  enabled: boolean,\n  isOpen: boolean,\n  onClose: () => void,\n): void {\n  useEffect(() => {\n    if (!enabled || !isOpen) return;\n    const handler = (event: KeyboardEvent): void => {\n      if (event.key === \"Escape\") onClose();\n    };\n    document.addEventListener(\"keydown\", handler);\n\n    return () => {\n      document.removeEventListener(\"keydown\", handler);\n    };\n  }, [enabled, isOpen, onClose]);\n}\n\n/**\n * Slide-out AI assistant panel anchored to the left or right edge. Renders\n * an `<aside role=\"complementary\">` so screen readers announce it as a\n * complementary region. Sets `aria-hidden` on close so its content is\n * skipped by assistive tech.\n *\n * @example\n * ```tsx\n * <AISidebarProvider defaultOpen={false}>\n *   <AISidebar>\n *     <AISidebarHeader>\n *       <AISidebarTitle>AI Assistant</AISidebarTitle>\n *       <AISidebarClose />\n *     </AISidebarHeader>\n *     <AISidebarContent>{messages}</AISidebarContent>\n *     <AISidebarFooter>{composer}</AISidebarFooter>\n *   </AISidebar>\n *   <AISidebarTrigger />\n *   <main>{children}</main>\n * </AISidebarProvider>\n * ```\n *\n * @public\n */\nexport const AISidebar = forwardRef<HTMLElement, AISidebarProps>(\n  (props, ref) => {\n    const { children, className, closeOnEscape = true, ...rest } = props;\n    const { close, labels, openState, position, width } = useAISidebar();\n    useEscapeToClose(closeOnEscape, openState, close);\n\n    return (\n      <aside\n        aria-hidden={!openState}\n        aria-label={labels.defaultTitle}\n        className={cn(\n          \"fixed top-0 z-40 flex h-full flex-col border-border bg-background shadow-lg transition-transform duration-200 ease-out\",\n          position === \"right\" ? \"right-0 border-l\" : \"left-0 border-r\",\n          openState\n            ? \"translate-x-0\"\n            : position === \"right\"\n              ? \"translate-x-full\"\n              : \"-translate-x-full\",\n          \"max-w-full\",\n          className,\n        )}\n        data-state={openState ? \"open\" : \"closed\"}\n        ref={ref}\n        style={{ width: `${width.toString()}px` }}\n        {...rest}\n      >\n        {children}\n      </aside>\n    );\n  },\n);\nAISidebar.displayName = \"AISidebar\";\n\n/**\n * Header slot for an {@link AISidebar}. Use to host the title, model\n * selector, and the close control.\n *\n * @public\n */\nexport const AISidebarHeader = forwardRef<\n  HTMLElement,\n  ComponentPropsWithoutRef<\"header\">\n>(({ children, className, ...rest }, ref) => (\n  <header\n    className={cn(\n      \"flex items-center gap-2 border-b border-border px-4 py-3\",\n      className,\n    )}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </header>\n));\nAISidebarHeader.displayName = \"AISidebarHeader\";\n\n/**\n * Title slot for {@link AISidebarHeader}. Defaults to the localized title\n * from the provider whenever the consumer omits children.\n *\n * @public\n */\nexport const AISidebarTitle = forwardRef<\n  HTMLHeadingElement,\n  ComponentPropsWithoutRef<\"h2\">\n>(({ children, className, ...rest }, ref) => {\n  const { labels } = useAISidebar();\n  return (\n    <h2\n      className={cn(\n        \"flex flex-1 items-center gap-2 text-sm font-semibold tracking-tight text-foreground\",\n        className,\n      )}\n      ref={ref}\n      {...rest}\n    >\n      <Bot aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n      {children ?? labels.defaultTitle}\n    </h2>\n  );\n});\nAISidebarTitle.displayName = \"AISidebarTitle\";\n\n/**\n * Close-button slot for {@link AISidebarHeader}.\n *\n * @public\n */\nexport const AISidebarClose = forwardRef<\n  HTMLButtonElement,\n  Omit<ComponentPropsWithoutRef<\"button\">, \"type\">\n>(({ className, onClick, ...rest }, ref) => {\n  const { close, labels } = useAISidebar();\n  const handleClick = useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      close();\n    },\n    [close, onClick],\n  );\n  return (\n    <Button\n      aria-label={labels.close}\n      className={cn(\"size-8\", className)}\n      onClick={handleClick}\n      ref={ref}\n      size=\"icon\"\n      type=\"button\"\n      variant=\"ghost\"\n      {...rest}\n    >\n      <X aria-hidden=\"true\" className=\"size-4\" />\n    </Button>\n  );\n});\nAISidebarClose.displayName = \"AISidebarClose\";\n\n/**\n * Scrollable middle section of {@link AISidebar}.\n *\n * @public\n */\nexport const AISidebarContent = forwardRef<\n  HTMLDivElement,\n  ComponentPropsWithoutRef<\"div\">\n>(({ children, className, ...rest }, ref) => (\n  <div\n    className={cn(\"flex flex-1 flex-col gap-2 overflow-y-auto p-4\", className)}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </div>\n));\nAISidebarContent.displayName = \"AISidebarContent\";\n\n/**\n * Bottom slot of {@link AISidebar}, typically the chat composer.\n *\n * @public\n */\nexport const AISidebarFooter = forwardRef<\n  HTMLElement,\n  ComponentPropsWithoutRef<\"footer\">\n>(({ children, className, ...rest }, ref) => (\n  <footer\n    className={cn(\"border-t border-border bg-background px-4 py-3\", className)}\n    ref={ref}\n    {...rest}\n  >\n    {children}\n  </footer>\n));\nAISidebarFooter.displayName = \"AISidebarFooter\";\n\n/**\n * Props for {@link AISidebarTrigger}.\n *\n * @public\n */\nexport type AISidebarTriggerProps = Omit<\n  ComponentPropsWithoutRef<\"button\">,\n  \"type\"\n>;\n\n/**\n * Standalone control that opens the sidebar. Place anywhere inside an\n * {@link AISidebarProvider}. Falls back to the default icon + label when\n * the consumer omits children.\n *\n * @public\n */\nexport const AISidebarTrigger = forwardRef<\n  HTMLButtonElement,\n  AISidebarTriggerProps\n>(({ children, className, onClick, ...rest }, ref) => {\n  const { labels, openState, toggle } = useAISidebar();\n  const handleClick = useCallback(\n    (event: React.MouseEvent<HTMLButtonElement>) => {\n      onClick?.(event);\n      if (event.defaultPrevented) return;\n      toggle();\n    },\n    [onClick, toggle],\n  );\n  return (\n    <Button\n      aria-expanded={openState}\n      aria-label={children ? undefined : labels.open}\n      className={cn(className)}\n      data-state={openState ? \"open\" : \"closed\"}\n      onClick={handleClick}\n      ref={ref}\n      size={children ? \"sm\" : \"icon\"}\n      type=\"button\"\n      variant=\"outline\"\n      {...rest}\n    >\n      {children ?? <MessageSquarePlus aria-hidden=\"true\" className=\"size-4\" />}\n    </Button>\n  );\n});\nAISidebarTrigger.displayName = \"AISidebarTrigger\";\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
