{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hold-to-confirm","type":"registry:ui","title":"Hold to Confirm","description":"A guard rail in front of destructive actions.","dependencies":["motion"],"categories":["action feedback"],"docs":"https://www.interior.dev/docs/hold-to-confirm","files":[{"path":"registry/interior/hold-to-confirm.tsx","type":"registry:ui","target":"components/interior/hold-to-confirm.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\n\nconst FACE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\n\n\nexport type HoldPhase = \"idle\" | \"holding\" | \"releasing\" | \"committed\";\n\nexport type UseHoldToConfirmOptions = {\n  onConfirm: () => void;\n  onAbort?: () => void;\n  duration?: number;\n  steps?: number;\n  releaseRate?: number;\n  moveTolerance?: number;\n  haptic?: boolean;\n  disabled?: boolean;\n};\n\nexport function useHoldToConfirm({\n  onConfirm,\n  onAbort,\n  duration = 1800,\n  steps = 20,\n  releaseRate = 2.5,\n  moveTolerance = 10,\n  haptic = true,\n  disabled = false,\n}: UseHoldToConfirmOptions) {\n  const [step, setStep] = useState(0);\n  const [phase, setPhase] = useState<HoldPhase>(\"idle\");\n\n  const phaseRef = useRef<HoldPhase>(\"idle\");\n  const down = useRef(false);\n  const elapsed = useRef(0);\n  const last = useRef(0);\n  const raf = useRef(0);\n  const origin = useRef<{ x: number; y: number } | null>(null);\n\n  const confirm = useRef(onConfirm);\n  confirm.current = onConfirm;\n  const abort = useRef(onAbort);\n  abort.current = onAbort;\n\n  const move = useCallback((next: HoldPhase) => {\n    phaseRef.current = next;\n    setPhase(next);\n  }, []);\n\n  const reset = useCallback(() => {\n    cancelAnimationFrame(raf.current);\n    raf.current = 0;\n    down.current = false;\n    elapsed.current = 0;\n    origin.current = null;\n    setStep(0);\n    move(\"idle\");\n  }, [move]);\n\n  const begin = useCallback(\n    (point?: { x: number; y: number }) => {\n      if (disabled) return;\n      if (phaseRef.current === \"committed\" || phaseRef.current === \"holding\") {\n        return;\n      }\n\n      origin.current = point ?? null;\n      down.current = true;\n      move(\"holding\");\n      if (raf.current) return;\n\n      last.current = performance.now();\n\n      const loop = (now: number) => {\n        const dt = Math.min(64, now - last.current);\n        last.current = now;\n        elapsed.current += down.current ? dt : -dt * releaseRate;\n\n        if (elapsed.current >= duration) {\n          raf.current = 0;\n          elapsed.current = duration;\n          down.current = false;\n          origin.current = null;\n          setStep(steps);\n          move(\"committed\");\n          if (haptic) navigator.vibrate?.(14);\n          confirm.current();\n          return;\n        }\n\n        if (elapsed.current <= 0) {\n          raf.current = 0;\n          elapsed.current = 0;\n          origin.current = null;\n          setStep(0);\n          move(\"idle\");\n          return;\n        }\n\n        const s = Math.min(\n          steps,\n          Math.floor((elapsed.current / duration) * steps),\n        );\n        setStep((prev) => (prev === s ? prev : s));\n        raf.current = requestAnimationFrame(loop);\n      };\n\n      raf.current = requestAnimationFrame(loop);\n    },\n    [disabled, duration, steps, releaseRate, haptic, move],\n  );\n\n  const release = useCallback(() => {\n    if (phaseRef.current !== \"holding\") return;\n    down.current = false;\n    origin.current = null;\n    move(\"releasing\");\n    abort.current?.();\n  }, [move]);\n\n  useEffect(() => {\n    const bail = () => release();\n    const onVisibility = () => {\n      if (document.hidden) release();\n    };\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      raf.current = 0;\n    };\n  }, [release]);\n\n  const bind = {\n    onPointerDown: (e: React.PointerEvent) => {\n      if (e.pointerType === \"mouse\" && e.button !== 0) 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 (phaseRef.current !== \"holding\" || !from) return;\n      if (Math.hypot(e.clientX - from.x, e.clientY - from.y) > moveTolerance) {\n        release();\n      }\n    },\n    onPointerUp: release,\n    onPointerCancel: release,\n    onPointerLeave: release,\n    onKeyDown: (e: React.KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        if (phaseRef.current === \"holding\" || phaseRef.current === \"releasing\") {\n          e.preventDefault();\n          reset();\n        }\n        return;\n      }\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\") release();\n    },\n    onBlur: release,\n    onClick: (e: React.MouseEvent) => {\n      e.preventDefault();\n      if (phaseRef.current === \"committed\") e.stopPropagation();\n    },\n    onContextMenu: (e: React.MouseEvent) => e.preventDefault(),\n  };\n\n  return {\n    bind,\n    step,\n    steps,\n    phase,\n    progress: step / steps,\n    reset,\n  };\n}\n\nexport type HoldToConfirmProps = {\n  onConfirm: () => void;\n  children: React.ReactNode;\n  onAbort?: () => void;\n  confirmLabel?: string;\n  duration?: number;\n  resetAfter?: number;\n  steps?: number;\n  releaseRate?: number;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport function HoldToConfirm({\n  onConfirm,\n  children,\n  onAbort,\n  confirmLabel = \"Confirmed\",\n  duration = 1800,\n  resetAfter = 1600,\n  steps = 20,\n  releaseRate = 2.5,\n  disabled = false,\n  className = \"\",\n}: HoldToConfirmProps) {\n  const { bind, phase, reset } = useHoldToConfirm({\n    onConfirm,\n    onAbort,\n    duration,\n    steps,\n    releaseRate,\n    disabled,\n  });\n\n  const reduced = useReducedMotion();\n  const hintId = useId();\n\n  const committed = phase === \"committed\";\n  const seconds = Math.round(duration / 100) / 10;\n\n  const swept = useMotionValue(0);\n  const clipPath = useTransform(\n    swept,\n    (v) => `inset(0 ${(1 - v) * 100}% 0 0)`,\n  );\n\n  useEffect(() => {\n    if (phase !== \"committed\" || resetAfter <= 0) return;\n    const back = setTimeout(reset, resetAfter);\n    return () => clearTimeout(back);\n  }, [phase, resetAfter, reset]);\n\n  useEffect(() => {\n    if (reduced) {\n      swept.set(phase === \"holding\" || phase === \"committed\" ? 1 : 0);\n      return;\n    }\n\n    if (phase === \"committed\") {\n      const controls = animate(swept, 1, { duration: 0.12, ease: \"linear\" });\n      return () => controls.stop();\n    }\n\n    const from = swept.get();\n\n    if (phase === \"holding\") {\n      const controls = animate(swept, 1, {\n        duration: (duration * (1 - from)) / 1000,\n        ease: \"linear\",\n      });\n      return () => controls.stop();\n    }\n\n    const controls = animate(swept, 0, {\n      duration: (duration * from) / releaseRate / 1000,\n      ease: [0.23, 1, 0.32, 1],\n    });\n    return () => controls.stop();\n  }, [phase, duration, releaseRate, reduced, swept]);\n\n  return (\n    <button\n      type=\"button\"\n      aria-disabled={disabled || committed}\n      aria-describedby={hintId}\n      {...bind}\n      style={{ touchAction: \"manipulation\", WebkitTouchCallout: \"none\" }}\n      className={`relative isolate inline-grid h-10 select-none place-items-center overflow-hidden rounded-[9px] border border-stone-200 bg-white px-4 text-[13px] font-medium text-stone-700 outline-none focus-visible:ring-2 focus-visible:ring-stone-400 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:focus-visible:ring-stone-500 ${\n        disabled ? \"cursor-not-allowed opacity-50\" : \"cursor-pointer\"\n      } ${className}`}\n    >\n      <Faces committed={committed} confirmLabel={confirmLabel}>\n        {children}\n      </Faces>\n\n      <motion.span\n        aria-hidden\n        style={{ clipPath }}\n        className=\"absolute inset-0 grid place-items-center bg-stone-800 px-4 text-white dark:bg-stone-100 dark:text-stone-900\"\n      >\n        <Faces committed={committed} confirmLabel={confirmLabel}>\n          {children}\n        </Faces>\n      </motion.span>\n\n      <span id={hintId} className=\"sr-only\">\n        Press and hold for {seconds} seconds to confirm. Releasing early cancels\n        and nothing happens.\n      </span>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {committed ? confirmLabel : \"\"}\n      </span>\n    </button>\n  );\n}\n\nfunction Faces({\n  committed,\n  confirmLabel,\n  children,\n}: {\n  committed: boolean;\n  confirmLabel: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <span className=\"col-start-1 row-start-1 grid\">\n      <motion.span\n        initial={false}\n        animate={{ opacity: committed ? 0 : 1 }}\n        transition={FACE}\n        className=\"col-start-1 row-start-1 flex items-center justify-center whitespace-nowrap\"\n      >\n        {children}\n      </motion.span>\n      <motion.span\n        initial={false}\n        animate={{ opacity: committed ? 1 : 0 }}\n        transition={FACE}\n        className=\"col-start-1 row-start-1 flex items-center justify-center gap-1.5 whitespace-nowrap\"\n      >\n        <svg\n          width=\"12\"\n          height=\"12\"\n          viewBox=\"0 0 12 12\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.7\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"M2.5 6.4 4.7 8.6 9.5 3.5\" />\n        </svg>\n        {confirmLabel}\n      </motion.span>\n    </span>\n  );\n}\n"}]}