{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "account-menu",
  "title": "Account menu",
  "description": "The account block at the foot of an app rail: avatar, name and role, opening upward into a frosted menu with the email, a highlight that slides between rows, icons that gesture as it lands, and a red Log out.",
  "dependencies": [
    "radix-ui",
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/items/account-menu/account-menu.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { DropdownMenu as MenuPrimitive } from \"radix-ui\";\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotionConfig,\n  type Transition,\n  type Variants,\n} from \"motion/react\";\nimport { ChevronsUpDown, LogOut } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* ─── Glyph gestures ──────────────────────────────────────────────────────\n * Every icon in the rail and its menu moves when its row is hovered or\n * highlighted. The gestures stay small (under ~10% scale, a few pixels, a few\n * degrees) so a column of them reads as one system rather than a toybox, and\n * each points at what its row does: the cog turns, the door's arrow leaves,\n * the person nods. Exported so a sidebar can drive its own rows the same way.\n */\n\nexport type IconGesture =\n  | \"none\"\n  /**\n   * The icon animates itself: it is handed `animate` while active and left\n   * still otherwise. For animated icon sets such as Animate UI's.\n   */\n  | \"own\"\n  /** Small bounce down and up. People, profiles. */\n  | \"bob\"\n  /** Half a turn. Settings, cogs. */\n  | \"spin\"\n  /** A speech-bubble wobble. Help, messages, inboxes. */\n  | \"wiggle\"\n  /** A gentle sway either side. Accessibility, compass. */\n  | \"sway\"\n  /** Slides two pixels right. Log out, \"open\", forward. */\n  | \"nudge\"\n  /** Slides two pixels left. \"Close\", back. */\n  | \"nudge-back\"\n  /** Dips two pixels. Download, import. */\n  | \"drop\"\n  /** Grows and leans left. Shields, admin. */\n  | \"brace\"\n  /** Grows and leans right. Thumbs up, feedback. */\n  | \"tilt\"\n  /** A quick pop. Charts, analytics, anything else. */\n  | \"pop\";\n\nconst GESTURE_SPRING: Transition = { type: \"spring\", stiffness: 420, damping: 17, mass: 0.6 };\n\nexport const ICON_GESTURES: Record<Exclude<IconGesture, \"none\" | \"own\">, Variants> = {\n  bob: {\n    rest: { y: 0 },\n    active: { y: [0, 2, -2, 0], transition: { duration: 0.6, ease: \"easeInOut\" } },\n  },\n  // Half a turn lands a cog on itself, so the return can snap back unseen.\n  spin: {\n    rest: { rotate: 0, transition: { duration: 0 } },\n    active: { rotate: [0, 90, 180], transition: { duration: 1.25, ease: \"easeInOut\" } },\n  },\n  wiggle: {\n    rest: { rotate: 0 },\n    active: {\n      rotate: [0, 8, -8, 2, 0],\n      transition: { duration: 0.8, ease: \"easeInOut\", times: [0, 0.4, 0.6, 0.8, 1] },\n    },\n  },\n  sway: {\n    rest: { rotate: 0 },\n    active: { rotate: [0, 6, -6, 0], transition: { duration: 0.8, ease: \"easeInOut\" } },\n  },\n  nudge: {\n    rest: { x: 0, transition: { duration: 0.3, ease: \"easeInOut\" } },\n    active: { x: 2, transition: { duration: 0.3, ease: \"easeInOut\" } },\n  },\n  \"nudge-back\": {\n    rest: { x: 0, transition: GESTURE_SPRING },\n    active: { x: -2, transition: GESTURE_SPRING },\n  },\n  drop: {\n    rest: { y: 0 },\n    active: { y: [0, 2, 0], transition: { duration: 0.6, ease: \"easeInOut\" } },\n  },\n  brace: {\n    rest: { scale: 1, rotate: 0, transition: GESTURE_SPRING },\n    active: { scale: 1.1, rotate: -6, transition: GESTURE_SPRING },\n  },\n  tilt: {\n    rest: { scale: 1, rotate: 0, transition: GESTURE_SPRING },\n    active: { scale: 1.1, rotate: 6, transition: GESTURE_SPRING },\n  },\n  pop: {\n    rest: { scale: 1 },\n    active: { scale: [1, 1.12, 1], transition: { duration: 0.45, ease: \"easeInOut\" } },\n  },\n};\n\n/**\n * Any icon component that takes a class and a stroke width: every lucide-react\n * icon fits. Icons used with the \"own\" gesture also take `animate`.\n */\nexport type GlyphComponent = React.ComponentType<{\n  className?: string;\n  strokeWidth?: number;\n  \"aria-hidden\"?: boolean | \"true\" | \"false\";\n  animate?: boolean;\n}>;\n\n/**\n * An icon that plays its gesture while `active`. Someone who asked the OS for\n * less motion gets a still glyph.\n */\nexport function GestureGlyph({\n  icon: Icon,\n  gesture = \"none\",\n  active = false,\n  className,\n  strokeWidth,\n}: {\n  icon: GlyphComponent;\n  gesture?: IconGesture;\n  active?: boolean;\n  className?: string;\n  strokeWidth?: number;\n}) {\n  const reduceMotion = useReducedMotionConfig();\n  const variants = gesture === \"none\" || gesture === \"own\" || reduceMotion ? undefined : ICON_GESTURES[gesture];\n  // Only \"own\" icons are handed `animate`: a lucide icon would put it on its svg.\n  const own = gesture === \"own\" ? { animate: active && !reduceMotion } : undefined;\n\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      className=\"inline-flex shrink-0\"\n      variants={variants}\n      initial={false}\n      animate={active ? \"active\" : \"rest\"}\n    >\n      <Icon className={className} strokeWidth={strokeWidth} aria-hidden={true} {...own} />\n    </motion.span>\n  );\n}\n\n/* ─── Theme scope ─────────────────────────────────────────────────────────\n * Floating layers are portalled to <body>, out of any `[data-theme]` scope\n * (and any scoped `.dark`) the trigger sits in. Read the scope off the\n * trigger as the layer opens, and put it back on the layer.\n */\n\nexport type ThemeScope = { theme?: string; dark: boolean };\n\nexport function readThemeScope(from: Element | null | undefined): ThemeScope {\n  const scope = from?.closest(\"[data-theme]\");\n  return {\n    theme: scope?.getAttribute(\"data-theme\") ?? undefined,\n    dark: Boolean(scope?.classList.contains(\"dark\")),\n  };\n}\n\n/* ─── Avatar ────────────────────────────────────────────────────────────── */\n\n/** First and last initials, then the first two letters, then the email's first letter. */\nfunction initialsOf(name: string | null | undefined, email: string | null | undefined): string {\n  const parts = (name ?? \"\").trim().split(/\\s+/).filter(Boolean);\n  if (parts.length >= 2) return (parts[0]![0]! + parts[parts.length - 1]![0]!).toUpperCase();\n  if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();\n  return (email ?? \"?\").slice(0, 1).toUpperCase();\n}\n\nfunction AccountAvatar({ user }: { user: AccountUser }) {\n  if (user.avatarUrl) {\n    return (\n      // eslint-disable-next-line @next/next/no-img-element -- ships to any React app, not only Next\n      <img\n        src={user.avatarUrl}\n        alt=\"\"\n        aria-hidden=\"true\"\n        className=\"size-9 shrink-0 rounded-lg object-cover\"\n      />\n    );\n  }\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand/15 text-xs font-bold tracking-wide text-brand-hover\"\n    >\n      {initialsOf(user.name, user.email)}\n    </span>\n  );\n}\n\n/* ─── Account menu ─────────────────────────────────────────────────────── */\n\nexport type AccountUser = {\n  name?: string | null;\n  email?: string | null;\n  /** A short label under the name: the role, plan or team. Shown in capitals. */\n  role?: string | null;\n  /** A photo. Without one, the initials stand in. */\n  avatarUrl?: string | null;\n};\n\nexport type AccountMenuItem = {\n  label: React.ReactNode;\n  icon?: GlyphComponent;\n  /** How the icon moves when the row is highlighted. Defaults to \"pop\". */\n  gesture?: IconGesture;\n  /** Renders the row as a link. */\n  href?: string;\n  /** Called when the row is chosen, by click or keyboard. */\n  onSelect?: () => void;\n  /** Red text and a red wash: for leaving or deleting. */\n  destructive?: boolean;\n  disabled?: boolean;\n};\n\n/** A row, or a hairline between groups. */\nexport type AccountMenuEntry = AccountMenuItem | \"separator\";\n\nexport interface AccountMenuProps {\n  user: AccountUser;\n  /** The rows between the identity header and \"Log out\". */\n  items?: AccountMenuEntry[];\n  /** Adds a red \"Log out\" row at the foot. Omit to leave it out. */\n  onSignOut?: () => void | Promise<void>;\n  /** Label for the sign-out row. Defaults to \"Log out\". */\n  signOutLabel?: React.ReactNode;\n  /**\n   * For client-side routers: called instead of a full page load when a row\n   * with an `href` is chosen. Without it, `href` rows are plain links.\n   */\n  onNavigate?: (href: string) => void;\n  /**\n   * Icon-only trigger at `md` and up, for a collapsed rail. Below `md` the\n   * name always shows, because a collapsed rail is a desktop-only state.\n   */\n  collapsed?: boolean;\n  /** Which side the menu opens on. Defaults to \"top\", for the foot of a rail. */\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Classes for the trigger row. */\n  className?: string;\n  /** Classes for the floating menu. */\n  contentClassName?: string;\n}\n\nconst HIGHLIGHT_SPRING: Transition = { type: \"spring\", stiffness: 350, damping: 35 };\n\n/**\n * The account block at the foot of an app rail. The row carries name and role,\n * the two things that decide what the rest of the rail shows, and holds the\n * email back for the menu, where checking *which* account you are signed into\n * is the reason you opened it.\n *\n * Radix owns open and close, focus and the keyboard. On top: the menu springs\n * up out of its trigger, a highlight slides between rows rather than jumping,\n * and each row's icon plays its gesture as the highlight lands on it.\n */\nexport function AccountMenu({\n  user,\n  items = [],\n  onSignOut,\n  signOutLabel = \"Log out\",\n  onNavigate,\n  collapsed = false,\n  side = \"top\",\n  align = \"start\",\n  className,\n  contentClassName,\n}: AccountMenuProps) {\n  const reduceMotion = useReducedMotionConfig();\n  const [open, setOpen] = React.useState(false);\n  const [highlighted, setHighlighted] = React.useState<string | null>(null);\n  // The menu is portalled to <body>; carry the trigger's theme scope across.\n  const [scope, setScope] = React.useState<ThemeScope>({ dark: false });\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const highlightId = React.useId();\n\n  const displayName = user.name || user.email || \"Account\";\n\n  function changeOpen(next: boolean) {\n    if (next) {\n      setScope(readThemeScope(triggerRef.current));\n    } else {\n      setHighlighted(null);\n    }\n    setOpen(next);\n  }\n\n  function clearOnLeave(event: React.PointerEvent<HTMLDivElement>) {\n    const related = event.relatedTarget as Node | null;\n    if (!related || !event.currentTarget.contains(related)) setHighlighted(null);\n  }\n\n  const rows: AccountMenuEntry[] = [...items];\n  if (onSignOut) {\n    if (rows.length > 0) rows.push(\"separator\");\n    rows.push({\n      label: signOutLabel,\n      icon: LogOut,\n      gesture: \"nudge\",\n      destructive: true,\n      onSelect: () => void onSignOut(),\n    });\n  }\n\n  return (\n    <MenuPrimitive.Root open={open} onOpenChange={changeOpen}>\n      <MenuPrimitive.Trigger {...{ asChild: true }}>\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          title={collapsed ? displayName : undefined}\n          aria-label={collapsed ? `Account: ${displayName}` : undefined}\n          className={cn(\n            \"flex w-full items-center gap-3 rounded-xl p-2.5 text-left font-body transition-all hover:bg-ink/10 focus-visible:ring-2 focus-visible:ring-lead/30 focus-visible:outline-none data-[state=open]:bg-ink/12 md:pl-1.5\",\n            collapsed && \"md:pr-1.5\",\n            className,\n          )}\n        >\n          <AccountAvatar user={user} />\n          {/* Hidden by class rather than unmounted: `collapsed` is a\n              desktop-only state, and below `md` this block always shows. */}\n          <span className={cn(\"min-w-0 flex-1\", collapsed && \"md:hidden\")}>\n            <span className=\"block truncate text-sm font-bold text-ink\">{displayName}</span>\n            {user.role && (\n              <span className=\"block truncate text-xs font-bold tracking-wide text-faint uppercase\">\n                {user.role}\n              </span>\n            )}\n          </span>\n          <ChevronsUpDown\n            className={cn(\"size-4 shrink-0 text-faint\", collapsed && \"md:hidden\")}\n            strokeWidth={1.75}\n            aria-hidden=\"true\"\n          />\n        </button>\n      </MenuPrimitive.Trigger>\n\n      <AnimatePresence>\n        {open && (\n          <MenuPrimitive.Portal forceMount>\n            <MenuPrimitive.Content\n              {...{ asChild: true }}\n              forceMount\n              side={side}\n              align={align}\n              sideOffset={8}\n              // Never flush against a phone's edge.\n              collisionPadding={12}\n            >\n              <motion.div\n                data-slot=\"account-menu\"\n                data-theme={scope.theme}\n                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}\n                transition={{ duration: reduceMotion ? 0.1 : 0.2 }}\n                onPointerLeave={clearOnLeave}\n                className={cn(\n                  scope.dark && \"dark\",\n                  \"z-50 max-h-(--radix-dropdown-menu-content-available-height) w-60 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-rule bg-popover/85 p-1 font-body text-popover-foreground shadow-md outline-none backdrop-blur-xl max-sm:max-w-[calc(100vw-1.5rem)]\",\n                  contentClassName,\n                )}\n              >\n                <MenuPrimitive.Label className=\"flex items-center gap-3 px-2 py-2 text-sm font-normal\">\n                  <AccountAvatar user={user} />\n                  <span className=\"min-w-0\">\n                    <span className=\"block truncate text-sm font-bold text-ink\">{displayName}</span>\n                    {user.email && (\n                      <span className=\"block truncate text-xs text-dim\">{user.email}</span>\n                    )}\n                  </span>\n                </MenuPrimitive.Label>\n\n                {rows.length > 0 && <MenuSeparator />}\n\n                {rows.map((row, index) => {\n                  if (row === \"separator\") return <MenuSeparator key={`separator-${index}`} />;\n                  const key = `row-${index}`;\n                  return (\n                    <MenuRow\n                      key={key}\n                      item={row}\n                      active={highlighted === key}\n                      highlightId={highlightId}\n                      reduceMotion={Boolean(reduceMotion)}\n                      onHighlight={() => setHighlighted(key)}\n                      onNavigate={onNavigate}\n                    />\n                  );\n                })}\n              </motion.div>\n            </MenuPrimitive.Content>\n          </MenuPrimitive.Portal>\n        )}\n      </AnimatePresence>\n    </MenuPrimitive.Root>\n  );\n}\n\nfunction MenuSeparator() {\n  return <MenuPrimitive.Separator className=\"-mx-1 my-1 h-px bg-border\" />;\n}\n\nfunction MenuRow({\n  item,\n  active,\n  highlightId,\n  reduceMotion,\n  onHighlight,\n  onNavigate,\n}: {\n  item: AccountMenuItem;\n  active: boolean;\n  highlightId: string;\n  reduceMotion: boolean;\n  onHighlight: () => void;\n  onNavigate?: (href: string) => void;\n}) {\n  const { label, icon, gesture = \"pop\", href, onSelect, destructive, disabled } = item;\n\n  const body = (\n    <>\n      {/* One highlight per menu, handed from row to row by its layout id, so\n          it slides to the row under the pointer instead of blinking there. */}\n      {active && (\n        <motion.span\n          aria-hidden=\"true\"\n          layoutId={highlightId}\n          transition={reduceMotion ? { duration: 0 } : HIGHLIGHT_SPRING}\n          className={cn(\n            \"absolute inset-0 rounded-sm\",\n            destructive ? \"bg-destructive/10\" : \"bg-accent\",\n          )}\n        />\n      )}\n      <span className=\"relative flex min-w-0 flex-1 items-center gap-2\">\n        {icon && <GestureGlyph icon={icon} gesture={gesture} active={active} />}\n        <span className=\"min-w-0 truncate\">{label}</span>\n      </span>\n    </>\n  );\n\n  const className = cn(\n    \"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none pointer-coarse:min-h-11\",\n    \"focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n    \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground\",\n    destructive &&\n      \"text-destructive focus:text-destructive [&_svg:not([class*='text-'])]:text-destructive\",\n  );\n\n  // A modified click (new tab, new window) is the browser's job, not the router's.\n  const modifiedClick = React.useRef(false);\n  const select = () => {\n    onSelect?.();\n    if (href && onNavigate && !modifiedClick.current) onNavigate(href);\n    modifiedClick.current = false;\n  };\n\n  if (href) {\n    return (\n      <MenuPrimitive.Item\n        {...{ asChild: true }}\n        disabled={disabled}\n        onSelect={select}\n        onFocus={onHighlight}\n        className={className}\n      >\n        <a\n          href={href}\n          onClick={(event) => {\n            const modified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;\n            modifiedClick.current = modified;\n            if (onNavigate && !modified) event.preventDefault();\n          }}\n        >\n          {body}\n        </a>\n      </MenuPrimitive.Item>\n    );\n  }\n\n  return (\n    <MenuPrimitive.Item\n      disabled={disabled}\n      onSelect={select}\n      onFocus={onHighlight}\n      className={className}\n    >\n      {body}\n    </MenuPrimitive.Item>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "kind": "widget",
    "whenToUse": "Use for the signed-in person's own menu (profile, settings, help, sign out) at the foot or top of a sidebar or header. Do not use for menus about content on the page, or for more than a handful of rows; that is a plain dropdown or a settings page.",
    "origin": {
      "project": "180Connect",
      "path": "src/components/sidebar-account-menu.tsx"
    },
    "credits": [
      {
        "name": "Animate UI (Radix Dropdown Menu, Highlight, animated icons)",
        "url": "https://animate-ui.com",
        "license": "MIT"
      }
    ]
  },
  "type": "registry:component"
}
