{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "app-sidebar",
  "title": "App Sidebar",
  "description": "Nested navigation with collapsed mode, a mobile disclosure, and active states.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "collapsible",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/components/app-sidebar.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { ChevronDown, Menu, PanelLeftClose, PanelLeftOpen, X } from \"lucide-react\";\nimport {\n  type ComponentProps,\n  createContext,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"@/components/ui/collapsible\";\nimport { cn } from \"@/lib/uai-utils\";\n\nexport const APP_SIDEBAR_VARIANTS = [\"panel\", \"inset\", \"compact\"] as const;\nexport type AppSidebarVariant = (typeof APP_SIDEBAR_VARIANTS)[number];\nexport type AppSidebarProps = Omit<ComponentProps<\"div\">, \"defaultValue\"> & {\n  variant?: AppSidebarVariant;\n  /** The active item value. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  collapsed?: boolean;\n  defaultCollapsed?: boolean;\n  onCollapsedChange?: (collapsed: boolean) => void;\n  /** Mobile disclosure state. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Media query that switches to the mobile disclosure. */\n  mobileQuery?: string;\n};\ntype SidebarContext = {\n  id: string;\n  variant: AppSidebarVariant;\n  value: string;\n  select: (value: string) => void;\n  collapsed: boolean;\n  setCollapsed: (collapsed: boolean) => void;\n  mobile: boolean;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n};\nconst Context = createContext<SidebarContext | null>(null);\nfunction useSidebar(part: string) {\n  const context = useContext(Context);\n  if (!context) throw new Error(`${part} must be used within AppSidebar`);\n  return context;\n}\nconst GroupContext = createContext<string | null>(null);\ntype SubmenuContext = { id: string; open: boolean; setOpen: (open: boolean) => void };\nconst Submenu = createContext<SubmenuContext | null>(null);\nconst ItemContext = createContext(false);\n\nfunction useMediaQuery(query: string) {\n  const [matches, setMatches] = useState(false);\n  useEffect(() => {\n    if (typeof window.matchMedia !== \"function\") return;\n    const media = window.matchMedia(query);\n    const update = () => setMatches(media.matches);\n    update();\n    media.addEventListener?.(\"change\", update);\n    return () => media.removeEventListener?.(\"change\", update);\n  }, [query]);\n  return matches;\n}\nfunction useControllable<T>(value: T | undefined, initial: T, onChange?: (value: T) => void) {\n  const [internal, setInternal] = useState(initial);\n  const current = value ?? internal;\n  const set = (next: T) => {\n    if (Object.is(next, current)) return;\n    if (value === undefined) setInternal(next);\n    onChange?.(next);\n  };\n  return [current, set] as const;\n}\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\nconst mobileReveal =\n  \"animate-in fade-in-0 slide-in-from-top-1 duration-200 ease-out-quint motion-reduce:animate-none\";\nconst easeOut = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n/** Fades a disclosure in when it opens. Skips the first render and reduced motion. */\nfunction useReveal(ref: React.RefObject<HTMLElement | null>, open: boolean) {\n  const mounted = useRef(false);\n  useIsomorphicLayoutEffect(() => {\n    if (!mounted.current) {\n      mounted.current = true;\n      return;\n    }\n    const element = ref.current;\n    if (!open || !element || typeof element.animate !== \"function\") return;\n    if (window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) return;\n    element.animate(\n      [\n        { opacity: 0, transform: \"translateY(-4px)\" },\n        { opacity: 1, transform: \"none\" },\n      ],\n      { duration: 220, easing: easeOut },\n    );\n  }, [open]);\n}\n// Press feedback and focus for rows and icon buttons. Idle rows add their own hover tint.\nconst rowInteraction =\n  \"[transition:background-color_120ms_ease-out,color_120ms_ease-out,scale_140ms_cubic-bezier(0.23,1,0.32,1)] active:scale-[0.985] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring motion-reduce:transition-none motion-reduce:active:scale-100\";\n// Labels fade while the width animates instead of vanishing on the first frame.\nconst labelFade = \"transition-opacity duration-150 ease-out motion-reduce:transition-none\";\nconst rowIdle = \"bg-transparent text-muted-foreground hover:bg-accent/70 hover:text-foreground\";\n\nfunction iconButtonClass(variant: AppSidebarVariant) {\n  return cn(\n    \"grid flex-none cursor-pointer place-items-center border-0 bg-transparent p-0 text-muted-foreground\",\n    \"transition-[background-color,color,scale] duration-140 ease-out-quint hover:bg-accent hover:text-foreground active:scale-[0.97] focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:outline-solid motion-reduce:transition-none motion-reduce:active:scale-100 dark:hover:bg-accent\",\n    variant === \"compact\" ? \"size-6 rounded-[7px]\" : \"size-7 rounded-lg\",\n  );\n}\n\nfunction rowClass(context: SidebarContext, active: boolean, nested: boolean) {\n  const compact = context.variant === \"compact\";\n  return cn(\n    \"flex w-full min-w-0 cursor-pointer items-center border-0 text-left font-medium no-underline\",\n    rowInteraction,\n    compact ? \"h-6.5 gap-2 rounded-md text-[12px]\" : \"h-7.5 gap-2.5 rounded-[7px] text-[12.5px]\",\n    // Rows stay start-aligned while the width animates; collapsed padding centres the icon.\n    \"justify-start\",\n    context.collapsed\n      ? cn(\"pr-0\", compact ? \"pl-2\" : \"pl-[11px]\")\n      : nested\n        ? cn(\"pr-2\", compact ? \"pl-7.5\" : \"pl-9\")\n        : \"px-2\",\n    active\n      ? cn(\n          \"text-foreground\",\n          context.variant === \"inset\"\n            ? \"bg-card shadow-[0_0_0_1px_var(--border),0_1px_2px_oklch(0_0_0/0.06)]\"\n            : \"bg-accent\",\n        )\n      : rowIdle,\n  );\n}\n\nconst appSidebarVariants = cva(\n  \"flex min-w-0 flex-col overflow-hidden text-[13px]/[18px] text-foreground transition-[width] duration-200 ease-out-quint motion-reduce:transition-none\",\n  {\n    variants: {\n      variant: {\n        panel: \"gap-3 rounded-[14px] border bg-card p-2\",\n        inset:\n          \"gap-3 rounded-[14px] border-0 bg-background p-2 shadow-[inset_0_0_0_1px_var(--border)]\",\n        compact: \"gap-2 rounded-xl border-0 bg-card p-1.5\",\n      },\n    },\n  },\n);\n\nexport function AppSidebar({\n  variant = \"panel\",\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  collapsed,\n  defaultCollapsed = false,\n  onCollapsedChange,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  mobileQuery = \"(max-width: 767px)\",\n  className,\n  ...props\n}: AppSidebarProps) {\n  const id = useId();\n  const mobile = useMediaQuery(mobileQuery);\n  const [active, select] = useControllable(value, defaultValue, onValueChange);\n  const [isCollapsed, setCollapsed] = useControllable(\n    collapsed,\n    defaultCollapsed,\n    onCollapsedChange,\n  );\n  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);\n  const showCollapsed = isCollapsed && !mobile;\n  const compact = variant === \"compact\";\n  // Escape closes the mobile disclosure and returns focus to its trigger.\n  useEffect(() => {\n    if (!mobile || !isOpen) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\" || event.defaultPrevented) return;\n      const root = document.querySelector(`[data-app-sidebar=\"${id}\"]`);\n      if (!root?.contains(document.activeElement)) return;\n      event.preventDefault();\n      setOpen(false);\n      document.getElementById(`${id}-trigger`)?.focus();\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [mobile, isOpen, id, setOpen]);\n  return (\n    <Context.Provider\n      value={{\n        id,\n        variant,\n        value: active,\n        select: (next) => {\n          select(next);\n          if (mobile) setOpen(false);\n        },\n        collapsed: showCollapsed,\n        setCollapsed,\n        mobile,\n        open: isOpen,\n        setOpen,\n      }}\n    >\n      <div\n        data-slot=\"app-sidebar\"\n        data-variant={variant}\n        data-collapsed={showCollapsed || undefined}\n        data-mobile={mobile || undefined}\n        className={cn(\n          appSidebarVariants({ variant }),\n          mobile ? \"h-auto w-full\" : \"h-full\",\n          !mobile && (showCollapsed ? (compact ? \"w-11\" : \"w-14\") : compact ? \"w-54\" : \"w-62\"),\n          className,\n        )}\n        {...props}\n        data-app-sidebar={id}\n      />\n    </Context.Provider>\n  );\n}\n\n/** Top row for the workspace switcher, toggles, and mobile trigger. */\nexport function AppSidebarHeader({ className, ...props }: ComponentProps<\"div\">) {\n  const context = useSidebar(\"AppSidebarHeader\");\n  return (\n    <div\n      data-slot=\"app-sidebar-header\"\n      className={cn(\n        \"relative flex min-w-0 items-center gap-1.5\",\n        context.collapsed ? \"flex-col\" : \"flex-row\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** Text that hides when the sidebar collapses, such as a workspace name. */\nexport function AppSidebarTitle({ className, ...props }: ComponentProps<\"span\">) {\n  const context = useSidebar(\"AppSidebarTitle\");\n  return (\n    <span\n      data-slot=\"app-sidebar-title\"\n      className={cn(\n        \"min-w-0 flex-1 truncate font-medium tracking-[-0.005em]\",\n        context.variant === \"compact\" ? \"pl-1 text-[12.5px]\" : \"pl-1.5 text-[13px]\",\n        labelFade,\n        // Leaves the column layout at once, then fades out behind the narrowing edge.\n        context.collapsed && \"pointer-events-none absolute opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function AppSidebarCollapseToggle({\n  className,\n  onClick,\n  ...props\n}: ComponentProps<\"button\">) {\n  const context = useSidebar(\"AppSidebarCollapseToggle\");\n  if (context.mobile) return null;\n  const Icon = context.collapsed ? PanelLeftOpen : PanelLeftClose;\n  return (\n    <Button\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"icon-sm\"\n      aria-label={context.collapsed ? \"Expand sidebar\" : \"Collapse sidebar\"}\n      data-slot=\"app-sidebar-collapse-toggle\"\n      className={cn(iconButtonClass(context.variant), className)}\n      {...props}\n      aria-expanded={!context.collapsed}\n      aria-controls={`${context.id}-nav`}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.setCollapsed(!context.collapsed);\n      }}\n    >\n      <Icon size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n    </Button>\n  );\n}\n\nexport function AppSidebarMobileTrigger({\n  className,\n  onClick,\n  ...props\n}: ComponentProps<\"button\">) {\n  const context = useSidebar(\"AppSidebarMobileTrigger\");\n  if (!context.mobile) return null;\n  const Icon = context.open ? X : Menu;\n  return (\n    <Button\n      type=\"button\"\n      variant=\"ghost\"\n      size=\"icon-sm\"\n      aria-label={context.open ? \"Close navigation\" : \"Open navigation\"}\n      data-slot=\"app-sidebar-mobile-trigger\"\n      className={cn(iconButtonClass(context.variant), \"ml-auto\", className)}\n      {...props}\n      id={`${context.id}-trigger`}\n      aria-expanded={context.open}\n      aria-controls={`${context.id}-nav`}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.setOpen(!context.open);\n      }}\n    >\n      <Icon\n        key={context.open ? \"close\" : \"open\"}\n        size={16}\n        strokeWidth={1.75}\n        aria-hidden=\"true\"\n        className=\"animate-in fade-in-0 zoom-in-75 duration-150 ease-out-quint motion-reduce:animate-none\"\n      />\n    </Button>\n  );\n}\n\nexport function AppSidebarNav({ className, ...props }: ComponentProps<\"nav\">) {\n  const context = useSidebar(\"AppSidebarNav\");\n  const hiddenOnMobile = context.mobile && !context.open;\n  return (\n    <nav\n      aria-label=\"Main\"\n      data-slot=\"app-sidebar-nav\"\n      className={cn(\n        \"min-h-0 flex-1 flex-col overflow-x-hidden overflow-y-auto\",\n        hiddenOnMobile ? \"hidden\" : \"flex\",\n        context.mobile && mobileReveal,\n        context.variant === \"compact\" ? \"gap-2.5\" : \"gap-4\",\n        className,\n      )}\n      {...props}\n      id={`${context.id}-nav`}\n      hidden={hiddenOnMobile}\n    />\n  );\n}\n\nexport function AppSidebarGroup({ className, ...props }: ComponentProps<\"div\">) {\n  useSidebar(\"AppSidebarGroup\");\n  const labelId = useId();\n  return (\n    <GroupContext.Provider value={labelId}>\n      {/* biome-ignore lint/a11y/useSemanticElements: a fieldset is for form controls; this labels a set of links. */}\n      <div\n        role=\"group\"\n        aria-labelledby={labelId}\n        data-slot=\"app-sidebar-group\"\n        className={cn(\"grid gap-px\", className)}\n        {...props}\n      />\n    </GroupContext.Provider>\n  );\n}\n\nexport function AppSidebarGroupLabel({ className, ...props }: ComponentProps<\"div\">) {\n  const labelId = useContext(GroupContext);\n  const context = useSidebar(\"AppSidebarGroupLabel\");\n  if (!labelId) throw new Error(\"AppSidebarGroupLabel must be used within AppSidebarGroup\");\n  return (\n    <div\n      data-slot=\"app-sidebar-group-label\"\n      className={cn(\n        \"px-2 pt-0.5 pb-1 text-[11.5px]/4 font-normal text-subtle-foreground\",\n        context.collapsed && \"sr-only\",\n        className,\n      )}\n      {...props}\n      id={labelId}\n    />\n  );\n}\n\nexport function AppSidebarList({ className, ...props }: ComponentProps<\"ul\">) {\n  return (\n    <ul\n      data-slot=\"app-sidebar-list\"\n      className={cn(\"m-0 grid list-none gap-px p-0\", className)}\n      {...props}\n    />\n  );\n}\n\nexport type AppSidebarItemProps = Omit<ComponentProps<\"a\">, \"children\"> & {\n  value: string;\n  /** Accessible name and tooltip when collapsed. Defaults to the label text. */\n  label?: string;\n  children: React.ReactNode;\n};\n\nexport function AppSidebarItem({\n  value,\n  label,\n  href,\n  children,\n  className,\n  onClick,\n  ...props\n}: AppSidebarItemProps) {\n  const context = useSidebar(\"AppSidebarItem\");\n  const submenu = useContext(Submenu);\n  const active = context.value === value;\n  return (\n    <li className=\"min-w-0\">\n      <ItemContext.Provider value={true}>\n        <a\n          href={href ?? `#${value}`}\n          title={context.collapsed ? label : undefined}\n          data-slot=\"app-sidebar-item\"\n          className={cn(rowClass(context, active, Boolean(submenu)), className)}\n          {...props}\n          aria-current={active ? \"page\" : undefined}\n          data-active={active || undefined}\n          onClick={(event) => {\n            onClick?.(event);\n            context.select(value);\n          }}\n        >\n          {children}\n        </a>\n      </ItemContext.Provider>\n    </li>\n  );\n}\n\nexport function AppSidebarItemIcon({ className, ...props }: ComponentProps<\"span\">) {\n  const context = useSidebar(\"AppSidebarItemIcon\");\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"app-sidebar-item-icon\"\n      className={cn(\n        \"grid flex-none place-items-center opacity-90\",\n        context.variant === \"compact\" ? \"size-4\" : \"size-4.5\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** Row label. It stays in the accessibility tree when collapsed. */\nexport function AppSidebarItemLabel({ className, ...props }: ComponentProps<\"span\">) {\n  const context = useSidebar(\"AppSidebarItemLabel\");\n  if (!useContext(ItemContext)) {\n    throw new Error(\"AppSidebarItemLabel must be used within AppSidebarItem or AppSidebarSubmenu\");\n  }\n  return (\n    <span\n      data-slot=\"app-sidebar-item-label\"\n      className={cn(\n        \"min-w-0 flex-1 truncate\",\n        labelFade,\n        context.collapsed && \"opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** A trailing count or status beside a row label. Hidden when collapsed. */\nexport function AppSidebarItemBadge({ className, ...props }: ComponentProps<\"span\">) {\n  const context = useSidebar(\"AppSidebarItemBadge\");\n  return (\n    <span\n      data-slot=\"app-sidebar-item-badge\"\n      aria-hidden={context.collapsed || undefined}\n      className={cn(\n        \"ml-auto min-w-4.5 rounded-full bg-foreground/7 px-1.5 text-center text-[11px]/[17px] font-medium text-muted-foreground tabular-nums\",\n        labelFade,\n        context.collapsed && \"opacity-0\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function AppSidebarSubmenu({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n  ...props\n}: ComponentProps<\"li\"> & {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}) {\n  const context = useSidebar(\"AppSidebarSubmenu\");\n  const id = useId();\n  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);\n  return (\n    <Submenu.Provider value={{ id, open: isOpen, setOpen }}>\n      <Collapsible\n        asChild\n        open={isOpen && !context.collapsed}\n        onOpenChange={(next) => {\n          // A collapsed sidebar expands first so the nested list has room to show.\n          if (context.collapsed) context.setCollapsed(false);\n          setOpen(next);\n        }}\n      >\n        <li\n          data-slot=\"app-sidebar-submenu\"\n          className={cn(\"grid min-w-0 gap-px\", className)}\n          {...props}\n        />\n      </Collapsible>\n    </Submenu.Provider>\n  );\n}\n\nfunction useSubmenu(part: string) {\n  const context = useContext(Submenu);\n  if (!context) throw new Error(`${part} must be used within AppSidebarSubmenu`);\n  return context;\n}\n\nexport function AppSidebarSubmenuTrigger({\n  label,\n  children,\n  className,\n  onClick,\n  ...props\n}: ComponentProps<\"button\"> & { label?: string }) {\n  const context = useSidebar(\"AppSidebarSubmenuTrigger\");\n  const submenu = useSubmenu(\"AppSidebarSubmenuTrigger\");\n  const expanded = submenu.open && !context.collapsed;\n  return (\n    <ItemContext.Provider value={true}>\n      <CollapsibleTrigger\n        type=\"button\"\n        title={context.collapsed ? label : undefined}\n        data-slot=\"app-sidebar-submenu-trigger\"\n        className={cn(rowClass(context, false, false), className)}\n        {...props}\n        aria-expanded={expanded}\n        aria-controls={`${submenu.id}-list`}\n        onClick={onClick}\n      >\n        {children}\n        {context.collapsed ? null : (\n          <ChevronDown\n            size={14}\n            strokeWidth={1.75}\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-3.5\",\n              \"ml-auto flex-none text-subtle-foreground transition-transform duration-180 ease-out-quint motion-reduce:transition-none\",\n              expanded && \"rotate-180\",\n            )}\n          />\n        )}\n      </CollapsibleTrigger>\n    </ItemContext.Provider>\n  );\n}\n\nexport function AppSidebarSubmenuList({ className, ...props }: ComponentProps<\"ul\">) {\n  const context = useSidebar(\"AppSidebarSubmenuList\");\n  const submenu = useSubmenu(\"AppSidebarSubmenuList\");\n  const ref = useRef<HTMLUListElement>(null);\n  useReveal(ref, submenu.open && !context.collapsed);\n  return (\n    <CollapsibleContent asChild>\n      <ul\n        data-slot=\"app-sidebar-submenu-list\"\n        className={cn(\"m-0 grid list-none gap-px p-0\", className)}\n        {...props}\n        ref={ref}\n        id={`${submenu.id}-list`}\n      />\n    </CollapsibleContent>\n  );\n}\n\n/** Bottom row for account or settings links. */\nexport function AppSidebarFooter({ className, ...props }: ComponentProps<\"div\">) {\n  const context = useSidebar(\"AppSidebarFooter\");\n  if (context.mobile && !context.open) return null;\n  return (\n    <div\n      data-slot=\"app-sidebar-footer\"\n      className={cn(\"grid gap-px border-t pt-2\", context.mobile && mobileReveal, className)}\n      {...props}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/uai/app-sidebar.tsx"
    }
  ],
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}