atlas-web
Producción · hace 2 h
Zona de peligro
Confirmar manteniendo pulsado
Un diálogo de borrado cuyo botón solo actúa si lo mantienes pulsado hasta que se llena. Si sueltas antes, se vacía.
Ajustes
Código
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
AnimatePresence,
animate,
motion,
useMotionValue,
useReducedMotion,
useTransform,
type AnimationPlaybackControls,
} from "framer-motion";
import { Check, Trash2 } from "lucide-react";
const spring = { type: "spring" as const, stiffness: 380, damping: 34 };
const FOCUSABLE =
'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';
// Modal accesible: enfoca el primer control (o el marcado con data-autofocus), atrapa Tab, cierra con Esc y devuelve el foco
function useDialog(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
const close = useRef(onClose);
useEffect(() => {
close.current = onClose;
});
useEffect(() => {
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
const node = ref.current;
const first =
node?.querySelector<HTMLElement>("[data-autofocus]") ?? node?.querySelector<HTMLElement>(FOCUSABLE);
first?.focus({ preventScroll: true });
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
close.current();
return;
}
if (e.key !== "Tab" || !node) return;
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
if (!items.length) return;
const firstItem = items[0];
const lastItem = items[items.length - 1];
if (!firstItem || !lastItem) return;
if (!node.contains(document.activeElement)) {
e.preventDefault();
firstItem.focus();
} else if (e.shiftKey && document.activeElement === firstItem) {
e.preventDefault();
lastItem.focus();
} else if (!e.shiftKey && document.activeElement === lastItem) {
e.preventDefault();
firstItem.focus();
}
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
previous?.focus({ preventScroll: true });
};
}, [open, ref]);
}
interface HoldToConfirmDialogProps {
holdMs?: number;
color?: string;
label?: string;
onConfirm?: () => void;
}
export default function HoldToConfirmDialog({
holdMs = 1500,
color = "#e5484d",
label = "Mantén para eliminar",
onConfirm,
}: HoldToConfirmDialogProps) {
const [open, setOpen] = useState(false);
const [holding, setHolding] = useState(false);
const [done, setDone] = useState(false);
const [deleted, setDeleted] = useState(false);
const panel = useRef<HTMLDivElement>(null);
const titleId = useId();
const descId = useId();
const reduce = useReducedMotion();
const progress = useMotionValue(0);
const clip = useTransform(progress, (p) => "inset(0 " + (100 - p * 100) + "% 0 0 round 999px)");
const anim = useRef<AnimationPlaybackControls | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useDialog(panel, open, () => setOpen(false));
useEffect(() => {
if (open) return;
anim.current?.stop();
progress.set(0);
setHolding(false);
setDone(false);
}, [open, progress]);
useEffect(() => () => clearTimeout(timer.current), []);
const start = () => {
if (done) return;
setHolding(true);
anim.current?.stop();
anim.current = animate(progress, 1, {
duration: (holdMs / 1000) * (1 - progress.get()),
ease: "linear",
onComplete: () => {
setDone(true);
setHolding(false);
navigator.vibrate?.(30);
onConfirm?.();
timer.current = setTimeout(() => {
setOpen(false);
setDeleted(true);
}, 800);
},
});
};
const cancel = () => {
if (done) return;
setHolding(false);
anim.current?.stop();
anim.current = animate(progress, 0, { type: "spring", stiffness: 260, damping: 30 });
};
const text = done ? "Eliminado" : holding ? "Sigue pulsando…" : label;
const Icon = done ? Check : Trash2;
return (
<div className="relative grid min-h-[420px] w-full place-items-center bg-[#f5f5f7] p-5 text-[#1d1d1f] dark:bg-[#0b0b0c] dark:text-[#f5f5f7]">
<div className="w-full max-w-[340px] rounded-[24px] border border-black/[0.06] bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_40px_-12px_rgba(0,0,0,0.12)] dark:border-white/[0.08] dark:bg-[#161618] dark:shadow-none">
<AnimatePresence mode="wait" initial={false}>
{deleted ? (
<motion.div
key="deleted"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
className="flex items-center justify-between gap-3"
>
<p className="text-[14px] text-[#86868b]">Proyecto eliminado</p>
<button
type="button"
onClick={() => setDeleted(false)}
className="h-9 rounded-full bg-black/5 px-4 text-[13px] font-medium transition hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
>
Deshacer
</button>
</motion.div>
) : (
<motion.div key="project" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}>
<div className="flex items-center gap-3">
<span className="grid h-11 w-11 place-items-center rounded-[14px] bg-gradient-to-br from-[#6e8bff] via-[#7c5cff] to-[#c056f5] text-[16px] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.3),0_6px_16px_-6px_rgba(124,92,255,0.7)]">
A
</span>
<div className="min-w-0">
<p className="text-[16px] font-semibold tracking-[-0.01em]">atlas-web</p>
<p className="text-[13px] text-[#86868b]">Producción · hace 2 h</p>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 border-t border-black/[0.06] pt-4 dark:border-white/[0.08]">
<p className="text-[13px] text-[#86868b]">Zona de peligro</p>
<button
type="button"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-[13px] font-medium transition active:scale-[0.97]"
style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 10%, transparent)" }}
>
<Trash2 className="h-4 w-4" strokeWidth={1.5} /> Eliminar
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{open && (
// fixed: el diálogo bloquea toda la ventana
<motion.div
key="overlay"
className="fixed inset-0 z-50 grid place-items-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.2 }}
>
<div aria-hidden onClick={() => setOpen(false)} className="absolute inset-0 bg-black/25 backdrop-blur-[6px] dark:bg-black/60" />
<motion.div
ref={panel}
role="alertdialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
initial={{ opacity: 0, scale: 0.94, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 6 }}
transition={reduce ? { duration: 0 } : spring}
className="relative w-full max-w-[360px] rounded-[28px] border border-black/[0.06] bg-white p-5 sm:p-6 text-[#1d1d1f] shadow-[0_10px_30px_rgba(0,0,0,0.12),0_40px_100px_-20px_rgba(0,0,0,0.4)] dark:border-white/10 dark:bg-[#1a1a1d] dark:text-[#f5f5f7]"
>
<div className="flex items-center gap-3">
<span
className="grid h-10 w-10 shrink-0 place-items-center rounded-full"
style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 12%, transparent)" }}
>
<Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
</span>
<h2 id={titleId} className="text-[18px] font-semibold tracking-[-0.02em]">
¿Eliminar atlas-web?
</h2>
</div>
<p id={descId} className="mt-3 text-[14px] leading-relaxed text-[#6e6e73] dark:text-[#a1a1a6]">
Se borrarán los despliegues, los dominios y el historial. No se puede deshacer.
</p>
<motion.button
type="button"
aria-label={label + " (mantén pulsado)"}
onPointerDown={(e) => {
if (e.button === 0) start();
}}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onContextMenu={(e) => e.preventDefault()}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
e.preventDefault();
start();
}
}}
onKeyUp={(e) => {
if (e.key === " " || e.key === "Enter") cancel();
}}
animate={{ scale: holding ? 0.97 : 1 }}
transition={spring}
className="mt-6 relative flex h-12 w-full touch-none select-none items-center justify-center gap-2 overflow-hidden rounded-full text-[14px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-[#1a1a1d]"
style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 10%, transparent)" }}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
{text}
<motion.span
aria-hidden
className="absolute inset-0 flex items-center justify-center gap-2 text-white"
style={{ backgroundColor: color, clipPath: clip }}
>
<Icon className="h-4 w-4" strokeWidth={1.5} />
{text}
</motion.span>
</motion.button>
<button
type="button"
data-autofocus
onClick={() => setOpen(false)}
className="mt-2 h-12 w-full rounded-full text-[14px] font-medium text-[#1d1d1f] transition hover:bg-black/5 dark:text-[#f5f5f7] dark:hover:bg-white/10"
>
Cancelar
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Prompt para IA
Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.
Más en Overlays
Dónde usarlo
Un botón de mantener pulsado para confirmar tiene sentido en lo que no se puede deshacer: borrar un proyecto en un panel de hosting, cerrar una cuenta o vaciar un carrito guardado. Ese segundo y medio da tiempo a pensarlo y evita el clic despistado. También funciona con teclado, manteniendo Espacio o Intro.
No lo pongas en acciones del día a día o se vuelve un fastidio. Con un segundo y medio basta; por encima de dos, la gente cree que no funciona. El proyecto y los textos son de ejemplo, y el borrado de verdad lo conectas tú.