{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hold-to-confirm","type":"registry:component","title":"Hold to Confirm","description":"Deliberate friction for actions that deserve a second of intent.","dependencies":[],"categories":["input","confirmation","pointer","keyboard","touch"],"files":[{"path":"components/lab/hold-to-confirm/hold-to-confirm.tsx","target":"components/lab/hold-to-confirm/hold-to-confirm.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\nimport \"./hold-to-confirm.css\";\n\nexport type HoldToConfirmVariant = \"accent\" | \"danger\";\nexport type HoldToConfirmProgress = \"fill\" | \"ring\";\n\nexport type HoldToConfirmProps = {\n  /** Idle label, e.g. \"Publish release\". */\n  label: string;\n  /** Hold time in milliseconds before the action commits. */\n  duration?: number;\n  /** Fired exactly once per completed hold. */\n  onConfirm?: () => void;\n  /** Tone of the control; danger is reserved for destructive actions. */\n  variant?: HoldToConfirmVariant;\n  /** How the hold is drawn: a background fill or a ring next to the label. */\n  progress?: HoldToConfirmProgress;\n  /** Return to idle a moment after confirming. */\n  resetAfterConfirm?: boolean;\n  disabled?: boolean;\n  className?: string;\n};\n\ntype HoldPhase = \"idle\" | \"holding\" | \"done\";\n\n/** How long a released, incomplete hold takes to ease back to zero. */\nconst CANCEL_EASE_MS = 180;\n/** How long the \"Done\" state stays before returning to idle. */\nconst DONE_RESET_MS = 1200;\n\nfunction isHoldKey(key: string) {\n  return key === \" \" || key === \"Enter\";\n}\n\nfunction prefersReducedMotion() {\n  return (\n    typeof window !== \"undefined\" &&\n    typeof window.matchMedia === \"function\" &&\n    window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  );\n}\n\n/**\n * HOLD TO CONFIRM — deliberate friction for consequential actions.\n *\n * Press and hold (pointer, touch or a held Space / Enter) and a real\n * progress reading advances over `duration`; releasing early eases the\n * progress back and announces the cancel, while a completed hold fires\n * `onConfirm` exactly once and shows a done state. Every loop is\n * frame-rate independent (real elapsed time), and cancels on release,\n * on blur, on hidden tabs and on unmount.\n */\nexport function HoldToConfirm({\n  label,\n  duration = 900,\n  onConfirm,\n  variant = \"accent\",\n  progress = \"fill\",\n  resetAfterConfirm = true,\n  disabled = false,\n  className,\n}: HoldToConfirmProps) {\n  const [phase, setPhase] = useState<HoldPhase>(\"idle\");\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const barRef = useRef<HTMLSpanElement>(null);\n  const rafRef = useRef<number | null>(null);\n  const doneTimerRef = useRef<number | null>(null);\n  const holdStartRef = useRef(0);\n  const holdFromRef = useRef(0);\n  const progressRef = useRef(0);\n  const lastPctRef = useRef(-1);\n  const phaseRef = useRef<HoldPhase>(\"idle\");\n  const durationRef = useRef(duration);\n  const confirmRef = useRef(onConfirm);\n  const resetRef = useRef(resetAfterConfirm);\n  const disabledRef = useRef(disabled);\n  const descId = useId();\n\n  useEffect(() => {\n    durationRef.current = duration;\n    confirmRef.current = onConfirm;\n    resetRef.current = resetAfterConfirm;\n    disabledRef.current = disabled;\n  });\n\n  /** Writes the live progress to the CSS variable and the ARIA bar. */\n  const writeProgress = useCallback((value: number) => {\n    const p = Math.min(1, Math.max(0, value));\n    progressRef.current = p;\n    rootRef.current?.style.setProperty(\"--hold-progress\", p.toFixed(4));\n    const bar = barRef.current;\n    if (!bar) return;\n    const pct = Math.round(p * 100);\n    if (pct !== lastPctRef.current) {\n      lastPctRef.current = pct;\n      bar.setAttribute(\"aria-valuenow\", String(pct));\n      bar.setAttribute(\"aria-valuetext\", `${pct}%`);\n    }\n  }, []);\n\n  const stopLoop = useCallback(() => {\n    if (rafRef.current !== null) {\n      window.cancelAnimationFrame(rafRef.current);\n      rafRef.current = null;\n    }\n  }, []);\n\n  const clearDoneTimer = useCallback(() => {\n    if (doneTimerRef.current !== null) {\n      window.clearTimeout(doneTimerRef.current);\n      doneTimerRef.current = null;\n    }\n  }, []);\n\n  /* Re-announces a repeated message by alternating an invisible char. */\n  const announce = useCallback((text: string) => {\n    setAnnouncement((current) => (current === text ? `${text}\\u200B` : text));\n  }, []);\n\n  const complete = useCallback(() => {\n    stopLoop();\n    writeProgress(1);\n    phaseRef.current = \"done\";\n    setPhase(\"done\");\n    announce(\"Done\");\n    confirmRef.current?.();\n    if (resetRef.current) {\n      doneTimerRef.current = window.setTimeout(() => {\n        doneTimerRef.current = null;\n        phaseRef.current = \"idle\";\n        setPhase(\"idle\");\n        writeProgress(0);\n      }, DONE_RESET_MS);\n    }\n  }, [announce, stopLoop, writeProgress]);\n\n  const startHold = useCallback(() => {\n    if (disabledRef.current || phaseRef.current === \"holding\") return;\n    stopLoop();\n    clearDoneTimer();\n    if (phaseRef.current === \"done\") {\n      lastPctRef.current = -1;\n      writeProgress(0);\n    }\n    phaseRef.current = \"holding\";\n    setPhase(\"holding\");\n\n    holdFromRef.current = progressRef.current;\n    holdStartRef.current =\n      performance.now() - holdFromRef.current * Math.max(1, durationRef.current);\n\n    const step = (now: number) => {\n      const ms = Math.max(1, durationRef.current);\n      const value = holdFromRef.current + (now - holdStartRef.current) / ms;\n      if (value >= 1) {\n        rafRef.current = null;\n        complete();\n        return;\n      }\n      writeProgress(value);\n      rafRef.current = window.requestAnimationFrame(step);\n    };\n    rafRef.current = window.requestAnimationFrame(step);\n  }, [clearDoneTimer, complete, stopLoop, writeProgress]);\n\n  const release = useCallback(() => {\n    if (phaseRef.current !== \"holding\") return;\n    stopLoop();\n    phaseRef.current = \"idle\";\n    setPhase(\"idle\");\n    announce(\"Cancelled\");\n\n    const from = progressRef.current;\n    if (from <= 0) return;\n    if (prefersReducedMotion()) {\n      writeProgress(0);\n      return;\n    }\n\n    const started = performance.now();\n    const step = (now: number) => {\n      const t = Math.min(1, (now - started) / CANCEL_EASE_MS);\n      if (t >= 1) {\n        rafRef.current = null;\n        writeProgress(0);\n        return;\n      }\n      const eased = 1 - Math.pow(1 - t, 3);\n      writeProgress(from * (1 - eased));\n      rafRef.current = window.requestAnimationFrame(step);\n    };\n    rafRef.current = window.requestAnimationFrame(step);\n  }, [announce, stopLoop, writeProgress]);\n\n  /* Cancel when the window loses focus or the tab is hidden. */\n  useEffect(() => {\n    const onBlur = () => release();\n    const onVisibility = () => {\n      if (document.hidden) release();\n    };\n    window.addEventListener(\"blur\", onBlur);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      window.removeEventListener(\"blur\", onBlur);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [release]);\n\n  /* Cancel if the host disables the control mid-hold. */\n  useEffect(() => {\n    if (disabled) release();\n  }, [disabled, release]);\n\n  /* Never leave a frame loop or timer behind. */\n  useEffect(() => {\n    return () => {\n      if (rafRef.current !== null) window.cancelAnimationFrame(rafRef.current);\n      if (doneTimerRef.current !== null) window.clearTimeout(doneTimerRef.current);\n    };\n  }, []);\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {\n    if (disabledRef.current) return;\n    if (event.pointerType === \"mouse\" && event.button !== 0) return;\n    try {\n      event.currentTarget.setPointerCapture(event.pointerId);\n    } catch {\n      /* Capture is an enhancement; the pointerdown still starts the hold. */\n    }\n    startHold();\n  };\n\n  const endPointer = (event: ReactPointerEvent<HTMLButtonElement>) => {\n    try {\n      if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n        event.currentTarget.releasePointerCapture(event.pointerId);\n      }\n    } catch {\n      /* The capture may already be gone. */\n    }\n    release();\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {\n    if (disabledRef.current || !isHoldKey(event.key)) return;\n    /* Space would scroll the page; Enter would synthesise a click early. */\n    if (event.key !== \"Enter\") event.preventDefault();\n    if (event.repeat) return;\n    startHold();\n  };\n\n  const onKeyUp = (event: ReactKeyboardEvent<HTMLButtonElement>) => {\n    if (!isHoldKey(event.key)) return;\n    release();\n  };\n\n  const seconds = (Math.max(1, duration) / 1000).toFixed(1);\n\n  return (\n    <span\n      ref={rootRef}\n      className={`hc-root${className ? ` ${className}` : \"\"}`}\n      data-phase={phase}\n      data-variant={variant}\n      data-progress={progress}\n    >\n      <button\n        type=\"button\"\n        className=\"hc-button\"\n        disabled={disabled}\n        aria-describedby={descId}\n        aria-label={phase === \"done\" ? `${label} — done` : undefined}\n        onPointerDown={onPointerDown}\n        onPointerUp={endPointer}\n        onPointerCancel={endPointer}\n        onKeyDown={onKeyDown}\n        onKeyUp={onKeyUp}\n        onContextMenu={(event) => event.preventDefault()}\n        onDragStart={(event) => event.preventDefault()}\n      >\n        <span className=\"hc-fill\" aria-hidden=\"true\" />\n        <span className=\"hc-face\">\n          {phase === \"done\" ? (\n            <>\n              <svg\n                className=\"hc-check\"\n                viewBox=\"0 0 16 16\"\n                width=\"15\"\n                height=\"15\"\n                aria-hidden=\"true\"\n                focusable=\"false\"\n              >\n                <path\n                  d=\"M3 8.5 6.2 11.6 13 4.7\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                />\n              </svg>\n              <span className=\"hc-label\">Done</span>\n            </>\n          ) : (\n            <>\n              {progress === \"ring\" ? (\n                <svg\n                  className=\"hc-ring\"\n                  viewBox=\"0 0 24 24\"\n                  width=\"16\"\n                  height=\"16\"\n                  aria-hidden=\"true\"\n                  focusable=\"false\"\n                >\n                  <circle className=\"hc-ring-track\" cx=\"12\" cy=\"12\" r=\"9\" />\n                  <circle className=\"hc-ring-arc\" cx=\"12\" cy=\"12\" r=\"9\" />\n                </svg>\n              ) : null}\n              <span className=\"hc-label\">{label}</span>\n            </>\n          )}\n        </span>\n      </button>\n\n      <span\n        ref={barRef}\n        className=\"hc-sr\"\n        role=\"progressbar\"\n        aria-label={`Hold progress for ${label}`}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={0}\n        aria-valuetext=\"0%\"\n      />\n      <span className=\"hc-sr\" id={descId}>\n        Press and hold for about {seconds} seconds to confirm. Release early to\n        cancel.\n      </span>\n      <span className=\"hc-sr\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </span>\n  );\n}\n"},{"path":"components/lab/hold-to-confirm/hold-to-confirm.css","target":"components/lab/hold-to-confirm/hold-to-confirm.css","type":"registry:file","content":"/* ============================================================\n   HOLD TO CONFIRM — self-contained styles.\n   Tokens are scoped on the root with fallbacks, so the control\n   carries its own theme into any codebase.\n   ============================================================ */\n\n.hc-root {\n  --hc-accent: #4f27e0;\n  --hc-accent-deep: #32119c;\n  --hc-danger: #b3261e;\n  --hc-danger-deep: #7c1a15;\n  --hc-base: var(--hc-accent);\n  --hc-fill: var(--hc-accent-deep);\n  --hc-focus: #4f27e0;\n  --hc-on-base: #ffffff;\n  --hc-ring-track: rgba(255, 255, 255, 0.34);\n  --hc-ring-arc: #ffffff;\n\n  position: relative;\n  display: inline-flex;\n  vertical-align: middle;\n}\n\n.hc-root[data-variant=\"danger\"] {\n  --hc-base: var(--hc-danger);\n  --hc-fill: var(--hc-danger-deep);\n  --hc-focus: #b3261e;\n}\n\n.hc-button {\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 9px;\n  min-height: 42px;\n  padding: 0 20px;\n  border: 0;\n  border-radius: 999px;\n  background: var(--hc-base);\n  color: var(--hc-on-base);\n  font: inherit;\n  font-size: 0.9375rem;\n  font-weight: 600;\n  letter-spacing: -0.005em;\n  line-height: 1;\n  cursor: pointer;\n  overflow: hidden;\n  isolation: isolate;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-touch-callout: none;\n  touch-action: none;\n  box-shadow:\n    0 1px 2px rgba(20, 20, 24, 0.14),\n    0 10px 22px -14px rgba(20, 20, 24, 0.4);\n  transition:\n    box-shadow 160ms ease,\n    transform 120ms ease,\n    opacity 160ms ease;\n}\n\n.hc-button:hover:not(:disabled) {\n  box-shadow:\n    0 2px 4px rgba(20, 20, 24, 0.16),\n    0 14px 26px -14px rgba(20, 20, 24, 0.45);\n}\n\n.hc-button:active:not(:disabled) {\n  transform: translateY(0.5px);\n}\n\n.hc-button:focus-visible {\n  outline: 2px solid var(--hc-focus);\n  outline-offset: 3px;\n}\n\n.hc-button:disabled {\n  cursor: not-allowed;\n  opacity: 0.48;\n  box-shadow: none;\n}\n\n/*\n * The sweeping fill is clipped rather than resized, so the frame loop\n * only ever writes one custom property: --hold-progress (0–1).\n */\n.hc-fill {\n  position: absolute;\n  inset: 0;\n  z-index: 0;\n  border-radius: inherit;\n  background: var(--hc-fill);\n  clip-path: inset(0 calc(100% - var(--hold-progress, 0) * 100%) 0 0);\n  pointer-events: none;\n}\n\n.hc-root[data-progress=\"ring\"] .hc-fill {\n  display: none;\n}\n\n.hc-face {\n  position: relative;\n  z-index: 1;\n  display: inline-flex;\n  align-items: center;\n  gap: 9px;\n  white-space: nowrap;\n}\n\n.hc-label {\n  display: block;\n}\n\n.hc-check {\n  flex: none;\n}\n\n/* Ring drawing: r = 9 => circumference ≈ 56.55. */\n.hc-ring {\n  flex: none;\n  transform: rotate(-90deg);\n}\n\n.hc-ring-track,\n.hc-ring-arc {\n  fill: none;\n  stroke-width: 3;\n}\n\n.hc-ring-track {\n  stroke: var(--hc-ring-track);\n}\n\n.hc-ring-arc {\n  stroke: var(--hc-ring-arc);\n  stroke-linecap: round;\n  stroke-dasharray: 56.55;\n  stroke-dashoffset: calc(56.55 - 56.55 * var(--hold-progress, 0));\n}\n\n/* Screen-reader-only carriers: progressbar, description, live region. */\n.hc-sr {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0 0 0 0);\n  clip-path: inset(50%);\n  white-space: nowrap;\n  border: 0;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .hc-button {\n    transition: none;\n  }\n\n  .hc-button:active:not(:disabled) {\n    transform: none;\n  }\n}\n"}]}