{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "system-states",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Irreversible actions, with accessible fallbacks.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/ledger/use-media-query\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { Button } from \"@/components/ledger/button\";\n\nexport interface HoldToConfirmProps {\n  className?: string;\n  disabled?: boolean;\n  durationMs?: number;\n  label: string;\n  onConfirm: () => void;\n  /** The exact string a keyboard/typed fallback must match. */\n  typedConfirmation?: string;\n}\n\n/**\n * Slow where the user is deciding, fast where the system is responding:\n * a 1500ms linear fill, a 200ms ease-out release.\n *\n * Gating a financial action behind a motor-skill requirement is an\n * accessibility failure, so hold-to-confirm is never the only path.\n * Space holds with aria-live progress at 0/50/100%, Escape aborts, and a\n * typed confirmation is always available.\n */\nexport function HoldToConfirm({\n  label,\n  onConfirm,\n  durationMs = 1500,\n  typedConfirmation,\n  className,\n  disabled = false,\n}: HoldToConfirmProps) {\n  const [progress, setProgress] = React.useState(0);\n  const [announced, setAnnounced] = React.useState<string>(\"\");\n  const [typed, setTyped] = React.useState(\"\");\n  const [showTyped, setShowTyped] = React.useState(false);\n  const raf = React.useRef<number | null>(null);\n  const start = React.useRef<number>(0);\n  const reduced = useReducedMotion();\n\n  const stop = React.useCallback(() => {\n    if (raf.current !== null) cancelAnimationFrame(raf.current);\n    raf.current = null;\n    setProgress(0);\n    setAnnounced(\"\");\n  }, []);\n\n  const begin = React.useCallback(() => {\n    if (disabled || raf.current !== null) return;\n    if (reduced) {\n      // No motor-timed gesture under reduced motion; confirm on activation.\n      onConfirm();\n      return;\n    }\n    start.current = performance.now();\n    setAnnounced(\"Holding, 0 percent\");\n    const step = (now: number) => {\n      const p = Math.min(1, (now - start.current) / durationMs);\n      setProgress(p);\n      if (p >= 0.5 && p < 0.55) setAnnounced(\"Holding, 50 percent\");\n      if (p >= 1) {\n        setAnnounced(\"Confirmed\");\n        raf.current = null;\n        setProgress(0);\n        onConfirm();\n        return;\n      }\n      raf.current = requestAnimationFrame(step);\n    };\n    raf.current = requestAnimationFrame(step);\n  }, [disabled, durationMs, onConfirm, reduced]);\n\n  React.useEffect(() => () => stop(), [stop]);\n\n  return (\n    <div className={cn(\"flex flex-col items-stretch gap-2\", className)}>\n      <button\n        aria-describedby=\"hold-help\"\n        className={cn(\n          \"relative h-12 select-none overflow-hidden rounded-sm bg-accent px-6\",\n          \"text-label font-medium text-accent-fg\",\n          \"transition-transform duration-1 ease-out\",\n          progress > 0 && \"scale-[0.97]\",\n          disabled && \"pointer-events-none opacity-50\",\n        )}\n        // It scales while held, from its own progress; the global press would add a second.\n        data-press=\"none\"\n        disabled={disabled}\n        onKeyDown={(e) => {\n          if (e.key === \" \" && !e.repeat) {\n            e.preventDefault();\n            begin();\n          }\n          if (e.key === \"Escape\") stop();\n        }}\n        onKeyUp={(e) => {\n          if (e.key === \" \") stop();\n        }}\n        onPointerCancel={stop}\n        onPointerDown={(e) => {\n          e.currentTarget.setPointerCapture(e.pointerId);\n          begin();\n        }}\n        onPointerLeave={stop}\n        onPointerUp={stop}\n        type=\"button\"\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 bg-accent-active\"\n          style={{\n            clipPath: `inset(0 ${100 - progress * 100}% 0 0)`,\n            transition: progress === 0 ? \"clip-path 200ms var(--ease-out)\" : \"none\",\n          }}\n        />\n        <span className=\"relative\">{label}</span>\n      </button>\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announced}\n      </span>\n      <span className=\"text-caption text-text-tertiary\" id=\"hold-help\">\n        Press and hold, or hold Space. Escape cancels.\n      </span>\n\n      {typedConfirmation ? (\n        showTyped ? (\n          <div className=\"flex items-center gap-2\">\n            <input\n              aria-label={`Type ${typedConfirmation} to confirm`}\n              className=\"h-10 flex-1 rounded-sm border border-border bg-surface px-3 text-body outline-none focus-visible:border-accent\"\n              onChange={(e) => setTyped(e.target.value)}\n              placeholder={typedConfirmation}\n              value={typed}\n            />\n            <Button disabled={typed !== typedConfirmation} onClick={onConfirm} variant=\"primary\">\n              Confirm\n            </Button>\n          </div>\n        ) : (\n          <button\n            className=\"self-start text-caption text-accent hover:underline\"\n            onClick={() => setShowTyped(true)}\n            type=\"button\"\n          >\n            Confirm by typing instead\n          </button>\n        )\n      ) : null}\n    </div>\n  );\n}\n",
      "path": "components/ledger/hold-to-confirm.tsx",
      "target": "components/ledger/hold-to-confirm.tsx",
      "type": "registry:component"
    }
  ],
  "name": "hold-to-confirm",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/button",
    "@lopifi/cn",
    "@lopifi/use-media-query"
  ],
  "title": "HoldToConfirm",
  "type": "registry:component"
}
