{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "confirm-morph",
  "type": "registry:ui",
  "title": "Confirm morph",
  "description": "A destructive button that morphs into an inline confirmation, a spinner, and a result with undo.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/confirm-morph/confirm-morph.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, Ref } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport type { AnimationPlaybackControls, MotionValue, Transition, Variants } from \"motion/react\";\nimport { CircleAlert, LoaderCircle } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/** Where the control is in its life: resting, asking, working, finished, or failed. */\nexport type ConfirmMorphState = \"idle\" | \"confirming\" | \"pending\" | \"done\" | \"error\";\n\n/** How the question is answered: a press, a press held until the fill completes, or a press followed by a short countdown. */\nexport type ConfirmMorphMode = \"press\" | \"hold\" | \"countdown\";\n\n/**\n * A button for destructive or important actions that asks in place. Pressing it morphs the same pill into an inline question\n * with Cancel and Confirm, then into a spinner, then into a result with Undo. The width springs to each face while the faces\n * crossfade at their natural width, so text never squashes and nothing around the control jumps. Escape, an outside press,\n * or the timeout all return it to rest.\n * Use it where a modal dialog would be heavy: deleting a project, revoking access, discarding a draft.\n */\nexport interface ConfirmMorphProps {\n  /** The resting label, such as \"Delete project\". */\n  label: ReactNode;\n  /** A plain icon before the resting label. */\n  icon?: ReactNode;\n  /** The question shown while confirming, such as \"Delete 3 files?\". Defaults to the label with a question mark. */\n  prompt?: ReactNode;\n  confirmLabel?: string;\n  cancelLabel?: string;\n  /** Shown beside the spinner while `onConfirm` resolves. */\n  pendingLabel?: string;\n  doneLabel?: string;\n  errorLabel?: string;\n  retryLabel?: string;\n  undoLabel?: string;\n  /** Shown beside the spinner while `onUndo` resolves. */\n  undoingLabel?: string;\n  /**\n   * `danger` (default) gives the confirm button the danger colour, for actions that destroy something. `neutral` gives it the\n   * accent, for important actions that can be reversed. The resting button is neutral either way.\n   */\n  tone?: \"danger\" | \"neutral\";\n  /** `press` confirms on a press. `hold` asks for a press held for `holdDuration`. `countdown` confirms, then counts down `countdown` seconds that Cancel or Escape can still stop. */\n  confirmMode?: ConfirmMorphMode;\n  /** Milliseconds the confirm button must be held in `hold` mode. */\n  holdDuration?: number;\n  /** Seconds counted down before the action runs in `countdown` mode. */\n  countdown?: number;\n  /** Text before the seconds in `countdown` mode, such as \"Deleting in\". Defaults to the pending label followed by \"in\". */\n  countdownLabel?: string;\n  /** Runs on confirm. Return a promise to show the pending face; a rejection shows the error face with Retry. */\n  onConfirm?: () => void | Promise<unknown>;\n  /** Offering it adds Undo to the result. Return a promise to show a pending face while it runs. */\n  onUndo?: () => void | Promise<unknown>;\n  onCancel?: () => void;\n  /** Controlled state. Pair it with `onStateChange`. */\n  state?: ConfirmMorphState;\n  /** Starting state when uncontrolled. */\n  defaultState?: ConfirmMorphState;\n  onStateChange?: (state: ConfirmMorphState) => void;\n  /** Milliseconds before an unanswered question returns to rest. Resting the pointer on the control pauses it. 0 turns it off. */\n  confirmTimeout?: number;\n  /** Milliseconds a result stays before returning to rest. Resting the pointer on the control pauses it. 0 turns it off. */\n  resultTimeout?: number;\n  /** A press outside the control cancels an open question. Defaults to true. */\n  cancelOnOutsidePress?: boolean;\n  disabled?: boolean;\n  className?: string;\n  /** Receives the root element, which also takes focus while the action is pending. */\n  ref?: Ref<HTMLDivElement>;\n}\n\n/** A press this soon after the question appears is the tail of the press that opened it, not an answer. */\nconst ARM_MS = 280;\nconst { blur } = motionTokens;\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\n/** Duration springs restated as stiffness and damping, so a retarget mid-flight keeps the velocity it already has. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\n/** Growing follows new content with a little life; shrinking settles without overshoot. */\nconst GROW = physical(motionTokens.spring.morph),\n  SHRINK = physical(motionTokens.spring.smooth);\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\n/*\n * Faces never move and never scale: each one sits at its natural width, centred in the pill, so the baseline stays put and\n * the pill's clip reveals it as the width springs. The old face fades out quickly; the new one's parts arrive in reading\n * order, each clearing a small blur.\n */\nconst faceVariants: Variants = {\n  hidden: { opacity: 1 },\n  shown: { opacity: 1, transition: { staggerChildren: motionTokens.stagger.item, delayChildren: 0.05 } },\n  gone: {\n    opacity: 0,\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.instant, ease: standard },\n  },\n};\nconst partVariants: Variants = {\n  hidden: { opacity: 0, filter: `blur(${blur.soft}px)` },\n  shown: { opacity: 1, filter: \"blur(0px)\", transition: { duration: 0.26, ease: enter } },\n};\nconst reducedFace: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1, transition: { duration: 0.14 } },\n  gone: { opacity: 0, transition: { duration: 0.1 } },\n};\nconst reducedPart: Variants = { hidden: { opacity: 1 }, shown: { opacity: 1 } };\n\ntype FaceKey = ConfirmMorphState | \"counting\";\n\n/* Shared by Cancel/Undo/Retry and the coloured answer: a small pill inside the pill. Keyboard focus reads through fill, never a ring. */\nconst answerBase =\n  \"relative inline-flex h-[calc(var(--control-height-sm)-8px)] flex-none cursor-pointer touch-manipulation items-center justify-center rounded-button border-0 px-3 font-medium transition-[background-color,color] duration-fast ease-standard motion-reduce:duration-0\";\nconst secondaryClass = cn(\n  answerBase,\n  \"bg-transparent text-ink-2 group-data-[face=done]/face:text-ink group-data-[face=error]/face:text-ink hover:not-active:bg-[color-mix(in_oklab,var(--ink)_7%,transparent)] hover:not-active:text-ink focus-visible:bg-[color-mix(in_oklab,var(--ink)_8%,transparent)] focus-visible:text-ink active:bg-[color-mix(in_oklab,var(--ink)_11%,transparent)] active:text-ink active:duration-0\",\n);\n/* The only coloured thing in the control: danger for a destructive answer, the accent for a reversible one. In hold mode the\n   label stays unselectable so a long press never opens the selection callout. */\nconst confirmClass = cn(\n  answerBase,\n  \"overflow-clip bg-[color-mix(in_oklab,var(--red)_13%,transparent)] text-red hover:not-active:bg-[color-mix(in_oklab,var(--red)_18%,transparent)] focus-visible:bg-[color-mix(in_oklab,var(--red)_20%,transparent)] active:bg-[color-mix(in_oklab,var(--red)_22%,transparent)] active:duration-0 contrast-more:bg-[color-mix(in_oklab,var(--red)_22%,transparent)] group-data-[mode=hold]/cm:select-none group-data-[mode=hold]/cm:[-webkit-touch-callout:none] group-data-[tone=neutral]/cm:bg-accent group-data-[tone=neutral]/cm:text-on-accent group-data-[tone=neutral]/cm:hover:not-active:bg-accent-ink group-data-[tone=neutral]/cm:focus-visible:bg-accent-ink group-data-[tone=neutral]/cm:active:bg-accent-ink\",\n);\nconst statusClass =\n  \"gap-2 pr-2.5 pl-3 font-medium text-ink tabular-nums [&>svg]:flex-none group-data-[face=counting]/face:pr-1.5 group-data-[face=counting]/face:text-ink-2 group-data-[face=pending]/face:ps-3 group-data-[face=pending]/face:pe-4 group-data-[face=pending]/face:text-ink-2 group-[[data-face=done]:not(:has(button))]/face:pr-3.5\";\nconst statusTextClass = \"inline-flex items-baseline gap-[.25em]\";\n\nfunction Face({\n  id,\n  reduced,\n  onSize,\n  children,\n  labelledBy,\n}: {\n  id: FaceKey;\n  reduced: boolean;\n  onSize: (id: FaceKey, width: number) => void;\n  children: ReactNode;\n  labelledBy?: string;\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const present = useIsPresent();\n  useLayoutEffect(() => {\n    const node = ref.current;\n    if (!node || !present) return;\n    // Fractional widths, so the spring lands exactly where the pill's natural width is and the hand back to auto is seamless.\n    const report = () => onSize(id, node.getBoundingClientRect().width);\n    report();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(report);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [id, onSize, present]);\n  return (\n    <motion.div\n      ref={ref}\n      className=\"group/face flex h-full flex-none items-center gap-0.5 px-1 whitespace-nowrap data-[face=idle]:p-0 [&[inert]]:pointer-events-none [&[inert]]:absolute [&[inert]]:start-0 [&[inert]]:top-0\"\n      data-face={id}\n      role={labelledBy ? \"group\" : undefined}\n      aria-labelledby={labelledBy}\n      variants={reduced ? reducedFace : faceVariants}\n      initial=\"hidden\"\n      animate=\"shown\"\n      exit=\"gone\"\n      inert={!present || undefined}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nfunction Part({ reduced, className, children }: { reduced: boolean; className?: string; children: ReactNode }) {\n  return (\n    <motion.span\n      className={cn(\"inline-flex h-full items-center\", className)}\n      variants={reduced ? reducedPart : partVariants}\n    >\n      {children}\n    </motion.span>\n  );\n}\n\n/** A success disc that pops in with a tick drawing across it, the moment the action lands. */\nfunction Check({ reduced }: { reduced: boolean }) {\n  return (\n    <svg className=\"size-[18px]\" viewBox=\"0 0 18 18\" fill=\"none\" aria-hidden=\"true\">\n      <motion.circle\n        className=\"fill-green\"\n        cx=\"9\"\n        cy=\"9\"\n        r=\"8\"\n        style={{ transformOrigin: \"9px 9px\" }}\n        initial={reduced ? false : { scale: 0.5, opacity: 0 }}\n        animate={{ scale: 1, opacity: 1 }}\n        transition={{ scale: physical(motionTokens.spring.snappy), opacity: { duration: 0.12 } }}\n      />\n      <motion.path\n        className=\"stroke-page\"\n        d=\"M5.6 9.3 7.8 11.4 12.4 6.7\"\n        strokeWidth=\"1.9\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={reduced ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.3, ease: enter, delay: 0.14 }}\n      />\n    </svg>\n  );\n}\n\n/** A ring that drains with the countdown. */\nfunction Ring({ progress }: { progress: MotionValue<number> }) {\n  return (\n    <svg className=\"size-4\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\">\n      <circle className=\"stroke-line\" cx=\"8\" cy=\"8\" r=\"6.25\" strokeWidth=\"1.75\" />\n      <motion.circle\n        className=\"stroke-red group-data-[tone=neutral]/cm:stroke-accent\"\n        cx=\"8\"\n        cy=\"8\"\n        r=\"6.25\"\n        strokeWidth=\"1.75\"\n        strokeLinecap=\"round\"\n        style={{ pathLength: progress, rotate: -90, transformOrigin: \"8px 8px\" }}\n      />\n    </svg>\n  );\n}\n\n/** Seconds that swap in place: the old digit fades up and out, the new one rises in, on tabular figures so the width holds. */\nfunction Seconds({ value, reduced }: { value: number; reduced: boolean }) {\n  return (\n    <span className=\"inline-grid min-w-[1ch] text-ink tabular-nums\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={value}\n          className=\"[grid-area:1/1]\"\n          initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6, filter: `blur(${blur.subtle}px)` }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6, filter: `blur(${blur.subtle}px)` }}\n          transition={{ duration: 0.22, ease: enter }}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport function ConfirmMorph({\n  label,\n  icon,\n  prompt,\n  confirmLabel = \"Delete\",\n  cancelLabel = \"Cancel\",\n  pendingLabel = \"Deleting\",\n  doneLabel = \"Deleted\",\n  errorLabel = \"Couldn’t finish\",\n  retryLabel = \"Retry\",\n  undoLabel = \"Undo\",\n  undoingLabel = \"Restoring\",\n  tone = \"danger\",\n  confirmMode = \"press\",\n  holdDuration = 1000,\n  countdown = 3,\n  countdownLabel,\n  onConfirm,\n  onUndo,\n  onCancel,\n  state: stateProp,\n  defaultState = \"idle\",\n  onStateChange,\n  confirmTimeout = 6000,\n  resultTimeout = 5000,\n  cancelOnOutsidePress = true,\n  disabled = false,\n  className,\n  ref,\n}: ConfirmMorphProps) {\n  const reduced = useReducedFlag();\n  const uid = useId();\n  const promptId = `${uid}-prompt`;\n  const holdHintId = `${uid}-hold`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => rootRef.current as HTMLDivElement, []);\n\n  const [inner, setInner] = useState<ConfirmMorphState>(defaultState);\n  const state = stateProp ?? inner;\n  const [working, setWorking] = useState<\"confirm\" | \"undo\">(\"confirm\");\n  const [counting, setCounting] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const faceKey: FaceKey = state === \"confirming\" && counting ? \"counting\" : state;\n\n  const live = useRef({ state, onStateChange, controlled: stateProp !== undefined });\n  useLayoutEffect(() => {\n    live.current = { state, onStateChange, controlled: stateProp !== undefined };\n  });\n  const pendingFocus = useRef(false);\n  const run = useRef(0);\n  const askedAt = useRef(0);\n\n  const keepFocus = useCallback(() => {\n    const root = rootRef.current;\n    // Focus follows the control between faces, but only when it was already inside; a timeout never steals focus from elsewhere.\n    pendingFocus.current =\n      !!root && (root.contains(document.activeElement) || document.activeElement === document.body);\n  }, []);\n\n  const go = useCallback(\n    (next: ConfirmMorphState) => {\n      if (next !== \"confirming\") setCounting(false);\n      if (next === live.current.state) return;\n      keepFocus();\n      if (next === \"confirming\") askedAt.current = performance.now();\n      if (!live.current.controlled) setInner(next);\n      live.current.state = next;\n      live.current.onStateChange?.(next);\n    },\n    [keepFocus],\n  );\n\n  // A controlled parent can move the state on its own; the countdown never survives that.\n  const [seenState, setSeenState] = useState(state);\n  if (seenState !== state) {\n    setSeenState(state);\n    if (state !== \"confirming\") setCounting(false);\n  }\n\n  const toIdle = useCallback(() => {\n    run.current++;\n    go(\"idle\");\n  }, [go]);\n\n  const perform = useCallback(\n    async (kind: \"confirm\" | \"undo\") => {\n      const handler = kind === \"confirm\" ? onConfirm : onUndo;\n      const token = ++run.current;\n      setWorking(kind);\n      setCounting(false);\n      let result: void | Promise<unknown> | undefined;\n      try {\n        result = handler?.();\n      } catch {\n        go(\"error\");\n        setAnnouncement(errorLabel);\n        return;\n      }\n      if (result && typeof (result as Promise<unknown>).then === \"function\") {\n        go(\"pending\");\n        setAnnouncement(kind === \"confirm\" ? pendingLabel : undoingLabel);\n        try {\n          await result;\n        } catch {\n          if (token !== run.current) return;\n          go(\"error\");\n          setAnnouncement(errorLabel);\n          return;\n        }\n        if (token !== run.current) return;\n      }\n      if (kind === \"undo\") {\n        go(\"idle\");\n        setAnnouncement(\"Undone\");\n        return;\n      }\n      go(\"done\");\n      setAnnouncement(onUndo ? `${doneLabel}. ${undoLabel} is available.` : doneLabel);\n    },\n    [doneLabel, errorLabel, go, onConfirm, onUndo, pendingLabel, undoLabel, undoingLabel],\n  );\n\n  const cancel = useCallback(() => {\n    onCancel?.();\n    toIdle();\n    setAnnouncement(\"Cancelled\");\n  }, [onCancel, toIdle]);\n  const expire = useRef(() => {});\n  useLayoutEffect(() => {\n    expire.current = () => {\n      if (live.current.state === \"confirming\") cancel();\n      else toIdle();\n    };\n  });\n\n  /* The shape: one pill whose width springs to whichever face is current. At rest it is auto, so it renders right before hydration. */\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const target = useRef(0);\n  const flight = useRef(0);\n  // Only the present face reports (a leaving face stops observing), so every report is the current face's width.\n  const onFaceSize = useCallback(\n    (_id: FaceKey, w: number) => {\n      if (Math.abs(w - target.current) < 0.25) return;\n      const from = target.current;\n      target.current = w;\n      if (!from || reduced) {\n        flight.current++;\n        width.jump(\"auto\");\n        return;\n      }\n      const now = width.get();\n      if (now === \"auto\") width.jump(from);\n      const token = ++flight.current;\n      void animate(width, w, w > (typeof now === \"number\" ? now : from) ? GROW : SHRINK).then(() => {\n        if (token === flight.current) width.jump(\"auto\");\n      });\n    },\n    [reduced, width],\n  );\n\n  /* The timeout runs as an invisible clock. A pointer resting on the control holds it, and so does a hidden tab. */\n  const drain = useMotionValue(1);\n  const clock = useRef<AnimationPlaybackControls | null>(null);\n  const holds = useRef({ hover: false, hidden: false });\n  const timeout =\n    state === \"confirming\" && !counting ? confirmTimeout : state === \"done\" || state === \"error\" ? resultTimeout : 0;\n  const sync = useCallback(() => {\n    const control = clock.current;\n    if (!control) return;\n    const held = holds.current.hover || holds.current.hidden;\n    if (held) control.pause();\n    else control.play();\n  }, []);\n  useEffect(() => {\n    if (!timeout) return;\n    drain.jump(1);\n    const control = animate(drain, 0, { duration: timeout / 1000, ease: \"linear\" });\n    clock.current = control;\n    void control.then(() => {\n      if (clock.current === control) {\n        clock.current = null;\n        expire.current();\n      }\n    });\n    sync();\n    return () => {\n      if (clock.current === control) clock.current = null;\n      control.stop();\n    };\n  }, [drain, state, sync, timeout]);\n  useEffect(() => {\n    const onVisibility = () => {\n      holds.current.hidden = document.hidden;\n      sync();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => document.removeEventListener(\"visibilitychange\", onVisibility);\n  }, [sync]);\n\n  /* Countdown: a short grace period after the answer, drawn as a draining ring and ticking seconds. */\n  const remaining = useMotionValue(1);\n  const [seconds, setSeconds] = useState(countdown);\n  const performRef = useRef(perform);\n  useLayoutEffect(() => {\n    performRef.current = perform;\n  });\n  useMotionValueEvent(remaining, \"change\", (value) => setSeconds(Math.max(1, Math.ceil(value * countdown))));\n  useEffect(() => {\n    if (!counting) return;\n    remaining.jump(1);\n    const control = animate(remaining, 0, { duration: countdown, ease: \"linear\" });\n    void control.then(() => {\n      void performRef.current(\"confirm\");\n    });\n    return () => control.stop();\n  }, [counting, countdown, remaining]);\n\n  /* Hold: the confirm button fills with the danger colour from the left while pressed, and drains back if let go early. */\n  const hold = useMotionValue(0);\n  const holding = useRef<AnimationPlaybackControls | null>(null);\n  const fillClip = useTransform(hold, (value) => `inset(0 ${(1 - value) * 100}% 0 0 round 999px)`);\n  const startHold = () => {\n    if (holding.current) return;\n    const control = animate(hold, 1, { duration: (holdDuration / 1000) * (1 - hold.get()), ease: \"linear\" });\n    holding.current = control;\n    void control.then(() => {\n      if (holding.current !== control) return;\n      holding.current = null;\n      void perform(\"confirm\");\n    });\n  };\n  const releaseHold = () => {\n    const control = holding.current;\n    if (!control) return;\n    holding.current = null;\n    control.stop();\n    if (hold.get() < 1) {\n      animate(hold, 0, reduced ? { duration: 0 } : { type: \"spring\", visualDuration: 0.3, bounce: 0 });\n      setAnnouncement(`Keep holding to ${confirmLabel.toLowerCase()}`);\n    }\n  };\n  useEffect(() => {\n    if (state !== \"confirming\") {\n      holding.current?.stop();\n      holding.current = null;\n      hold.jump(0);\n    }\n  }, [hold, state]);\n\n  const answer = () => {\n    if (performance.now() - askedAt.current < ARM_MS) return;\n    if (confirmMode === \"countdown\") {\n      keepFocus();\n      setCounting(true);\n      setAnnouncement(`${countdownLabel ?? `${pendingLabel} in`} ${countdown} seconds`);\n      return;\n    }\n    void perform(\"confirm\");\n  };\n\n  // An outside press answers the question with no.\n  useEffect(() => {\n    if (state !== \"confirming\" || !cancelOnOutsidePress) return;\n    const down = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) cancel();\n    };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [cancel, cancelOnOutsidePress, state]);\n\n  // Focus moves with the morph: Confirm while asking, the root while counting or working, Undo or Retry on a result, the trigger at rest.\n  useLayoutEffect(() => {\n    if (!pendingFocus.current) return;\n    pendingFocus.current = false;\n    const root = rootRef.current;\n    if (!root) return;\n    const face = root.querySelector<HTMLElement>(`[data-face=\"${faceKey}\"]`);\n    const autofocus = face?.querySelector<HTMLElement>(\"[data-autofocus]:not(:disabled)\");\n    (autofocus ?? root).focus({ preventScroll: true });\n  }, [faceKey]);\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      if (state === \"confirming\") {\n        event.preventDefault();\n        event.stopPropagation();\n        cancel();\n      } else if (state === \"done\" || state === \"error\") {\n        event.preventDefault();\n        event.stopPropagation();\n        toIdle();\n      }\n      return;\n    }\n    // Enter answers yes from anywhere in the question except a button of its own (Cancel keeps meaning Cancel).\n    if (\n      event.key === \"Enter\" &&\n      state === \"confirming\" &&\n      !event.repeat &&\n      !(event.target instanceof HTMLButtonElement)\n    ) {\n      event.preventDefault();\n      if (counting) void perform(\"confirm\");\n      else if (confirmMode !== \"hold\") answer();\n    }\n  };\n\n  const holdKeys = (event: ReactKeyboardEvent<HTMLButtonElement>, down: boolean) => {\n    if (event.key !== \"Enter\" && event.key !== \" \") return;\n    event.preventDefault();\n    if (down) {\n      if (!event.repeat) startHold();\n    } else releaseHold();\n  };\n  const holdPointer = (event: ReactPointerEvent<HTMLButtonElement>) => {\n    if (event.button !== 0) return;\n    event.currentTarget.setPointerCapture?.(event.pointerId);\n    startHold();\n  };\n\n  const shownPrompt = prompt ?? <>{label}?</>;\n  const shownCountdown = countdownLabel ?? `${pendingLabel} in`;\n  const confirmButton =\n    confirmMode === \"hold\" ? (\n      <button\n        type=\"button\"\n        className={confirmClass}\n        data-autofocus\n        aria-describedby={holdHintId}\n        onPointerDown={holdPointer}\n        onPointerUp={releaseHold}\n        onPointerCancel={releaseHold}\n        onLostPointerCapture={releaseHold}\n        onKeyDown={(event) => holdKeys(event, true)}\n        onKeyUp={(event) => holdKeys(event, false)}\n        onBlur={releaseHold}\n        onContextMenu={(event) => event.preventDefault()}\n      >\n        <span>{confirmLabel}</span>\n        <motion.span\n          className=\"pointer-events-none absolute inset-0 inline-flex items-center justify-center bg-red text-page group-data-[tone=neutral]/cm:bg-accent-ink group-data-[tone=neutral]/cm:text-on-accent\"\n          style={{ clipPath: fillClip }}\n          aria-hidden=\"true\"\n        >\n          <span>{confirmLabel}</span>\n        </motion.span>\n      </button>\n    ) : (\n      <button\n        type=\"button\"\n        className={confirmClass}\n        data-autofocus\n        onClick={answer}\n        onKeyDown={(event) => {\n          if (event.repeat) event.preventDefault();\n        }}\n      >\n        {confirmLabel}\n      </button>\n    );\n\n  const face = (() => {\n    switch (faceKey) {\n      case \"confirming\":\n        return (\n          <>\n            <Part\n              reduced={reduced}\n              className=\"max-w-64 overflow-hidden pr-2 pl-3 font-medium text-ink tabular-nums [&>span]:overflow-hidden [&>span]:text-ellipsis\"\n            >\n              <span id={promptId}>{shownPrompt}</span>\n            </Part>\n            <Part reduced={reduced}>\n              <button type=\"button\" className={secondaryClass} onClick={cancel}>\n                {cancelLabel}\n              </button>\n            </Part>\n            <Part reduced={reduced}>{confirmButton}</Part>\n            {confirmMode === \"hold\" && (\n              <span id={holdHintId} className=\"sr-only\">\n                Press and hold\n              </span>\n            )}\n          </>\n        );\n      case \"counting\":\n        return (\n          <>\n            <Part reduced={reduced} className={statusClass}>\n              <Ring progress={remaining} />\n              <span className={statusTextClass}>\n                {shownCountdown} <Seconds value={seconds} reduced={reduced} />\n              </span>\n            </Part>\n            <Part reduced={reduced}>\n              <button type=\"button\" className={secondaryClass} onClick={cancel}>\n                {cancelLabel}\n              </button>\n            </Part>\n          </>\n        );\n      case \"pending\":\n        return (\n          <Part reduced={reduced} className={statusClass}>\n            <LoaderCircle\n              className=\"animate-[spin_.7s_linear_infinite] motion-reduce:[animation-duration:1.6s]\"\n              size={16}\n              strokeWidth={1.75}\n              aria-hidden=\"true\"\n            />\n            <span className={statusTextClass}>{working === \"undo\" ? undoingLabel : pendingLabel}</span>\n          </Part>\n        );\n      case \"done\":\n        return (\n          <>\n            <Part reduced={reduced} className={statusClass}>\n              <Check reduced={reduced} />\n              <span className={statusTextClass}>{doneLabel}</span>\n            </Part>\n            {onUndo && (\n              <Part reduced={reduced}>\n                <button type=\"button\" className={secondaryClass} data-autofocus onClick={() => void perform(\"undo\")}>\n                  {undoLabel}\n                </button>\n              </Part>\n            )}\n          </>\n        );\n      case \"error\":\n        return (\n          <>\n            <Part reduced={reduced} className={statusClass}>\n              <CircleAlert className=\"text-red\" size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n              <span className={statusTextClass}>{errorLabel}</span>\n            </Part>\n            <Part reduced={reduced}>\n              <button type=\"button\" className={secondaryClass} data-autofocus onClick={() => void perform(working)}>\n                {retryLabel}\n              </button>\n            </Part>\n          </>\n        );\n      default:\n        return (\n          <Part reduced={reduced}>\n            <button\n              type=\"button\"\n              className=\"group/trig inline-flex h-full cursor-pointer items-center gap-2 rounded-button border-0 bg-transparent pr-4 pl-3.5 font-medium text-ink transition-[background-color,color] duration-fast ease-standard hover:enabled:not-active:bg-[color-mix(in_oklab,var(--ink)_5%,transparent)] focus-visible:bg-[color-mix(in_oklab,var(--ink)_6%,transparent)] enabled:active:bg-[color-mix(in_oklab,var(--ink)_9%,transparent)] enabled:active:duration-0 disabled:cursor-not-allowed disabled:text-ink-3 motion-reduce:duration-0\"\n              data-autofocus\n              disabled={disabled}\n              onClick={() => {\n                setAnnouncement(typeof shownPrompt === \"string\" ? shownPrompt : \"\");\n                go(\"confirming\");\n              }}\n            >\n              {icon && (\n                <span\n                  className=\"grid size-4 place-items-center text-ink-2 group-disabled/trig:text-inherit group-disabled/trig:opacity-70 [&>svg]:size-4\"\n                  aria-hidden=\"true\"\n                >\n                  {icon}\n                </span>\n              )}\n              <span>{label}</span>\n            </button>\n          </Part>\n        );\n    }\n  })();\n\n  return (\n    // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- the root is not a control; it catches Escape and pointer presence from the buttons inside it\n    <div\n      ref={rootRef}\n      className={cn(\n        \"group/cm relative inline-flex max-w-full min-w-0 align-middle font-body text-sm leading-5 tracking-body text-ink outline-none [-webkit-tap-highlight-color:transparent] [&_:focus]:outline-none\",\n        className,\n      )}\n      data-state={state}\n      data-face={faceKey}\n      data-tone={tone}\n      data-mode={confirmMode}\n      data-disabled={disabled || undefined}\n      tabIndex={-1}\n      onKeyDown={onKeyDown}\n      aria-busy={state === \"pending\" || undefined}\n      onPointerEnter={() => {\n        holds.current.hover = true;\n        sync();\n      }}\n      onPointerLeave={() => {\n        holds.current.hover = false;\n        sync();\n      }}\n      onPointerCancel={() => {\n        holds.current.hover = false;\n        sync();\n      }}\n    >\n      <motion.div\n        className=\"relative flex h-control-sm max-w-full justify-start overflow-clip rounded-button bg-surface shadow-resting transition-[background-color,box-shadow] duration-standard ease-standard after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:border after:border-line after:transition-[border-color] after:duration-standard after:ease-standard after:content-[''] group-[[data-disabled][data-state=idle]]/cm:shadow-none group-[[data-disabled][data-state=idle]]/cm:after:border-line-soft motion-reduce:duration-0 motion-reduce:after:duration-0 contrast-more:after:border-line-strong\"\n        style={{ width }}\n      >\n        <AnimatePresence initial={false}>\n          <Face\n            key={faceKey}\n            id={faceKey}\n            reduced={reduced}\n            onSize={onFaceSize}\n            labelledBy={faceKey === \"confirming\" ? promptId : undefined}\n          >\n            {face}\n          </Face>\n        </AnimatePresence>\n      </motion.div>\n      <output className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </output>\n    </div>\n  );\n}\n\nexport default ConfirmMorph;\n",
      "type": "registry:component",
      "target": "@components/iris/confirm-morph/confirm-morph.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/confirm-morph",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "ConfirmMorph",
    "tags": [
      "actions",
      "new"
    ],
    "docs": "https://irisui.dev/components/confirm-morph"
  }
}
