{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pie-menu",
  "title": "Pie Menu",
  "description": "A radial menu with press-drag-release selection, keyboard support, and enter and exit animations.",
  "dependencies": [
    "radix-ui"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/ui/pie-menu.tsx",
      "content": "\"use client\"\n\nimport type { ComponentProps } from \"react\"\n\nimport * as PieMenuPrimitive from \"@/lib/pie-menu/primitive\"\nimport { cn } from \"@/lib/utils\"\n\nconst EASE_OUT = \"ease-[cubic-bezier(0.22,1,0.36,1)]\"\n\nfunction PieMenu(props: PieMenuPrimitive.PieMenuProps) {\n  return <PieMenuPrimitive.Root {...props} />\n}\n\nfunction PieMenuTrigger({\n  className,\n  ...props\n}: PieMenuPrimitive.PieMenuTriggerProps) {\n  return (\n    <PieMenuPrimitive.Trigger\n      data-slot=\"pie-menu-trigger\"\n      className={cn(\n        \"select-none [-webkit-touch-callout:none] data-[open-on=press]:touch-none\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction PieMenuContent({\n  className,\n  overlayProps,\n  ...props\n}: PieMenuPrimitive.PieMenuContentProps) {\n  return (\n    <PieMenuPrimitive.Content\n      data-slot=\"pie-menu-content\"\n      className={cn(\"group/pie-menu outline-none\", className)}\n      overlayProps={\n        {\n          \"data-slot\": \"pie-menu-overlay\",\n          ...overlayProps,\n          className: cn(\"z-50\", overlayProps?.className),\n        } as ComponentProps<\"div\">\n      }\n      {...props}\n    />\n  )\n}\n\nfunction PieMenuItem({\n  className,\n  variant = \"default\",\n  ...props\n}: PieMenuPrimitive.PieMenuItemProps & {\n  variant?: \"default\" | \"destructive\"\n}) {\n  return (\n    <PieMenuPrimitive.Item\n      data-slot=\"pie-menu-item\"\n      data-variant={variant}\n      className={cn(\n        // Base look: a shadcn/ui popover pill.\n        \"z-10 flex h-9 cursor-default items-center gap-2 rounded-full border bg-popover px-3.5 text-sm font-medium whitespace-nowrap text-popover-foreground shadow-md outline-hidden select-none\",\n        \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground\",\n        // Position on the ring, and motion between states.\n        \"[translate:var(--pie-item-translate)] transition-[translate,scale,opacity,background-color,color,border-color,box-shadow] duration-200\",\n        EASE_OUT,\n        // Highlight. Items float over any background, so the highlight uses the strong primary pair.\n        \"data-highlighted:scale-105 data-highlighted:border-primary data-highlighted:bg-primary data-highlighted:text-primary-foreground data-highlighted:shadow-lg data-highlighted:[&_svg:not([class*='text-'])]:text-primary-foreground\",\n        \"data-[variant=destructive]:text-destructive data-[variant=destructive]:[&_svg:not([class*='text-'])]:text-destructive\",\n        \"data-[variant=destructive]:data-highlighted:border-destructive data-[variant=destructive]:data-highlighted:bg-destructive data-[variant=destructive]:data-highlighted:text-white data-[variant=destructive]:data-highlighted:[&_svg:not([class*='text-'])]:text-white\",\n        \"data-disabled:opacity-50\",\n        // Enter: grow out of the center, one item after another.\n        \"group-data-entering/pie-menu:delay-[calc(var(--pie-item-index)*16ms)]\",\n        \"group-data-starting-style/pie-menu:opacity-0 motion-safe:group-data-starting-style/pie-menu:[translate:-50%_-50%] motion-safe:group-data-starting-style/pie-menu:scale-50\",\n        // An item added while the menu is open grows out of the center on its own.\n        \"data-starting-style:opacity-0 motion-safe:data-starting-style:[translate:-50%_-50%] motion-safe:data-starting-style:scale-50\",\n        // Exit: the chosen item lingers and swells, the others fold back in.\n        \"group-data-ending-style/pie-menu:opacity-0\",\n        \"group-data-ending-style/pie-menu:not-data-selected:duration-150 motion-safe:group-data-ending-style/pie-menu:not-data-selected:[translate:-50%_-50%] motion-safe:group-data-ending-style/pie-menu:not-data-selected:scale-75\",\n        \"group-data-ending-style/pie-menu:data-selected:duration-300 motion-safe:group-data-ending-style/pie-menu:data-selected:scale-110\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction PieMenuCenter({\n  className,\n  ...props\n}: ComponentProps<typeof PieMenuPrimitive.Center>) {\n  return (\n    <PieMenuPrimitive.Center\n      data-slot=\"pie-menu-center\"\n      className={cn(\n        \"grid -translate-1/2 place-items-center transition-[opacity,scale] duration-200\",\n        EASE_OUT,\n        \"group-data-starting-style/pie-menu:opacity-0 motion-safe:group-data-starting-style/pie-menu:scale-75\",\n        \"group-data-ending-style/pie-menu:opacity-0 group-data-ending-style/pie-menu:duration-200 motion-safe:group-data-ending-style/pie-menu:scale-90\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/** A ring around the dead zone with an arc that turns toward the highlighted item. */\nfunction PieMenuIndicator({\n  className,\n  children,\n  ...props\n}: ComponentProps<typeof PieMenuPrimitive.Indicator>) {\n  return (\n    <PieMenuPrimitive.Indicator\n      data-slot=\"pie-menu-indicator\"\n      className={cn(\n        \"group/pie-indicator pointer-events-none size-[calc(var(--pie-dead-zone)*2_+_16px)] -translate-1/2 text-primary\",\n        \"[rotate:var(--pie-indicator-rotate)] transition-[rotate,opacity,scale] duration-200 motion-reduce:transition-[opacity]\",\n        EASE_OUT,\n        \"group-data-ending-style/pie-menu:opacity-0 group-data-ending-style/pie-menu:duration-100 group-data-starting-style/pie-menu:opacity-0\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? (\n        <svg\n          viewBox=\"0 0 100 100\"\n          className=\"size-full overflow-visible drop-shadow-sm\"\n          fill=\"none\"\n        >\n          <circle\n            cx=\"50\"\n            cy=\"50\"\n            r=\"44\"\n            className=\"fill-popover stroke-border\"\n            strokeWidth=\"1.5\"\n            vectorEffect=\"non-scaling-stroke\"\n          />\n          <path\n            d=\"M29.34 11.15 A44 44 0 0 1 70.66 11.15\"\n            stroke=\"currentColor\"\n            strokeWidth=\"3\"\n            strokeLinecap=\"round\"\n            vectorEffect=\"non-scaling-stroke\"\n            className=\"opacity-0 transition-opacity duration-150 group-data-visible/pie-indicator:opacity-100\"\n          />\n        </svg>\n      )}\n    </PieMenuPrimitive.Indicator>\n  )\n}\n\nconst usePieMenuAim = PieMenuPrimitive.usePieMenuAim\n\nexport {\n  PieMenu,\n  PieMenuTrigger,\n  PieMenuContent,\n  PieMenuItem,\n  PieMenuCenter,\n  PieMenuIndicator,\n  usePieMenuAim,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/lib/pie-menu/primitive.tsx",
      "content": "\"use client\"\n\nimport {\n  createContext,\n  use,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactNode,\n  type RefObject,\n} from \"react\"\nimport { Slot } from \"radix-ui\"\nimport { createPortal } from \"react-dom\"\n\nimport {\n  aimFromVector,\n  aimStyle,\n  createAimStore,\n  IDLE_AIM,\n  useAimStore,\n  type AimStore,\n} from \"./aim\"\nimport {\n  clampCenter,\n  degToRad,\n  fitScale,\n  itemTranslate,\n  layoutItems,\n  menuExtents,\n  nearestIndex,\n  radToDeg,\n  scaleExtents,\n  shortestRotation,\n  stepIndex,\n  vectorAngle,\n  wedgeIndex,\n  type ItemLayout,\n  type Point,\n} from \"./geometry\"\nimport {\n  callHandler,\n  composeHandlers,\n  useComposedRefs,\n  useControllableState,\n  useLatest,\n} from \"./hooks\"\nimport { usePresence, type PresenceStatus } from \"./use-presence\"\n\nexport type { Aim } from \"./aim\"\nexport type { PresenceStatus } from \"./use-presence\"\n\nconst LONG_PRESS_MS = 450\nconst LONG_PRESS_TOLERANCE = 10\nconst TYPEAHEAD_RESET_MS = 600\n\n/* -------------------------------------------------------------------------------------------------\n * Root\n * -----------------------------------------------------------------------------------------------*/\n\ntype OpenSource = \"pointer\" | \"keyboard\"\n\ninterface OpenRequest {\n  key: number\n  point: Point\n  source: OpenSource\n  /** The pointer that opened the menu is still down, so a release can select. */\n  pressed: boolean\n  /** Only this pointer can end the press. Undefined accepts any pointer. */\n  pointerId?: number\n}\n\ninterface RootContextValue {\n  open: boolean\n  request: OpenRequest | null\n  openAt: (request: Omit<OpenRequest, \"key\">) => void\n  close: () => void\n  isOpen: () => boolean\n  triggerRef: RefObject<HTMLElement | null>\n  triggerId: string\n  contentId: string\n}\n\nconst RootContext = createContext<RootContextValue | null>(null)\n\nfunction useRootContext(consumer: string) {\n  const context = use(RootContext)\n  if (!context) throw new Error(`<${consumer}> must be used inside <PieMenu>.`)\n  return context\n}\n\nexport interface PieMenuProps {\n  open?: boolean\n  defaultOpen?: boolean\n  onOpenChange?: (open: boolean) => void\n  children?: ReactNode\n}\n\nexport function Root({\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  children,\n}: PieMenuProps) {\n  const [open, setOpen] = useControllableState({\n    value: openProp,\n    defaultValue: defaultOpen,\n    onChange: onOpenChange,\n  })\n  const [request, setRequest] = useState<OpenRequest | null>(null)\n  const triggerRef = useRef<HTMLElement | null>(null)\n  // Mirrors `open` without waiting for a render, so two events in one frame cannot open twice.\n  const openRef = useRef(open)\n  useLayoutEffect(() => {\n    openRef.current = open\n  }, [open])\n  const keyRef = useRef(0)\n  const triggerId = useId()\n  const contentId = useId()\n\n  const openAt = useCallback(\n    (next: Omit<OpenRequest, \"key\">) => {\n      openRef.current = true\n      keyRef.current += 1\n      setRequest({ ...next, key: keyRef.current })\n      setOpen(true)\n    },\n    [setOpen]\n  )\n\n  const close = useCallback(() => {\n    openRef.current = false\n    setOpen(false)\n  }, [setOpen])\n\n  const value = useMemo<RootContextValue>(\n    () => ({\n      open,\n      request,\n      openAt,\n      close,\n      isOpen: () => openRef.current,\n      triggerRef,\n      triggerId,\n      contentId,\n    }),\n    [open, request, openAt, close, triggerId, contentId]\n  )\n\n  return <RootContext value={value}>{children}</RootContext>\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Trigger\n * -----------------------------------------------------------------------------------------------*/\n\nexport interface PieMenuTriggerProps extends ComponentProps<\"button\"> {\n  asChild?: boolean\n  /**\n   * - `press`: primary press opens the menu at the pointer. Drag and release to select.\n   *   Enter and Space open it from the keyboard.\n   * - `contextmenu`: right click or long press opens the menu. Enter and Space open it\n   *   when the trigger itself has focus, so keys typed in a child input are left alone.\n   *\n   * In both modes, the ContextMenu key and Shift+F10 open the menu from the keyboard.\n   */\n  openOn?: \"press\" | \"contextmenu\"\n}\n\nexport function Trigger({\n  asChild = false,\n  openOn = \"press\",\n  disabled,\n  ref,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onPointerCancel,\n  onContextMenu,\n  onKeyDown,\n  ...props\n}: PieMenuTriggerProps) {\n  const context = useRootContext(\"PieMenuTrigger\")\n  const longPress = useRef<{\n    timer: number\n    pointerId: number\n    x: number\n    y: number\n  } | null>(null)\n\n  const cancelLongPress = useCallback(() => {\n    if (!longPress.current) return\n    window.clearTimeout(longPress.current.timer)\n    longPress.current = null\n  }, [])\n\n  useEffect(() => cancelLongPress, [cancelLongPress])\n\n  const composedRef = useComposedRefs(ref, context.triggerRef)\n  const Comp = asChild ? Slot.Root : \"button\"\n\n  return (\n    <Comp\n      type={asChild ? undefined : \"button\"}\n      aria-haspopup=\"menu\"\n      aria-expanded={context.open}\n      aria-controls={context.open ? context.contentId : undefined}\n      data-state={context.open ? \"open\" : \"closed\"}\n      data-open-on={openOn}\n      data-disabled={disabled ? \"\" : undefined}\n      disabled={disabled}\n      {...props}\n      id={props.id ?? context.triggerId}\n      ref={composedRef}\n      onPointerDown={(event) => {\n        if (callHandler(onPointerDown, event) || disabled || context.isOpen())\n          return\n        const { clientX: x, clientY: y, pointerId } = event\n\n        if (openOn === \"press\") {\n          // Ctrl + click is a context click on macOS.\n          if (\n            event.pointerType === \"mouse\" &&\n            (event.button !== 0 || event.ctrlKey)\n          )\n            return\n          event.preventDefault()\n          context.openAt({\n            point: { x, y },\n            source: \"pointer\",\n            pressed: true,\n            pointerId,\n          })\n          return\n        }\n\n        if (event.pointerType === \"mouse\") return\n        cancelLongPress()\n        longPress.current = {\n          pointerId,\n          x,\n          y,\n          timer: window.setTimeout(() => {\n            longPress.current = null\n            context.openAt({\n              point: { x, y },\n              source: \"pointer\",\n              pressed: true,\n              pointerId,\n            })\n          }, LONG_PRESS_MS),\n        }\n      }}\n      onPointerMove={(event) => {\n        if (callHandler(onPointerMove, event)) return\n        const pending = longPress.current\n        if (!pending || pending.pointerId !== event.pointerId) return\n        if (\n          Math.hypot(event.clientX - pending.x, event.clientY - pending.y) >\n          LONG_PRESS_TOLERANCE\n        )\n          cancelLongPress()\n      }}\n      onPointerUp={(event) => {\n        if (!callHandler(onPointerUp, event)) cancelLongPress()\n      }}\n      onPointerCancel={(event) => {\n        if (!callHandler(onPointerCancel, event)) cancelLongPress()\n      }}\n      onContextMenu={(event) => {\n        if (callHandler(onContextMenu, event)) return\n        if (context.isOpen()) {\n          event.preventDefault()\n          return\n        }\n        if (openOn !== \"contextmenu\" || disabled) return\n        event.preventDefault()\n        cancelLongPress()\n        context.openAt({\n          point: { x: event.clientX, y: event.clientY },\n          source: \"pointer\",\n          // macOS fires this on press, Windows on release.\n          pressed: event.buttons !== 0,\n        })\n      }}\n      onKeyDown={(event) => {\n        if (callHandler(onKeyDown, event) || disabled) return\n        const menuKey =\n          event.key === \"ContextMenu\" || (event.shiftKey && event.key === \"F10\")\n        const onTrigger =\n          openOn === \"press\" || event.target === event.currentTarget\n        const activate =\n          onTrigger && (event.key === \"Enter\" || event.key === \" \")\n        if (!menuKey && !activate) return\n        event.preventDefault()\n        const rect = event.currentTarget.getBoundingClientRect()\n        context.openAt({\n          point: {\n            x: rect.left + rect.width / 2,\n            y: rect.top + rect.height / 2,\n          },\n          source: \"keyboard\",\n          pressed: false,\n        })\n      }}\n    />\n  )\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Content\n * -----------------------------------------------------------------------------------------------*/\n\ninterface ItemRecord {\n  id: string\n  element: HTMLElement\n  disabled: boolean\n  textValue: string\n  onSelect: RefObject<((event: Event) => void) | undefined>\n  onHighlight: RefObject<(() => void) | undefined>\n}\n\ninterface ContentContextValue {\n  register: (record: ItemRecord) => () => void\n  layoutById: ReadonlyMap<string, ItemLayout>\n  highlightedId: string | null\n  selectedId: string | null\n  /** Degrees, or null when nothing is highlighted. */\n  highlightedAngle: number | null\n  aimStore: AimStore\n}\n\nconst ContentContext = createContext<ContentContextValue | null>(null)\n\nfunction useContentContext(consumer: string) {\n  const context = use(ContentContext)\n  if (!context)\n    throw new Error(`<${consumer}> must be used inside <PieMenuContent>.`)\n  return context\n}\n\nexport interface PieMenuContentProps extends ComponentProps<\"div\"> {\n  /** Distance from the center to each item anchor, in px. */\n  radius?: number\n  /** Radius of the center area that selects nothing, in px. */\n  deadZone?: number\n  /** Direction of the first item, in degrees clockwise from 12 o'clock. */\n  startAngle?: number\n  /** Minimum space between the menu and the viewport edges, in px. */\n  collisionPadding?: number\n  /** Move focus back to the trigger when the menu closes. */\n  returnFocus?: boolean\n  /** Call `event.preventDefault()` to keep the menu open. */\n  onEscapeKeyDown?: (event: KeyboardEvent) => void\n  /** Portal target. Defaults to `document.body`. */\n  container?: Element | null\n  /** Props for the full-screen layer that captures pointer input while the menu is open. */\n  overlayProps?: ComponentProps<\"div\">\n}\n\nexport function Content({ container, ...props }: PieMenuContentProps) {\n  const root = useRootContext(\"PieMenuContent\")\n  const menuRef = useRef<HTMLDivElement | null>(null)\n  const { mounted, status } = usePresence(root.open, menuRef)\n\n  if (!mounted || typeof document === \"undefined\") return null\n  const request = root.request ?? requestFromTrigger(root.triggerRef.current)\n\n  return createPortal(\n    <ContentImpl\n      key={request.key}\n      {...props}\n      request={request}\n      status={status}\n      menuRef={menuRef}\n    />,\n    container ?? document.body\n  )\n}\n\ninterface Gesture {\n  /** `drag`: the opening press is still down. `sticky`: the menu waits for a click or a key. */\n  mode: \"drag\" | \"sticky\"\n  origin: Point\n  pointerId?: number\n  leftDeadZone: boolean\n  pressed: boolean\n}\n\ninterface ContentImplProps extends Omit<PieMenuContentProps, \"container\"> {\n  request: OpenRequest\n  status: PresenceStatus\n  menuRef: RefObject<HTMLDivElement | null>\n}\n\nfunction ContentImpl({\n  request,\n  status,\n  menuRef,\n  radius = 96,\n  deadZone = 20,\n  startAngle = 0,\n  collisionPadding = 8,\n  returnFocus = true,\n  onEscapeKeyDown,\n  overlayProps,\n  ref,\n  style,\n  children,\n  ...menuProps\n}: ContentImplProps) {\n  const root = useRootContext(\"PieMenuContent\")\n  const startRadians = degToRad(startAngle)\n\n  const [items, setItems] = useState<ItemRecord[]>([])\n  const [highlightedId, setHighlightedId] = useState<string | null>(null)\n  const [selectedId, setSelectedId] = useState<string | null>(null)\n  const [fit, setFit] = useState({ center: request.point, scale: 1 })\n  const overlayRef = useRef<HTMLDivElement | null>(null)\n  const highlightedRef = useRef<string | null>(null)\n  const lastInput = useRef<OpenSource>(request.source)\n  const gesture = useRef<Gesture>({\n    mode: request.pressed ? \"drag\" : \"sticky\",\n    origin: request.point,\n    pointerId: request.pointerId,\n    leftDeadZone: false,\n    pressed: false,\n  })\n\n  const [aimStore] = useState(createAimStore)\n\n  // Mirror the aim into CSS variables on the menu element, with no React render.\n  useLayoutEffect(\n    () =>\n      aimStore.subscribe(() => {\n        const menu = menuRef.current\n        if (!menu) return\n        for (const [name, value] of Object.entries(aimStyle(aimStore.get())))\n          menu.style.setProperty(name, value)\n      }),\n    [aimStore, menuRef]\n  )\n\n  const layouts = useMemo(\n    () => layoutItems(items.length, radius, startRadians),\n    [items.length, radius, startRadians]\n  )\n  const layoutById = useMemo(\n    () => new Map(items.map((item, index) => [item.id, layouts[index]!])),\n    [items, layouts]\n  )\n\n  const itemsRef = useLatest(items)\n  const layoutsRef = useLatest(layouts)\n  const fitRef = useLatest(fit)\n  const onEscapeKeyDownRef = useLatest(onEscapeKeyDown)\n\n  const register = useCallback((record: ItemRecord) => {\n    setItems((current) =>\n      sortByDocumentPosition([\n        ...current.filter((item) => item.id !== record.id),\n        record,\n      ])\n    )\n    return () =>\n      setItems((current) => current.filter((item) => item.id !== record.id))\n  }, [])\n\n  /** Pointer highlights come with a pointer aim. Keyboard highlights aim at the item. */\n  const highlight = useCallback(\n    (id: string | null, source: OpenSource) => {\n      lastInput.current = source\n      const index = itemsRef.current.findIndex((item) => item.id === id)\n      if (highlightedRef.current !== id) {\n        highlightedRef.current = id\n        setHighlightedId(id)\n        itemsRef.current[index]?.onHighlight.current?.()\n      }\n      const target =\n        index === -1 ? menuRef.current : itemsRef.current[index]!.element\n      if (target && document.activeElement !== target) focus(target, source)\n      if (source === \"keyboard\") {\n        const layout = layoutsRef.current[index]\n        aimStore.set(\n          layout ? aimFromVector(layout.sin, -layout.cos, 1) : IDLE_AIM\n        )\n      }\n    },\n    [aimStore, itemsRef, layoutsRef, menuRef]\n  )\n\n  const select = useCallback(\n    (id: string, originalEvent: Event) => {\n      const item = itemsRef.current.find((record) => record.id === id)\n      if (!item || item.disabled) return\n      const event = new CustomEvent(\"piemenu.select\", {\n        cancelable: true,\n        detail: { originalEvent },\n      })\n      item.onSelect.current?.(event)\n      if (event.defaultPrevented) {\n        gesture.current = {\n          ...gesture.current,\n          mode: \"sticky\",\n          pointerId: undefined,\n          pressed: false,\n        }\n        return\n      }\n      setSelectedId(id)\n      root.close()\n    },\n    [itemsRef, root]\n  )\n\n  // Keep the whole menu inside the viewport: shrink it when it is too big, then move it.\n  // This runs before paint, so the menu never jumps.\n  useLayoutEffect(() => {\n    const menu = menuRef.current\n    if (!menu) return\n    const sizes = items.map(({ element }) => ({\n      width: element.offsetWidth,\n      height: element.offsetHeight,\n    }))\n    const centerElement = menu.querySelector<HTMLElement>(\n      \"[data-pie-menu-center]\"\n    )\n    const centerSize = centerElement\n      ? { width: centerElement.offsetWidth, height: centerElement.offsetHeight }\n      : undefined\n    const html = document.documentElement\n    const viewport = {\n      width: html.clientWidth || window.innerWidth,\n      height: html.clientHeight || window.innerHeight,\n    }\n    const extents = menuExtents(layouts, sizes, centerSize)\n    const scale = fitScale(extents, viewport, collisionPadding)\n    const center = clampCenter(\n      request.point,\n      scaleExtents(extents, scale),\n      viewport,\n      collisionPadding\n    )\n    setFit((current) =>\n      current.scale === scale &&\n      current.center.x === center.x &&\n      current.center.y === center.y\n        ? current\n        : { center, scale }\n    )\n  }, [items, layouts, request.point, collisionPadding, menuRef])\n\n  // Initial focus: the first item for keyboard users, the menu itself for pointer users.\n  const focusedOnOpen = useRef(false)\n  useLayoutEffect(() => {\n    if (focusedOnOpen.current) return\n    const first = items.find((item) => !item.disabled)\n    if (request.source === \"keyboard\" && first) {\n      focusedOnOpen.current = true\n      highlight(first.id, \"keyboard\")\n    } else if (request.source === \"pointer\" || items.length > 0) {\n      focusedOnOpen.current = true\n      if (menuRef.current) focus(menuRef.current, request.source)\n    }\n  }, [items, request.source, highlight, menuRef])\n\n  // Give focus back to the trigger on close, unless something else took it.\n  useEffect(() => {\n    if (root.open || !returnFocus) return\n    const active = document.activeElement\n    const trigger = root.triggerRef.current\n    if (\n      trigger &&\n      (!active || active === document.body || menuRef.current?.contains(active))\n    ) {\n      focus(trigger, lastInput.current)\n    }\n  }, [root.open, root.triggerRef, returnFocus, menuRef])\n\n  // Pointer input. Listeners sit on the window in the capture phase, so a drag works\n  // even when the pointer leaves the overlay, and the press that opened the menu is not seen twice.\n  useEffect(() => {\n    if (!root.open) return\n\n    const track = (event: PointerEvent) => {\n      const current = gesture.current\n      const reference =\n        current.mode === \"drag\" ? current.origin : fitRef.current.center\n      const dx = event.clientX - reference.x\n      const dy = event.clientY - reference.y\n      const distance = Math.hypot(dx, dy)\n      if (distance > deadZone) current.leftDeadZone = true\n      aimStore.set(aimFromVector(dx, dy, radius * fitRef.current.scale))\n\n      const records = itemsRef.current\n      const hovered =\n        event.target instanceof Element\n          ? event.target.closest(\"[data-pie-menu-item]\")\n          : null\n      const hoveredItem = hovered\n        ? records.find((item) => item.element === hovered)\n        : undefined\n      // The dead zone never highlights. This matters on open: items start stacked at the\n      // center, right under the pointer, so the element under it can be an item.\n      let next: ItemRecord | undefined\n      if (distance > deadZone) {\n        next =\n          hoveredItem ??\n          records[wedgeIndex(vectorAngle(dx, dy), records.length, startRadians)]\n      }\n      highlight(next && !next.disabled ? next.id : null, \"pointer\")\n      return distance\n    }\n\n    const commit = (event: PointerEvent) => {\n      const id = highlightedRef.current\n      if (id) select(id, event)\n      else root.close()\n    }\n\n    const ownsPointer = (event: PointerEvent) =>\n      gesture.current.pointerId === undefined ||\n      gesture.current.pointerId === event.pointerId\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (gesture.current.mode === \"drag\" && !ownsPointer(event)) return\n      track(event)\n    }\n\n    const onPointerDown = (event: PointerEvent) => {\n      const current = gesture.current\n      if (current.mode !== \"sticky\") return\n      event.preventDefault()\n      current.pressed = true\n      current.pointerId = event.pointerId\n      track(event)\n    }\n\n    const onPointerUp = (event: PointerEvent) => {\n      const current = gesture.current\n      if (!ownsPointer(event)) return\n      if (current.mode === \"drag\") {\n        // A fast flick can release before any move event arrives.\n        track(event)\n        // A click that stays in the center leaves the menu open for a second click.\n        if (current.leftDeadZone) commit(event)\n        else\n          gesture.current = { ...current, mode: \"sticky\", pointerId: undefined }\n        return\n      }\n      if (!current.pressed) return\n      current.pressed = false\n      current.pointerId = undefined\n      // A click in the center cancels, the same as the Escape key.\n      if (track(event) <= deadZone) root.close()\n      else commit(event)\n    }\n\n    const close = () => root.close()\n    const options = { capture: true }\n    window.addEventListener(\"pointermove\", onPointerMove, options)\n    window.addEventListener(\"pointerdown\", onPointerDown, options)\n    window.addEventListener(\"pointerup\", onPointerUp, options)\n    window.addEventListener(\"pointercancel\", close, options)\n    window.addEventListener(\"resize\", close)\n    window.addEventListener(\"blur\", close)\n    return () => {\n      window.removeEventListener(\"pointermove\", onPointerMove, options)\n      window.removeEventListener(\"pointerdown\", onPointerDown, options)\n      window.removeEventListener(\"pointerup\", onPointerUp, options)\n      window.removeEventListener(\"pointercancel\", close, options)\n      window.removeEventListener(\"resize\", close)\n      window.removeEventListener(\"blur\", close)\n    }\n  }, [\n    root,\n    deadZone,\n    radius,\n    startRadians,\n    aimStore,\n    highlight,\n    select,\n    itemsRef,\n    fitRef,\n  ])\n\n  // Keyboard input. Arrow keys pick by direction and combine for diagonals (Up + Right = north-east).\n  useEffect(() => {\n    if (!root.open) return\n    const arrows = new Set<string>()\n    let typeahead = \"\"\n    let typeaheadTimer = 0\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      lastInput.current = \"keyboard\"\n      const records = itemsRef.current\n      const enabled = records.map((item) => !item.disabled)\n      const current = records.findIndex(\n        (item) => item.id === highlightedRef.current\n      )\n      const go = (index: number) => {\n        const item = records[index]\n        if (item) highlight(item.id, \"keyboard\")\n      }\n\n      if (event.key === \"Escape\") {\n        onEscapeKeyDownRef.current?.(event)\n        if (event.defaultPrevented) return\n        event.preventDefault()\n        event.stopPropagation()\n        root.close()\n      } else if (event.key in ARROW_VECTORS) {\n        event.preventDefault()\n        arrows.add(event.key)\n        const vector = [...arrows].reduce(\n          (sum, key) => ({\n            x: sum.x + ARROW_VECTORS[key]!.x,\n            y: sum.y + ARROW_VECTORS[key]!.y,\n          }),\n          { x: 0, y: 0 }\n        )\n        if (vector.x === 0 && vector.y === 0) return\n        const angles = layoutsRef.current.map((layout) => layout.angle)\n        go(nearestIndex(vectorAngle(vector.x, vector.y), angles, enabled))\n      } else if (event.key === \"Tab\") {\n        event.preventDefault()\n        const step = event.shiftKey ? -1 : 1\n        go(\n          stepIndex(\n            current === -1 ? (step === 1 ? -1 : 0) : current,\n            step,\n            enabled\n          )\n        )\n      } else if (event.key === \"Home\") {\n        event.preventDefault()\n        go(stepIndex(-1, 1, enabled))\n      } else if (event.key === \"End\") {\n        event.preventDefault()\n        go(stepIndex(0, -1, enabled))\n      } else if (event.key === \"Enter\" || event.key === \" \") {\n        event.preventDefault()\n        // A held key from the trigger must not select at once.\n        if (event.repeat || !highlightedRef.current) return\n        select(highlightedRef.current, event)\n      } else if (\n        event.key.length === 1 &&\n        !event.ctrlKey &&\n        !event.metaKey &&\n        !event.altKey\n      ) {\n        window.clearTimeout(typeaheadTimer)\n        typeaheadTimer = window.setTimeout(\n          () => (typeahead = \"\"),\n          TYPEAHEAD_RESET_MS\n        )\n        typeahead += event.key.toLowerCase()\n        const start =\n          typeahead.length === 1 ? current + 1 : Math.max(current, 0)\n        for (let offset = 0; offset < records.length; offset++) {\n          const index = (start + offset) % records.length\n          const item = records[index]!\n          if (\n            !item.disabled &&\n            item.textValue.toLowerCase().startsWith(typeahead)\n          )\n            return go(index)\n        }\n      }\n    }\n\n    const onKeyUp = (event: KeyboardEvent) => arrows.delete(event.key)\n    const onBlur = () => arrows.clear()\n    document.addEventListener(\"keydown\", onKeyDown, true)\n    document.addEventListener(\"keyup\", onKeyUp, true)\n    window.addEventListener(\"blur\", onBlur)\n    return () => {\n      window.clearTimeout(typeaheadTimer)\n      document.removeEventListener(\"keydown\", onKeyDown, true)\n      document.removeEventListener(\"keyup\", onKeyUp, true)\n      window.removeEventListener(\"blur\", onBlur)\n    }\n  }, [root, highlight, select, itemsRef, layoutsRef, onEscapeKeyDownRef])\n\n  // The menu is modal: the page under it must not scroll.\n  useEffect(() => {\n    const overlay = overlayRef.current\n    if (!overlay) return\n    const prevent = (event: Event) => event.preventDefault()\n    overlay.addEventListener(\"wheel\", prevent, { passive: false })\n    return () => overlay.removeEventListener(\"wheel\", prevent)\n  }, [])\n\n  const highlightedLayout = highlightedId\n    ? layoutById.get(highlightedId)\n    : undefined\n  const overlayComposedRef = useComposedRefs(overlayProps?.ref, overlayRef)\n  const menuComposedRef = useComposedRefs(ref, menuRef)\n\n  const contentContext = useMemo<ContentContextValue>(\n    () => ({\n      register,\n      layoutById,\n      highlightedId,\n      selectedId,\n      highlightedAngle: highlightedLayout\n        ? radToDeg(highlightedLayout.angle)\n        : null,\n      aimStore,\n    }),\n    [\n      register,\n      layoutById,\n      highlightedId,\n      selectedId,\n      highlightedLayout,\n      aimStore,\n    ]\n  )\n\n  return (\n    <div\n      {...overlayProps}\n      ref={overlayComposedRef}\n      role=\"presentation\"\n      data-pie-menu-overlay=\"\"\n      data-state={root.open ? \"open\" : \"closed\"}\n      style={{\n        position: \"fixed\",\n        inset: 0,\n        touchAction: \"none\",\n        pointerEvents: status === \"ending\" ? \"none\" : undefined,\n        ...overlayProps?.style,\n      }}\n      onContextMenu={composeHandlers(overlayProps?.onContextMenu, (event) =>\n        event.preventDefault()\n      )}\n    >\n      <div\n        aria-labelledby={menuProps[\"aria-label\"] ? undefined : root.triggerId}\n        {...menuProps}\n        ref={menuComposedRef}\n        id={root.contentId}\n        role=\"menu\"\n        tabIndex={-1}\n        data-state={root.open ? \"open\" : \"closed\"}\n        data-starting-style={status === \"starting\" ? \"\" : undefined}\n        data-entering={status === \"entering\" ? \"\" : undefined}\n        data-ending-style={status === \"ending\" ? \"\" : undefined}\n        data-highlighting={highlightedId ? \"\" : undefined}\n        style={\n          {\n            position: \"fixed\",\n            left: fit.center.x,\n            top: fit.center.y,\n            width: 0,\n            height: 0,\n            scale: fit.scale === 1 ? undefined : String(fit.scale),\n            \"--pie-radius\": `${radius}px`,\n            \"--pie-dead-zone\": `${deadZone}px`,\n            ...style,\n          } as CSSProperties\n        }\n      >\n        <ContentContext value={contentContext}>{children}</ContentContext>\n      </div>\n    </div>\n  )\n}\n\nconst ARROW_VECTORS: Record<string, Point> = {\n  ArrowUp: { x: 0, y: -1 },\n  ArrowDown: { x: 0, y: 1 },\n  ArrowLeft: { x: -1, y: 0 },\n  ArrowRight: { x: 1, y: 0 },\n}\n\n/**\n * Script focus shows a focus ring in some browsers even after a click.\n * Pointer users get no ring, keyboard users do.\n */\nfunction focus(element: HTMLElement, source: OpenSource) {\n  element.focus({\n    preventScroll: true,\n    focusVisible: source === \"keyboard\",\n  } as FocusOptions)\n}\n\nfunction requestFromTrigger(trigger: HTMLElement | null): OpenRequest {\n  const rect = trigger?.getBoundingClientRect()\n  const point = rect\n    ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }\n    : { x: window.innerWidth / 2, y: window.innerHeight / 2 }\n  return { key: 0, point, source: \"keyboard\", pressed: false }\n}\n\nfunction sortByDocumentPosition(records: ItemRecord[]) {\n  return records.sort((a, b) =>\n    a.element.compareDocumentPosition(b.element) &\n    Node.DOCUMENT_POSITION_FOLLOWING\n      ? -1\n      : 1\n  )\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Item\n * -----------------------------------------------------------------------------------------------*/\n\nexport interface PieMenuItemProps extends Omit<\n  ComponentProps<\"div\">,\n  \"onSelect\"\n> {\n  disabled?: boolean\n  /** Text for typeahead. Defaults to the text content of the item. */\n  textValue?: string\n  /** Call `event.preventDefault()` to keep the menu open. */\n  onSelect?: (event: Event) => void\n  /** Runs when the item becomes the highlighted item, by pointer or by keyboard. */\n  onHighlight?: () => void\n}\n\nexport function Item({\n  disabled = false,\n  textValue,\n  onSelect,\n  onHighlight,\n  ref,\n  style,\n  ...props\n}: PieMenuItemProps) {\n  const context = useContentContext(\"PieMenuItem\")\n  const id = useId()\n  const elementRef = useRef<HTMLDivElement | null>(null)\n  const onSelectRef = useLatest(onSelect)\n  const onHighlightRef = useLatest(onHighlight)\n  const { register } = context\n\n  // An item can mount while the menu is open. It gets its own first frame in the\n  // start state, so it can transition in like the items that opened with the menu.\n  const [starting, setStarting] = useState(true)\n  useEffect(() => {\n    let frame = requestAnimationFrame(() => {\n      frame = requestAnimationFrame(() => setStarting(false))\n    })\n    return () => cancelAnimationFrame(frame)\n  }, [])\n\n  useLayoutEffect(() => {\n    const element = elementRef.current\n    if (!element) return\n    return register({\n      id,\n      element,\n      disabled,\n      textValue: textValue ?? element.textContent?.trim() ?? \"\",\n      onSelect: onSelectRef,\n      onHighlight: onHighlightRef,\n    })\n  }, [register, id, disabled, textValue, onSelectRef, onHighlightRef])\n\n  const composedRef = useComposedRefs(ref, elementRef)\n  const layout = context.layoutById.get(id)\n\n  return (\n    <div\n      {...props}\n      ref={composedRef}\n      id={id}\n      role=\"menuitem\"\n      tabIndex={-1}\n      aria-disabled={disabled || undefined}\n      data-pie-menu-item=\"\"\n      data-disabled={disabled ? \"\" : undefined}\n      data-highlighted={context.highlightedId === id ? \"\" : undefined}\n      data-selected={context.selectedId === id ? \"\" : undefined}\n      data-starting-style={starting ? \"\" : undefined}\n      style={{\n        position: \"absolute\",\n        left: 0,\n        top: 0,\n        ...(layout && itemStyle(layout)),\n        ...style,\n      }}\n    />\n  )\n}\n\nfunction itemStyle(layout: ItemLayout) {\n  return {\n    \"--pie-item-index\": layout.index,\n    \"--pie-item-angle\": `${radToDeg(layout.angle)}deg`,\n    \"--pie-item-x\": `${layout.x}px`,\n    \"--pie-item-y\": `${layout.y}px`,\n    \"--pie-item-sin\": layout.sin,\n    \"--pie-item-cos\": layout.cos,\n    \"--pie-item-translate\": itemTranslate(layout),\n  } as CSSProperties\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Center and Indicator\n * -----------------------------------------------------------------------------------------------*/\n\n/** Content at the center of the menu, such as an avatar. It is decorative by default. */\nexport function Center({ style, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      aria-hidden\n      {...props}\n      data-pie-menu-center=\"\"\n      style={{ position: \"absolute\", left: 0, top: 0, ...style }}\n    />\n  )\n}\n\n/**\n * Turns toward the highlighted item. It always takes the short way around,\n * so a move from 350° to 10° turns 20°, not 340°.\n */\nexport function Indicator({ style, ...props }: ComponentProps<\"div\">) {\n  const { highlightedAngle } = useContentContext(\"PieMenuIndicator\")\n  const [rotation, setRotation] = useState(highlightedAngle ?? 0)\n  if (highlightedAngle !== null) {\n    const target = shortestRotation(rotation, highlightedAngle)\n    if (Math.abs(target - rotation) > 0.01) setRotation(target)\n  }\n\n  return (\n    <div\n      aria-hidden\n      {...props}\n      data-pie-menu-indicator=\"\"\n      data-visible={highlightedAngle !== null ? \"\" : undefined}\n      style={\n        {\n          position: \"absolute\",\n          left: 0,\n          top: 0,\n          \"--pie-indicator-rotate\": `${rotation}deg`,\n          ...style,\n        } as CSSProperties\n      }\n    />\n  )\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Hooks\n * -----------------------------------------------------------------------------------------------*/\n\n/**\n * The live aim inside `<PieMenuContent>`. It re-renders on every pointer move.\n * For styles, prefer the `--pie-aim-*` CSS variables: they update without a render.\n */\nexport function usePieMenuAim() {\n  return useAimStore(useContentContext(\"usePieMenuAim\").aimStore)\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/pie-menu/geometry.ts",
      "content": "/**\n * Pure math for pie menus. Angles are in radians, measured clockwise from\n * 12 o'clock, so 0 is \"up\" and PI / 2 is \"right\". Screen y grows downward.\n */\n\nexport const TAU = Math.PI * 2\n\nexport interface Point {\n  x: number\n  y: number\n}\n\nexport interface Size {\n  width: number\n  height: number\n}\n\nexport interface ItemLayout {\n  index: number\n  angle: number\n  /** Offset of the anchor point from the menu center, in px. */\n  x: number\n  y: number\n  sin: number\n  cos: number\n}\n\nexport function degToRad(degrees: number) {\n  return (degrees * Math.PI) / 180\n}\n\nexport function radToDeg(radians: number) {\n  return (radians * 180) / Math.PI\n}\n\nexport function normalizeAngle(angle: number) {\n  return ((angle % TAU) + TAU) % TAU\n}\n\n/** Angle of a vector in screen space. */\nexport function vectorAngle(dx: number, dy: number) {\n  return normalizeAngle(Math.atan2(dx, -dy))\n}\n\n/** Smallest distance between two angles, in the range [0, PI]. */\nexport function angularDistance(a: number, b: number) {\n  const diff = Math.abs(normalizeAngle(a) - normalizeAngle(b))\n  return Math.min(diff, TAU - diff)\n}\n\nexport function itemAngle(index: number, count: number, startAngle = 0) {\n  return normalizeAngle(startAngle + (index * TAU) / count)\n}\n\nexport function layoutItems(\n  count: number,\n  radius: number,\n  startAngle = 0\n): ItemLayout[] {\n  return Array.from({ length: count }, (_, index) => {\n    const angle = itemAngle(index, count, startAngle)\n    const sin = Math.sin(angle)\n    const cos = Math.cos(angle)\n    return { index, angle, sin, cos, x: sin * radius, y: -cos * radius }\n  })\n}\n\n/** Index of the wedge that contains the angle. Each wedge is centered on its item. */\nexport function wedgeIndex(angle: number, count: number, startAngle = 0) {\n  if (count === 0) return -1\n  const slice = TAU / count\n  return Math.round(normalizeAngle(angle - startAngle) / slice) % count\n}\n\n/** Index of the enabled item closest to the angle, or -1 when no item is enabled. */\nexport function nearestIndex(\n  angle: number,\n  angles: readonly number[],\n  enabled: readonly boolean[]\n) {\n  let best = -1\n  let bestDistance = Infinity\n  angles.forEach((itemAngle, index) => {\n    if (!enabled[index]) return\n    const distance = angularDistance(angle, itemAngle)\n    if (distance < bestDistance - 1e-9) {\n      best = index\n      bestDistance = distance\n    }\n  })\n  return best\n}\n\n/** Next enabled index when walking the ring. Returns -1 when no item is enabled. */\nexport function stepIndex(\n  from: number,\n  step: 1 | -1,\n  enabled: readonly boolean[]\n) {\n  const count = enabled.length\n  for (let offset = 1; offset <= count; offset++) {\n    const index = (((from + step * offset) % count) + count) % count\n    if (enabled[index]) return index\n  }\n  return -1\n}\n\n/**\n * Where an item box sits relative to its anchor point. Boxes grow away from the\n * center: a right-side label starts at the anchor, a top label ends at it.\n * The result is a fraction of the box size, in the range [-1, 0].\n */\nexport function anchorOffset(sin: number, cos: number) {\n  return { x: -0.5 + 0.5 * sin, y: -0.5 - 0.5 * cos }\n}\n\n/** CSS `translate` value that puts an item box at its anchor point. */\nexport function itemTranslate(layout: ItemLayout) {\n  const offset = anchorOffset(layout.sin, layout.cos)\n  const px = (value: number) => `${round(value)}px`\n  const pct = (value: number) => `${round(value * 100)}%`\n  return `calc(${px(layout.x)} + ${pct(offset.x)}) calc(${px(layout.y)} + ${pct(offset.y)})`\n}\n\nexport interface Extents {\n  left: number\n  right: number\n  top: number\n  bottom: number\n}\n\n/** Bounding box of all item boxes and a centered box, relative to the menu center. */\nexport function menuExtents(\n  layouts: readonly ItemLayout[],\n  sizes: readonly Size[],\n  centerSize?: Size\n): Extents {\n  const extents: Extents = { left: 0, right: 0, top: 0, bottom: 0 }\n  const include = (left: number, top: number, size: Size) => {\n    extents.left = Math.min(extents.left, left)\n    extents.top = Math.min(extents.top, top)\n    extents.right = Math.max(extents.right, left + size.width)\n    extents.bottom = Math.max(extents.bottom, top + size.height)\n  }\n  layouts.forEach((layout, index) => {\n    const size = sizes[index]\n    if (!size) return\n    const offset = anchorOffset(layout.sin, layout.cos)\n    include(\n      layout.x + offset.x * size.width,\n      layout.y + offset.y * size.height,\n      size\n    )\n  })\n  if (centerSize)\n    include(-centerSize.width / 2, -centerSize.height / 2, centerSize)\n  return extents\n}\n\n/** Scale that makes the menu fit inside the viewport, never above 1 and never below `min`. */\nexport function fitScale(\n  extents: Extents,\n  viewport: Size,\n  padding = 8,\n  min = 0.5\n) {\n  const width = extents.right - extents.left\n  const height = extents.bottom - extents.top\n  const scale = Math.min(\n    1,\n    (viewport.width - padding * 2) / width,\n    (viewport.height - padding * 2) / height\n  )\n  return Math.max(min, Number.isFinite(scale) ? scale : 1)\n}\n\nexport function scaleExtents(extents: Extents, scale: number): Extents {\n  return {\n    left: extents.left * scale,\n    right: extents.right * scale,\n    top: extents.top * scale,\n    bottom: extents.bottom * scale,\n  }\n}\n\n/** Moves the center so the whole menu stays inside the viewport. */\nexport function clampCenter(\n  point: Point,\n  extents: Extents,\n  viewport: Size,\n  padding = 8\n): Point {\n  const clampAxis = (value: number, min: number, max: number, size: number) =>\n    min > max ? size / 2 : Math.min(Math.max(value, min), max)\n  return {\n    x: clampAxis(\n      point.x,\n      padding - extents.left,\n      viewport.width - padding - extents.right,\n      viewport.width\n    ),\n    y: clampAxis(\n      point.y,\n      padding - extents.top,\n      viewport.height - padding - extents.bottom,\n      viewport.height\n    ),\n  }\n}\n\n/** Rotation target in degrees that turns the shortest way from the current value. */\nexport function shortestRotation(fromDegrees: number, toDegrees: number) {\n  const delta = ((((toDegrees - fromDegrees) % 360) + 540) % 360) - 180\n  return fromDegrees + delta\n}\n\nfunction round(value: number) {\n  return Math.round(value * 100) / 100\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/pie-menu/aim.ts",
      "content": "import { useSyncExternalStore } from \"react\"\n\nimport { radToDeg, vectorAngle } from \"./geometry\"\n\n/**\n * Where the user \"aims\" inside an open pie menu: the pointer direction, or the\n * direction of the highlighted item when the user drives the menu by keyboard.\n * `x` and `y` are in the range [-1, 1], relative to the menu radius.\n */\nexport interface Aim {\n  x: number\n  y: number\n  /** Degrees, clockwise from 12 o'clock. */\n  angle: number\n  /** 0 at the center, 1 at the item ring or farther. */\n  distance: number\n}\n\nexport const IDLE_AIM: Aim = { x: 0, y: 0, angle: 0, distance: 0 }\n\nexport function aimFromVector(dx: number, dy: number, radius: number): Aim {\n  const length = Math.hypot(dx, dy)\n  if (length === 0) return IDLE_AIM\n  const distance = Math.min(length / radius, 1)\n  return {\n    x: (dx / length) * distance,\n    y: (dy / length) * distance,\n    angle: radToDeg(vectorAngle(dx, dy)),\n    distance,\n  }\n}\n\ntype Listener = () => void\n\n/**\n * A tiny external store. Pointer moves arrive at display rate, so the menu\n * writes them here and to CSS variables instead of into React state.\n */\nexport function createAimStore() {\n  let aim = IDLE_AIM\n  const listeners = new Set<Listener>()\n  return {\n    get: () => aim,\n    set(next: Aim) {\n      if (next.x === aim.x && next.y === aim.y) return\n      aim = next\n      listeners.forEach((listener) => listener())\n    },\n    subscribe(listener: Listener) {\n      listeners.add(listener)\n      return () => {\n        listeners.delete(listener)\n      }\n    },\n  }\n}\n\nexport type AimStore = ReturnType<typeof createAimStore>\n\nexport function useAimStore(store: AimStore | null) {\n  return useSyncExternalStore(\n    store?.subscribe ?? noopSubscribe,\n    store?.get ?? getIdle,\n    getIdle\n  )\n}\n\n/** CSS custom properties that mirror the aim, for styles that follow the pointer. */\nexport function aimStyle(aim: Aim) {\n  return {\n    \"--pie-aim-x\": round(aim.x),\n    \"--pie-aim-y\": round(aim.y),\n    \"--pie-aim-angle\": `${round(aim.angle)}deg`,\n    \"--pie-aim-distance\": round(aim.distance),\n  }\n}\n\nconst noopSubscribe = () => () => {}\nconst getIdle = () => IDLE_AIM\n\nfunction round(value: number) {\n  return String(Math.round(value * 1000) / 1000)\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/pie-menu/hooks.ts",
      "content": "import { useCallback, useLayoutEffect, useRef, useState, type Ref } from \"react\"\n\nexport function useLatest<T>(value: T) {\n  const ref = useRef(value)\n  useLayoutEffect(() => {\n    ref.current = value\n  })\n  return ref\n}\n\nexport function useControllableState<T>(options: {\n  value: T | undefined\n  defaultValue: T\n  onChange?: (value: T) => void\n}) {\n  const [uncontrolled, setUncontrolled] = useState(options.defaultValue)\n  const isControlled = options.value !== undefined\n  const value = isControlled ? (options.value as T) : uncontrolled\n  const onChange = useLatest(options.onChange)\n\n  const setValue = useCallback(\n    (next: T) => {\n      if (!isControlled) setUncontrolled(next)\n      if (!Object.is(next, value)) onChange.current?.(next)\n    },\n    [isControlled, value, onChange]\n  )\n\n  return [value, setValue] as const\n}\n\nexport function composeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    const cleanups = refs.map((ref) => {\n      if (typeof ref === \"function\") return ref(node)\n      if (ref) ref.current = node\n      return undefined\n    })\n    return () => {\n      cleanups.forEach((cleanup, index) => {\n        const ref = refs[index]\n        if (typeof cleanup === \"function\") cleanup()\n        else if (typeof ref === \"function\") ref(null)\n        else if (ref) ref.current = null\n      })\n    }\n  }\n}\n\n/** A stable callback ref that sets both refs. */\nexport function useComposedRefs<T>(\n  a: Ref<T> | undefined,\n  b: Ref<T> | undefined\n) {\n  return useCallback((node: T | null) => composeRefs(a, b)(node), [a, b])\n}\n\n/**\n * Calls the user handler. Returns true when the user prevented the default,\n * so the caller can skip its own handling.\n */\nexport function callHandler<E extends { defaultPrevented: boolean }>(\n  handler: ((event: E) => void) | undefined,\n  event: E\n) {\n  handler?.(event)\n  return event.defaultPrevented\n}\n\n/** Calls the user handler first. The internal handler runs unless the user prevented it. */\nexport function composeHandlers<E extends { defaultPrevented: boolean }>(\n  userHandler: ((event: E) => void) | undefined,\n  ownHandler: (event: E) => void\n) {\n  return (event: E) => {\n    userHandler?.(event)\n    if (!event.defaultPrevented) ownHandler(event)\n  }\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/pie-menu/use-presence.ts",
      "content": "import { useEffect, useState, type RefObject } from \"react\"\n\n/**\n * - `starting`: first frame after mount. Styles show the enter start state.\n * - `entering`: enter transitions run. Use it for enter-only effects, such as a stagger.\n * - `open`: idle.\n * - `ending`: exit transitions run. The element unmounts when they finish.\n */\nexport type PresenceStatus = \"starting\" | \"entering\" | \"open\" | \"ending\"\n\n/**\n * Keeps an element mounted while its CSS transitions finish, in the style of\n * Base UI's `data-starting-style` and `data-ending-style`. It waits on the Web\n * Animations API, so it works with transitions and keyframes and needs no timers.\n */\nexport function usePresence(\n  present: boolean,\n  ref: RefObject<HTMLElement | null>\n) {\n  const [mounted, setMounted] = useState(present)\n  const [status, setStatus] = useState<PresenceStatus>(\n    present ? \"starting\" : \"ending\"\n  )\n\n  // Adjust the state when the prop changes, during render. This is the React pattern\n  // for state that follows a prop, and it avoids an extra render from an effect.\n  const [previous, setPrevious] = useState(present)\n  if (present !== previous) {\n    setPrevious(present)\n    if (present) {\n      setMounted(true)\n      setStatus(\"starting\")\n    } else if (status !== \"starting\") {\n      setStatus(\"ending\")\n    }\n  }\n\n  useEffect(() => {\n    if (!mounted) return\n    let cancelled = false\n    let frame = 0\n\n    if (status === \"starting\") {\n      // Two frames: the browser must paint the start state before it can transition from it.\n      frame = requestAnimationFrame(() => {\n        frame = requestAnimationFrame(() =>\n          setStatus(present ? \"entering\" : \"ending\")\n        )\n      })\n    } else if (status === \"entering\" || status === \"ending\") {\n      frame = requestAnimationFrame(() => {\n        waitForAnimations(ref.current).then(() => {\n          if (cancelled) return\n          if (status === \"entering\") setStatus(\"open\")\n          else setMounted(false)\n        })\n      })\n    }\n\n    return () => {\n      cancelled = true\n      cancelAnimationFrame(frame)\n    }\n  }, [mounted, status, present, ref])\n\n  return { mounted, status }\n}\n\nfunction waitForAnimations(element: HTMLElement | null) {\n  if (!element || typeof element.getAnimations !== \"function\")\n    return Promise.resolve()\n  // Skip endless animations, such as a spinner inside the element. They never finish.\n  const animations = element\n    .getAnimations({ subtree: true })\n    .filter(\n      (animation) => animation.effect?.getComputedTiming().endTime !== Infinity\n    )\n  return Promise.allSettled(animations.map((animation) => animation.finished))\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}