{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "orbit-menu",
  "title": "Orbit Menu",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Botão que abre as ações em arco ao redor dele, com mola, pra escolher arrastando o dedo ou clicando.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/orbit-menu.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type Transition } from \"motion/react\";\nimport { Check, MoreHorizontal, X } from \"lucide-react\";\n\nimport { DUR } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Orbit Menu — um botão que abre as ações em arco ao redor dele.\n *\n * Três a seis ações sobre UM objeto (a foto, a nota, o pedido) num lugar onde\n * não cabe barra de ferramentas. Menu suspenso resolve, mas pede dois toques e\n * uma leitura de lista. A órbita junta tudo num gesto só: aperta, arrasta até a\n * ação, solta. É o gesto que o polegar já conhece de app de câmera.\n *\n * Dois jeitos de usar, e os dois precisam funcionar sem aviso:\n *\n * 1. **Apertar e arrastar.** O arco abre no `pointerdown`. Enquanto o dedo anda,\n *    a ação mais próxima em ÂNGULO cresce e o rótulo central diz o nome dela.\n *    Soltar dispara. Soltar perto do botão não dispara nada.\n * 2. **Clicar.** Um clique sem arrasto deixa o arco aberto, e aí cada ação é um\n *    botão comum. Clicar no botão central de novo fecha.\n *\n * ── Decisões que não são óbvias ────────────────────────────────────────────\n *\n * - **A escolha é por ângulo, não por acerto.** Exigir que o dedo pouse em cima\n *   de um alvo de 44px num arco é pedir precisão de mouse num telefone. Basta\n *   apontar a direção e passar de uma distância mínima do centro.\n * - **O vetor vem do centro medido na tela.** Ângulo não muda com a escala da\n *   vitrine, e a distância mínima é dividida pela escala (`r.width / offsetWidth`)\n *   pra valer em px locais, os mesmos do raio.\n * - **Depois de disparar, o botão vira um visto por um instante** e o rótulo diz\n *   o `feedback` da ação. Quem dispara às cegas, no gesto, precisa da confirmação\n *   no mesmo lugar onde o dedo está.\n * - **Teclado:** o botão abre com Enter/Espaço/seta e o foco vai pra primeira\n *   ação; setas giram pelo arco, Enter/Espaço dispara, Esc e Tab fecham e devolvem\n *   o foco. As ações são `menuitem` dentro de `menu`.\n * - **Redução de movimento:** o arco aparece no lugar, só com opacidade.\n */\n\nexport interface OrbitAction {\n  id: string;\n  /** Nome curto, aparece no rótulo central e no leitor de tela. */\n  label: string;\n  icon: React.ReactNode;\n  /** Frase de confirmação depois de disparar: \"Fixado\". Sem isto, usa o `label`. */\n  feedback?: string;\n  /** `danger` pinta o ícone com o token destrutivo. */\n  tone?: \"default\" | \"danger\";\n}\n\nexport interface OrbitMenuProps {\n  actions: OrbitAction[];\n  /** Chamado com o `id` da ação escolhida. */\n  onAction?: (id: string) => void;\n  /** Nome do menu: rótulo do botão e texto central com o arco aberto. */\n  label?: string;\n  /** Distância do centro do botão até o centro de cada ação, em px. */\n  radius?: number;\n  /** Abertura do arco em graus, centrado em cima do botão. */\n  spread?: number;\n  /** Ícone do botão fechado. */\n  icon?: React.ReactNode;\n  /** Avisa quando o arco abre ou fecha (pra esmaecer o objeto, por exemplo). */\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\nconst BOTAO = 52;\nconst ACAO = 44;\nconst CONFIRMA_MS = 1200;\n\nconst mola: Transition = { type: \"spring\", stiffness: 520, damping: 30, mass: 0.8 };\n\nexport function OrbitMenu({\n  actions,\n  onAction,\n  label = \"Ações\",\n  radius = 100,\n  spread = 160,\n  icon,\n  onOpenChange,\n  className,\n}: OrbitMenuProps) {\n  const reduzir = useReducedMotion();\n  const [aberto, setAberto] = React.useState(false);\n  const [mira, setMira] = React.useState<number | null>(null);\n  const [feito, setFeito] = React.useState<string | null>(null);\n  const botaoRef = React.useRef<HTMLButtonElement>(null);\n  const itensRef = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const gesto = React.useRef<{ x: number; y: number; moveu: boolean; abriuAgora: boolean } | null>(null);\n  const timer = React.useRef<number | undefined>(undefined);\n\n  const raizRef = React.useRef<HTMLDivElement>(null);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  // Aberto por clique, fecha com toque fora, como qualquer menu.\n  React.useEffect(() => {\n    if (!aberto) return;\n    const fora = (e: PointerEvent) => {\n      if (!raizRef.current?.contains(e.target as Node)) {\n        setAberto(false);\n        setMira(null);\n        onOpenChange?.(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", fora);\n    return () => document.removeEventListener(\"pointerdown\", fora);\n  }, [aberto, onOpenChange]);\n\n  const n = actions.length;\n  // Ângulo de cada ação em graus, 0 = direita, -90 = em cima (eixo y da tela).\n  const angulos = React.useMemo(() => {\n    if (n === 1) return [-90];\n    const ini = -90 - spread / 2;\n    return actions.map((_, i) => ini + (spread * i) / (n - 1));\n  }, [actions, n, spread]);\n\n  const mudar = (sim: boolean) => {\n    setAberto(sim);\n    if (!sim) setMira(null);\n    onOpenChange?.(sim);\n  };\n\n  const disparar = (i: number) => {\n    const acao = actions[i];\n    if (!acao) return;\n    mudar(false);\n    setFeito(acao.feedback ?? acao.label);\n    window.clearTimeout(timer.current);\n    timer.current = window.setTimeout(() => setFeito(null), CONFIRMA_MS);\n    onAction?.(acao.id);\n    botaoRef.current?.focus();\n  };\n\n  const mirar = (cx: number, cy: number) => {\n    const el = botaoRef.current;\n    if (!el) return null;\n    const r = el.getBoundingClientRect();\n    const escala = r.width / el.offsetWidth || 1;\n    const dx = (cx - (r.left + r.width / 2)) / escala;\n    const dy = (cy - (r.top + r.height / 2)) / escala;\n    if (Math.hypot(dx, dy) < radius * 0.45) return null;\n    const a = (Math.atan2(dy, dx) * 180) / Math.PI;\n    let melhor = 0;\n    let menor = Infinity;\n    angulos.forEach((alvo, i) => {\n      const d = Math.abs(((a - alvo + 540) % 360) - 180);\n      if (d < menor) {\n        menor = d;\n        melhor = i;\n      }\n    });\n    // Apontar pra baixo do botão, longe de qualquer ação, não escolhe nada.\n    return menor <= 50 ? melhor : null;\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0) return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    gesto.current = { x: e.clientX, y: e.clientY, moveu: false, abriuAgora: !aberto };\n    if (!aberto) mudar(true);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {\n    const g = gesto.current;\n    if (!g) return;\n    if (!g.moveu && Math.hypot(e.clientX - g.x, e.clientY - g.y) > 6) g.moveu = true;\n    if (g.moveu) setMira(mirar(e.clientX, e.clientY));\n  };\n\n  const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {\n    const g = gesto.current;\n    gesto.current = null;\n    if (!g) return;\n    if (g.moveu) {\n      const alvo = mirar(e.clientX, e.clientY);\n      // Arrastou e voltou pro centro: é desistência, fecha sem disparar.\n      if (alvo !== null) disparar(alvo);\n      else mudar(false);\n    } else if (!g.abriuAgora) {\n      // Clique simples com o arco já aberto: fecha.\n      mudar(false);\n    }\n  };\n\n  const abrirPeloTeclado = (foco: number) => {\n    mudar(true);\n    requestAnimationFrame(() => itensRef.current[foco]?.focus());\n  };\n\n  const onKeyDownBotao = (e: React.KeyboardEvent) => {\n    if (e.key === \"Enter\" || e.key === \" \" || e.key === \"ArrowUp\" || e.key === \"ArrowDown\") {\n      e.preventDefault();\n      if (aberto) mudar(false);\n      else abrirPeloTeclado(0);\n    } else if (e.key === \"Escape\" && aberto) {\n      e.preventDefault();\n      mudar(false);\n    }\n  };\n\n  const onKeyDownItem = (e: React.KeyboardEvent, i: number) => {\n    const ir = (j: number) => {\n      const k = (j + n) % n;\n      setMira(k);\n      itensRef.current[k]?.focus();\n    };\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n      e.preventDefault();\n      ir(i + 1);\n    } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      ir(i - 1);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      ir(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      ir(n - 1);\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      mudar(false);\n      botaoRef.current?.focus();\n    } else if (e.key === \"Tab\") {\n      mudar(false);\n    }\n  };\n\n  // Aberto sem nada na mira não escreve nada: o nome genérico do menu (\"Ações\")\n  // em cima do arco era texto repetindo o que os ícones já dizem. Ele segue no\n  // `aria-label` do gatilho, que é onde ele serve.\n  const texto = feito ?? (aberto && mira !== null ? actions[mira]?.label : null);\n\n  return (\n    <div\n      ref={raizRef}\n      className={cn(\"relative inline-flex items-center justify-center\", className)}\n      style={{ width: BOTAO, height: BOTAO }}\n    >\n      <AnimatePresence>\n        {aberto && (\n          <motion.div\n            key=\"arco\"\n            role=\"menu\"\n            aria-label={label}\n            className=\"pointer-events-none absolute left-1/2 top-1/2 z-10\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: DUR.press } }}\n          >\n            {actions.map((acao, i) => {\n              const rad = (angulos[i] * Math.PI) / 180;\n              const x = Math.cos(rad) * radius;\n              const y = Math.sin(rad) * radius;\n              const ativo = mira === i;\n              return (\n                <motion.button\n                  key={acao.id}\n                  ref={(el) => {\n                    itensRef.current[i] = el;\n                  }}\n                  type=\"button\"\n                  role=\"menuitem\"\n                  aria-label={acao.label}\n                  tabIndex={-1}\n                  onClick={() => disparar(i)}\n                  onKeyDown={(e) => onKeyDownItem(e, i)}\n                  onFocus={() => setMira(i)}\n                  onPointerEnter={() => !gesto.current && setMira(i)}\n                  className={cn(\n                    \"pointer-events-auto absolute grid place-items-center rounded-[var(--r-pill)] border border-border [box-shadow:var(--e-raised)] [font:inherit]\",\n                    \"transition-[background-color,color] duration-[var(--dur-fast)] ease-[var(--ease-out)]\",\n                    \"focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\",\n                    ativo ? \"bg-foreground text-background\" : \"bg-background text-foreground\",\n                    !ativo && acao.tone === \"danger\" && \"text-destructive\",\n                  )}\n                  style={{ width: ACAO, height: ACAO, left: -ACAO / 2, top: -ACAO / 2 }}\n                  initial={reduzir ? { x, y, scale: 1, opacity: 0 } : { x: 0, y: 0, scale: 0.4, opacity: 0 }}\n                  animate={{ x, y, scale: ativo ? 1.18 : 1, opacity: 1 }}\n                  exit={reduzir ? { opacity: 0 } : { x: 0, y: 0, scale: 0.4, opacity: 0 }}\n                  transition={reduzir ? { duration: DUR.press } : { ...mola, delay: aberto ? i * 0.025 : 0 }}\n                >\n                  {acao.icon}\n                </motion.button>\n              );\n            })}\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <AnimatePresence>\n        {texto && (\n          <motion.span\n            key=\"rotulo\"\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-[var(--r-pill)] border border-border bg-background px-3 py-1 text-xs text-foreground [box-shadow:var(--e-raised)]\"\n            style={{ bottom: BOTAO + 4 }}\n            initial={{ opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: DUR.press } }}\n            transition={{ duration: DUR.fast }}\n          >\n            {feito && <Check aria-hidden=\"true\" className=\"-ml-0.5 mr-1 inline size-3.5 align-[-2px]\" />}\n            {texto}\n          </motion.span>\n        )}\n      </AnimatePresence>\n\n      <button\n        ref={botaoRef}\n        type=\"button\"\n        aria-label={label}\n        aria-haspopup=\"menu\"\n        aria-expanded={aberto}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={() => {\n          gesto.current = null;\n          setMira(null);\n        }}\n        onKeyDown={onKeyDownBotao}\n        className={cn(\n          \"relative z-20 grid touch-none select-none place-items-center rounded-[var(--r-pill)] bg-foreground text-background [box-shadow:var(--e-raised)] [font:inherit]\",\n          \"transition-transform duration-[var(--dur-press)] ease-[var(--ease-out)] active:scale-95\",\n          \"focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\",\n        )}\n        style={{ width: BOTAO, height: BOTAO }}\n      >\n        <AnimatePresence mode=\"wait\" initial={false}>\n          <motion.span\n            key={feito ? \"feito\" : aberto ? \"fechar\" : \"abrir\"}\n            className=\"grid place-items-center\"\n            initial={reduzir ? { opacity: 0 } : { opacity: 0, rotate: -45, scale: 0.6 }}\n            animate={{ opacity: 1, rotate: 0, scale: 1 }}\n            exit={reduzir ? { opacity: 0 } : { opacity: 0, rotate: 45, scale: 0.6 }}\n            transition={{ duration: DUR.press }}\n          >\n            {feito ? (\n              <Check aria-hidden=\"true\" className=\"size-5\" />\n            ) : aberto ? (\n              <X aria-hidden=\"true\" className=\"size-5\" />\n            ) : (\n              (icon ?? <MoreHorizontal aria-hidden=\"true\" className=\"size-5\" />)\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </button>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {feito ?? \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Botão flutuante que abre poucas ações em arco, escolhidas arrastando o dedo, em app mobile",
      "evite": "Navegação principal: side-nav ou nav-drawer; barra de atalhos no desktop, magnify-dock"
    }
  },
  "categories": [
    "controles",
    "menu",
    "acoes",
    "gesto",
    "arco",
    "mola"
  ],
  "type": "registry:ui"
}