{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "alert-dialog",
  "type": "registry:ui",
  "title": "Alert Dialog",
  "description": "Destructive confirm that holds open while it works and explains failures in place.",
  "categories": [
    "dialogs"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/alert-dialog.tsx",
      "content": "\"use client\";\nimport { AlertDialog as BaseAlertDialog } from \"@base-ui/react/alert-dialog\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Tone = \"default\" | \"danger\";\n\ntype Ctx = {\n  pending: boolean;\n  error: string | null;\n  cancelRef: React.RefObject<HTMLButtonElement | null>;\n  fieldRef: React.RefObject<HTMLInputElement | null>;\n  /** AlertDialogAction registers itself so Enter in the confirm field can run it. */\n  registerAction: (fn: (() => void) | null) => void;\n  submit: () => void;\n  armed: boolean;\n  setRequirement: (met: boolean | null) => void;\n  run: (fn: () => unknown, describe: (error: unknown) => string) => Promise<void>;\n};\nconst AlertCtx = createContext<Ctx | null>(null);\nconst useAlert = () => {\n  const ctx = useContext(AlertCtx);\n  if (!ctx) throw new Error(\"AlertDialog parts must be inside <AlertDialog>\");\n  return ctx;\n};\nconst ToneCtx = createContext<Tone>(\"default\");\n\nexport type AlertDialogProps = Omit<BaseAlertDialog.Root.Props, \"open\" | \"defaultOpen\" | \"onOpenChange\"> & {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\n/**\n * A confirm for the irreversible. It only closes by a choice (never an outside\n * click), refuses to close while its action is running, and keeps the dialog\n * open with the reason when the action fails.\n */\nexport function AlertDialog({ open: openProp, defaultOpen = false, onOpenChange, children, ...rest }: AlertDialogProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [pending, setPending] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [requirement, setRequirement] = useState<boolean | null>(null);\n  const cancelRef = useRef<HTMLButtonElement>(null);\n  const fieldRef = useRef<HTMLInputElement>(null);\n  const actionRef = useRef<(() => void) | null>(null);\n\n  const registerAction = (fn: (() => void) | null) => {\n    actionRef.current = fn;\n  };\n  const submit = () => actionRef.current?.();\n\n  const run: Ctx[\"run\"] = async (fn, describe) => {\n    setError(null);\n    const result = fn();\n    // A plain function closes at once; a promise holds the dialog open until it settles.\n    if (!(result instanceof Promise)) return setOpen(false);\n    setPending(true);\n    try {\n      await result;\n      setPending(false);\n      setOpen(false);\n    } catch (e) {\n      setPending(false);\n      setError(describe(e));\n    }\n  };\n\n  return (\n    <AlertCtx.Provider\n      value={{ pending, error, cancelRef, fieldRef, registerAction, submit, armed: requirement !== false, setRequirement, run }}\n    >\n      <BaseAlertDialog.Root\n        open={open}\n        onOpenChange={(next, details) => {\n          if (!next && pending) {\n            details.cancel();\n            return;\n          }\n          if (next) setError(null);\n          setOpen(next);\n        }}\n        {...rest}\n      >\n        {children}\n      </BaseAlertDialog.Root>\n    </AlertCtx.Provider>\n  );\n}\n\nexport type AlertDialogTriggerProps = BaseAlertDialog.Trigger.Props;\n\nexport function AlertDialogTrigger(props: AlertDialogTriggerProps) {\n  return <BaseAlertDialog.Trigger {...props} />;\n}\n\nexport type AlertDialogContentProps = Omit<BaseAlertDialog.Popup.Props, \"className\"> & {\n  className?: string;\n  /** Danger turns the action red and the icon tile with it. */\n  tone?: Tone;\n  /** A 16px glyph shown in a tile above the title. */\n  icon?: React.ReactNode;\n  /** Render into this element instead of document.body. */\n  container?: BaseAlertDialog.Portal.Props[\"container\"];\n};\n\nexport function AlertDialogContent({ tone = \"default\", icon, container, className, children, ...rest }: AlertDialogContentProps) {\n  const { cancelRef, fieldRef } = useAlert();\n  const contained = container != null;\n  return (\n    <ToneCtx.Provider value={tone}>\n      <BaseAlertDialog.Portal container={container}>\n        <BaseAlertDialog.Backdrop\n          className={cn(\n            contained ? \"absolute\" : \"fixed\",\n            \"inset-0 z-(--z-overlay) bg-overlay transition-opacity duration-200 ease-out-quart\",\n            \"data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-150\",\n          )}\n        />\n        <BaseAlertDialog.Viewport\n          className={cn(\n            contained ? \"absolute\" : \"fixed\",\n            \"inset-0 z-(--z-dialog) flex items-center justify-center p-4\",\n            \"max-sm:items-end max-sm:p-2 max-sm:pb-[max(8px,env(safe-area-inset-bottom))]\",\n          )}\n        >\n          <BaseAlertDialog.Popup\n            // A typed confirmation is the safe place to start; otherwise Cancel, never the destructive verb.\n            initialFocus={() => fieldRef.current ?? cancelRef.current ?? true}\n            data-tone={tone}\n            className={cn(\n              \"relative flex w-full max-w-[400px] flex-col rounded-2xl border border-line-2 bg-raised p-5 text-fg shadow-pop outline-none\",\n              \"origin-center transition-[opacity,scale,translate] duration-[260ms] ease-out-expo\",\n              \"data-starting-style:translate-y-1.5 data-starting-style:scale-[0.97] data-starting-style:opacity-0\",\n              \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-[160ms] data-ending-style:ease-out-quart\",\n              \"max-sm:data-starting-style:translate-y-4 max-sm:data-starting-style:scale-100\",\n              className,\n            )}\n            {...rest}\n          >\n            {icon && (\n              <span\n                aria-hidden\n                className={cn(\n                  \"mb-4 grid size-9 place-items-center rounded-full [&_svg]:size-4\",\n                  tone === \"danger\" ? \"bg-danger-soft text-danger\" : \"bg-hover text-fg-2\",\n                )}\n              >\n                {icon}\n              </span>\n            )}\n            {children}\n          </BaseAlertDialog.Popup>\n        </BaseAlertDialog.Viewport>\n      </BaseAlertDialog.Portal>\n    </ToneCtx.Provider>\n  );\n}\n\nexport type AlertDialogTitleProps = Omit<BaseAlertDialog.Title.Props, \"className\"> & { className?: string };\n\nexport function AlertDialogTitle({ className, ...rest }: AlertDialogTitleProps) {\n  return (\n    <BaseAlertDialog.Title\n      className={cn(\"text-[15px] font-medium leading-[1.3] tracking-[-0.015em] text-fg text-balance\", className)}\n      {...rest}\n    />\n  );\n}\n\nexport type AlertDialogDescriptionProps = Omit<BaseAlertDialog.Description.Props, \"className\"> & { className?: string };\n\nexport function AlertDialogDescription({ className, ...rest }: AlertDialogDescriptionProps) {\n  return <BaseAlertDialog.Description className={cn(\"mt-1.5 text-[13px] leading-[1.5] text-fg-2 text-pretty\", className)} {...rest} />;\n}\n\nexport type AlertDialogConfirmFieldProps = Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\"> & {\n  /** The exact text to type before the action unlocks, usually the name of the thing being deleted. */\n  match: string;\n  /** Label above the field. Defaults to “Type <match> to confirm”. */\n  label?: React.ReactNode;\n};\n\n/** Typed confirmation for the highest stakes: the action stays locked until the text matches. */\nexport function AlertDialogConfirmField({ match, label, className, ...rest }: AlertDialogConfirmFieldProps) {\n  const { fieldRef, setRequirement, submit, pending } = useAlert();\n  const [text, setText] = useState(\"\");\n  const reduce = useReducedMotion();\n  const id = useId();\n  const met = text === match;\n\n  useEffect(() => {\n    setRequirement(met);\n  }, [met, setRequirement]);\n  useEffect(() => () => setRequirement(null), [setRequirement]);\n\n  return (\n    <div className=\"mt-4\">\n      <label htmlFor={id} className=\"mb-1.5 block text-[12.5px] text-fg-2\">\n        {label ?? (\n          <>\n            Type <span className=\"select-all rounded bg-hover px-1 py-px font-mono text-[12px] text-fg\">{match}</span> to confirm\n          </>\n        )}\n      </label>\n      <div className=\"relative\">\n        <input\n          ref={fieldRef}\n          id={id}\n          value={text}\n          onChange={(e) => setText(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" && met && !pending) {\n              e.preventDefault();\n              submit();\n            }\n          }}\n          readOnly={pending}\n          autoComplete=\"off\"\n          autoCapitalize=\"none\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          enterKeyHint=\"done\"\n          className={cn(\n            \"h-9 w-full rounded-lg border border-line-2 bg-raised pl-3 pr-9 font-mono text-base text-fg shadow-[var(--shadow)] outline-none sm:text-[13px]\",\n            \"transition-[border-color,box-shadow] duration-150 ease-out-quart hover:border-fg-4 focus:border-fg-3 focus:ring-3 focus:ring-fg/8\",\n            \"read-only:opacity-60\",\n            className,\n          )}\n          {...rest}\n        />\n        {/* A tick draws in once the text matches: the unlock is visible before the button is. */}\n        <span aria-hidden className=\"pointer-events-none absolute inset-y-0 right-3 grid place-items-center text-success\">\n          <AnimatePresence initial={false}>\n            {met && (\n              <motion.svg\n                key=\"tick\"\n                width=\"16\"\n                height=\"16\"\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={1.6}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n              >\n                <motion.path\n                  d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                  initial={{ pathLength: reduce ? 1 : 0 }}\n                  animate={{ pathLength: 1 }}\n                  transition={{ duration: 0.28, ease: ease.out, delay: 0.04 }}\n                />\n              </motion.svg>\n            )}\n          </AnimatePresence>\n        </span>\n      </div>\n    </div>\n  );\n}\n\nexport type AlertDialogFooterProps = React.ComponentProps<\"div\">;\n\n/** The actions, with the failure message opening above them when the action throws. */\nexport function AlertDialogFooter({ className, children, ...rest }: AlertDialogFooterProps) {\n  const { error } = useAlert();\n  return (\n    <div className=\"mt-5 flex flex-col\">\n      <div\n        data-open={error ? \"\" : undefined}\n        className=\"grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-in-out-quart data-open:grid-rows-[1fr]\"\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <p role=\"alert\" className=\"mb-3 flex gap-2 rounded-lg bg-danger-soft px-3 py-2 text-[12.5px] leading-[1.45] text-danger\">\n            {error && (\n              <>\n                <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" aria-hidden className=\"mt-px shrink-0\">\n                  <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n                  <path d=\"M8 5v3.5\" />\n                  <circle cx=\"8\" cy=\"10.9\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n                </svg>\n                <span>{error}</span>\n              </>\n            )}\n          </p>\n        </div>\n      </div>\n      <div\n        className={cn(\n          \"flex items-center justify-end gap-2\",\n          \"max-sm:grid max-sm:auto-cols-fr max-sm:grid-flow-col max-sm:*:h-10\",\n          className,\n        )}\n        {...rest}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n\nconst base = cn(\n  \"relative inline-flex h-8 shrink-0 select-none items-center justify-center whitespace-nowrap rounded-lg px-2.5 text-[12.5px] font-medium tracking-[-0.005em]\",\n  \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n  \"transition-[background-color,border-color,color,opacity,scale] duration-150 ease-out-quart active:scale-[0.97] active:duration-75\",\n);\n\nexport type AlertDialogCancelProps = Omit<BaseAlertDialog.Close.Props, \"className\"> & { className?: string };\n\n/** Closes without acting. Receives focus on open, and is locked while the action runs. */\nexport function AlertDialogCancel({ className, disabled, children = \"Cancel\", ...rest }: AlertDialogCancelProps) {\n  const { cancelRef, pending } = useAlert();\n  return (\n    <BaseAlertDialog.Close\n      ref={cancelRef}\n      disabled={disabled || pending}\n      className={cn(\n        base,\n        \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </BaseAlertDialog.Close>\n  );\n}\n\nexport type AlertDialogActionProps = Omit<React.ComponentProps<\"button\">, \"onClick\" | \"children\"> & {\n  /** The verb: “Delete”, “Revoke”, “Remove member”. Never “OK” or “Yes”. */\n  children: string;\n  /** Runs on press. Return a promise to hold the dialog open, busy, until it settles. */\n  onAction?: () => void | Promise<unknown>;\n  /** Shown with a spinner while the promise runs, e.g. “Deleting…”. */\n  pendingLabel?: string;\n  /** Turns a rejection into the message shown above the buttons. */\n  errorMessage?: (error: unknown) => string;\n};\n\nexport function AlertDialogAction({\n  children,\n  onAction,\n  pendingLabel,\n  errorMessage = () => \"That didn’t go through. Try again.\",\n  disabled,\n  className,\n  ...rest\n}: AlertDialogActionProps) {\n  const { run, pending, armed, registerAction } = useAlert();\n  const tone = useContext(ToneCtx);\n  const reduce = useReducedMotion();\n  const busyLabel = pendingLabel ?? `${children}…`;\n  const locked = disabled || !armed;\n\n  const fire = () => {\n    if (pending || locked) return;\n    void run(() => onAction?.(), errorMessage);\n  };\n  useEffect(() => {\n    registerAction(fire);\n    return () => registerAction(null);\n  });\n\n  const t = { duration: reduce ? 0.12 : 0.2, ease: ease.out };\n  return (\n    <button\n      type=\"button\"\n      data-tone={tone}\n      aria-busy={pending || undefined}\n      aria-disabled={locked || undefined}\n      disabled={locked}\n      onClick={fire}\n      className={cn(\n        base,\n        \"disabled:pointer-events-none disabled:opacity-45 aria-busy:cursor-progress aria-busy:active:scale-100\",\n        tone === \"danger\"\n          ? \"bg-danger text-frame shadow-[var(--shadow)] hover:bg-danger/90\"\n          : \"bg-fg text-frame shadow-[var(--shadow)] hover:bg-fg/90\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* Both labels share one grid cell, so the button is as wide as the longer and never jumps. */}\n      <span className=\"grid overflow-hidden py-1\">\n        <span aria-hidden className=\"invisible col-start-1 row-start-1\">{children}</span>\n        <span aria-hidden className=\"invisible col-start-1 row-start-1 pl-[22px]\">{busyLabel}</span>\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={pending ? \"busy\" : \"idle\"}\n            className=\"col-start-1 row-start-1 flex items-center justify-center gap-1.5\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(2px)\" }}\n            transition={t}\n          >\n            {pending && <Spinner />}\n            {pending ? busyLabel : children}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <span role=\"status\" className=\"sr-only\">\n        {pending ? busyLabel : \"\"}\n      </span>\n    </button>\n  );\n}\n\nfunction Spinner() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"shrink-0 animate-[spin_0.7s_linear_infinite]\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.5\" />\n      <path d=\"M8 2.25A5.75 5.75 0 0 1 13.75 8\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/alert-dialog"
}
