{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "type-to-confirm",
  "type": "registry:ui",
  "title": "Type to Confirm",
  "description": "Type the exact name to unlock a destructive action; the padlock opens on match.",
  "categories": [
    "settings"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/type-to-confirm.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, Loader } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nfunction commonPrefix(a: string, b: string) {\n  let i = 0;\n  while (i < a.length && i < b.length && a[i] === b[i]) i++;\n  return i;\n}\n\n/**\n * The matching logic on its own: whether the text matches, how much of it is\n * right so far, and a hint for the near misses people actually make (a stray\n * space, the wrong capitals).\n */\nexport function useTypeToConfirm(resource: string, typed: string) {\n  const matched = typed === resource;\n  const progress = resource.length ? commonPrefix(typed, resource) / resource.length : 0;\n  let hint: \"space\" | \"case\" | null = null;\n  if (!matched && typed.length >= resource.length) {\n    if (typed.trim() === resource) hint = \"space\";\n    else if (typed.toLowerCase() === resource.toLowerCase()) hint = \"case\";\n  }\n  return { matched, progress: matched ? 1 : progress, hint };\n}\n\ntype Phase = \"idle\" | \"pending\" | \"error\" | \"done\";\n\nexport type TypeToConfirmProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"children\" | \"defaultValue\"> & {\n  /** The exact text to type: a project slug, a repo name, an email. */\n  resource: string;\n  /** The verb on the button. */\n  action?: string;\n  /** Runs on an exact match. Return a promise to show progress; a rejection shows the error and lets them try again. */\n  onConfirm: () => void | Promise<unknown>;\n  /** Shows a Cancel button. */\n  onCancel?: () => void;\n  cancelLabel?: string;\n  /** Replaces the default \"To confirm, type … below\". */\n  label?: React.ReactNode;\n  /** The button's label once onConfirm resolves. */\n  confirmedLabel?: string;\n  /** Shown when onConfirm rejects. */\n  errorText?: React.ReactNode;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  disabled?: boolean;\n  autoFocus?: boolean;\n};\n\n/** The last gate before something irreversible: type the name, and only an exact match unlocks the button. */\nexport function TypeToConfirm({\n  resource,\n  action = \"Delete\",\n  onConfirm,\n  onCancel,\n  cancelLabel = \"Cancel\",\n  label,\n  confirmedLabel = \"Done\",\n  errorText,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  disabled = false,\n  autoFocus = false,\n  className,\n  ...rest\n}: TypeToConfirmProps) {\n  const id = useId();\n  const reduce = !!useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [typed, setTyped] = useControllableState({ value, defaultValue, onChange: onValueChange });\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [nudged, setNudged] = useState(false);\n  const { matched, progress, hint } = useTypeToConfirm(resource, typed);\n  const locked = !matched || disabled;\n  const busy = phase === \"pending\";\n  const done = phase === \"done\";\n\n  const submit = async () => {\n    if (busy || done || disabled) return;\n    if (!matched) {\n      setNudged(true);\n      inputRef.current?.focus();\n      return;\n    }\n    setPhase(\"pending\");\n    try {\n      await onConfirm();\n      setPhase(\"done\");\n    } catch {\n      setPhase(\"error\");\n    }\n  };\n\n  const message =\n    phase === \"error\"\n      ? (errorText ?? `Couldn’t ${action.toLowerCase()} ${resource}. Try again.`)\n      : hint === \"space\"\n        ? \"Remove the extra space\"\n        : hint === \"case\"\n          ? \"Match the capitals exactly\"\n          : nudged && !matched\n            ? `Type ${resource} exactly to continue`\n            : null;\n  const tone = phase === \"error\" || (nudged && !matched && !hint) ? \"danger\" : \"muted\";\n\n  return (\n    <form\n      noValidate\n      data-state={done ? \"done\" : matched ? \"unlocked\" : \"locked\"}\n      onSubmit={(e) => {\n        e.preventDefault();\n        submit();\n      }}\n      className={cn(\"flex w-full min-w-0 flex-col gap-2\", className)}\n      {...rest}\n    >\n      <label htmlFor={`${id}-input`} className=\"text-[12.5px] leading-[18px] text-fg-2 text-pretty\">\n        {label ?? (\n          <>\n            To confirm, type <code className=\"rounded-[4px] border border-line bg-hover px-1 py-px font-mono text-[12px] text-fg\">{resource}</code> below\n          </>\n        )}\n      </label>\n\n      <div\n        className={cn(\n          \"relative flex h-9 items-center rounded-lg border border-line-2 bg-frame\",\n          \"transition-[border-color,box-shadow] duration-150 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/10\",\n          (disabled || done) && \"opacity-60\",\n        )}\n      >\n        <input\n          ref={inputRef}\n          id={`${id}-input`}\n          value={typed}\n          onChange={(e) => {\n            setTyped(e.target.value);\n            setNudged(false);\n            if (phase === \"error\") setPhase(\"idle\");\n          }}\n          readOnly={busy || done}\n          disabled={disabled}\n          autoFocus={autoFocus}\n          autoComplete=\"off\"\n          autoCapitalize=\"none\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          enterKeyHint=\"done\"\n          aria-invalid={tone === \"danger\" || undefined}\n          aria-describedby={message ? `${id}-message` : undefined}\n          className=\"h-full w-full min-w-0 rounded-lg bg-transparent px-3 font-mono text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n        />\n        {/* How much of the name is right so far. It grows as they type and pulls back to where a typo starts. */}\n        <span aria-hidden className=\"pointer-events-none absolute inset-x-2.5 -bottom-px h-px overflow-hidden\">\n          <span\n            className={cn(\n              \"block h-full origin-left transition-[scale,background-color] duration-200 ease-out-expo motion-reduce:transition-none\",\n              matched ? \"bg-danger\" : \"bg-fg-3\",\n            )}\n            style={{ scale: `${progress} 1` }}\n          />\n        </span>\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-end gap-x-3 gap-y-2 pt-1\">\n        {/* Hints share the button row, so they never push the layout around. */}\n        <p\n          id={`${id}-message`}\n          role={phase === \"error\" ? \"alert\" : \"status\"}\n          aria-live=\"polite\"\n          className={cn(\"relative min-w-0 flex-[1_1_10rem] text-[12px] leading-[18px]\", tone === \"danger\" ? \"text-danger\" : \"text-fg-3\")}\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {message && (\n              <motion.span\n                key={String(message)}\n                className=\"block\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 3 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.18, ease: ease.out }}\n              >\n                {message}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </p>\n        <div className=\"ml-auto flex shrink-0 items-center gap-2\">\n          {onCancel && !done && (\n            <button\n              type=\"button\"\n              onClick={onCancel}\n              disabled={busy}\n              className={cn(\n                \"inline-flex h-8 items-center rounded-lg border border-line-2 bg-raised px-3 text-[12.5px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n                \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 disabled:opacity-50\",\n              )}\n            >\n              {cancelLabel}\n            </button>\n          )}\n          <button\n            type=\"submit\"\n            // Locked stays focusable and pressable: pressing it explains what's missing instead of doing nothing.\n            aria-disabled={locked || busy || done || undefined}\n            aria-busy={busy || undefined}\n            data-state={done ? \"done\" : matched ? \"unlocked\" : \"locked\"}\n            className={cn(\n              \"relative inline-flex h-8 items-center rounded-lg px-3 text-[12.5px] font-medium outline-none\",\n              \"transition-[background-color,color,box-shadow,scale] duration-200 ease-out\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              locked ? \"cursor-not-allowed bg-danger-soft text-danger/70\" : \"bg-danger text-frame shadow-[var(--shadow)] hover:bg-danger/90 active:scale-[0.97] active:duration-75\",\n              (busy || done) && \"pointer-events-none\",\n            )}\n          >\n            <span className={cn(\"inline-flex items-center gap-1.5 transition-opacity duration-150\", busy && \"opacity-0\")}>\n              <span className=\"relative grid size-3.5 place-items-center\">\n                <AnimatePresence initial={false}>\n                  <motion.span\n                    key={done ? \"done\" : \"lock\"}\n                    className=\"absolute inset-0 grid place-items-center\"\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                    transition={reduce ? { duration: 0.12 } : spring.pop}\n                  >\n                    {done ? <Check size={14} /> : <Padlock open={matched && !disabled} reduce={reduce} />}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n              {/* Both labels share one cell so the button never changes width. */}\n              <span className=\"grid\">\n                <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n                  {action}\n                </span>\n                <span aria-hidden className=\"invisible col-start-1 row-start-1\">\n                  {confirmedLabel}\n                </span>\n                <span className=\"col-start-1 row-start-1 text-center\">{done ? confirmedLabel : action}</span>\n              </span>\n            </span>\n            {busy && (\n              <span className=\"absolute inset-0 grid place-items-center\">\n                <Loader size={14} className=\"animate-spin-slow\" />\n              </span>\n            )}\n          </button>\n        </div>\n      </div>\n    </form>\n  );\n}\n\n// The shackle lifts out of the body on an exact match: the one moment this component exists for.\nfunction Padlock({ open, reduce }: { open: boolean; reduce: boolean }) {\n  return (\n    <svg width={14} height={14} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <rect x=\"3.5\" y=\"7\" width=\"9\" height=\"6.5\" rx=\"1.5\" />\n      <motion.path\n        initial={false}\n        animate={{ d: open ? \"M5.5 7V3.9a2.5 2.5 0 0 1 5 0V5.2\" : \"M5.5 7V5.25a2.5 2.5 0 0 1 5 0V7\" }}\n        transition={reduce ? { duration: 0 } : { ...spring.bouncy }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/type-to-confirm"
}
