{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hold-to-confirm",
  "title": "Hold to Confirm",
  "author": "WebberUI",
  "description": "長按填充確認按鈕：按住時進度由左到右填滿、接近完成時輕微震顫，填滿才觸發，防止危險操作誤觸。",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/webber/hold-to-confirm.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} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/** 填充進度超過此比例後開始震顫 */\nconst SHAKE_START = 0.65;\n/** 震顫最大振幅（px），隨進度逼近 1 線性放大 */\nconst SHAKE_AMPLITUDE = 1.75;\n/** 震顫角速度（rad/s），約 16Hz 的細微高頻抖動 */\nconst SHAKE_SPEED = 100;\n\ninterface HoldToConfirmProps\n  extends Omit<\n    React.ComponentProps<\"button\">,\n    // 與 motion 事件 props 型別衝突，長按元件本身也不支援拖曳\n    \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n  > {\n  /** 需按住多久（秒）才觸發確認，填充以此時長等速填滿 */\n  holdDuration?: number;\n  /** 填充填滿時觸發，每次成功只觸發一次 */\n  onConfirm: () => void;\n  /** 確認成功後顯示的內容，未提供時沿用 children */\n  confirmedChildren?: React.ReactNode;\n  /** 視覺變體：`danger` 紅系（預設，危險操作用）、`default` 中性色 */\n  variant?: \"danger\" | \"default\";\n}\n\nexport function HoldToConfirm({\n  holdDuration = 1.2,\n  onConfirm,\n  confirmedChildren,\n  variant = \"danger\",\n  className,\n  children,\n  disabled,\n  onPointerDown,\n  onPointerUp,\n  onPointerLeave,\n  onPointerCancel,\n  onKeyDown,\n  onKeyUp,\n  onBlur,\n  ...props\n}: HoldToConfirmProps) {\n  const reducedMotion = useReducedMotion();\n  const [confirmed, setConfirmed] = React.useState(false);\n\n  const holdingRef = React.useRef(false);\n  const animationRef = React.useRef<AnimationPlaybackControls | null>(null);\n  const onConfirmRef = React.useRef(onConfirm);\n  React.useEffect(() => {\n    onConfirmRef.current = onConfirm;\n  });\n\n  // 0 → 1 的填充進度；scaleX 夾在 0 以上，spring 回退略微過衝時不會翻面\n  const progress = useMotionValue(0);\n  const fillScaleX = useTransform(progress, (p) => Math.max(0, p));\n\n  // 震顫獨立成一個 motion value，取消或完成時能立即歸零\n  const shakeX = useMotionValue(0);\n  useMotionValueEvent(progress, \"change\", (p) => {\n    if (reducedMotion || !holdingRef.current || p < SHAKE_START) {\n      if (shakeX.get() !== 0) shakeX.set(0);\n      return;\n    }\n    const intensity = (p - SHAKE_START) / (1 - SHAKE_START);\n    // 以經過時間（p × holdDuration）驅動正弦抖動，頻率不受 holdDuration 影響\n    shakeX.set(\n      Math.sin(p * holdDuration * SHAKE_SPEED) * intensity * SHAKE_AMPLITUDE,\n    );\n  });\n\n  function startHold() {\n    if (disabled || confirmed || holdingRef.current) return;\n    holdingRef.current = true;\n    animationRef.current?.stop();\n    // 從目前進度續跑剩餘時長，整段填充維持相同等速\n    const remaining = holdDuration * (1 - progress.get());\n    animationRef.current = animate(progress, 1, {\n      duration: Math.max(0, remaining),\n      ease: \"linear\",\n      onComplete: () => {\n        holdingRef.current = false;\n        shakeX.set(0);\n        setConfirmed(true);\n        onConfirmRef.current();\n      },\n    });\n  }\n\n  function cancelHold() {\n    if (!holdingRef.current) return;\n    holdingRef.current = false;\n    shakeX.set(0);\n    animationRef.current?.stop();\n    animationRef.current = animate(\n      progress,\n      0,\n      reducedMotion\n        ? { duration: 0.15, ease: \"linear\" }\n        : { type: \"spring\", stiffness: 500, damping: 42 },\n    );\n  }\n\n  // 卸載時停掉進行中的動畫，避免 onComplete 於卸載後觸發 setState\n  React.useEffect(() => {\n    return () => animationRef.current?.stop();\n  }, []);\n\n  function handlePointerDown(e: React.PointerEvent<HTMLButtonElement>) {\n    onPointerDown?.(e);\n    if (e.button !== 0) return;\n    startHold();\n  }\n\n  function handlePointerUp(e: React.PointerEvent<HTMLButtonElement>) {\n    onPointerUp?.(e);\n    cancelHold();\n  }\n\n  function handlePointerLeave(e: React.PointerEvent<HTMLButtonElement>) {\n    onPointerLeave?.(e);\n    cancelHold();\n  }\n\n  function handlePointerCancel(e: React.PointerEvent<HTMLButtonElement>) {\n    onPointerCancel?.(e);\n    cancelHold();\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLButtonElement>) {\n    onKeyDown?.(e);\n    if (e.key !== \" \") return;\n    // 攔下 Space 的原生 click 行為，長按才是唯一的確認手勢\n    e.preventDefault();\n    if (e.repeat) return;\n    startHold();\n  }\n\n  function handleKeyUp(e: React.KeyboardEvent<HTMLButtonElement>) {\n    onKeyUp?.(e);\n    if (e.key !== \" \") return;\n    e.preventDefault();\n    cancelHold();\n  }\n\n  function handleBlur(e: React.FocusEvent<HTMLButtonElement>) {\n    onBlur?.(e);\n    // 按住空白鍵期間失焦時收不到 keyup，這裡補上取消\n    cancelHold();\n  }\n\n  const hintId = React.useId();\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      aria-describedby={confirmed ? undefined : hintId}\n      data-confirmed={confirmed ? \"\" : undefined}\n      onPointerDown={handlePointerDown}\n      onPointerUp={handlePointerUp}\n      onPointerLeave={handlePointerLeave}\n      onPointerCancel={handlePointerCancel}\n      onKeyDown={handleKeyDown}\n      onKeyUp={handleKeyUp}\n      onBlur={handleBlur}\n      style={{ x: shakeX }}\n      className={cn(\n        \"relative inline-flex touch-none items-center justify-center overflow-hidden rounded-md px-6 py-3 text-sm font-medium select-none\",\n        \"transition-colors duration-[var(--wb-duration-fast,200ms)]\",\n        \"focus-visible:outline-2 focus-visible:outline-offset-2\",\n        \"disabled:cursor-not-allowed disabled:opacity-50\",\n        confirmed\n          ? \"bg-emerald-600 text-white focus-visible:outline-emerald-500\"\n          : variant === \"danger\"\n            ? \"bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-500 dark:bg-red-600 dark:hover:bg-red-500\"\n            : \"bg-neutral-900 text-white hover:bg-neutral-700 focus-visible:outline-neutral-400 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200\",\n        className,\n      )}\n      {...props}\n    >\n      {/* 填充層：origin-left 由左到右 scaleX 填滿 */}\n      <motion.span\n        aria-hidden\n        style={{ scaleX: fillScaleX }}\n        className={cn(\n          \"absolute inset-0 origin-left\",\n          \"transition-colors duration-[var(--wb-duration-fast,200ms)]\",\n          confirmed\n            ? \"bg-emerald-600\"\n            : variant === \"danger\"\n              ? \"bg-red-900\"\n              : \"bg-neutral-600 dark:bg-neutral-300\",\n        )}\n      />\n      <span className=\"relative z-10 inline-flex items-center justify-center gap-2\">\n        {confirmed ? (\n          <>\n            <CheckIcon instant={Boolean(reducedMotion)} />\n            {confirmedChildren ?? children}\n          </>\n        ) : (\n          children\n        )}\n      </span>\n      <span id={hintId} className=\"sr-only\">\n        按住以確認，提前放開會取消\n      </span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {confirmed ? \"已確認\" : \"\"}\n      </span>\n    </motion.button>\n  );\n}\n\n/** 成功態勾勾：以 pathLength 畫線進場，reduced-motion 時直接顯示 */\nfunction CheckIcon({ instant }: { instant: boolean }) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 16 16\"\n      className=\"size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={{ pathLength: instant ? 1 : 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: instant ? 0 : 0.3, ease: \"easeOut\" }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "cssVars": {
    "light": {
      "wb-duration-fast": "200ms"
    }
  },
  "categories": [
    "button"
  ],
  "type": "registry:ui"
}