"use client"; import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref, } from "react"; import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion, type AnimationPlaybackControls, type Transition, type Variants, } from "motion/react"; import { CircleAlert, LoaderCircle } from "lucide-react"; import { cn } from "@/lib/utils"; export type ConfirmMorphState = | "idle" | "confirming" | "pending" | "done" | "error"; export interface ConfirmMorphProps { label: ReactNode; icon?: ReactNode; prompt?: ReactNode; confirmLabel?: string; cancelLabel?: string; pendingLabel?: string; doneLabel?: string; errorLabel?: string; retryLabel?: string; undoLabel?: string; undoingLabel?: string; tone?: "danger" | "neutral"; onConfirm?: () => void | Promise; onUndo?: () => void | Promise; onCancel?: () => void; state?: ConfirmMorphState; defaultState?: ConfirmMorphState; onStateChange?: (state: ConfirmMorphState) => void; confirmTimeout?: number; resultTimeout?: number; cancelOnOutsidePress?: boolean; disabled?: boolean; className?: string; ref?: Ref; } const GROW: Transition = { type: "spring", stiffness: 420, damping: 32, mass: 0.8, }; const SHRINK: Transition = { type: "spring", stiffness: 460, damping: 34, mass: 0.8, }; const SLIDE: Transition = { type: "spring", stiffness: 420, damping: 32, mass: 0.8, }; const emptySubscribe = () => () => {}; function useReducedFlag() { const hydrated = useSyncExternalStore( emptySubscribe, () => true, () => false, ); const reducedMotion = useReducedMotion(); return Boolean(reducedMotion && hydrated); } const faceVariants: Variants = { hidden: (direction: number) => ({ opacity: 0, x: direction * 8, }), shown: { opacity: 1, x: 0, transition: { x: SLIDE, opacity: { duration: 0.16, ease: [0.16, 1, 0.3, 1] }, }, }, gone: (direction: number) => ({ opacity: 0, x: direction * -6, transition: { duration: 0.09, ease: [0.2, 0, 0, 1], }, }), }; const fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: 0.12 } }, gone: { opacity: 0, transition: { duration: 0.08 } }, }; function Face({ id, direction, reduced, onSize, children, labelledBy, }: { id: ConfirmMorphState; direction: number; reduced: boolean; onSize: (id: ConfirmMorphState, width: number) => void; children: ReactNode; labelledBy?: string; }) { const ref = useRef(null); const present = useIsPresent(); useLayoutEffect(() => { const node = ref.current; if (!node || !present) return; const report = () => { const flex = node.style.flex; node.style.flex = "none"; const width = node.scrollWidth || Math.ceil(node.getBoundingClientRect().width); node.style.flex = flex; onSize(id, width); }; report(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(report); observer.observe(node); return () => observer.disconnect(); }, [id, onSize, present]); return ( {children} ); } function Check({ reduced }: { reduced: boolean }) { return ( ); } export function ConfirmMorph({ label, icon, prompt, confirmLabel = "Delete", cancelLabel = "Cancel", pendingLabel = "Deleting", doneLabel = "Deleted", errorLabel = "Couldn’t finish", retryLabel = "Retry", undoLabel = "Undo", undoingLabel = "Restoring", tone = "danger", onConfirm, onUndo, onCancel, state: stateProp, defaultState = "idle", onStateChange, confirmTimeout = 6000, resultTimeout = 5000, cancelOnOutsidePress = true, disabled = false, className, ref, }: ConfirmMorphProps) { const reduced = useReducedFlag(); const uid = useId(); const promptId = `${uid}-prompt`; const rootRef = useRef(null); useImperativeHandle(ref, () => rootRef.current as HTMLDivElement, []); const [inner, setInner] = useState(defaultState); const state = stateProp ?? inner; const [direction, setDirection] = useState(1); const [working, setWorking] = useState<"confirm" | "undo">("confirm"); const [announcement, setAnnouncement] = useState(""); const live = useRef({ state, onStateChange, controlled: stateProp !== undefined, }); useLayoutEffect(() => { live.current = { state, onStateChange, controlled: stateProp !== undefined, }; }); const pendingFocus = useRef(false); const run = useRef(0); const go = useCallback((next: ConfirmMorphState) => { const current = live.current.state; if (next === current) return; const root = rootRef.current; pendingFocus.current = Boolean(root) && (root?.contains(document.activeElement) || document.activeElement === document.body); setDirection(next === "idle" ? -1 : 1); if (!live.current.controlled) setInner(next); live.current.state = next; live.current.onStateChange?.(next); }, []); const toIdle = useCallback(() => { run.current++; go("idle"); }, [go]); const perform = useCallback( async (kind: "confirm" | "undo") => { const handler = kind === "confirm" ? onConfirm : onUndo; const token = ++run.current; setWorking(kind); let result: void | Promise | undefined; try { result = handler?.(); } catch { go("error"); setAnnouncement(errorLabel); return; } if (result && typeof (result as Promise).then === "function") { go("pending"); setAnnouncement(kind === "confirm" ? pendingLabel : undoingLabel); try { await result; } catch { if (token !== run.current) return; go("error"); setAnnouncement(errorLabel); return; } if (token !== run.current) return; } if (kind === "undo") { go("idle"); setAnnouncement("Undone"); return; } go("done"); setAnnouncement( onUndo ? `${doneLabel}. ${undoLabel} is available.` : doneLabel, ); }, [ doneLabel, errorLabel, go, onConfirm, onUndo, pendingLabel, undoLabel, undoingLabel, ], ); const cancel = useCallback(() => { onCancel?.(); toIdle(); setAnnouncement("Cancelled"); }, [onCancel, toIdle]); const expire = useRef(() => {}); useLayoutEffect(() => { expire.current = () => { if (live.current.state === "confirming") cancel(); else toIdle(); }; }); const width = useMotionValue("auto"); const target = useRef(0); const flight = useRef(0); const onFaceSize = useCallback( (id: ConfirmMorphState, w: number) => { if (id !== live.current.state || Math.abs(w - target.current) < 0.5) return; const from = target.current; target.current = w; if (!from || reduced) { width.set(w); return; } if (width.get() === "auto") width.jump(from); const token = ++flight.current; animate(width, w, w > from ? GROW : SHRINK).then(() => { if (token === flight.current) width.set(w); }); }, [reduced, width], ); const drain = useMotionValue(1); const clock = useRef(null); const holds = useRef({ hover: false, hidden: false }); const timeout = state === "confirming" ? confirmTimeout : state === "done" || state === "error" ? resultTimeout : 0; const sync = useCallback(() => { const control = clock.current; if (!control) return; const held = holds.current.hover || holds.current.hidden; if (held) control.pause(); else control.play(); }, []); useEffect(() => { if (!timeout) return; drain.jump(1); const control = animate(drain, 0, { duration: timeout / 1000, ease: "linear", }); clock.current = control; control.then(() => { if (clock.current === control) { clock.current = null; expire.current(); } }); sync(); return () => { if (clock.current === control) clock.current = null; control.stop(); }; }, [drain, state, sync, timeout]); useEffect(() => { const onVisibility = () => { holds.current.hidden = document.hidden; sync(); }; document.addEventListener("visibilitychange", onVisibility); return () => document.removeEventListener("visibilitychange", onVisibility); }, [sync]); useEffect(() => { if (state !== "confirming" || !cancelOnOutsidePress) return; const down = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) cancel(); }; document.addEventListener("pointerdown", down); return () => document.removeEventListener("pointerdown", down); }, [cancel, cancelOnOutsidePress, state]); useLayoutEffect(() => { if (!pendingFocus.current) return; pendingFocus.current = false; const root = rootRef.current; if (!root) return; const faceElement = root.querySelector( `[data-face="${state}"]`, ); const autofocus = faceElement?.querySelector( "[data-autofocus]:not(:disabled)", ); (autofocus ?? root).focus({ preventScroll: true }); }, [state]); const onKeyDown = (event: ReactKeyboardEvent) => { if (event.key !== "Escape") return; if (state === "confirming") { event.preventDefault(); event.stopPropagation(); cancel(); } else if (state === "done" || state === "error") { event.preventDefault(); event.stopPropagation(); toIdle(); } }; const shownPrompt = prompt ?? <>{label}?; const face = (() => { switch (state) { case "confirming": return ( <> {shownPrompt} ); case "pending": return ( ); case "done": return ( <> {doneLabel} {onUndo && ( )} ); case "error": return ( <> ); default: return ( ); } })(); return (
{ holds.current.hover = true; sync(); }} onPointerLeave={() => { holds.current.hover = false; sync(); }} onPointerCancel={() => { holds.current.hover = false; sync(); }} > {face} {announcement}
); } export default ConfirmMorph;