{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sidebar",
  "title": "Sidebar",
  "description": "The account navigation: a collapsible rail on desktop and a drawer on mobile, with grouped items and a footer.",
  "dependencies": [
    "@base-ui/react@^1.9.0"
  ],
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/theme.json",
    "https://sproutboat.com/ikada/r/cn.json",
    "https://sproutboat.com/ikada/r/icons.json",
    "https://sproutboat.com/ikada/r/portal.json",
    "https://sproutboat.com/ikada/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/ikada/ui/sidebar.tsx",
      "content": "\"use client\";\n\nimport { Dialog as BaseDialog } from \"@base-ui/react/dialog\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { createContext, useCallback, useContext, useMemo, useState, useSyncExternalStore } from \"react\";\nimport type { ComponentProps, MouseEvent, ReactNode } from \"react\";\nimport { cn, coarseTarget, focusRing, pressMotion, type WithClass } from \"@/components/ikada/lib/cn\";\nimport { MenuIcon, SidebarIcon } from \"@/components/ikada/lib/icons\";\nimport { usePortalContainer } from \"@/components/ikada/lib/portal\";\nimport { Tooltip } from \"@/components/ikada/tooltip\";\n\nconst DESKTOP = \"(min-width: 48rem)\";\nconst subscribe = (cb: () => void) => {\n  const mq = globalThis.matchMedia?.(DESKTOP);\n  if (!mq) return () => {};\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n};\nconst snapshot = () => globalThis.matchMedia?.(DESKTOP).matches ?? true;\n/** True from 768px up. Server and tests without matchMedia count as desktop. */\nexport function useIsDesktop(): boolean {\n  return useSyncExternalStore(subscribe, snapshot, () => true);\n}\n\ninterface SidebarContextValue {\n  rail: boolean;\n  drawer: boolean;\n  closeDrawer: () => void;\n}\nconst SidebarContext = createContext<SidebarContextValue>({ rail: false, drawer: false, closeDrawer: () => {} });\n\nexport interface SidebarProps extends Omit<ComponentProps<\"aside\">, \"children\"> {\n  /** Sidebar content: a header, nav and footer. */\n  children: ReactNode;\n  /** `static` is always a column, `drawer` is always an overlay, `auto` switches at 768px. */\n  mode?: \"auto\" | \"static\" | \"drawer\";\n  /** Icon-only rail (desktop only). Controlled. */\n  collapsed?: boolean;\n  /** Mobile drawer state. Controlled. */\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Accessible name of the drawer dialog. */\n  drawerLabel?: string;\n}\n\n/**\n * The account navigation. On desktop a 240px column (or a 56px icon rail); on mobile a\n * modal drawer that traps focus, closes on Escape or scrim click and returns focus to the\n * trigger. Only one copy of the content is in the DOM at a time.\n */\nexport function Sidebar({\n  children,\n  mode = \"auto\",\n  collapsed = false,\n  open = false,\n  onOpenChange,\n  drawerLabel = \"Navigation\",\n  className,\n  ref,\n  ...props\n}: SidebarProps) {\n  const isDesktop = useIsDesktop();\n  const drawer = mode === \"drawer\" || (mode === \"auto\" && !isDesktop);\n  const container = usePortalContainer();\n  const closeDrawer = useCallback(() => onOpenChange?.(false), [onOpenChange]);\n  const drawerContext = useMemo(() => ({ rail: false, drawer: true, closeDrawer }), [closeDrawer]);\n  const columnContext = useMemo(() => ({ rail: collapsed, drawer: false, closeDrawer }), [collapsed, closeDrawer]);\n\n  if (drawer) {\n    const place = container ? \"absolute\" : \"fixed\";\n    return (\n      <BaseDialog.Root open={open} onOpenChange={(next) => onOpenChange?.(next)} modal>\n        <BaseDialog.Portal container={container}>\n          <BaseDialog.Backdrop\n            className={cn(\n              place,\n              \"inset-0 z-(--sb-z-overlay) bg-scrim\",\n              \"transition-opacity duration-(--sb-duration-dialog-out) ease-out data-open:duration-(--sb-duration-dialog-in)\",\n              \"data-starting-style:opacity-0 data-ending-style:opacity-0\",\n            )}\n          />\n          <BaseDialog.Popup\n            className={cn(\n              place,\n              \"inset-y-0 start-0 z-(--sb-z-modal) flex w-72 max-w-[85%] flex-col bg-surface text-fg shadow-dialog outline-hidden\",\n              // Slides in on the smooth spring, out in 150ms ease-out.\n              \"[transition:translate_var(--sb-duration-spring-smooth)_var(--sb-spring-smooth),opacity_var(--sb-duration-dialog-in)_var(--ease-out)]\",\n              \"data-ending-style:[transition:translate_var(--sb-duration-dialog-out)_var(--ease-out),opacity_var(--sb-duration-dialog-out)_var(--ease-out)]\",\n              \"data-starting-style:-translate-x-[calc(100%*var(--sb-move))] data-starting-style:opacity-0\",\n              \"data-ending-style:-translate-x-[calc(100%*var(--sb-move))] data-ending-style:opacity-0\",\n              className,\n            )}\n          >\n            <BaseDialog.Title className=\"sr-only\">{drawerLabel}</BaseDialog.Title>\n            <SidebarContext value={drawerContext}>\n              <div className=\"flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-3\">{children}</div>\n            </SidebarContext>\n          </BaseDialog.Popup>\n        </BaseDialog.Portal>\n      </BaseDialog.Root>\n    );\n  }\n\n  return (\n    <aside\n      data-slot=\"sidebar\"\n      ref={ref}\n      data-collapsed={collapsed || undefined}\n      className={cn(\n        \"flex h-full shrink-0 flex-col gap-1 overflow-y-auto overflow-x-hidden bg-surface p-3 text-fg shadow-[inset_-1px_0_0_var(--sb-hairline)]\",\n        \"w-60 transition-[width] duration-(--sb-duration-slide) ease-in-out motion-reduce:transition-none\",\n        collapsed && \"w-14\",\n        className,\n      )}\n      {...props}\n    >\n      <SidebarContext value={columnContext}>{children}</SidebarContext>\n    </aside>\n  );\n}\n\n/** Top slot of the sidebar: the product mark or a project switcher. */\nexport function SidebarHeader({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div data-slot=\"sidebar-header\" className={cn(\"mb-2 flex items-center gap-2 px-1 pt-1\", className)} {...props} />\n  );\n}\n\n/** Bottom slot of the sidebar: account, theme or the rail toggle. */\nexport function SidebarFooter({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-footer\"\n      className={cn(\"mt-auto flex flex-col gap-1 border-t border-hairline pt-3\", className)}\n      {...props}\n    />\n  );\n}\n\n/** A flat list of links. Give each group a `label`; there is no nesting. */\nexport function SidebarNav({\n  label = \"Account\",\n  className,\n  children,\n  ...props\n}: ComponentProps<\"nav\"> & { label?: string }) {\n  return (\n    <nav data-slot=\"sidebar-nav\" aria-label={label} className={className} {...props}>\n      <ul className=\"flex flex-col gap-0.5\">{children}</ul>\n    </nav>\n  );\n}\n\nexport interface SidebarItemProps extends WithClass<Omit<useRender.ComponentProps<\"a\">, \"children\">> {\n  /** Plain destination. With a router, leave it out and pass `render={<RouterLink to=\"/x\" />}`. */\n  href?: string;\n  /** Icon before the label. */\n  icon?: ReactNode;\n  /** Marks the page you are on: `aria-current=\"page\"` and a raised fill. */\n  current?: boolean;\n  /** Trailing text or a Badge, hidden in the rail. */\n  trailing?: ReactNode;\n  /** The item's label. */\n  children: string;\n}\n\n/** In the rail the label hides visually (it stays the accessible name) and a tooltip shows it. */\nexport function SidebarItem({\n  icon,\n  current = false,\n  trailing,\n  className,\n  children,\n  onClick,\n  ref,\n  render,\n  ...props\n}: SidebarItemProps) {\n  const { rail, drawer, closeDrawer } = useContext(SidebarContext);\n  const link = useRender({\n    defaultTagName: \"a\",\n    render,\n    ref,\n    props: {\n      \"aria-current\": current ? \"page\" : undefined,\n      onClick: (e: MouseEvent<HTMLAnchorElement>) => {\n        onClick?.(e);\n        if (drawer) closeDrawer();\n      },\n      className: cn(\n        \"flex h-9 items-center gap-3 overflow-hidden whitespace-nowrap rounded-md px-2 text-sb-base font-medium text-fg-muted\",\n        \"transition-colors duration-(--sb-duration-hover) ease-hover hover:bg-hover hover:text-fg\",\n        \"aria-[current=page]:bg-hover aria-[current=page]:text-fg aria-[current=page]:shadow-pill\",\n        \"pointer-coarse:h-11\",\n        focusRing,\n        className,\n      ),\n      ...props,\n      children: (\n        <>\n          {icon}\n          <span\n            className={cn(\n              \"min-w-0 flex-1 truncate transition-opacity duration-(--sb-duration-hover) ease-out motion-reduce:transition-none\",\n              rail && \"opacity-0\",\n            )}\n            title={rail ? undefined : children}\n          >\n            {children}\n          </span>\n          {rail ? null : trailing}\n        </>\n      ),\n    },\n  });\n  return (\n    <li data-slot=\"sidebar-item\">\n      {rail ? (\n        <Tooltip content={children} side=\"end\">\n          {link}\n        </Tooltip>\n      ) : (\n        link\n      )}\n    </li>\n  );\n}\n\n/** Collapses the desktop column to a rail. Renders nothing in the drawer. */\nexport function SidebarToggle({\n  collapsed,\n  onCollapsedChange,\n  className,\n  ...props\n}: Omit<ComponentProps<\"button\">, \"onClick\"> & {\n  collapsed: boolean;\n  onCollapsedChange: (collapsed: boolean) => void;\n}) {\n  const { drawer } = useContext(SidebarContext);\n  if (drawer) return null;\n  const label = collapsed ? \"Expand sidebar\" : \"Collapse sidebar\";\n  return (\n    <Tooltip content={label} side=\"end\">\n      <button\n        type=\"button\"\n        aria-label={label}\n        aria-expanded={!collapsed}\n        onClick={() => onCollapsedChange(!collapsed)}\n        className={cn(\n          \"inline-grid size-8 shrink-0 place-items-center rounded-md text-fg-muted hover:bg-hover hover:text-fg\",\n          pressMotion,\n          coarseTarget,\n          focusRing,\n          className,\n        )}\n        {...props}\n      >\n        <SidebarIcon />\n      </button>\n    </Tooltip>\n  );\n}\n\n/** The button that opens the drawer: show it where the sidebar is not a column. */\nexport function SidebarTrigger({\n  className,\n  label = \"Open navigation\",\n  ...props\n}: ComponentProps<\"button\"> & { label?: string }) {\n  return (\n    <button\n      data-slot=\"sidebar-trigger\"\n      type=\"button\"\n      aria-label={label}\n      aria-haspopup=\"dialog\"\n      className={cn(\n        \"inline-grid size-9 place-items-center rounded-md text-fg-muted hover:bg-hover hover:text-fg\",\n        pressMotion,\n        coarseTarget,\n        focusRing,\n        className,\n      )}\n      {...props}\n    >\n      <MenuIcon />\n    </button>\n  );\n}\n\n/** Convenience state for apps that do not need to control the sidebar elsewhere. */\nexport function useSidebarState(initialCollapsed = false) {\n  const [collapsed, setCollapsed] = useState(initialCollapsed);\n  const [open, setOpen] = useState(false);\n  return { collapsed, setCollapsed, open, setOpen };\n}\n",
      "type": "registry:ui",
      "target": "@components/ikada/sidebar.tsx"
    }
  ],
  "docs": "Wrap the app shell in the sidebar's state hook as shown in the docs; on mobile it becomes a drawer.",
  "categories": [
    "navigation",
    "layout"
  ],
  "type": "registry:ui"
}