{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sidebar",
  "type": "registry:component",
  "title": "Sidebar",
  "description": "Wibo mark header, sections, nav items with a sliding hover and active highlight and a pending state, groups whose nested items fold under a row that links to its page, and the footer rows.",
  "dependencies": [
    "radix-ui",
    "lucide-react"
  ],
  "registryDependencies": [
    "@wibo/utils",
    "@wibo/use-sliding-indicator",
    "@wibo/tooltip",
    "@wibo/app-shell",
    "@wibo/wibo-logo"
  ],
  "files": [
    {
      "path": "src/shell/sidebar.tsx",
      "type": "registry:component",
      "content": "import { Children, isValidElement, useId, useState, type ComponentProps, type ReactNode } from \"react\";\nimport { Collapsible } from \"radix-ui\";\nimport { ChevronRight, LoaderCircle, Menu, PanelLeft, type LucideIcon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { indicatorClass, useSlidingIndicator } from \"@/lib/use-sliding-indicator\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\nimport { useAppShell, useSidebarPlacement, type LinkComponent } from \"@/components/app-shell\";\nimport { WiboMark } from \"@/components/wibo-logo\";\n\n/*\n * Recipes follow the Mastra Studio sidebar (mastra-ai/mastra,\n * packages/playground-ui/src/ds/components/Sidebar, Apache-2.0): 8 px panel\n * inset, 40 px header, 12 px section labels, 30 px rows with 13 px medium\n * labels, two tones only (quiet rows in muted text, the current row in ink),\n * icons inheriting the row colour.\n *\n * Highlights are not painted per row: `SidebarContent` and `SidebarFooter`\n * each hold one hover indicator that slides from row to row under the\n * pointer, and one active indicator that glides to the current row when the\n * route changes, or to the clicked row while its navigation is pending. A\n * row is the element marked `data-sidebar-row`: the link itself, or for a\n * `SidebarGroup` with a page the box holding its link and its chevron.\n */\n\n/** True when the desktop sidebar is reduced to icons (never inside the mobile sheet). */\nfunction useRail(): boolean {\n  const { collapsed } = useAppShell();\n  const placement = useSidebarPlacement();\n  return collapsed && placement === \"desktop\";\n}\n\n/** Left navigation column. Compose `SidebarHeader`, `SidebarContent` with sections, and `SidebarFooter`. */\nfunction Sidebar({ className, ...props }: ComponentProps<\"div\">) {\n  const rail = useRail();\n  return (\n    <div\n      data-slot=\"sidebar\"\n      data-rail={rail || undefined}\n      className={cn(\"flex h-full w-full min-w-0 flex-col bg-sidebar p-2 text-sidebar-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\ninterface SidebarHeaderProps extends ComponentProps<\"div\"> {\n  /**\n   * Leading brand mark. Defaults to the Wibo mark (decorative: the product\n   * name next to it is the accessible label). Pass `null` to hide it.\n   */\n  logo?: ReactNode;\n}\n\n/**\n * Brand row: the Wibo mark, then the product or workspace name and a\n * `SidebarToggle` as children. On the icon rail only the mark and the toggle\n * stay visible; the other children remain for screen readers.\n */\nfunction SidebarHeader({ logo, className, children, ...props }: SidebarHeaderProps) {\n  const rail = useRail();\n  const mark = logo === undefined ? <WiboMark alt=\"\" /> : logo;\n  return (\n    <div\n      data-slot=\"sidebar-header\"\n      className={cn(\n        \"mb-1 flex h-10 shrink-0 items-center gap-2 pr-0.5 pl-2 text-sm font-medium tracking-tight\",\n        rail &&\n          \"h-auto flex-col justify-center gap-2 px-0 py-1.5 [&>:not([data-slot=sidebar-logo]):not([data-slot=sidebar-toggle])]:sr-only\",\n        className,\n      )}\n      {...props}\n    >\n      {mark !== null ? (\n        <span data-slot=\"sidebar-logo\" className=\"flex shrink-0 items-center\">\n          {mark}\n        </span>\n      ) : null}\n      {children}\n    </div>\n  );\n}\n\n/** Selectors shared by the two indicator scopes. */\nconst ROW = \"[data-sidebar-row]\";\nconst ACTIVE_ROW = \"[data-sidebar-row][data-active=true]\";\nconst PENDING_ROW = \"[data-sidebar-row]:has([data-slot=sidebar-item-pending])\";\n\n/** The hover and active indicators of one scrolling list of rows. */\nfunction useRowIndicators<T extends HTMLElement>() {\n  const indicator = useSlidingIndicator<T>({ item: ROW, active: ACTIVE_ROW, pending: PENDING_ROW });\n  const layers = (\n    <>\n      <span\n        ref={indicator.activeRef}\n        aria-hidden=\"true\"\n        data-slot=\"sidebar-active-indicator\"\n        className={cn(indicatorClass, \"rounded-lg bg-sidebar-active\")}\n      />\n      <span\n        ref={indicator.hoverRef}\n        aria-hidden=\"true\"\n        data-slot=\"sidebar-hover-indicator\"\n        className={cn(indicatorClass, \"rounded-lg bg-sidebar-hover duration-150\")}\n      />\n    </>\n  );\n  return { ...indicator, layers };\n}\n\n/** The scrolling navigation landmark. */\nfunction SidebarContent({\n  className,\n  children,\n  \"aria-label\": ariaLabel = \"Main\",\n  ...props\n}: ComponentProps<\"nav\">) {\n  const { containerRef, handlers, layers } = useRowIndicators<HTMLElement>();\n  return (\n    <nav\n      ref={containerRef}\n      data-slot=\"sidebar-content\"\n      data-indicator=\"off\"\n      aria-label={ariaLabel}\n      className={cn(\"relative isolate flex min-h-0 flex-1 flex-col overflow-y-auto pb-2\", className)}\n      {...handlers}\n      {...props}\n    >\n      {layers}\n      {children}\n    </nav>\n  );\n}\n\n/**\n * Bottom of the sidebar, pinned under the scrolling navigation: secondary\n * rows (Settings, Help) in a `SidebarList`, then the `SidebarAccount` row,\n * which draws the hairline that sets the signed-in person apart from the\n * navigation. Rows here share their own sliding highlight.\n */\nfunction SidebarFooter({ className, children, ...props }: ComponentProps<\"div\">) {\n  const { containerRef, handlers, layers } = useRowIndicators<HTMLDivElement>();\n  return (\n    <div\n      ref={containerRef}\n      data-slot=\"sidebar-footer\"\n      data-indicator=\"off\"\n      className={cn(\"relative isolate mt-auto flex shrink-0 flex-col gap-2\", className)}\n      {...handlers}\n      {...props}\n    >\n      {layers}\n      {children}\n    </div>\n  );\n}\n\nfunction SidebarSeparator({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"sidebar-separator\"\n      role=\"separator\"\n      className={cn(\"mx-3 my-2 h-px bg-sidebar-border\", className)}\n      {...props}\n    />\n  );\n}\n\n/** A plain list of rows, for `SidebarFooter` or a section of your own. */\nfunction SidebarList({ className, ...props }: ComponentProps<\"ul\">) {\n  return <ul data-slot=\"sidebar-list\" className={cn(\"flex flex-col gap-0.5\", className)} {...props} />;\n}\n\ninterface SidebarSectionProps extends Omit<ComponentProps<\"div\">, \"children\"> {\n  /** Section label, for example \"Observability\". Omit for an unlabeled first section. */\n  label?: ReactNode;\n  /** Let the user fold the section by clicking its label. */\n  collapsible?: boolean;\n  defaultOpen?: boolean;\n  children: ReactNode;\n}\n\nconst sectionLabelClass = \"flex min-h-7 w-full items-center px-3 text-xs font-medium text-muted-foreground\";\n\n/** Labeled group of nav items. On the icon rail the label becomes a hairline. */\nfunction SidebarSection({\n  label,\n  collapsible = false,\n  defaultOpen = true,\n  className,\n  children,\n  ...props\n}: SidebarSectionProps) {\n  const rail = useRail();\n  const labelId = useId();\n  const [open, setOpen] = useState(defaultOpen);\n  const list = <SidebarList>{children}</SidebarList>;\n\n  if (rail) {\n    return (\n      <div\n        data-slot=\"sidebar-section\"\n        role=\"group\"\n        aria-labelledby={label ? labelId : undefined}\n        className={className}\n        {...props}\n      >\n        {label ? (\n          <div className=\"flex h-5 items-center\">\n            <span id={labelId} className=\"sr-only\">\n              {label}\n            </span>\n            <span aria-hidden=\"true\" className=\"mx-3 h-px flex-1 bg-sidebar-border\" />\n          </div>\n        ) : null}\n        {list}\n      </div>\n    );\n  }\n\n  if (collapsible && label) {\n    return (\n      <Collapsible.Root open={open} onOpenChange={setOpen} asChild>\n        <div data-slot=\"sidebar-section\" role=\"group\" aria-labelledby={labelId} className={cn(\"mt-3\", className)} {...props}>\n          <Collapsible.Trigger\n            id={labelId}\n            className={cn(\n              sectionLabelClass,\n              \"group/section justify-between rounded-lg transition-[color] duration-150 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\",\n            )}\n          >\n            {label}\n            <ChevronRight\n              aria-hidden=\"true\"\n              className=\"size-3.5 transition-transform duration-200 ease-glide group-data-[state=open]/section:rotate-90\"\n            />\n          </Collapsible.Trigger>\n          <Collapsible.Content>{list}</Collapsible.Content>\n        </div>\n      </Collapsible.Root>\n    );\n  }\n\n  return (\n    <div\n      data-slot=\"sidebar-section\"\n      role=\"group\"\n      aria-labelledby={label ? labelId : undefined}\n      className={cn(label && \"mt-3\", className)}\n      {...props}\n    >\n      {label ? (\n        <div id={labelId} className={sectionLabelClass}>\n          {label}\n        </div>\n      ) : null}\n      {list}\n    </div>\n  );\n}\n\n/*\n * A row is a control: 30 px. Its highlight comes from the list's sliding\n * indicators; the `in-data-[indicator=off]` fallbacks paint it until the\n * indicators have measured (server render, no script). A pressed row settles\n * to 99 %; a row whose navigation is pending turns to ink at once.\n */\nconst itemClass =\n  \"press-item group/item relative flex h-7.5 w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg px-3 text-label font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-[active=true]:text-foreground has-[>[data-slot=sidebar-item-pending]]:text-foreground in-data-[indicator=off]:hover:bg-sidebar-hover in-data-[indicator=off]:data-[active=true]:bg-sidebar-active [&_svg]:size-4 [&_svg]:shrink-0\";\n\nfunction renderIcon(icon: LucideIcon | ReactNode) {\n  if (icon === undefined || icon === null) return null;\n  if (isValidElement(icon)) return icon;\n  if (typeof icon === \"function\" || (typeof icon === \"object\" && \"render\" in (icon as object))) {\n    const Icon = icon as LucideIcon;\n    return <Icon aria-hidden=\"true\" />;\n  }\n  return icon as ReactNode;\n}\n\n/**\n * Rendered inside a row's link: asks the shell's `useLinkStatus` whether this\n * link's navigation is pending and, while it is, leaves a marker the active\n * indicator moves to and a small spinner at the end of the row.\n */\nfunction SidebarItemPending({ rail }: { rail: boolean }) {\n  const { useLinkStatus } = useAppShell();\n  const { pending } = useLinkStatus();\n  if (!pending) return null;\n  return (\n    <span\n      data-slot=\"sidebar-item-pending\"\n      aria-hidden=\"true\"\n      className={cn(\n        \"flex shrink-0 animate-[wibo-fade-in_150ms_ease-out_100ms_both] items-center text-muted-foreground\",\n        rail ? \"absolute top-1 right-1 [&_svg]:size-2.5!\" : \"ml-auto [&_svg]:size-3.5!\",\n      )}\n    >\n      <LoaderCircle className=\"animate-spin\" />\n    </span>\n  );\n}\n\n/** A row's click: the row's own handler, then the phone navigation sheet closes. */\nfunction useRowClick(onClick?: () => void): () => void {\n  const { setMobileOpen } = useAppShell();\n  const placement = useSidebarPlacement();\n  return () => {\n    onClick?.();\n    if (placement === \"sheet\") setMobileOpen(false);\n  };\n}\n\ninterface RowLinkOptions {\n  href: string;\n  active: boolean;\n  rail: boolean;\n  /** Mark the link as the row the indicators measure; false when a box around it is the row. */\n  row?: boolean;\n  className: string;\n  onClick: () => void;\n  children: ReactNode;\n}\n\n/**\n * A row's link through the shell's `linkComponent`, with its pending marker.\n * A plain function, not a component: the element it returns can be a\n * tooltip's `asChild` trigger.\n */\nfunction rowLink(Link: LinkComponent | \"a\", { href, active, rail, row = true, className, onClick, children }: RowLinkOptions) {\n  return (\n    <Link\n      href={href}\n      data-sidebar-row={row ? \"\" : undefined}\n      data-active={active || undefined}\n      aria-current={active ? \"page\" : undefined}\n      className={className}\n      onClick={onClick}\n    >\n      {children}\n      {Link === \"a\" ? null : <SidebarItemPending rail={rail} />}\n    </Link>\n  );\n}\n\n/** On the icon rail a row shows only its icon; the tooltip names it. */\nfunction RailTooltip({ label, children }: { label: string; children: ReactNode }) {\n  return (\n    <Tooltip>\n      <TooltipTrigger asChild>{children}</TooltipTrigger>\n      <TooltipContent side=\"right\">{label}</TooltipContent>\n    </Tooltip>\n  );\n}\n\ninterface SidebarItemProps {\n  label: string;\n  /** A Lucide icon component (`Home`) or an element. */\n  icon?: LucideIcon | ReactNode;\n  /** Renders a link (through the shell's `linkComponent`). Without it the item is a button. */\n  href?: string;\n  active?: boolean;\n  /** Trailing count or badge. */\n  badge?: ReactNode;\n  onClick?: () => void;\n  className?: string;\n}\n\n/** Navigation item with a Lucide icon and an active state (`aria-current=\"page\"`). */\nfunction SidebarItem({ label, icon, href, active = false, badge, onClick, className }: SidebarItemProps) {\n  const { linkComponent: Link } = useAppShell();\n  const rail = useRail();\n  const handleClick = useRowClick(onClick);\n\n  const content = (\n    <>\n      {renderIcon(icon)}\n      <span className={cn(\"min-w-0 flex-1 truncate text-left\", rail && \"sr-only\")}>{label}</span>\n      {badge && !rail ? (\n        <span className=\"ml-auto font-mono text-xs font-normal text-muted-foreground tabular-nums group-has-[>[data-slot=sidebar-item-pending]]/item:hidden\">\n          {badge}\n        </span>\n      ) : null}\n    </>\n  );\n\n  const classes = cn(itemClass, rail && \"justify-center px-0\", className);\n\n  const element = href ? (\n    rowLink(Link, { href, active, rail, className: classes, onClick: handleClick, children: content })\n  ) : (\n    <button type=\"button\" data-sidebar-row=\"\" data-active={active || undefined} className={classes} onClick={handleClick}>\n      {content}\n    </button>\n  );\n\n  return <li data-slot=\"sidebar-item\">{rail ? <RailTooltip label={label}>{element}</RailTooltip> : element}</li>;\n}\n\ninterface SidebarGroupProps {\n  label: string;\n  icon?: LucideIcon | ReactNode;\n  /**\n   * The section's own page. With it the parent row is a link, and a separate\n   * chevron button at its end shows and hides the nested rows (on hover or\n   * focus of the row with a mouse, always on touch screens). Without it the\n   * whole row shows and hides them.\n   */\n  href?: string;\n  /** The section is current: its own page, or the page of one of its nested rows. */\n  active?: boolean;\n  /**\n   * One of the nested rows is the current page (and is passed `active`\n   * itself). While the group is open that row takes the highlight; closed,\n   * or on the icon rail, the parent row keeps it. Defaults to `active` for a\n   * group without `href`, whose own row is no page.\n   */\n  childActive?: boolean;\n  /** Uncontrolled first state: open for a group with `href`, otherwise open when `active`. */\n  defaultOpen?: boolean;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Accessible names of the chevron button of a group with `href`: \"Show agents\", \"Hide agents\". */\n  expandLabel?: string;\n  collapseLabel?: string;\n  /**\n   * Nested `SidebarItem`s, without icons: indented under the parent's label.\n   * With none, a group with `href` is a plain row with no chevron.\n   */\n  children?: ReactNode;\n  className?: string;\n}\n\n/*\n * The chevron of a group with a page: a small button over the end of the\n * row. With a mouse it shows while the row is hovered or holds focus; on a\n * touch screen, which has no hover, it always shows.\n */\nconst groupToggleClass =\n  \"press-icon absolute top-1/2 right-1 inline-flex size-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none duration-150 hover:bg-sidebar-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 pointer-fine:opacity-0 pointer-fine:group-hover/row:opacity-100 pointer-fine:group-focus-within/row:opacity-100 [&_svg]:size-3.5\";\n\n/**\n * Nav item with nested `SidebarItem`s that fold under it, Radix Collapsible.\n * With `href` the parent row stays a link to the section's page and the\n * chevron button beside it folds the nested rows (ChatGPT and Claude style);\n * without it the row itself folds them. The nested rows slide open and shut\n * with the sidebar's glide. On the icon rail there are no nested rows: a\n * group with `href` is a plain icon row, one without it opens the sidebar\n * and itself.\n */\nfunction SidebarGroup({\n  label,\n  icon,\n  href,\n  active = false,\n  childActive,\n  defaultOpen,\n  open: openProp,\n  onOpenChange,\n  expandLabel = \"Show\",\n  collapseLabel = \"Hide\",\n  children,\n  className,\n}: SidebarGroupProps) {\n  const { linkComponent: Link, setCollapsed } = useAppShell();\n  const rail = useRail();\n  const handleClick = useRowClick();\n  const [internalOpen, setInternalOpen] = useState(defaultOpen ?? (href !== undefined || active));\n  const open = openProp ?? internalOpen;\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n  const nested = Children.toArray(children).length > 0;\n\n  if (href !== undefined && (rail || !nested)) {\n    return <SidebarItem label={label} icon={icon} href={href} active={active} className={className} />;\n  }\n\n  if (rail) {\n    return (\n      <li data-slot=\"sidebar-group\">\n        <RailTooltip label={label}>\n          <button\n            type=\"button\"\n            data-sidebar-row=\"\"\n            data-active={active || undefined}\n            className={cn(itemClass, \"justify-center px-0\", className)}\n            onClick={() => {\n              setCollapsed(false);\n              setOpen(true);\n            }}\n          >\n            {renderIcon(icon)}\n            <span className=\"sr-only\">{label}</span>\n          </button>\n        </RailTooltip>\n      </li>\n    );\n  }\n\n  // The most specific row in view carries the highlight: an open group's current nested row, else the parent.\n  const parentActive = active && !(open && (childActive ?? href === undefined));\n  const chevron = (\n    <ChevronRight\n      aria-hidden=\"true\"\n      className={cn(\"size-3.5 shrink-0 transition-transform duration-200 ease-glide\", open && \"rotate-90\")}\n    />\n  );\n  const labelText = <span className=\"min-w-0 flex-1 truncate text-left\">{label}</span>;\n\n  return (\n    <li data-slot=\"sidebar-group\">\n      <Collapsible.Root open={open} onOpenChange={setOpen}>\n        {href !== undefined ? (\n          <div data-slot=\"sidebar-group-row\" data-sidebar-row=\"\" data-active={parentActive || undefined} className=\"group/row relative\">\n            {rowLink(Link, {\n              href,\n              active: parentActive,\n              rail,\n              row: false,\n              className: cn(itemClass, \"pr-8\", className),\n              onClick: handleClick,\n              children: (\n                <>\n                  {renderIcon(icon)}\n                  {labelText}\n                </>\n              ),\n            })}\n            <Collapsible.Trigger aria-label={open ? collapseLabel : expandLabel} className={groupToggleClass}>\n              {chevron}\n            </Collapsible.Trigger>\n          </div>\n        ) : (\n          <Collapsible.Trigger data-sidebar-row=\"\" data-active={parentActive || undefined} className={cn(itemClass, className)}>\n            {renderIcon(icon)}\n            {labelText}\n            {chevron}\n          </Collapsible.Trigger>\n        )}\n        <Collapsible.Content className=\"overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down\">\n          <ul data-slot=\"sidebar-group-items\" className=\"my-0.5 ml-5 flex flex-col gap-0.5 border-l border-sidebar-border pl-1.5 [&>li>a]:pl-2.25 [&>li>button]:pl-2.25\">\n            {children}\n          </ul>\n        </Collapsible.Content>\n      </Collapsible.Root>\n    </li>\n  );\n}\n\nconst iconButton =\n  \"press-icon inline-flex size-7.5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 [&_svg]:size-4\";\n\n/** Desktop button that collapses the sidebar to an icon rail. */\nfunction SidebarToggle({\n  className,\n  collapseLabel = \"Collapse sidebar\",\n  expandLabel = \"Expand sidebar\",\n}: {\n  className?: string;\n  collapseLabel?: string;\n  expandLabel?: string;\n}) {\n  const { collapsed, toggleCollapsed } = useAppShell();\n  const placement = useSidebarPlacement();\n  if (placement === \"sheet\") return null;\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"sidebar-toggle\"\n      onClick={toggleCollapsed}\n      aria-label={collapsed ? expandLabel : collapseLabel}\n      aria-expanded={!collapsed}\n      className={cn(iconButton, \"hidden hover:bg-sidebar-hover md:inline-flex\", className)}\n    >\n      <PanelLeft aria-hidden=\"true\" />\n    </button>\n  );\n}\n\n/** Mobile menu button that opens the navigation sheet. Placed by the shell's mobile bar. */\nfunction SidebarTrigger({ className, label = \"Open navigation\" }: { className?: string; label?: string }) {\n  const { setMobileOpen } = useAppShell();\n  return (\n    <button\n      type=\"button\"\n      onClick={() => setMobileOpen(true)}\n      aria-label={label}\n      className={cn(iconButton, \"hover:bg-foreground/4 md:hidden\", className)}\n    >\n      <Menu aria-hidden=\"true\" />\n    </button>\n  );\n}\n\nexport {\n  Sidebar,\n  SidebarContent,\n  SidebarFooter,\n  SidebarGroup,\n  SidebarHeader,\n  SidebarItem,\n  SidebarList,\n  SidebarSection,\n  SidebarSeparator,\n  SidebarToggle,\n  SidebarTrigger,\n  useRail,\n};\nexport type { SidebarGroupProps, SidebarHeaderProps, SidebarItemProps, SidebarSectionProps };\n"
    }
  ]
}
