{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"long-press","type":"registry:ui","title":"Long Press","description":"Intent confirmed by time, and cancelled by everything else.","dependencies":["motion"],"categories":["gesture"],"docs":"https://www.interior.dev/docs/long-press","files":[{"path":"registry/interior/long-press.tsx","type":"registry:ui","target":"components/interior/long-press.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst POP = { type: \"spring\", stiffness: 640, damping: 22, mass: 0.7 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type UseLongPressOptions = {\n  onLongPress: () => void;\n  duration?: number;\n  steps?: number;\n  moveTolerance?: number;\n  haptic?: boolean;\n  disabled?: boolean;\n  onCancel?: () => void;\n};\n\ntype Phase = \"idle\" | \"holding\" | \"fired\";\n\nexport function useLongPress({\n  onLongPress,\n  duration = 550,\n  steps = 12,\n  moveTolerance = 8,\n  haptic = true,\n  disabled = false,\n  onCancel,\n}: UseLongPressOptions) {\n  const cells = Math.max(1, Math.round(steps));\n\n  const [step, setStep] = useState(0);\n  const [holding, setHolding] = useState(false);\n\n  const phase = useRef<Phase>(\"idle\");\n  const raf = useRef(0);\n  const startedAt = useRef(0);\n  const origin = useRef<{ x: number; y: number } | null>(null);\n  const settle = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const fire = useRef(onLongPress);\n  fire.current = onLongPress;\n  const cancelled = useRef(onCancel);\n  cancelled.current = onCancel;\n\n  const reset = useCallback(() => {\n    cancelAnimationFrame(raf.current);\n    raf.current = 0;\n    if (settle.current) {\n      clearTimeout(settle.current);\n      settle.current = null;\n    }\n    origin.current = null;\n    phase.current = \"idle\";\n    setHolding(false);\n    setStep(0);\n  }, []);\n\n  const end = useCallback(() => {\n    if (phase.current !== \"holding\") return;\n    reset();\n    cancelled.current?.();\n  }, [reset]);\n\n  const begin = useCallback(\n    (point?: { x: number; y: number }) => {\n      if (disabled || phase.current !== \"idle\") return;\n\n      phase.current = \"holding\";\n      origin.current = point ?? null;\n      startedAt.current = performance.now();\n      setHolding(true);\n      setStep(0);\n\n      const tick = (now: number) => {\n        const p = Math.min(1, (now - startedAt.current) / duration);\n\n        const s = Math.floor(p * cells);\n        setStep((prev) => (prev === s ? prev : s));\n\n        if (p < 1) {\n          raf.current = requestAnimationFrame(tick);\n          return;\n        }\n\n        raf.current = 0;\n        phase.current = \"fired\";\n        setStep(cells);\n        if (haptic) navigator.vibrate?.(12);\n        fire.current();\n\n        settle.current = setTimeout(() => {\n          if (phase.current === \"fired\") reset();\n        }, 260);\n      };\n\n      raf.current = requestAnimationFrame(tick);\n    },\n    [disabled, duration, cells, haptic, reset],\n  );\n\n  useEffect(() => {\n    const bail = () => end();\n    const onVisibility = () => document.hidden && end();\n    window.addEventListener(\"blur\", bail);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      window.removeEventListener(\"blur\", bail);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      cancelAnimationFrame(raf.current);\n      if (settle.current) clearTimeout(settle.current);\n    };\n  }, [end]);\n\n  const bind = {\n    onPointerDown: (e: React.PointerEvent) => {\n      if (e.button !== 0 && e.pointerType === \"mouse\") return;\n      e.currentTarget.setPointerCapture?.(e.pointerId);\n      begin({ x: e.clientX, y: e.clientY });\n    },\n    onPointerMove: (e: React.PointerEvent) => {\n      const from = origin.current;\n      if (phase.current !== \"holding\" || !from) return;\n      if (Math.hypot(e.clientX - from.x, e.clientY - from.y) > moveTolerance) {\n        end();\n      }\n    },\n    onPointerUp: end,\n    onPointerCancel: end,\n    onPointerLeave: end,\n    onKeyDown: (e: React.KeyboardEvent) => {\n      if (e.repeat) return;\n      if (e.key === \" \" || e.key === \"Enter\") {\n        e.preventDefault();\n        begin();\n      }\n    },\n    onKeyUp: (e: React.KeyboardEvent) => {\n      if (e.key === \" \" || e.key === \"Enter\") end();\n      if (e.key === \"Escape\") end();\n    },\n    onBlur: end,\n    onClick: (e: React.MouseEvent) => {\n      if (phase.current === \"fired\") {\n        e.preventDefault();\n        e.stopPropagation();\n      }\n    },\n    onContextMenu: (e: React.MouseEvent) => e.preventDefault(),\n  };\n\n  return {\n    bind,\n    step,\n    steps: cells,\n    holding,\n    fired: step === cells,\n    progress: step / cells,\n  };\n}\n\nexport type LongPressButtonProps = {\n  onLongPress: () => void;\n  children: React.ReactNode;\n  duration?: number;\n  steps?: number;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport function LongPressButton({\n  onLongPress,\n  children,\n  duration = 550,\n  steps = 12,\n  disabled = false,\n  className = \"\",\n}: LongPressButtonProps) {\n  const hintId = useId();\n  const reduced = useReducedMotion() === true;\n  const { bind, step, steps: cells, holding, fired } = useLongPress({\n    onLongPress,\n    duration,\n    steps,\n    disabled,\n  });\n\n  const progress = fired ? 1 : step / cells;\n\n  return (\n    <motion.button\n      type=\"button\"\n      aria-disabled={disabled}\n      aria-describedby={hintId}\n      initial={false}\n      animate={{ scale: reduced ? 1 : fired ? [1, 1.045, 1] : 1 }}\n      transition={reduced ? INSTANT : POP}\n      className={`group relative inline-flex h-9 select-none items-center rounded-[9px] border px-3.5 text-[13px] font-medium outline-none transition-[border-color,background-color,box-shadow,transform] duration-150 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)] ${\n        holding\n          ? \"translate-y-px border-stone-200 bg-stone-100/70 shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:border-white/[0.08] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\"\n          : fired\n            ? \"border-[#4568FF] bg-[#4568FF]/[0.07] dark:border-[#93B0FF] dark:bg-[#93B0FF]/[0.12]\"\n            : \"border-stone-200 bg-white shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] hover:bg-stone-50 dark:border-white/[0.16] dark:bg-[#252522] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#2A2A27]\"\n      } ${disabled ? \"cursor-not-allowed opacity-50\" : \"cursor-pointer\"} ${className}`}\n      style={{ touchAction: \"manipulation\", WebkitTouchCallout: \"none\" }}\n      {...bind}\n    >\n      <span className=\"relative grid\">\n        <span className=\"col-start-1 row-start-1 whitespace-nowrap text-stone-700 dark:text-stone-200\">\n          {children}\n        </span>\n        <motion.span\n          aria-hidden\n          initial={false}\n          animate={{\n            clipPath: `inset(0 ${((1 - progress) * 100).toFixed(2)}% 0 0)`,\n          }}\n          transition={reduced ? INSTANT : CELL}\n          className=\"col-start-1 row-start-1 whitespace-nowrap text-[#4568FF] dark:text-[#93B0FF]\"\n        >\n          {children}\n        </motion.span>\n      </span>\n      <span id={hintId} className=\"sr-only\">\n        Press and hold for {Math.round(duration / 100) / 10} seconds to confirm\n      </span>\n    </motion.button>\n  );\n}\n"}]}