{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pull-to-refresh",
  "title": "Pull to Refresh",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Painel que estica quando você puxa pra baixo, conta os pontos até a marca e gira enquanto atualiza.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@visant/sfx-bus"
  ],
  "files": [
    {
      "path": "registry/visant/ui/pull-to-refresh.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n  type Transition,\n} from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { playSfx } from \"@/lib/sfx-bus\";\n\n/**\n * Pull to Refresh: puxar o painel pra baixo pra buscar dado novo.\n *\n * É pra widget de altura fixa (saldo, placar, fila de pedidos) que mostra um\n * dado que envelhece, e onde um botão de atualizar seria mais um ícone\n * disputando o canto. NÃO é pra lista longa que rola: ali o navegador do\n * telefone já tem o gesto nativo, e reimplementá-lo briga com a rolagem.\n *\n * ── Decisões que não são óbvias ────────────────────────────────────────────\n *\n * 1. **O painel estica, não desliza.** O topo ganha altura e o conteúdo desce;\n *    a borda de baixo fica onde estava. Lê como elástico preso, que é o que\n *    convida a soltar.\n * 2. **Resistência que cresce.** O puxão vira altura por uma curva que satura\n *    (`1 - 1/(x+1)`): os primeiros px respondem quase 1:1 e depois cada px de\n *    dedo rende menos. Nunca trava, nunca passa de `max`.\n * 3. **Os pontos contam quanto falta.** Cada ponto do anel acende numa fração\n *    do caminho até a marca; com todos acesos, soltar atualiza. Quando a\n *    promessa resolve, o anel para de girar e o painel volta com mola.\n * 4. **O arrasto é medido em px locais.** O delta do ponteiro é dividido pela\n *    escala do elemento, porque a vitrine e muito layout escalam por\n *    `transform`.\n * 5. **Teclado:** com o painel focado, Enter ou Espaço atualizam, com o mesmo\n *    anel e o mesmo aviso no `role=\"status\"`. Tecla vinda de um botão de\n *    dentro não dispara nada.\n * 6. **O gesto se anuncia sem legenda.** Uma alça no topo diz \"isto puxa\"\n *    antes de qualquer toque; durante o puxão a frase ao lado do anel muda de\n *    \"Puxe pra atualizar\" pra \"Solte pra atualizar\" no ponto exato em que\n *    soltar passa a valer, com um clique baixo (`detent`) no mesmo quadro. Com\n *    `nudge`, o painel ainda dá uma puxadinha sozinho de tempos em tempos até a\n *    pessoa mexer nele: é a demonstração do gesto, no próprio componente.\n */\n\n// Aceno que ensina o gesto: é uma demonstração, precisa de tempo para ser lida.\nconst ACENO_S = 0.9;\n\nexport interface PullToRefreshProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onAnimationStart\" | \"onDrag\" | \"onDragEnd\" | \"onDragStart\" | \"onAnimationEnd\"> {\n  /** Busca o dado novo. Enquanto a promessa não resolve, o anel gira. */\n  onRefresh?: () => Promise<unknown> | void;\n  /** Quanto puxar (px de painel esticado) até valer. */\n  threshold?: number;\n  /** Teto do esticão, em px. */\n  max?: number;\n  /** Pontos no anel. */\n  dots?: number;\n  /** Nome do painel pra leitor de tela. */\n  label?: string;\n  disabled?: boolean;\n  /** Alça no topo do painel, o sinal de \"isto puxa\". Default `true`. */\n  handle?: boolean;\n  /** As frases ao lado do anel. `false` some com elas e deixa só o anel. */\n  labels?: { pull?: string; release?: string; refreshing?: string } | false;\n  /**\n   * Puxadinha sozinha a cada `nudge` ms (`true` = 4000) enquanto ninguém mexeu\n   * no painel. Para no primeiro toque, tecla ou foco. Desligada sob movimento\n   * reduzido.\n   */\n  nudge?: boolean | number;\n}\n\nconst MOLA_VOLTA: Transition = { type: \"spring\", visualDuration: 0.3, bounce: 0.2 };\nconst MOLA_PARA: Transition = { type: \"spring\", visualDuration: 0.25, bounce: 0 };\n\nfunction Ponto({ i, total, progresso }: { i: number; total: number; progresso: MotionValue<number> }) {\n  const limiar = (i + 1) / total;\n  const opacidade = useTransform(progresso, [limiar - 1 / total, limiar], [0.18, 1]);\n  const ang = (i / total) * Math.PI * 2 - Math.PI / 2;\n  return (\n    <motion.span\n      className=\"absolute left-1/2 top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-[var(--r-pill)] bg-foreground\"\n      style={{ opacity: opacidade, marginLeft: Math.cos(ang) * 8, marginTop: Math.sin(ang) * 8 }}\n    />\n  );\n}\n\nexport function PullToRefresh({\n  onRefresh,\n  threshold = 56,\n  max = 120,\n  dots = 8,\n  label = \"Painel\",\n  disabled = false,\n  handle = true,\n  labels,\n  nudge = false,\n  className,\n  children,\n  onKeyDown,\n  onFocus,\n  ...props\n}: PullToRefreshProps) {\n  const reduzido = useReducedMotion() ?? false;\n  const altura = useMotionValue(0);\n  const progresso = useTransform(altura, (v) => Math.min(1, v / threshold));\n  const giroPuxao = useTransform(altura, (v) => (v / threshold) * 180);\n  const [carregando, setCarregando] = React.useState(false);\n  const [aviso, setAviso] = React.useState(\"\");\n  const carregandoRef = React.useRef(false);\n  const gesto = React.useRef<{ id: number; inicioY: number; escala: number } | null>(null);\n  const anim = React.useRef<AnimationPlaybackControls | null>(null);\n  const vivo = React.useRef(true);\n  const descanso = Math.min(threshold, 48); // altura que o anel ocupa enquanto gira\n  const [pronto, setPronto] = React.useState(false);\n  const [tocado, setTocado] = React.useState(false);\n  const frases = labels === false ? null : { pull: \"Puxe pra atualizar\", release: \"Solte pra atualizar\", refreshing: \"Atualizando\", ...labels };\n  const opacidadeFrase = useTransform(altura, [8, 28], [0, 1]);\n  // A alça sai de cena assim que o painel estica: o anel e a frase assumem.\n  const opacidadeAlca = useTransform(altura, [0, 10], [1, 0]);\n\n  // A virada de \"puxe\" pra \"solte\" é o momento em que soltar passa a valer: a\n  // frase troca e o clique toca no mesmo quadro. Só durante o gesto do dedo,\n  // nunca na volta da mola nem na puxadinha automática.\n  useMotionValueEvent(altura, \"change\", (v) => {\n    const agora = v >= threshold;\n    if (agora === pronto) return;\n    setPronto(agora);\n    if (agora && gesto.current) playSfx(\"detent\");\n  });\n\n  // A puxadinha: um convite que se repete até alguém aceitar.\n  const intervalo = nudge === true ? 4000 : typeof nudge === \"number\" ? nudge : 0;\n  React.useEffect(() => {\n    if (!intervalo || tocado || reduzido || disabled) return;\n    const id = window.setInterval(() => {\n      if (gesto.current || carregandoRef.current) return;\n      anim.current?.stop();\n      anim.current = animate(altura, [0, Math.round(threshold * 0.45), 0], {\n        duration: ACENO_S,\n        times: [0, 0.35, 1],\n        ease: [\"easeOut\", \"easeInOut\"],\n      });\n    }, intervalo);\n    return () => window.clearInterval(id);\n  }, [intervalo, tocado, reduzido, disabled, threshold, altura]);\n\n  React.useEffect(\n    () => () => {\n      vivo.current = false;\n      anim.current?.stop();\n    },\n    [],\n  );\n\n  function ir(alvo: number, mola: Transition) {\n    anim.current?.stop();\n    if (reduzido) altura.jump(alvo);\n    else anim.current = animate(altura, alvo, mola);\n  }\n\n  async function atualizar() {\n    if (carregandoRef.current || disabled) return;\n    carregandoRef.current = true;\n    setCarregando(true);\n    setAviso(\"Atualizando\");\n    ir(descanso, MOLA_PARA);\n    try {\n      await onRefresh?.();\n    } finally {\n      if (vivo.current) {\n        carregandoRef.current = false;\n        setCarregando(false);\n        setAviso(\"Atualizado\");\n        ir(0, MOLA_VOLTA);\n      }\n    }\n  }\n\n  function soltar() {\n    if (!gesto.current) return;\n    gesto.current = null;\n    if (altura.get() >= threshold) void atualizar();\n    else ir(0, MOLA_VOLTA);\n  }\n\n  return (\n    <div\n      {...props}\n      role=\"region\"\n      aria-label={label}\n      aria-busy={carregando || undefined}\n      aria-keyshortcuts=\"Enter\"\n      tabIndex={disabled ? undefined : 0}\n      onFocus={(e) => {\n        onFocus?.(e);\n        setTocado(true);\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.target !== e.currentTarget || (e.key !== \"Enter\" && e.key !== \" \")) return;\n        e.preventDefault();\n        void atualizar();\n      }}\n      onPointerDown={(e) => {\n        if (disabled || carregandoRef.current || !e.isPrimary || e.button !== 0) return;\n        const el = e.currentTarget;\n        setTocado(true);\n        anim.current?.stop();\n        gesto.current = {\n          id: e.pointerId,\n          inicioY: e.clientY,\n          escala: el.offsetHeight ? el.getBoundingClientRect().height / el.offsetHeight : 1,\n        };\n        // A base é a altura atual: pegar o painel no meio da volta continua dali.\n        gesto.current.inicioY -= cruDe(altura.get(), max) * gesto.current.escala;\n      }}\n      onPointerMove={(e) => {\n        const g = gesto.current;\n        if (!g || g.id !== e.pointerId) return;\n        const dy = (e.clientY - g.inicioY) / (g.escala || 1);\n        // Toque já tem captura implícita: pedir de novo dispara `lostpointercapture`, e o gesto morria aos 10px.\n        if (dy > 4 && e.pointerType !== \"touch\" && !e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.setPointerCapture(e.pointerId);\n        altura.set(dy <= 0 ? 0 : max * (1 - 1 / (dy / max + 1)));\n      }}\n      onPointerUp={soltar}\n      onPointerLeave={(e) => {\n        // Sem captura ainda (não passou dos 4 px): sair do painel encerra o gesto.\n        if (!e.currentTarget.hasPointerCapture(e.pointerId)) soltar();\n      }}\n      onPointerCancel={soltar}\n      onLostPointerCapture={soltar}\n      className={cn(\n        \"relative flex select-none flex-col overflow-hidden rounded-[var(--r-surface)] border border-border bg-card text-card-foreground outline-none [box-shadow:var(--e-raised)] [touch-action:pan-x] supports-[touch-action:pan-down]:[touch-action:pan-x_pan-down] focus-visible:[box-shadow:var(--focus)]\",\n        disabled && \"opacity-60\",\n        className,\n      )}\n    >\n      {handle ? (\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-1/2 top-1.5 z-10 h-1 w-8 -translate-x-1/2 rounded-[var(--r-pill)] bg-border\"\n          style={{ opacity: opacidadeAlca }}\n        />\n      ) : null}\n      <motion.div aria-hidden=\"true\" className=\"relative shrink-0 overflow-hidden\" style={{ height: altura }}>\n        <div className=\"absolute inset-0 flex items-center justify-center gap-2.5\">\n          <motion.span className=\"relative size-6 shrink-0\" style={{ rotate: giroPuxao }}>\n            {/* O giro de espera é CSS na camada de dentro: quando para, não briga com o giro do puxão. */}\n            <span className={cn(\"absolute inset-0\", carregando && \"animate-spin motion-reduce:animate-none\")}>\n              {Array.from({ length: dots }, (_, i) => (\n                <Ponto key={i} i={i} total={dots} progresso={progresso} />\n              ))}\n            </span>\n          </motion.span>\n          {frases ? (\n            <motion.span\n              className={cn(\n                \"whitespace-nowrap text-xs font-medium\",\n                pronto || carregando ? \"text-foreground\" : \"text-muted-foreground\",\n              )}\n              style={{ opacity: opacidadeFrase }}\n            >\n              {carregando ? frases.refreshing : pronto ? frases.release : frases.pull}\n            </motion.span>\n          ) : null}\n        </div>\n      </motion.div>\n      <div className=\"min-h-0 flex-1\">{children}</div>\n      <span className=\"sr-only\" role=\"status\">\n        {aviso}\n      </span>\n    </div>\n  );\n}\n\n/** Inverso da curva de resistência: de altura esticada pra px de dedo. */\nfunction cruDe(h: number, max: number) {\n  if (h <= 0) return 0;\n  const f = Math.min(0.99, h / max);\n  return (1 / (1 - f) - 1) * max;\n}\n\nexport default PullToRefresh;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Atualizar feed ou widget no celular puxando pra baixo, com a marca girando enquanto carrega",
      "evite": "Desktop ou conteúdo que atualiza sozinho: botão de atualizar; carga inicial, skeleton-rail"
    }
  },
  "categories": [
    "feedback",
    "atualizar",
    "arrasto",
    "widget",
    "carregando"
  ],
  "type": "registry:ui"
}