{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "circular-context-menu",
  "type": "registry:ui",
  "title": "Circular Context Menu",
  "description": "An edge-aware compound context menu that fans a small set of direct actions away from a selected object.",
  "files": [
    {
      "path": "registry/sonaui/circular-context-menu/circular-context-menu.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  Children,\n  cloneElement,\n  createContext,\n  type ReactElement,\n  type ReactNode,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\ntype Point = { x: number; y: number };\nexport type CircularContextMenuPlacement = \"top\" | \"right\" | \"bottom\" | \"left\";\n\nconst PLACEMENT_ANGLES: Record<CircularContextMenuPlacement, number> = {\n  top: -Math.PI / 2,\n  right: 0,\n  bottom: Math.PI / 2,\n  left: Math.PI,\n};\n\ntype ContextValue = {\n  anchorRef: React.RefObject<HTMLButtonElement | null>;\n  contentRef: React.RefObject<HTMLDivElement | null>;\n  contentId: string;\n  disabled: boolean;\n  itemRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n};\n\nconst CircularContext = createContext<ContextValue | null>(null);\n\nfunction useCircularContext() {\n  const context = useContext(CircularContext);\n  if (!context) {\n    throw new Error(\n      \"CircularContextMenu subcomponents must be used within Root.\",\n    );\n  }\n  return context;\n}\n\nfunction pointsFor(\n  placement: CircularContextMenuPlacement,\n  count: number,\n  radius: number,\n  spread: number,\n) {\n  const direction = PLACEMENT_ANGLES[placement];\n  return Array.from({ length: count }, (_, index) => {\n    const progress = count <= 1 ? 0.5 : index / (count - 1);\n    const angle = direction + (progress - 0.5) * ((spread * Math.PI) / 180);\n    return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };\n  });\n}\n\n/** Owns the selected anchor, open state, dismissal, and focus recovery. */\nexport interface CircularContextMenuRootProps {\n  /** The Anchor and Content subcomponents. */\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial uncontrolled open state. @default false */\n  defaultOpen?: boolean;\n  /** Called whenever the menu opens or closes. */\n  onOpenChange?: (open: boolean) => void;\n  /** Prevents opening the menu. @default false */\n  disabled?: boolean;\n}\n\nfunction Root({\n  children,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n}: CircularContextMenuRootProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const anchorRef = useRef<HTMLButtonElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const contentId = useId();\n  const isOpen = open ?? internalOpen;\n  const setOpen = useCallback(\n    (nextOpen: boolean) => {\n      if (disabled) return;\n      if (open === undefined) setInternalOpen(nextOpen);\n      onOpenChange?.(nextOpen);\n    },\n    [disabled, onOpenChange, open],\n  );\n\n  useEffect(() => {\n    if (!isOpen) return;\n    const dismiss = (event: PointerEvent) => {\n      const target = event.target as Node;\n      if (\n        !rootRef.current?.contains(target) &&\n        !contentRef.current?.contains(target)\n      )\n        setOpen(false);\n    };\n    const handleEscape = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      setOpen(false);\n      anchorRef.current?.focus();\n    };\n    document.addEventListener(\"pointerdown\", dismiss);\n    document.addEventListener(\"keydown\", handleEscape);\n    return () => {\n      document.removeEventListener(\"pointerdown\", dismiss);\n      document.removeEventListener(\"keydown\", handleEscape);\n    };\n  }, [isOpen, setOpen]);\n\n  return (\n    <CircularContext.Provider\n      value={{\n        anchorRef,\n        contentRef,\n        contentId,\n        disabled,\n        itemRefs,\n        open: isOpen,\n        setOpen,\n      }}\n    >\n      <span ref={rootRef} className=\"inline-flex\">\n        {children}\n      </span>\n    </CircularContext.Provider>\n  );\n}\n\n/** The selected object that opens the contextual action arc. */\nexport interface CircularContextMenuAnchorProps\n  extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  /** Anchor content. */ children: ReactNode;\n}\nfunction Anchor({\n  children,\n  className,\n  onClick,\n  onKeyDown,\n  ...props\n}: CircularContextMenuAnchorProps) {\n  const { anchorRef, contentId, disabled, itemRefs, open, setOpen } =\n    useCircularContext();\n  return (\n    <button\n      ref={anchorRef}\n      type=\"button\"\n      aria-controls={contentId}\n      aria-expanded={open}\n      className={cn(\n        \"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        className,\n      )}\n      disabled={disabled}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) setOpen(!open);\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (\n          (event.key === \"ArrowDown\" || event.key === \"ArrowRight\") &&\n          !event.defaultPrevented\n        ) {\n          event.preventDefault();\n          setOpen(true);\n          requestAnimationFrame(() => itemRefs.current[0]?.focus());\n        }\n      }}\n      {...props}\n    >\n      {children}\n    </button>\n  );\n}\n\n/** Positions a small action arc from a consumer-selected anchor direction. */\nexport interface CircularContextMenuContentProps {\n  /** Action items. */ children: ReactNode /** Arc radius in pixels. @default 92 */;\n  radius?: number /** Arc angle in degrees. @default 68 */;\n  spread?: number /** Accessible menu label. @default \"Context actions\" */;\n  /** Direction the action arc opens from its anchor. @default \"right\" */\n  placement?: CircularContextMenuPlacement;\n  \"aria-label\"?: string /** Additional CSS classes. */;\n  className?: string;\n}\nfunction Content({\n  children,\n  radius = 92,\n  spread = 68,\n  placement = \"right\",\n  \"aria-label\": ariaLabel = \"Context actions\",\n  className,\n}: CircularContextMenuContentProps) {\n  const { anchorRef, contentId, contentRef, open } = useCircularContext();\n  const [anchor, setAnchor] = useState<Point | null>(null);\n  const reduce = useReducedMotion();\n  const items = Children.toArray(children) as ReactElement[];\n  const measure = useCallback(() => {\n    const rect = anchorRef.current?.getBoundingClientRect();\n    if (rect)\n      setAnchor({\n        x: rect.left + rect.width / 2,\n        y: rect.top + rect.height / 2,\n      });\n  }, [anchorRef]);\n  useLayoutEffect(() => {\n    if (!open) return;\n    measure();\n    const observer = new ResizeObserver(measure);\n    if (anchorRef.current) observer.observe(anchorRef.current);\n    window.addEventListener(\"resize\", measure);\n    window.addEventListener(\"scroll\", measure, true);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"resize\", measure);\n      window.removeEventListener(\"scroll\", measure, true);\n    };\n  }, [measure, open, anchorRef]);\n  const points = pointsFor(placement, items.length, radius, spread);\n  if (typeof document === \"undefined\") return null;\n\n  return createPortal(\n    <AnimatePresence initial={false}>\n      {open && anchor && (\n        <motion.div\n          ref={contentRef}\n          id={contentId}\n          role=\"menu\"\n          aria-label={ariaLabel}\n          className={cn(\"pointer-events-none fixed inset-0 z-50\", className)}\n        >\n          {items.map((item, index) =>\n            cloneElement(item, {\n              __anchor: anchor,\n              __index: index,\n              __point: points[index],\n              __reduce: reduce,\n            } as never),\n          )}\n        </motion.div>\n      )}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\n/** One icon or compact action in the contextual arc. */\nexport interface CircularContextMenuItemProps\n  extends Omit<\n    React.ButtonHTMLAttributes<HTMLButtonElement>,\n    | \"onAnimationEnd\"\n    | \"onAnimationIteration\"\n    | \"onAnimationStart\"\n    | \"onDrag\"\n    | \"onDragEnd\"\n    | \"onDragStart\"\n  > {\n  /** Icon or concise action content. */ children: ReactNode /** Called before the menu closes. */;\n  onSelect?: () => void /** Marks the action destructive. @default false */;\n  destructive?: boolean;\n}\nfunction Item({\n  children,\n  className,\n  destructive = false,\n  onClick,\n  onKeyDown,\n  onSelect,\n  __index = 0,\n  __anchor = { x: 0, y: 0 },\n  __point = { x: 0, y: 0 },\n  __reduce = false,\n  ...props\n}: CircularContextMenuItemProps & {\n  __anchor?: Point;\n  __index?: number;\n  __point?: Point;\n  __reduce?: boolean;\n}) {\n  const { anchorRef, itemRefs, setOpen } = useCircularContext();\n  const index = __index;\n  const point = __point;\n  const reduce = __reduce;\n  return (\n    <motion.button\n      ref={(node) => {\n        itemRefs.current[index] = node;\n      }}\n      type=\"button\"\n      role=\"menuitem\"\n      className={cn(\n        \"pointer-events-auto absolute top-0 left-0 flex size-11 cursor-pointer items-center justify-center rounded-full border border-border bg-popover text-popover-foreground shadow-lg outline-none focus-visible:ring-1.5 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        destructive && \"text-destructive hover:border-destructive/45\",\n        className,\n      )}\n      initial={\n        reduce\n          ? false\n          : {\n              opacity: 0,\n              scale: 0.72,\n              filter: \"blur(4px)\",\n              x: __anchor.x - 22,\n              y: __anchor.y - 22,\n            }\n      }\n      animate={{\n        opacity: 1,\n        scale: 1,\n        filter: \"blur(0px)\",\n        x: __anchor.x - 22 + point.x,\n        y: __anchor.y - 22 + point.y,\n        transition: reduce\n          ? { duration: 0 }\n          : {\n              type: \"spring\",\n              stiffness: 480,\n              damping: 34,\n              mass: 0.7,\n              delay: index * 0.035,\n            },\n      }}\n      exit={\n        reduce\n          ? { opacity: 0, transition: { duration: 0 } }\n          : {\n              opacity: 0,\n              scale: 0.72,\n              filter: \"blur(4px)\",\n              x: __anchor.x - 22,\n              y: __anchor.y - 22,\n              transition: { duration: 0.16 },\n            }\n      }\n      whileHover={reduce ? undefined : { scale: 1.06 }}\n      whileTap={reduce ? undefined : { scale: 0.94 }}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) {\n          onSelect?.();\n          setOpen(false);\n          anchorRef.current?.focus();\n        }\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (\n          [\"ArrowDown\", \"ArrowRight\", \"ArrowUp\", \"ArrowLeft\"].includes(\n            event.key,\n          )\n        ) {\n          event.preventDefault();\n          const delta =\n            event.key === \"ArrowDown\" || event.key === \"ArrowRight\" ? 1 : -1;\n          itemRefs.current[\n            (index + delta + itemRefs.current.length) % itemRefs.current.length\n          ]?.focus();\n        }\n      }}\n      {...props}\n    >\n      {children}\n    </motion.button>\n  );\n}\n\nconst CircularContextMenu = { Root, Anchor, Content, Item };\n\nexport {\n  Anchor as CircularContextMenuAnchor,\n  Content as CircularContextMenuContent,\n  Item as CircularContextMenuItem,\n  Root as CircularContextMenuRoot,\n};\nexport default CircularContextMenu;\n",
      "target": "components/ui/circular-context-menu/circular-context-menu.tsx"
    }
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}